From c28ee05bde7bed0b7e45928c01ef1fc60c106bf0 Mon Sep 17 00:00:00 2001 From: Osten <11805592+LagradOst@users.noreply.github.com> Date: Fri, 23 Jan 2026 20:48:30 +0100 Subject: [PATCH 001/236] Added more software decoding options --- .../cloudstream3/ui/player/CS3IPlayer.kt | 42 ++++++++++--------- app/src/main/res/values/array.xml | 12 +++--- 2 files changed, 30 insertions(+), 24 deletions(-) diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/CS3IPlayer.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/CS3IPlayer.kt index 1bd2e722c..88c4889b2 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/CS3IPlayer.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/CS3IPlayer.kt @@ -15,6 +15,7 @@ import android.widget.FrameLayout import androidx.annotation.MainThread import androidx.annotation.OptIn import androidx.appcompat.app.AlertDialog +import androidx.core.net.toUri import androidx.media3.common.C.TIME_UNSET import androidx.media3.common.C.TRACK_TYPE_AUDIO import androidx.media3.common.C.TRACK_TYPE_TEXT @@ -54,8 +55,8 @@ import androidx.media3.exoplayer.source.ClippingMediaSource import androidx.media3.exoplayer.source.ConcatenatingMediaSource import androidx.media3.exoplayer.source.ConcatenatingMediaSource2 import androidx.media3.exoplayer.source.DefaultMediaSourceFactory -import androidx.media3.exoplayer.source.MergingMediaSource import androidx.media3.exoplayer.source.MediaSource +import androidx.media3.exoplayer.source.MergingMediaSource import androidx.media3.exoplayer.source.SingleSampleMediaSource import androidx.media3.exoplayer.text.TextOutput import androidx.media3.exoplayer.text.TextRenderer @@ -65,6 +66,7 @@ import androidx.media3.extractor.mp4.FragmentedMp4Extractor import androidx.media3.ui.SubtitleView import androidx.preference.PreferenceManager import com.lagradost.cloudstream3.APIHolder.getApiFromNameNull +import com.lagradost.cloudstream3.AudioFile import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey import com.lagradost.cloudstream3.CommonActivity.activity @@ -73,32 +75,33 @@ import com.lagradost.cloudstream3.MainActivity.Companion.deleteFileOnExit import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.TvType import com.lagradost.cloudstream3.USER_AGENT -import com.lagradost.cloudstream3.AudioFile import com.lagradost.cloudstream3.app import com.lagradost.cloudstream3.mvvm.debugAssert import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.mvvm.safe import com.lagradost.cloudstream3.ui.player.CustomDecoder.Companion.fixSubtitleAlignment -import com.lagradost.cloudstream3.ui.settings.Globals.TV +import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR +import com.lagradost.cloudstream3.ui.settings.Globals.PHONE import com.lagradost.cloudstream3.ui.settings.Globals.isLayout import com.lagradost.cloudstream3.ui.subtitles.SaveCaptionStyle import com.lagradost.cloudstream3.ui.subtitles.SubtitlesFragment.Companion.applyStyle import com.lagradost.cloudstream3.utils.AppContextUtils.isUsingMobileData import com.lagradost.cloudstream3.utils.AppContextUtils.setDefaultFocus +import com.lagradost.cloudstream3.utils.CLEARKEY_UUID import com.lagradost.cloudstream3.utils.Coroutines.ioSafe import com.lagradost.cloudstream3.utils.Coroutines.runOnMainThread import com.lagradost.cloudstream3.utils.DataStoreHelper.currentAccount import com.lagradost.cloudstream3.utils.DrmExtractorLink import com.lagradost.cloudstream3.utils.EpisodeSkip import com.lagradost.cloudstream3.utils.ExtractorLink -import com.lagradost.cloudstream3.utils.CLEARKEY_UUID -import com.lagradost.cloudstream3.utils.WIDEVINE_UUID -import com.lagradost.cloudstream3.utils.PLAYREADY_UUID import com.lagradost.cloudstream3.utils.ExtractorLinkPlayList import com.lagradost.cloudstream3.utils.ExtractorLinkType +import com.lagradost.cloudstream3.utils.PLAYREADY_UUID import com.lagradost.cloudstream3.utils.SubtitleHelper.fromTagToLanguageName +import com.lagradost.cloudstream3.utils.WIDEVINE_UUID import io.github.anilbeesetti.nextlib.media3ext.ffdecoder.NextRenderersFactory import kotlinx.coroutines.delay +import okhttp3.Interceptor import org.chromium.net.CronetEngine import java.io.File import java.util.UUID @@ -106,10 +109,6 @@ import java.util.concurrent.Executors import javax.net.ssl.HttpsURLConnection import javax.net.ssl.SSLContext import javax.net.ssl.SSLSession -import kotlin.collections.HashSet -import kotlin.text.StringBuilder -import androidx.core.net.toUri -import okhttp3.Interceptor const val TAG = "CS3ExoPlayer" const val PREFERRED_AUDIO_LANGUAGE_KEY = "preferred_audio_language" @@ -1079,22 +1078,27 @@ class CS3IPlayer : IPlayer { context.getString(R.string.software_decoding_key), -1 ) - val softwareDecoding = when (current) { - 0 -> true // yes - 1 -> false // no + val (isSoftwareDecodingEnabled, isSoftwareDecodingPreferred) = when (current) { + 0 -> true to false // HW+SW, aka on but prefer hw + 2 -> true to true // SW+HW, aka on but prefer sw + 1 -> false to false // HW, aka off // -1 = automatic - else -> { - // we do not want tv to have software decoding, because of crashes - !isLayout(TV) - } + // We do not want tv to have software decoding, because of crashes + else -> isLayout(PHONE or EMULATOR) to false } - val factory = if (softwareDecoding) { + val factory = if (isSoftwareDecodingEnabled) { NextRenderersFactory(context).apply { setEnableDecoderFallback(true) - setExtensionRendererMode(DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON) + setExtensionRendererMode( + if (isSoftwareDecodingPreferred) + DefaultRenderersFactory.EXTENSION_RENDERER_MODE_PREFER + else + DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON + ) } } else { + // no nextlib = EXTENSION_RENDERER_MODE_OFF DefaultRenderersFactory(context) } diff --git a/app/src/main/res/values/array.xml b/app/src/main/res/values/array.xml index 83b6afe56..9ccba36e6 100644 --- a/app/src/main/res/values/array.xml +++ b/app/src/main/res/values/array.xml @@ -236,14 +236,16 @@ @string/automatic - @string/yes - @string/no + HW+SW + SW+HW + HW - -1 - 0 - 1 + -1 + 0 + 2 + 1 From 663c8a93cbcc363f4be0f5fea150c40c95fa3f87 Mon Sep 17 00:00:00 2001 From: CranberrySoup <142951702+CranberrySoup@users.noreply.github.com> Date: Sat, 24 Jan 2026 13:44:10 +0000 Subject: [PATCH 002/236] fix chapter skipping (#2444) --- .../lagradost/cloudstream3/ui/player/FullScreenPlayer.kt | 8 +++++++- .../lagradost/cloudstream3/ui/player/GeneratorPlayer.kt | 2 -- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/FullScreenPlayer.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/FullScreenPlayer.kt index b770f541d..3cd053244 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/FullScreenPlayer.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/FullScreenPlayer.kt @@ -124,6 +124,7 @@ open class FullScreenPlayer : AbstractPlayerFragment() { protected var isShowing = false private var uiShowingBeforeGesture = false protected var isLocked = false + protected var timestampShowState = false protected var hasEpisodes = false private set @@ -1583,7 +1584,12 @@ open class FullScreenPlayer : AbstractPlayerFragment() { when (keyCode) { KeyEvent.KEYCODE_DPAD_CENTER -> { if (!isShowing) { - if (!isLocked) player.handleEvent(CSPlayerEvent.PlayPauseToggle) + // If UI is not shown make click instantly skip to next chapter even if locked + if (timestampShowState) { + player.handleEvent(CSPlayerEvent.SkipCurrentChapter) + } else if (!isLocked) { + player.handleEvent(CSPlayerEvent.PlayPauseToggle) + } onClickChange() return true } 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 53e8fb647..1bd0b158f 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 @@ -1880,8 +1880,6 @@ class GeneratorPlayer : FullScreenPlayer() { super.onDestroyView() } - var timestampShowState = false - var skipAnimator: ValueAnimator? = null var skipIndex = 0 From 58ca69c2845d27b9371b87d1bc7151d8086083c9 Mon Sep 17 00:00:00 2001 From: PiterDev <71133634+PiterWeb@users.noreply.github.com> Date: Sat, 24 Jan 2026 15:04:37 +0100 Subject: [PATCH 003/236] Kitsu added as sync provider (#2440) --- .../syncproviders/AccountManager.kt | 6 +- .../cloudstream3/syncproviders/AuthAPI.kt | 1 + .../syncproviders/providers/KitsuApi.kt | 628 ++++++++++++++++++ .../ui/result/ResultViewModel2.kt | 8 +- .../cloudstream3/ui/result/SyncViewModel.kt | 2 + .../ui/settings/SettingsAccount.kt | 2 + .../cloudstream3/utils/BackupUtils.kt | 2 + .../res/values/donottranslate-strings.xml | 1 + app/src/main/res/xml/settings_account.xml | 4 + .../com/lagradost/cloudstream3/MainAPI.kt | 12 + .../cloudstream3/syncproviders/SyncAPI.kt | 1 + 11 files changed, 664 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/AccountManager.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/AccountManager.kt index 93df0fd26..0d95de086 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/AccountManager.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/AccountManager.kt @@ -5,6 +5,7 @@ import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey import com.lagradost.cloudstream3.LoadResponse import com.lagradost.cloudstream3.syncproviders.providers.Addic7ed import com.lagradost.cloudstream3.syncproviders.providers.AniListApi +import com.lagradost.cloudstream3.syncproviders.providers.KitsuApi import com.lagradost.cloudstream3.syncproviders.providers.LocalList import com.lagradost.cloudstream3.syncproviders.providers.MALApi import com.lagradost.cloudstream3.syncproviders.providers.OpenSubtitlesApi @@ -18,6 +19,7 @@ abstract class AccountManager { companion object { const val NONE_ID: Int = -1 val malApi = MALApi() + val kitsuApi = KitsuApi() val aniListApi = AniListApi() val simklApi = SimklApi() val localListApi = LocalList() @@ -59,10 +61,10 @@ abstract class AccountManager { val allApis = arrayOf( SyncRepo(malApi), + SyncRepo(kitsuApi), SyncRepo(aniListApi), SyncRepo(simklApi), SyncRepo(localListApi), - SubtitleRepo(openSubtitlesApi), SubtitleRepo(addic7ed), SubtitleRepo(subDlApi) @@ -107,6 +109,7 @@ abstract class AccountManager { // accessing other classes fun initMainAPI() { LoadResponse.malIdPrefix = malApi.idPrefix + LoadResponse.kitsuIdPrefix = kitsuApi.idPrefix LoadResponse.aniListIdPrefix = aniListApi.idPrefix LoadResponse.simklIdPrefix = simklApi.idPrefix } @@ -118,6 +121,7 @@ abstract class AccountManager { ) val syncApis = arrayOf( SyncRepo(malApi), + SyncRepo(kitsuApi), SyncRepo(aniListApi), SyncRepo(simklApi), SyncRepo(localListApi) diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/AuthAPI.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/AuthAPI.kt index 0303e03c6..83a7a0984 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/AuthAPI.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/AuthAPI.kt @@ -28,6 +28,7 @@ import com.lagradost.cloudstream3.syncproviders.providers.Addic7ed import com.lagradost.cloudstream3.syncproviders.providers.AniListApi import com.lagradost.cloudstream3.syncproviders.providers.LocalList import com.lagradost.cloudstream3.syncproviders.providers.MALApi +import com.lagradost.cloudstream3.syncproviders.providers.KitsuApi import com.lagradost.cloudstream3.syncproviders.providers.OpenSubtitlesApi import com.lagradost.cloudstream3.syncproviders.providers.SimklApi import com.lagradost.cloudstream3.syncproviders.providers.SubDlApi diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/KitsuApi.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/KitsuApi.kt index 724d72163..3f079d9d5 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/KitsuApi.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/KitsuApi.kt @@ -1,8 +1,636 @@ package com.lagradost.cloudstream3.syncproviders.providers + +import androidx.annotation.StringRes import com.fasterxml.jackson.annotation.JsonProperty +import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey +import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey import com.lagradost.cloudstream3.app +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.Score +import com.lagradost.cloudstream3.ShowStatus +import com.lagradost.cloudstream3.TvType import com.lagradost.cloudstream3.mvvm.logError +import com.lagradost.cloudstream3.syncproviders.AuthData +import com.lagradost.cloudstream3.syncproviders.AuthLoginRequirement +import com.lagradost.cloudstream3.syncproviders.AuthLoginResponse +import com.lagradost.cloudstream3.syncproviders.AuthToken +import com.lagradost.cloudstream3.syncproviders.AuthUser +import com.lagradost.cloudstream3.syncproviders.SyncAPI +import com.lagradost.cloudstream3.syncproviders.SyncIdName +import com.lagradost.cloudstream3.ui.SyncWatchType +import com.lagradost.cloudstream3.ui.library.ListSorting +import com.lagradost.cloudstream3.utils.AppUtils.toJson +import com.lagradost.cloudstream3.utils.txt +import kotlinx.coroutines.flow.asFlow +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.withIndex +import okhttp3.RequestBody.Companion.toRequestBody +import java.text.SimpleDateFormat +import java.time.Instant +import java.time.LocalDate +import java.time.format.DateTimeFormatter +import java.util.Date +import java.util.Locale +import kotlin.collections.set + +const val KITSU_MAX_SEARCH_LIMIT = 20 + +class KitsuApi: SyncAPI() { + override var name = "Kitsu" + override val idPrefix = "kitsu" + + private val apiUrl = "https://kitsu.io/api/edge" + private val oauthUrl = "https://kitsu.io/api/oauth" + override val hasInApp = true + override val mainUrl = "https://kitsu.app" + override val icon = R.drawable.kitsu_icon + override val syncIdName = SyncIdName.Kitsu + override val createAccountUrl = mainUrl + + override val supportedWatchTypes = setOf( + SyncWatchType.WATCHING, + SyncWatchType.COMPLETED, + SyncWatchType.PLANTOWATCH, + SyncWatchType.DROPPED, + SyncWatchType.ONHOLD, + SyncWatchType.NONE + ) + + override val inAppLoginRequirement = AuthLoginRequirement( + password = true, + email = true + ) + + override suspend fun login(form: AuthLoginResponse): AuthToken? { + val username = form.email ?: return null + val password = form.password ?: return null + + val grantType = "password" + + val token = app.post( + "$oauthUrl/token", + data = mapOf( + "grant_type" to grantType, + "username" to username, + "password" to password + ) + ).parsed() + return AuthToken( + accessTokenLifetime = unixTime + token.expiresIn.toLong(), + refreshToken = token.refreshToken, + accessToken = token.accessToken, + ) + } + + override suspend fun refreshToken(token: AuthToken): AuthToken { + val res = app.post( + "$oauthUrl/token", + data = mapOf( + "grant_type" to "refresh_token", + "refresh_token" to token.refreshToken!! + ) + ).parsed() + + return AuthToken( + accessToken = res.accessToken, + refreshToken = res.refreshToken, + accessTokenLifetime = unixTime + res.expiresIn.toLong() + ) + } + + override suspend fun user(token: AuthToken?): AuthUser? { + val user = app.get( + "$apiUrl/users?filter[self]=true", + headers = mapOf( + "Authorization" to "Bearer ${token?.accessToken ?: return null}" + ), cacheTime = 0 + ).parsed() + + if (user.data.isEmpty()) { + return null + } + + return AuthUser( + id = user.data[0].id.toInt(), + name = user.data[0].attributes.name, + profilePicture = user.data[0].attributes.avatar?.original + ) + } + + override suspend fun search(auth: AuthData?, query: String): List? { + val auth = auth?.token?.accessToken ?: return null + val animeSelectedFields = arrayOf("titles","canonicalTitle","posterImage","episodeCount") + val url = "$apiUrl/anime?filter[text]=$query&page[limit]=$KITSU_MAX_SEARCH_LIMIT&fields[anime]=${animeSelectedFields.joinToString(",")}" + val res = app.get( + url, headers = mapOf( + "Authorization" to "Bearer $auth", + ), cacheTime = 0 + ).parsed() + return res.data.map { + val attributes = it.attributes + + val title = attributes.canonicalTitle ?: attributes.titles?.enJp ?: attributes.titles?.jaJp ?: "No title" + + SyncSearchResult( + title, + this.name, + it.id, + "$mainUrl/anime/${it.id}/", + attributes.posterImage?.large ?: attributes.posterImage?.medium + ) + } + } + + override suspend fun load(auth : AuthData?, id: String): SyncResult? { + val auth = auth?.token?.accessToken ?: return null + if (id.toIntOrNull() == null) { + return null + } + + data class KitsuResponse( + @field:JsonProperty(value = "data") + val data: KitsuNode, + ) + + val url = + "$apiUrl/anime/$id" + + val anime = app.get( + url, headers = mapOf( + "Authorization" to "Bearer $auth" + ) + ).parsed().data.attributes + + return SyncResult( + id = id, + totalEpisodes = anime.episodeCount, + title = anime.canonicalTitle ?: anime.titles?.enJp ?: anime.titles?.jaJp.orEmpty(), + publicScore = Score.from(anime.ratingTwenty.toString(), 20), + duration = anime.episodeLength, + synopsis = anime.synopsis, + airStatus = when(anime.status) { + "finished" -> ShowStatus.Completed + "current" -> ShowStatus.Ongoing + else -> null + }, + nextAiring = null, + studio = null, + genres = null, + trailers = null, + startDate = LocalDate.parse(anime.startDate).toEpochDay(), + endDate = LocalDate.parse(anime.endDate).toEpochDay(), + recommendations = null, + nextSeason =null, + prevSeason = null, + actors = null, + ) + + } + + override suspend fun status(auth : AuthData?, id: String): AbstractSyncStatus? { + val accessToken = auth?.token?.accessToken ?: return null + val userId = auth.user.id + + val selectedFields = arrayOf("status","ratingTwenty", "progress") + + val url = + "$apiUrl/library-entries?filter[userId]=$userId&filter[animeId]=$id&fields[libraryEntries]=${selectedFields.joinToString(",")}" + + val anime = app.get( + url, headers = mapOf( + "Authorization" to "Bearer $accessToken" + ) + ).parsed().data.firstOrNull()?.attributes + + if (anime == null) { + return SyncStatus( + score = null, + status = SyncWatchType.NONE, + isFavorite = null, + watchedEpisodes = null + ) + } + + return SyncStatus( + score = Score.from(anime.ratingTwenty.toString(), 20), + status = SyncWatchType.fromInternalId(kitsuStatusAsString.indexOf(anime.status)), + isFavorite = null, + watchedEpisodes = anime.progress, + ) + } + suspend fun getAnimeIdByTitle(title: String): String? { + + val animeSelectedFields = arrayOf("titles","canonicalTitle") + val url = "$apiUrl/anime?filter[text]=$title&page[limit]=$KITSU_MAX_SEARCH_LIMIT&fields[anime]=${animeSelectedFields.joinToString(",")}" + val res = app.get(url).parsed() + + return res.data.firstOrNull()?.id + + } + + override fun urlToId(url: String): String? = + Regex("""/anime/((.*)/|(.*))""").find(url)?.groupValues?.first() + + override suspend fun updateStatus( + auth : AuthData?, + id: String, + newStatus: AbstractSyncStatus + ): Boolean { + + return setScoreRequest( + auth ?: return false, + id.toIntOrNull() ?: return false, + fromIntToAnimeStatus(newStatus.status), + newStatus.score?.toInt(20), + newStatus.watchedEpisodes + ) + } + + private suspend fun setScoreRequest( + auth : AuthData, + id: Int, + status: KitsuStatusType? = null, + score: Int? = null, + numWatchedEpisodes: Int? = null, + ): Boolean { + + val libraryEntryId = getAnimeLibraryEntryId(auth, id) + + // Exists entry for anime in library + if (libraryEntryId != null) { + + // Delete anime from library + if (status == null || status == KitsuStatusType.None) { + + val res = app.delete( + "$apiUrl/library-entries/$libraryEntryId", + headers = mapOf( + "Authorization" to "Bearer ${auth.token.accessToken}" + ), + ) + + return res.isSuccessful + + } + + return setScoreRequest( + auth, + libraryEntryId, + kitsuStatusAsString[maxOf(0, status.value)], + score, + numWatchedEpisodes + ) + + } + + val data = mapOf( + "data" to mapOf( + "type" to "libraryEntries", + "attributes" to mapOf( + "ratingTwenty" to score, + "progress" to numWatchedEpisodes, + "status" to if (status == null) null else kitsuStatusAsString[maxOf(0, status.value)], + ), + "relationships" to mapOf( + "anime" to mapOf( + "data" to mapOf( + "type" to "anime", + "id" to id.toString() + ) + ), + "user" to mapOf( + "data" to mapOf( + "type" to "users", + "id" to auth.user.id + ) + ) + ) + ) + ) + + val res = app.post( + "$apiUrl/library-entries", + headers = mapOf( + "content-type" to "application/vnd.api+json", + "Authorization" to "Bearer ${auth.token.accessToken}" + ), + requestBody = data.toJson().toRequestBody() + ) + + return res.isSuccessful + + } + + @Suppress("UNCHECKED_CAST") + private suspend fun setScoreRequest( + auth : AuthData, + id: Int, + status: String? = null, + score: Int? = null, + numWatchedEpisodes: Int? = null, + ): Boolean { + val data = mapOf( + "data" to mapOf( + "type" to "libraryEntries", + "id" to id.toString(), + "attributes" to mapOf( + "ratingTwenty" to score, + "progress" to numWatchedEpisodes, + "status" to status + ) + ) + ) + + val res = app.patch( + "$apiUrl/library-entries/$id", + headers = mapOf( + "content-type" to "application/vnd.api+json", + "Authorization" to "Bearer ${auth.token.accessToken}" + ), + requestBody = data.toJson().toRequestBody() + ) + + return res.isSuccessful + + } + + private suspend fun getAnimeLibraryEntryId(auth: AuthData, id: Int): Int? { + + val userId = auth.user.id + + val res = app.get( + "$apiUrl/library-entries?filter[userId]=$userId&filter[animeId]=$id", + headers = mapOf( + "Authorization" to "Bearer ${auth.token.accessToken}" + ), + ).parsed().data.firstOrNull() ?: return null + + return res.id.toInt() + + } + + override suspend fun library(auth : AuthData?): LibraryMetadata? { + val list = getKitsuAnimeListSmart(auth ?: return null)?.groupBy { + convertToStatus(it.attributes.status ?: "").stringRes + }?.mapValues { group -> + group.value.map { it.toLibraryItem() } + } ?: emptyMap() + + // To fill empty lists when Kitsu does not return them + val baseMap = + KitsuStatusType.entries.filter { it.value >= 0 }.associate { + it.stringRes to emptyList() + } + + return LibraryMetadata( + (baseMap + list).map { LibraryList(txt(it.key), it.value) }, + setOf( + ListSorting.AlphabeticalA, + ListSorting.AlphabeticalZ, + ListSorting.UpdatedNew, + ListSorting.UpdatedOld, + ListSorting.ReleaseDateNew, + ListSorting.ReleaseDateOld, + ListSorting.RatingHigh, + ListSorting.RatingLow, + ) + ) + } + + private suspend fun getKitsuAnimeListSmart(auth : AuthData): Array? { + return if (requireLibraryRefresh) { + val list = getKitsuAnimeList(auth.token, auth.user.id) + setKey(KITSU_CACHED_LIST, auth.user.id.toString(), list) + list + } else { + getKey>(KITSU_CACHED_LIST, auth.user.id.toString()) as? Array + } + } + + private suspend fun getKitsuAnimeList(token: AuthToken, userId: Int): Array { + + val animeSelectedFields = arrayOf("titles","canonicalTitle","posterImage","synopsis","startDate","episodeCount") + val libraryEntriesSelectedFields = arrayOf("progress","rating","updatedAt", "status") + val limit = 500 + var url = "$apiUrl/library-entries?filter[userId]=$userId&filter[kind]=anime&include=anime&page[limit]=$limit&page[offset]=0&fields[anime]=${animeSelectedFields.joinToString(",")}&fields[libraryEntries]=${libraryEntriesSelectedFields.joinToString(",")}" + + val fullList = mutableListOf() + + while (true) { + + val data: KitsuResponse = getKitsuAnimeListSlice(token, url) + + data.data.forEachIndexed { index, value -> + value.anime = data.included?.get(index) + } + + fullList.addAll(data.data) + + url = data.links?.next ?: break + } + + + return fullList.toTypedArray() + } + + private suspend fun getKitsuAnimeListSlice(token: AuthToken, url: String): KitsuResponse { + val res = app.get( + url, headers = mapOf( + "Authorization" to "Bearer ${token.accessToken}", + ) + ).parsed() + return res + } + + + data class ResponseToken( + @JsonProperty("token_type") val tokenType: String, + @JsonProperty("expires_in") val expiresIn: Int, + @JsonProperty("access_token") val accessToken: String, + @JsonProperty("refresh_token") val refreshToken: String, + ) + + data class KitsuNode( + @JsonProperty("id") val id: String, + @JsonProperty("attributes") val attributes: KitsuNodeAttributes, + /* User list anime node */ + @JsonProperty("relationships") val relationships: KitsuRelationships?, + var anime: KitsuAnimeData? + ) { + fun toLibraryItem(): LibraryItem { + + val animeItem = this.anime + + val numEpisodes = animeItem?.attributes?.episodeCount + + val startDate = animeItem?.attributes?.startDate + + val posterImage = animeItem?.attributes?.posterImage + + val canonicalTitle = animeItem?.attributes?.canonicalTitle + val titles = animeItem?.attributes?.titles + + val animeId = animeItem?.id + + val description: String? = animeItem?.attributes?.synopsis + + return LibraryItem( + canonicalTitle ?: titles?.enJp ?: titles?.jaJp.orEmpty(), + "https://kitsu.app/anime/${animeId}/", + this.id, + this.attributes.progress, + numEpisodes, + Score.from(this.attributes.ratingTwenty.toString(), 20), + parseDateLong(this.attributes.updatedAt), + "Kitsu", + TvType.Anime, + posterImage?.large ?: posterImage?.medium, + null, + null, + plot = description, + releaseDate = if (startDate == null) null else try { + Date.from( + Instant.from( + DateTimeFormatter.ofPattern(if (startDate.length == 4) "yyyy" else if (startDate.length == 7) "yyyy-MM" else "yyyy-MM-dd") + .parse(startDate) + ) + ) + } catch (_: RuntimeException) { + null + } + ) + } + + } + + data class KitsuAnimeAttributes( + @JsonProperty("titles") val titles: KitsuTitles?, + @JsonProperty("canonicalTitle") val canonicalTitle: String?, + @JsonProperty("posterImage") val posterImage: KitsuPosterImage?, + @JsonProperty("synopsis") val synopsis: String?, + @JsonProperty("startDate") val startDate: String?, + @JsonProperty("endDate") val endDate: String?, + @JsonProperty("episodeCount") val episodeCount: Int?, + @JsonProperty("episodeLength") val episodeLength: Int?, + ) + + data class KitsuAnimeData( + @JsonProperty("id") val id: String, + @JsonProperty("attributes") val attributes: KitsuAnimeAttributes, + ) + + + data class KitsuNodeAttributes( + /* General attributes */ + @JsonProperty("titles") val titles: KitsuTitles?, + @JsonProperty("canonicalTitle") val canonicalTitle: String?, + @JsonProperty("posterImage") val posterImage: KitsuPosterImage?, + @JsonProperty("synopsis") val synopsis: String?, + @JsonProperty("startDate") val startDate: String?, + @JsonProperty("endDate") val endDate: String?, + @JsonProperty("episodeCount") val episodeCount: Int?, + @JsonProperty("episodeLength") val episodeLength: Int?, + /* User attributes */ + @JsonProperty("name") val name: String?, + @JsonProperty("location") val location: String?, + @JsonProperty("createdAt") val createdAt: String?, + @JsonProperty("avatar") val avatar: KitsuUserAvatar?, + /* User list anime attributes */ + @JsonProperty("progress") val progress: Int?, + @JsonProperty("ratingTwenty") val ratingTwenty: Float?, + @JsonProperty("updatedAt") val updatedAt: String?, + @JsonProperty("status") val status: String?, + ) + + data class KitsuRelationships( + @JsonProperty("anime") val anime: KitsuRelationshipsAnime? + ) + + data class KitsuRelationshipsAnime( + @JsonProperty("links") val links: KitsuLinks? + ) + + data class KitsuPosterImage( + @JsonProperty("large") val large: String?, + @JsonProperty("medium") val medium: String?, + ) + + data class KitsuTitles( + @JsonProperty("en_jp") val enJp: String?, + @JsonProperty("ja_jp") val jaJp: String? + ) + + data class KitsuUserAvatar( + @JsonProperty("original") val original: String? + ) + + data class KitsuLinks( + /* Pagination */ + @JsonProperty("first") val first: String?, + @JsonProperty("next") val next: String?, + @JsonProperty("last") val last: String?, + /* Relationships */ + @JsonProperty("related") val related: String? + ) + + data class KitsuResponse( + @JsonProperty("links") val links: KitsuLinks?, + @JsonProperty("data") val data: List, + /* When requesting related info (User library entry -> anime) */ + @JsonProperty("included") val included: List?, + ) + + + companion object { + + const val KITSU_CACHED_LIST: String = "kitsu_cached_list" + private fun parseDateLong(string: String?): Long? { + return try { + SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssZ", Locale.getDefault()).parse( + string ?: return null + )?.time?.div(1000) + } catch (e: Exception) { + null + } + } + + private val kitsuStatusAsString = + arrayOf("current", "completed", "on_hold", "dropped", "planned") + private fun fromIntToAnimeStatus(inp: SyncWatchType): KitsuStatusType { + return when (inp) { + SyncWatchType.NONE -> KitsuStatusType.None + SyncWatchType.WATCHING -> KitsuStatusType.Watching + SyncWatchType.COMPLETED -> KitsuStatusType.Completed + SyncWatchType.ONHOLD -> KitsuStatusType.OnHold + SyncWatchType.DROPPED -> KitsuStatusType.Dropped + SyncWatchType.PLANTOWATCH -> KitsuStatusType.PlanToWatch + SyncWatchType.REWATCHING -> KitsuStatusType.Watching + } + } + + enum class KitsuStatusType(var value: Int, @StringRes val stringRes: Int) { + Watching(0, R.string.type_watching), + Completed(1, R.string.type_completed), + OnHold(2, R.string.type_on_hold), + Dropped(3, R.string.type_dropped), + PlanToWatch(4, R.string.type_plan_to_watch), + None(-1, R.string.type_none) + } + + private fun convertToStatus(string: String): KitsuStatusType { + return when (string) { + "current" -> KitsuStatusType.Watching + "completed" -> KitsuStatusType.Completed + "on_hold" -> KitsuStatusType.OnHold + "dropped" -> KitsuStatusType.Dropped + "planned" -> KitsuStatusType.PlanToWatch + else -> KitsuStatusType.None + } + } + } +} // modified code from from https://github.com/saikou-app/saikou/blob/main/app/src/main/java/ani/saikou/others/Kitsu.kt // GNU General Public License v3.0 https://github.com/saikou-app/saikou/blob/main/LICENSE.md 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 d0d9b8c93..4b4d0b5fa 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 @@ -24,6 +24,7 @@ import com.lagradost.cloudstream3.CommonActivity.getCastSession import com.lagradost.cloudstream3.CommonActivity.showToast 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 @@ -1855,7 +1856,7 @@ class ResultViewModel2 : ViewModel() { { if (this !is AnimeLoadResponse) return@runAllAsync // already exist, no need to run getTracker - if (this.getAniListId() != null && this.getMalId() != null) return@runAllAsync + if (this.getAniListId() != null && this.getKitsuId() != null && this.getMalId() != null) return@runAllAsync val res = APIHolder.getTracker( listOfNotNull( @@ -1873,9 +1874,12 @@ class ResultViewModel2 : ViewModel() { this.year ) + val kitsuId = AccountManager.kitsuApi.getAnimeIdByTitle(this.name) + val ids = arrayOf( AccountManager.malApi.idPrefix to res?.malId?.toString(), - AccountManager.aniListApi.idPrefix to res?.aniId + AccountManager.aniListApi.idPrefix to res?.aniId, + AccountManager.kitsuApi.idPrefix to kitsuId ) if (ids.any { (id, new) -> diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/result/SyncViewModel.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/result/SyncViewModel.kt index 35680b060..6c5c64ff8 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/result/SyncViewModel.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/result/SyncViewModel.kt @@ -11,6 +11,7 @@ import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.mvvm.throwAbleToResource import com.lagradost.cloudstream3.syncproviders.AccountManager import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.aniListApi +import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.kitsuApi import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.malApi import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.simklApi import com.lagradost.cloudstream3.syncproviders.SyncAPI @@ -276,6 +277,7 @@ class SyncViewModel : ViewModel() { // fix because of bad old data :pensive: val realName = when (syncName) { "MAL" -> malApi.idPrefix + "Kitsu" -> kitsuApi.idPrefix "Simkl" -> simklApi.idPrefix "AniList" -> aniListApi.idPrefix else -> syncName diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsAccount.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsAccount.kt index 53d29cdb8..7c24cd7a9 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsAccount.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsAccount.kt @@ -29,6 +29,7 @@ import com.lagradost.cloudstream3.databinding.DeviceAuthBinding import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.aniListApi import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.malApi +import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.kitsuApi import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.openSubtitlesApi import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.simklApi import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.subDlApi @@ -462,6 +463,7 @@ class SettingsAccount : BasePreferenceFragmentCompat(), BiometricCallback { val syncApis = listOf( R.string.mal_key to SyncRepo(malApi), + R.string.kitsu_key to SyncRepo(kitsuApi), R.string.anilist_key to SyncRepo(aniListApi), R.string.simkl_key to SyncRepo(simklApi), R.string.opensubtitles_key to SubtitleRepo(openSubtitlesApi), diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/BackupUtils.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/BackupUtils.kt index 90305182e..96171aa90 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/BackupUtils.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/BackupUtils.kt @@ -20,6 +20,7 @@ import com.lagradost.cloudstream3.plugins.PLUGINS_KEY_LOCAL import com.lagradost.cloudstream3.syncproviders.AccountManager import com.lagradost.cloudstream3.syncproviders.providers.AniListApi.Companion.ANILIST_CACHED_LIST import com.lagradost.cloudstream3.syncproviders.providers.MALApi.Companion.MAL_CACHED_LIST +import com.lagradost.cloudstream3.syncproviders.providers.KitsuApi.Companion.KITSU_CACHED_LIST import com.lagradost.cloudstream3.utils.Coroutines.ioSafe import com.lagradost.cloudstream3.utils.Coroutines.main import com.lagradost.cloudstream3.utils.DataStore.getDefaultSharedPrefs @@ -49,6 +50,7 @@ object BackupUtils { private val nonTransferableKeys = listOf( ANILIST_CACHED_LIST, MAL_CACHED_LIST, + KITSU_CACHED_LIST, // The plugins themselves are not backed up PLUGINS_KEY, diff --git a/app/src/main/res/values/donottranslate-strings.xml b/app/src/main/res/values/donottranslate-strings.xml index 5f2186fae..696843553 100644 --- a/app/src/main/res/values/donottranslate-strings.xml +++ b/app/src/main/res/values/donottranslate-strings.xml @@ -94,6 +94,7 @@ anilist_key simkl_key mal_key + kitsu_key opensubtitles_key subdl_key diff --git a/app/src/main/res/xml/settings_account.xml b/app/src/main/res/xml/settings_account.xml index bbef5f05b..3b8ce2294 100644 --- a/app/src/main/res/xml/settings_account.xml +++ b/app/src/main/res/xml/settings_account.xml @@ -9,6 +9,10 @@ android:icon="@drawable/mal_logo" android:key="@string/mal_key" /> + + diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/MainAPI.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/MainAPI.kt index 2f196af3e..d8d666a5d 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/MainAPI.kt +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/MainAPI.kt @@ -232,6 +232,7 @@ object APIHolder { Tracker( res.idMal, + null, res.id.toString(), res.coverImage?.extraLarge ?: res.coverImage?.large, res.bannerImage @@ -1797,6 +1798,8 @@ interface LoadResponse { companion object { var malIdPrefix = "" //malApi.idPrefix + + var kitsuIdPrefix = "" //kitsuApi.idPrefix var aniListIdPrefix = "" //aniListApi.idPrefix var simklIdPrefix = "" //simklApi.idPrefix var isTrailersEnabled = true @@ -1857,6 +1860,9 @@ interface LoadResponse { return this.syncData[malIdPrefix] } + fun LoadResponse.getKitsuId(): String? { + return this.syncData[kitsuIdPrefix] + } fun LoadResponse.getAniListId(): String? { return this.syncData[aniListIdPrefix] } @@ -1878,6 +1884,11 @@ interface LoadResponse { this.addSimklId(SimklSyncServices.Mal, id.toString()) } + @Prerelease + fun LoadResponse.addKitsuId(id: Int?) { + this.syncData[kitsuIdPrefix] = (id ?: return).toString() + } + fun LoadResponse.addAniListId(id: Int?) { this.syncData[aniListIdPrefix] = (id ?: return).toString() this.addSimklId(SimklSyncServices.AniList, id.toString()) @@ -2662,6 +2673,7 @@ fun String?.toRatingInt(): Int? = data class Tracker( val malId: Int? = null, + val kitsuId: String? = null, val aniId: String? = null, val image: String? = null, val cover: String? = null, diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/syncproviders/SyncAPI.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/syncproviders/SyncAPI.kt index 676ac6fef..7ec9905b5 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/syncproviders/SyncAPI.kt +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/syncproviders/SyncAPI.kt @@ -3,6 +3,7 @@ package com.lagradost.cloudstream3.syncproviders enum class SyncIdName { Anilist, MyAnimeList, + Kitsu, Trakt, Imdb, Simkl, From fda9f0f8c031d048029913e6086660c6d455f7d2 Mon Sep 17 00:00:00 2001 From: Yashas <153766011+y-hbb@users.noreply.github.com> Date: Sat, 24 Jan 2026 09:15:21 -0500 Subject: [PATCH 004/236] feat: Add random play button to TV interface (#2430) --- .../cloudstream3/ui/home/HomeFragment.kt | 28 +++++------ .../ui/library/LibraryFragment.kt | 47 +++++++++++-------- .../cloudstream3/ui/settings/SettingsUI.kt | 2 - app/src/main/res/layout/fragment_home.xml | 7 +++ app/src/main/res/layout/fragment_home_tv.xml | 18 +++++++ app/src/main/res/layout/fragment_library.xml | 7 +++ .../main/res/layout/fragment_library_tv.xml | 20 +++++++- 7 files changed, 92 insertions(+), 37 deletions(-) diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeFragment.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeFragment.kt index 6c58fac9a..49c6d0d77 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeFragment.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeFragment.kt @@ -85,7 +85,6 @@ class HomeFragment : BaseFragment( // Used for configuration changed events to fix any popups that are not attached to a fragment val configEvent = EmptyEvent() var currentSpan = 1 - val listHomepageItems = mutableListOf() private val errorProfilePics = listOf( R.drawable.monke_benene, @@ -642,11 +641,6 @@ class HomeFragment : BaseFragment( activity?.showAccountSelectLinear() } - homeRandom.setOnClickListener { - if (listHomepageItems.isNotEmpty()) { - activity.loadSearchResult(listHomepageItems.random()) - } - } homeMasterAdapter = HomeParentItemAdapterPreview( fragment = this@HomeFragment, homeViewModel, accountViewModel @@ -725,8 +719,9 @@ class HomeFragment : BaseFragment( settingsManager.getBoolean( getString(R.string.random_button_key), false - ) && isLayout(PHONE) + ) binding.homeRandom.visibility = View.GONE + binding.homeRandomButtonTv.visibility = View.GONE } observe(homeViewModel.apiName) { apiName -> @@ -752,23 +747,28 @@ class HomeFragment : BaseFragment( saveHomepageToTV(d) - listHomepageItems.clear() homeLoading.isVisible = false homeLoadingError.isVisible = false homeMasterRecycler.isVisible = true homeLoadingShimmer.stopShimmer() //home_loaded?.isVisible = true if (toggleRandomButton) { - //Flatten list - val mutableListOfResponse = mutableListOf() - d.values.forEach { dlist -> - mutableListOfResponse.addAll(dlist.list.list) + val distinct = d.values + .flatMap { it.list.list } + .distinctBy { it.url } + val hasItems = distinct.isNotEmpty() + val isPhone = isLayout(PHONE) + val randomClickListener = View.OnClickListener { + distinct.randomOrNull()?.let { activity.loadSearchResult(it) } } - listHomepageItems.addAll(mutableListOfResponse.distinctBy { it.url }) - homeRandom.isVisible = listHomepageItems.isNotEmpty() + homeRandom.isVisible = isPhone && hasItems + homeRandom.setOnClickListener(randomClickListener) + homeRandomButtonTv.isVisible = !isPhone && hasItems + homeRandomButtonTv.setOnClickListener(randomClickListener) } else { homeRandom.isGone = true + homeRandomButtonTv.isGone = true } } diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/library/LibraryFragment.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/library/LibraryFragment.kt index c9be2ed5c..6e28c128d 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/library/LibraryFragment.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/library/LibraryFragment.kt @@ -80,8 +80,6 @@ class LibraryFragment : BaseFragment( BaseFragment.BindingCreator.Bind(FragmentLibraryBinding::bind) ) { companion object { - - val listLibraryItems = mutableListOf() fun newInstance() = LibraryFragment() /** @@ -104,14 +102,19 @@ class LibraryFragment : BaseFragment( super.onSaveInstanceState(outState) } - private fun updateRandom(binding: FragmentLibraryBinding) { + private fun updateRandomVisibility(binding: FragmentLibraryBinding) { + if (!toggleRandomButton) { + binding.libraryRandom.isGone = true + binding.libraryRandomButtonTv.isGone = true + return + } val position = libraryViewModel.currentPage.value ?: 0 val pages = (libraryViewModel.pages.value as? Resource.Success)?.value ?: return - if (toggleRandomButton) { - listLibraryItems.clear() - listLibraryItems.addAll(pages[position].items) - binding.libraryRandom.isVisible = listLibraryItems.isNotEmpty() - } else binding.libraryRandom.isGone = true + val hasItems = pages[position].items.isNotEmpty() + val isPhone = isLayout(PHONE) + + binding.libraryRandom.isVisible = isPhone && hasItems + binding.libraryRandomButtonTv.isVisible = !isPhone && hasItems } override fun fixLayout(view: View) { @@ -194,17 +197,9 @@ class LibraryFragment : BaseFragment( settingsManager.getBoolean( getString(R.string.random_button_key), false - ) && isLayout(PHONE) + ) binding.libraryRandom.visibility = View.GONE - } - - binding.libraryRandom.setOnClickListener { - if (listLibraryItems.isNotEmpty()) { - val listLibraryItem = listLibraryItems.random() - libraryViewModel.currentSyncApi?.syncIdName?.let { - loadLibraryItem(it, listLibraryItem.syncId, listLibraryItem) - } - } + binding.libraryRandomButtonTv.visibility = View.GONE } /** @@ -387,7 +382,19 @@ class LibraryFragment : BaseFragment( binding.searchBar.setExpanded(true) } - updateRandom(binding) + // Set up random button click listener + if (toggleRandomButton) { + val randomClickListener = View.OnClickListener { + val position = libraryViewModel.currentPage.value ?: 0 + val syncIdName = libraryViewModel.currentSyncApi?.syncIdName ?: return@OnClickListener + pages[position].items.randomOrNull()?.let { item -> + loadLibraryItem(syncIdName, item.syncId, item) + } + } + libraryRandom.setOnClickListener(randomClickListener) + libraryRandomButtonTv.setOnClickListener(randomClickListener) + } + updateRandomVisibility(binding) // Only stop loading after 300ms to hide the fade effect the viewpager produces when updating // Without this there would be a flashing effect: @@ -466,7 +473,7 @@ class LibraryFragment : BaseFragment( } observe(libraryViewModel.currentPage) { position -> - updateRandom(binding) + updateRandomVisibility(binding) val all = binding.viewpager.allViews.toList() .filterIsInstance() diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsUI.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsUI.kt index 33add0e95..9e61a0b40 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsUI.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsUI.kt @@ -44,8 +44,6 @@ class SettingsUI : BasePreferenceFragmentCompat() { setPreferencesFromResource(R.xml.settings_ui, rootKey) val settingsManager = PreferenceManager.getDefaultSharedPreferences(requireContext()) - getPref(R.string.random_button_key)?.hideOn(EMULATOR or TV) - (getPref(R.string.overscan_key)?.hideOn(PHONE or EMULATOR) as? SeekBarPreference)?.setOnPreferenceChangeListener { pref, newValue -> val padding = (newValue as? Int)?.toPx ?: return@setOnPreferenceChangeListener true (pref.context.getActivity() as? MainActivity)?.binding?.homeRoot?.setPadding(padding, padding, padding, padding) diff --git a/app/src/main/res/layout/fragment_home.xml b/app/src/main/res/layout/fragment_home.xml index 77a41b2e2..99a764dee 100644 --- a/app/src/main/res/layout/fragment_home.xml +++ b/app/src/main/res/layout/fragment_home.xml @@ -247,4 +247,11 @@ app:icon="@drawable/ic_baseline_play_arrow_24" tools:ignore="ContentDescription" tools:visibility="visible" /> + + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_home_tv.xml b/app/src/main/res/layout/fragment_home_tv.xml index add54cf3c..d1d5c9e3b 100644 --- a/app/src/main/res/layout/fragment_home_tv.xml +++ b/app/src/main/res/layout/fragment_home_tv.xml @@ -224,6 +224,24 @@ android:tag="@string/tv_no_focus_tag" app:tint="@color/player_on_button_tv_attr" /> + + + + + @@ -108,6 +109,23 @@ + + Date: Sat, 24 Jan 2026 20:06:54 +0100 Subject: [PATCH 005/236] Fixed #2448 and hdr by removing brightness filter --- .../cloudstream3/ui/player/FullScreenPlayer.kt | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/FullScreenPlayer.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/FullScreenPlayer.kt index 3cd053244..ceb2a9d7e 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/FullScreenPlayer.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/FullScreenPlayer.kt @@ -210,7 +210,8 @@ open class FullScreenPlayer : AbstractPlayerFragment() { playerBinding = PlayerCustomLayoutBinding.bind(root.findViewById(R.id.player_holder)) // Create GPUPlayerView dynamically and attach it to the PlayerView's content frame - safe { + // !!! Removed due to HDR conflict !!! + /*safe { val pv = root.findViewById(R.id.player_view) val packageName = context?.packageName ?: return@safe val contentId = resources.getIdentifier("exo_content_frame", "id", packageName) @@ -225,7 +226,7 @@ open class FullScreenPlayer : AbstractPlayerFragment() { contentFrame.addView(gpu, 0, lp) gpuPlayerView = gpu } - } + }*/ return root } @@ -1463,12 +1464,12 @@ open class FullScreenPlayer : AbstractPlayerFragment() { } val lastRequested = currentRequestedBrightness - val nextBrightness = currentRequestedBrightness + verticalAddition + val nextBrightness = (currentRequestedBrightness + verticalAddition).coerceIn(0.0f, 1.0f) // !!! Removed due to HDR conflict !!! // // Log.e("Brightness", "Current: $currentRequestedBrightness, Next: $nextBrightness") // show toast if (nextBrightness > 1.0f && isBrightnessLocked && !hasShownBrightnessToast) { - showToast(R.string.slide_up_again_to_exceed_100) + //showToast(R.string.slide_up_again_to_exceed_100) hasShownBrightnessToast = true } currentRequestedBrightness = nextBrightness @@ -1478,14 +1479,15 @@ open class FullScreenPlayer : AbstractPlayerFragment() { setBrightness(currentRequestedBrightness) val level1ProgressBar = playerProgressbarRightLevel1 - val level2ProgressBar = playerProgressbarRightLevel2 + //val level2ProgressBar = playerProgressbarRightLevel2 // max is set high to make it smooth level1ProgressBar.max = 100_000 level1ProgressBar.progress = max(2_000, (min(1.0f, currentRequestedBrightness) * 100_000f).toInt()) - if (!isBrightnessLocked) { + // !!! Removed due to HDR conflict !!! + /*if (!isBrightnessLocked) { currentExtraBrightness = if (currentRequestedBrightness > 1.0f) min(2.0f, currentRequestedBrightness) - 1.0f else 0.0f level2ProgressBar.max = 100_000 level2ProgressBar.progress = @@ -1539,7 +1541,7 @@ open class FullScreenPlayer : AbstractPlayerFragment() { ) ) } - } + }*/ // Log.i("Brightness", "current: $currentRequestedBrightness, ce: $currentExtraBrightness L1: ${level1ProgressBar.progress}, L2: ${level2ProgressBar.progress}") playerProgressbarRightIcon.setImageResource( From f6f3e3ff73dc28cbb436cb4da317ab4c04554fa7 Mon Sep 17 00:00:00 2001 From: firelight <147925818+fire-light42@users.noreply.github.com> Date: Sun, 25 Jan 2026 19:53:39 +0000 Subject: [PATCH 006/236] Fix: Added backwards for subtitle+audio interceptor, Closes #2442 --- .../cloudstream3/ui/player/CS3IPlayer.kt | 54 +++++++++++++------ 1 file changed, 37 insertions(+), 17 deletions(-) diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/CS3IPlayer.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/CS3IPlayer.kt index 88c4889b2..f6df94b1b 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/CS3IPlayer.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/CS3IPlayer.kt @@ -741,13 +741,23 @@ class CS3IPlayer : IPlayer { private var simpleCache: SimpleCache? = null /// Create a small factory for small things, no cache, no cronet - private fun createOnlineSource(headers: Map?): HttpDataSource.Factory { - val source = OkHttpDataSource.Factory(app.baseClient).setUserAgent(USER_AGENT) - return source.apply { - if (!headers.isNullOrEmpty()) { - setDefaultRequestProperties(headers) - } + private fun createOnlineSource( + headers: Map?, + interceptor: Interceptor? + ): HttpDataSource.Factory { + val client = if (interceptor == null) { + app.baseClient + } else { + app.baseClient.newBuilder() + .addInterceptor(interceptor) + .build() } + val source = OkHttpDataSource.Factory(client).setUserAgent(USER_AGENT) + + if (!headers.isNullOrEmpty()) { + source.setDefaultRequestProperties(headers) + } + return source } fun tryCreateEngine(context: Context, diskCacheSize: Long): CronetEngine? { @@ -786,10 +796,9 @@ class CS3IPlayer : IPlayer { private fun createVideoSource( link: ExtractorLink, - engine: CronetEngine? + engine: CronetEngine?, + interceptor: Interceptor?, ): HttpDataSource.Factory { - val provider = getApiFromNameNull(link.source) - val interceptor: Interceptor? = provider?.getVideoInterceptor(link) val userAgent = link.headers.entries.find { it.key.equals("User-Agent", ignoreCase = true) }?.value ?: USER_AGENT @@ -1639,7 +1648,8 @@ class CS3IPlayer : IPlayer { val (subSources, activeSubtitles) = getSubSources( offlineSourceFactory = offlineSourceFactory, - subtitleHelper, + subHelper = subtitleHelper, + interceptor = null, ) subtitleHelper.setActiveSubtitles(activeSubtitles.toSet()) @@ -1653,6 +1663,7 @@ class CS3IPlayer : IPlayer { private fun getSubSources( offlineSourceFactory: DataSource.Factory?, subHelper: PlayerSubtitleHelper, + interceptor: Interceptor?, ): Pair, List> { val activeSubtitles = ArrayList() val subSources = subHelper.getAllSubtitles().mapNotNull { sub -> @@ -1674,8 +1685,9 @@ class CS3IPlayer : IPlayer { } SubtitleOrigin.URL -> { + val dataSourceFactory = createOnlineSource(sub.headers, interceptor) activeSubtitles.add(sub) - SingleSampleMediaSource.Factory(createOnlineSource(sub.headers)) + SingleSampleMediaSource.Factory(dataSourceFactory) .createMediaSource(subConfig, TIME_UNSET) } } @@ -1690,14 +1702,13 @@ class CS3IPlayer : IPlayer { */ private fun getAudioSources( audioTracks: List, + interceptor: Interceptor?, ): List { - if (audioTracks.isEmpty()) return emptyList() return audioTracks.mapNotNull { audio -> try { val mediaItem = getMediaItem(MimeTypes.AUDIO_UNKNOWN, audio.url) - DefaultMediaSourceFactory(createOnlineSource(audio.headers)).createMediaSource( - mediaItem - ) + val dataSourceFactory = createOnlineSource(audio.headers, interceptor) + DefaultMediaSourceFactory(dataSourceFactory).createMediaSource(mediaItem) } catch (e: Exception) { Log.e(TAG, "Failed to create audio source for ${audio.url}: ${e.message}") null @@ -1869,19 +1880,28 @@ class CS3IPlayer : IPlayer { ) } + val provider = getApiFromNameNull(link.source) + val interceptor: Interceptor? = provider?.getVideoInterceptor(link) + val onlineSourceFactory = - createVideoSource(link, tryCreateEngine(context, simpleCacheSize)) + createVideoSource( + link = link, + engine = tryCreateEngine(context, simpleCacheSize), + interceptor = interceptor + ) val offlineSourceFactory = context.createOfflineSource() val (subSources, activeSubtitles) = getSubSources( offlineSourceFactory = offlineSourceFactory, - subtitleHelper + subHelper = subtitleHelper, + interceptor = interceptor, // Backwards compatibility, needs a new api to work properly ) // Create audio sources from ExtractorLink's audioTracks val audioSources = getAudioSources( audioTracks = link.audioTracks, + interceptor = interceptor, // Backwards compatibility, needs a new api to work properly ) subtitleHelper.setActiveSubtitles(activeSubtitles.toSet()) From 0c25630f0bc53109697117d52fa8b999f793a3b4 Mon Sep 17 00:00:00 2001 From: DieGon7771 Date: Sun, 25 Jan 2026 21:00:04 +0100 Subject: [PATCH 007/236] Update VotingApi.kt (#2451) --- .../cloudstream3/plugins/VotingApi.kt | 84 ++++++++----------- 1 file changed, 37 insertions(+), 47 deletions(-) diff --git a/app/src/main/java/com/lagradost/cloudstream3/plugins/VotingApi.kt b/app/src/main/java/com/lagradost/cloudstream3/plugins/VotingApi.kt index 930106644..85a806f0b 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/plugins/VotingApi.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/plugins/VotingApi.kt @@ -12,87 +12,76 @@ import com.lagradost.cloudstream3.utils.Coroutines.main import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock -object VotingApi { // please do not cheat the votes lol +object VotingApi { + private const val LOGKEY = "VotingApi" + private const val API_DOMAIN = "https://api.countify.xyz" - private const val API_DOMAIN = "https://counterapi.com/api" - - private fun transformUrl(url: String): String = // dont touch or all votes get reset + private fun transformUrl(url: String): String = MessageDigest .getInstance("SHA-256") .digest("${url}#funny-salt".toByteArray()) .fold("") { str, it -> str + "%02x".format(it) } - suspend fun SitePlugin.getVotes(): Int { - return getVotes(url) - } + suspend fun SitePlugin.getVotes(): Int = getVotes(url) + fun SitePlugin.hasVoted(): Boolean = hasVoted(url) + suspend fun SitePlugin.vote(): Int = vote(url) + fun SitePlugin.canVote(): Boolean = canVote(this.url) - fun SitePlugin.hasVoted(): Boolean { - return hasVoted(url) - } - - suspend fun SitePlugin.vote(): Int { - return vote(url) - } - - fun SitePlugin.canVote(): Boolean { - return canVote(this.url) - } - - // Plugin url to Int private val votesCache = mutableMapOf() - private fun getRepository(pluginUrl: String) = pluginUrl - .split("/") - .drop(2) - .take(3) - .joinToString("-") - private suspend fun readVote(pluginUrl: String): Int { - val url = "${API_DOMAIN}/cs-${getRepository(pluginUrl)}/vote/${transformUrl(pluginUrl)}?readOnly=true" - Log.d(LOGKEY, "Requesting: $url") - return app.get(url).parsedSafe()?.value ?: 0 + val id = transformUrl(pluginUrl) + val url = "$API_DOMAIN/get-total/$id" + Log.d(LOGKEY, "Requesting GET: $url") + return app.get(url).parsedSafe()?.count ?: 0 } private suspend fun writeVote(pluginUrl: String): Boolean { - val url = "${API_DOMAIN}/cs-${getRepository(pluginUrl)}/vote/${transformUrl(pluginUrl)}" - Log.d(LOGKEY, "Requesting: $url") - return app.get(url).parsedSafe()?.value != null + val id = transformUrl(pluginUrl) + val url = "$API_DOMAIN/increment/$id" + Log.d(LOGKEY, "Requesting POST: $url") + return app.post(url, emptyMap()) + .parsedSafe()?.count != null } suspend fun getVotes(pluginUrl: String): Int = - votesCache[pluginUrl] ?: readVote(pluginUrl).also { - votesCache[pluginUrl] = it - } + votesCache[pluginUrl] ?: readVote(pluginUrl).also { + votesCache[pluginUrl] = it + } fun hasVoted(pluginUrl: String) = getKey("cs3-votes/${transformUrl(pluginUrl)}") ?: false - fun canVote(pluginUrl: String): Boolean { - return PluginManager.urlPlugins.contains(pluginUrl) - } + fun canVote(pluginUrl: String): Boolean = + PluginManager.urlPlugins.contains(pluginUrl) private val voteLock = Mutex() + suspend fun vote(pluginUrl: String): Int { - // Prevent multiple requests at the same time. voteLock.withLock { if (!canVote(pluginUrl)) { main { - Toast.makeText(context, R.string.extension_install_first, Toast.LENGTH_SHORT) - .show() + Toast.makeText( + context, + R.string.extension_install_first, + Toast.LENGTH_SHORT + ).show() } return getVotes(pluginUrl) } if (hasVoted(pluginUrl)) { main { - Toast.makeText(context, R.string.already_voted, Toast.LENGTH_SHORT) - .show() + Toast.makeText( + context, + R.string.already_voted, + Toast.LENGTH_SHORT + ).show() } return getVotes(pluginUrl) } - if (writeVote(pluginUrl)) { setKey("cs3-votes/${transformUrl(pluginUrl)}", true) votesCache[pluginUrl] = votesCache[pluginUrl]?.plus(1) ?: 1 @@ -102,7 +91,8 @@ object VotingApi { // please do not cheat the votes lol } } - private data class Result( - val value: Int? + private data class CountifyResult( + val id: String? = null, + val count: Int? = null ) -} \ No newline at end of file +} From 6f1e4a959f52158aaede54474ba78fa438a3e0f0 Mon Sep 17 00:00:00 2001 From: Bnyro Date: Mon, 26 Jan 2026 19:49:37 +0100 Subject: [PATCH 008/236] feat(extractors): add streamix extractor (streamup mirror) (#2455) --- .../kotlin/com/lagradost/cloudstream3/extractors/Streamup.kt | 5 +++++ .../kotlin/com/lagradost/cloudstream3/utils/ExtractorApi.kt | 2 ++ 2 files changed, 7 insertions(+) diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Streamup.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Streamup.kt index e9898c48e..b043186ed 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Streamup.kt +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Streamup.kt @@ -8,6 +8,11 @@ import com.lagradost.cloudstream3.utils.ExtractorLink import com.lagradost.cloudstream3.utils.ExtractorLinkType import com.lagradost.cloudstream3.utils.newExtractorLink +class Streamix(): Streamup() { + override val name: String = "Streamix" + override val mainUrl = "https://streamix.so" +} + open class Streamup() : ExtractorApi() { override val name: String = "Streamup" override val mainUrl: String = "https://strmup.to" diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/ExtractorApi.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/ExtractorApi.kt index 9d4cdb453..bc9eb6df7 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/ExtractorApi.kt +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/ExtractorApi.kt @@ -227,6 +227,7 @@ import com.lagradost.cloudstream3.extractors.StreamWishExtractor import com.lagradost.cloudstream3.extractors.StreamhideCom import com.lagradost.cloudstream3.extractors.StreamhideTo import com.lagradost.cloudstream3.extractors.Streamhub2 +import com.lagradost.cloudstream3.extractors.Streamix import com.lagradost.cloudstream3.extractors.Streamlare import com.lagradost.cloudstream3.extractors.StreamoUpload import com.lagradost.cloudstream3.extractors.Streamplay @@ -1136,6 +1137,7 @@ val extractorApis: MutableList = arrayListOf( Jeniusplay(), StreamoUpload(), Streamup(), + Streamix(), GamoVideo(), Gdriveplayerapi(), From 4b28140f8b6925cc6f9a2d6d55d541165519fcf8 Mon Sep 17 00:00:00 2001 From: Nivin <89772187+NivinCNC@users.noreply.github.com> Date: Tue, 27 Jan 2026 00:29:31 +0530 Subject: [PATCH 009/236] Add search suggestions to search UI (#2294) --- .../cloudstream3/ui/search/SearchFragment.kt | 147 +++++++++++++----- .../ui/search/SearchHistoryAdaptor.kt | 41 ++++- .../ui/search/SearchSuggestionAdapter.kt | 85 ++++++++++ .../ui/search/SearchSuggestionApi.kt | 60 +++++++ .../cloudstream3/ui/search/SearchViewModel.kt | 35 +++++ .../drawable/ic_baseline_north_west_24.xml | 9 ++ app/src/main/res/layout/fragment_search.xml | 62 ++++---- .../main/res/layout/fragment_search_tv.xml | 67 ++++---- .../main/res/layout/search_history_footer.xml | 14 ++ .../res/layout/search_suggestion_footer.xml | 14 ++ .../res/layout/search_suggestion_item.xml | 43 +++++ app/src/main/res/values/strings.xml | 3 + app/src/main/res/xml/settings_ui.xml | 6 + 13 files changed, 478 insertions(+), 108 deletions(-) create mode 100644 app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchSuggestionAdapter.kt create mode 100644 app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchSuggestionApi.kt create mode 100644 app/src/main/res/drawable/ic_baseline_north_west_24.xml create mode 100644 app/src/main/res/layout/search_history_footer.xml create mode 100644 app/src/main/res/layout/search_suggestion_footer.xml create mode 100644 app/src/main/res/layout/search_suggestion_item.xml diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchFragment.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchFragment.kt index ae31d03fb..c24e81882 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchFragment.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchFragment.kt @@ -21,6 +21,7 @@ import androidx.core.view.isVisible import androidx.fragment.app.activityViewModels import androidx.preference.PreferenceManager import androidx.recyclerview.widget.GridLayoutManager +import androidx.recyclerview.widget.LinearLayoutManager import androidx.activity.result.contract.ActivityResultContracts import com.google.android.material.bottomsheet.BottomSheetBehavior import com.google.android.material.bottomsheet.BottomSheetDialog @@ -56,6 +57,7 @@ import com.lagradost.cloudstream3.ui.home.ParentItemAdapter import com.lagradost.cloudstream3.ui.result.FOCUS_SELF import com.lagradost.cloudstream3.ui.result.setLinearListLayout 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.isLandscape import com.lagradost.cloudstream3.ui.settings.Globals.isLayout @@ -71,6 +73,8 @@ import com.lagradost.cloudstream3.utils.Coroutines.main import com.lagradost.cloudstream3.utils.DataStoreHelper import com.lagradost.cloudstream3.utils.DataStoreHelper.currentAccount import com.lagradost.cloudstream3.utils.SubtitleHelper +import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.attachBackPressedCallback +import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.detachBackPressedCallback import com.lagradost.cloudstream3.utils.UIHelper.dismissSafe import com.lagradost.cloudstream3.utils.UIHelper.fixSystemBarsPadding import com.lagradost.cloudstream3.utils.UIHelper.getSpanCount @@ -137,6 +141,7 @@ class SearchFragment : BaseFragment( override fun onDestroyView() { hideKeyboard() bottomSheetDialog?.ownHide() + activity?.detachBackPressedCallback("SearchFragment") super.onDestroyView() } @@ -400,17 +405,29 @@ class SearchFragment : BaseFragment( val settingsManager = context?.let { PreferenceManager.getDefaultSharedPreferences(it) } val isAdvancedSearch = settingsManager?.getBoolean("advanced_search", true) ?: true + val isSearchSuggestionsEnabled = settingsManager?.getBoolean("search_suggestions_enabled", true) ?: true selectedSearchTypes = DataStoreHelper.searchPreferenceTags.toMutableList() - if (isLayout(TV)) { + if (!isLayout(PHONE)) { binding.searchFilter.isFocusable = true binding.searchFilter.isFocusableInTouchMode = true } + + // Hide suggestions when search view loses focus (phone only) + if (isLayout(PHONE)) { + binding.mainSearch.setOnQueryTextFocusChangeListener { _, hasFocus -> + if (!hasFocus) { + searchViewModel.clearSuggestions() + } + } + } + binding.mainSearch.setOnQueryTextListener(object : SearchView.OnQueryTextListener { override fun onQueryTextSubmit(query: String): Boolean { search(query) + searchViewModel.clearSuggestions() binding.mainSearch.let { hideKeyboard(it) @@ -425,51 +442,25 @@ class SearchFragment : BaseFragment( if (showHistory) { searchViewModel.clearSearch() searchViewModel.updateHistory() + searchViewModel.clearSuggestions() + } else { + // Fetch suggestions when user is typing (if enabled) + if (isSearchSuggestionsEnabled) { + searchViewModel.fetchSuggestions(newText) + } } binding.apply { - searchHistoryHolder.isVisible = showHistory + searchHistoryRecycler.isVisible = showHistory searchMasterRecycler.isVisible = !showHistory && isAdvancedSearch searchAutofitResults.isVisible = !showHistory && !isAdvancedSearch + // Hide suggestions when showing history or showing search results + searchSuggestionsRecycler.isVisible = !showHistory && isSearchSuggestionsEnabled } return true } }) - binding.searchClearCallHistory.setOnClickListener { - activity?.let { ctx -> - val builder: AlertDialog.Builder = AlertDialog.Builder(ctx) - val dialogClickListener = - DialogInterface.OnClickListener { _, which -> - when (which) { - DialogInterface.BUTTON_POSITIVE -> { - removeKeys("$currentAccount/$SEARCH_HISTORY_KEY") - searchViewModel.updateHistory() - } - - DialogInterface.BUTTON_NEGATIVE -> { - } - } - } - - try { - builder.setTitle(R.string.clear_history).setMessage( - ctx.getString(R.string.delete_message).format( - ctx.getString(R.string.history) - ) - ) - .setPositiveButton(R.string.sort_clear, dialogClickListener) - .setNegativeButton(R.string.cancel, dialogClickListener) - .show().setDefaultFocus() - } catch (e: Exception) { - logError(e) - // ye you somehow fucked up formatting did you? - } - } - - - } - observe(searchViewModel.searchResponse) { when (it) { is Resource.Success -> { @@ -559,6 +550,7 @@ class SearchFragment : BaseFragment( val searchItem = click.item when (click.clickAction) { SEARCH_HISTORY_OPEN -> { + if (searchItem == null) return@SearchHistoryAdaptor searchViewModel.clearSearch() if (searchItem.type.isNotEmpty()) updateChips( @@ -569,9 +561,42 @@ class SearchFragment : BaseFragment( } SEARCH_HISTORY_REMOVE -> { + if (searchItem == null) return@SearchHistoryAdaptor removeKey("$currentAccount/$SEARCH_HISTORY_KEY", searchItem.key) searchViewModel.updateHistory() } + + SEARCH_HISTORY_CLEAR -> { + // Show confirmation dialog (from footer button) + activity?.let { ctx -> + val builder: AlertDialog.Builder = AlertDialog.Builder(ctx) + val dialogClickListener = + DialogInterface.OnClickListener { _, which -> + when (which) { + DialogInterface.BUTTON_POSITIVE -> { + removeKeys("$currentAccount/$SEARCH_HISTORY_KEY") + searchViewModel.updateHistory() + } + + DialogInterface.BUTTON_NEGATIVE -> { + } + } + } + + try { + builder.setTitle(R.string.clear_history).setMessage( + ctx.getString(R.string.delete_message).format( + ctx.getString(R.string.history) + ) + ) + .setPositiveButton(R.string.sort_clear, dialogClickListener) + .setNegativeButton(R.string.cancel, dialogClickListener) + .show().setDefaultFocus() + } catch (e: Exception) { + logError(e) + } + } + } else -> { // wth are you doing??? @@ -579,11 +604,33 @@ class SearchFragment : BaseFragment( } } + val suggestionAdapter = SearchSuggestionAdapter { callback -> + when (callback.clickAction) { + SEARCH_SUGGESTION_CLICK -> { + // Search directly + binding.mainSearch.setQuery(callback.suggestion, true) + searchViewModel.clearSuggestions() + } + SEARCH_SUGGESTION_FILL -> { + // Fill the search box without searching + binding.mainSearch.setQuery(callback.suggestion, false) + } + SEARCH_SUGGESTION_CLEAR -> { + // Clear suggestions (from footer button) + searchViewModel.clearSuggestions() + } + } + } + binding.apply { searchHistoryRecycler.adapter = historyAdapter searchHistoryRecycler.setLinearListLayout(isHorizontal = false, nextRight = FOCUS_SELF) //searchHistoryRecycler.layoutManager = GridLayoutManager(context, 1) + // Setup suggestions RecyclerView + searchSuggestionsRecycler.adapter = suggestionAdapter + searchSuggestionsRecycler.layoutManager = LinearLayoutManager(context) + searchMasterRecycler.setRecycledViewPool(ParentItemAdapter.sharedPool) searchMasterRecycler.adapter = masterAdapter //searchMasterRecycler.setLinearListLayout(isHorizontal = false, nextRight = FOCUS_SELF) @@ -608,8 +655,34 @@ class SearchFragment : BaseFragment( } observe(searchViewModel.currentHistory) { list -> - binding.searchClearCallHistory.isVisible = list.isNotEmpty() (binding.searchHistoryRecycler.adapter as? SearchHistoryAdaptor?)?.submitList(list) + // Scroll to top to show newest items (list is sorted by newest first) + if (list.isNotEmpty()) { + binding.searchHistoryRecycler.scrollToPosition(0) + } + } + + // Observe search suggestions + observe(searchViewModel.searchSuggestions) { suggestions -> + val hasSuggestions = suggestions.isNotEmpty() + binding.searchSuggestionsRecycler.isVisible = hasSuggestions + (binding.searchSuggestionsRecycler.adapter as? SearchSuggestionAdapter?)?.submitList(suggestions) + + // On non-phone layouts, redirect focus and handle back button + if (!isLayout(PHONE)) { + if (hasSuggestions) { + binding.tvtypesChipsScroll.tvtypesChips.root.nextFocusDownId = R.id.search_suggestions_recycler + // Attach back button callback to clear suggestions + activity?.attachBackPressedCallback("SearchFragment") { + searchViewModel.clearSuggestions() + } + } else { + // Reset to default focus target (history) + binding.tvtypesChipsScroll.tvtypesChips.root.nextFocusDownId = R.id.search_history_recycler + // Detach back button callback when no suggestions + activity?.detachBackPressedCallback("SearchFragment") + } + } } searchViewModel.updateHistory() diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchHistoryAdaptor.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchHistoryAdaptor.kt index 2a95c76b2..4868abb3d 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchHistoryAdaptor.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchHistoryAdaptor.kt @@ -2,12 +2,17 @@ package com.lagradost.cloudstream3.ui.search import android.view.LayoutInflater import android.view.ViewGroup +import androidx.core.view.isGone import com.fasterxml.jackson.annotation.JsonProperty import com.lagradost.cloudstream3.TvType +import com.lagradost.cloudstream3.databinding.SearchHistoryFooterBinding import com.lagradost.cloudstream3.databinding.SearchHistoryItemBinding import com.lagradost.cloudstream3.ui.BaseDiffCallback import com.lagradost.cloudstream3.ui.NoStateAdapter import com.lagradost.cloudstream3.ui.ViewHolderState +import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR +import com.lagradost.cloudstream3.ui.settings.Globals.TV +import com.lagradost.cloudstream3.ui.settings.Globals.isLayout data class SearchHistoryItem( @JsonProperty("searchedAt") val searchedAt: Long, @@ -17,18 +22,31 @@ data class SearchHistoryItem( ) data class SearchHistoryCallback( - val item: SearchHistoryItem, + val item: SearchHistoryItem?, val clickAction: Int, ) const val SEARCH_HISTORY_OPEN = 0 const val SEARCH_HISTORY_REMOVE = 1 +const val SEARCH_HISTORY_CLEAR = 2 class SearchHistoryAdaptor( private val clickCallback: (SearchHistoryCallback) -> Unit, ) : NoStateAdapter(diffCallback = BaseDiffCallback(itemSame = { a,b -> a.searchedAt == b.searchedAt && a.searchText == b.searchText })) { + + // Add footer for all layouts + override val footers = 1 + + override fun submitList(list: Collection?, commitCallback: Runnable?) { + super.submitList(list, commitCallback) + // Notify footer to rebind when list changes to update visibility + if (footers > 0) { + notifyItemChanged(itemCount - 1) + } + } + override fun onCreateContent(parent: ViewGroup): ViewHolderState { return ViewHolderState( SearchHistoryItemBinding.inflate(LayoutInflater.from(parent.context), parent, false), @@ -52,4 +70,25 @@ class SearchHistoryAdaptor( } } } + + override fun onCreateFooter(parent: ViewGroup): ViewHolderState { + return ViewHolderState( + SearchHistoryFooterBinding.inflate(LayoutInflater.from(parent.context), parent, false) + ) + } + + override fun onBindFooter(holder: ViewHolderState) { + val binding = holder.view as? SearchHistoryFooterBinding ?: return + // Hide footer when list is empty + binding.searchClearCallHistory.apply { + isGone = immutableCurrentList.isEmpty() + if (isLayout(TV or EMULATOR)) { + isFocusable = true + isFocusableInTouchMode = true + } + setOnClickListener { + clickCallback.invoke(SearchHistoryCallback(null, SEARCH_HISTORY_CLEAR)) + } + } + } } diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchSuggestionAdapter.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchSuggestionAdapter.kt new file mode 100644 index 000000000..74d5e7b08 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchSuggestionAdapter.kt @@ -0,0 +1,85 @@ +package com.lagradost.cloudstream3.ui.search + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.core.view.isGone +import com.lagradost.cloudstream3.databinding.SearchSuggestionFooterBinding +import com.lagradost.cloudstream3.databinding.SearchSuggestionItemBinding +import com.lagradost.cloudstream3.ui.BaseDiffCallback +import com.lagradost.cloudstream3.ui.NoStateAdapter +import com.lagradost.cloudstream3.ui.ViewHolderState +import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR +import com.lagradost.cloudstream3.ui.settings.Globals.TV +import com.lagradost.cloudstream3.ui.settings.Globals.isLayout + +const val SEARCH_SUGGESTION_CLICK = 0 +const val SEARCH_SUGGESTION_FILL = 1 +const val SEARCH_SUGGESTION_CLEAR = 2 + +data class SearchSuggestionCallback( + val suggestion: String, + val clickAction: Int, +) + +class SearchSuggestionAdapter( + private val clickCallback: (SearchSuggestionCallback) -> Unit, +) : NoStateAdapter(diffCallback = BaseDiffCallback(itemSame = { a, b -> a == b })) { + + // Add footer for all layouts + override val footers = 1 + + override fun submitList(list: Collection?, commitCallback: Runnable?) { + super.submitList(list, commitCallback) + // Notify footer to rebind when list changes to update visibility + if (footers > 0) { + notifyItemChanged(itemCount - 1) + } + } + + override fun onCreateContent(parent: ViewGroup): ViewHolderState { + return ViewHolderState( + SearchSuggestionItemBinding.inflate(LayoutInflater.from(parent.context), parent, false), + ) + } + + override fun onBindContent( + holder: ViewHolderState, + item: String, + position: Int + ) { + val binding = holder.view as? SearchSuggestionItemBinding ?: return + binding.apply { + suggestionText.text = item + + // Click on the whole item to search + suggestionItem.setOnClickListener { + clickCallback.invoke(SearchSuggestionCallback(item, SEARCH_SUGGESTION_CLICK)) + } + + // Click on the arrow to fill the search box without searching + suggestionFill.setOnClickListener { + clickCallback.invoke(SearchSuggestionCallback(item, SEARCH_SUGGESTION_FILL)) + } + } + } + + override fun onCreateFooter(parent: ViewGroup): ViewHolderState { + return ViewHolderState( + SearchSuggestionFooterBinding.inflate(LayoutInflater.from(parent.context), parent, false) + ) + } + + override fun onBindFooter(holder: ViewHolderState) { + val binding = holder.view as? SearchSuggestionFooterBinding ?: return + binding.clearSuggestionsButton.apply { + isGone = immutableCurrentList.isEmpty() + if (isLayout(TV or EMULATOR)) { + isFocusable = true + isFocusableInTouchMode = true + } + setOnClickListener { + clickCallback.invoke(SearchSuggestionCallback("", SEARCH_SUGGESTION_CLEAR)) + } + } + } +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchSuggestionApi.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchSuggestionApi.kt new file mode 100644 index 000000000..ea2dfd30b --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchSuggestionApi.kt @@ -0,0 +1,60 @@ +package com.lagradost.cloudstream3.ui.search + +import com.lagradost.cloudstream3.app +import com.lagradost.cloudstream3.mvvm.logError +import com.lagradost.nicehttp.NiceResponse + +/** + * API for fetching search suggestions from external sources. + * Uses Google's suggestion API which provides movie/show related suggestions. + */ +object SearchSuggestionApi { + private const val GOOGLE_SUGGESTION_URL = "https://suggestqueries.google.com/complete/search" + + /** + * Fetches search suggestions from Google's autocomplete API. + * + * @param query The search query to get suggestions for + * @return List of suggestion strings, empty list on failure + */ + suspend fun getSuggestions(query: String): List { + if (query.isBlank() || query.length < 2) return emptyList() + + return try { + val response = app.get( + GOOGLE_SUGGESTION_URL, + params = mapOf( + "client" to "firefox", // Returns JSON format + "q" to query, + "hl" to "en" // Language hint + ), + cacheTime = 60 * 24 // Cache for 1 day (cacheUnit default is Minutes) + ) + + // Response format: ["query",["suggestion1","suggestion2",...]] + parseSuggestions(response) + } catch (e: Exception) { + logError(e) + emptyList() + } + } + + /** + * Parses the Google suggestion JSON response. + * Format: ["query",["suggestion1","suggestion2",...]] + */ + private fun parseSuggestions(response: NiceResponse): List { + return try { + val parsed = response.parsed>() + val suggestions = parsed.getOrNull(1) + when (suggestions) { + is List<*> -> suggestions.filterIsInstance().take(10) + is Array<*> -> suggestions.filterIsInstance().take(10) + else -> emptyList() + } + } catch (e: Exception) { + logError(e) + emptyList() + } + } +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchViewModel.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchViewModel.kt index 63fb8c10e..27db8d1ae 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchViewModel.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchViewModel.kt @@ -21,6 +21,7 @@ import com.lagradost.cloudstream3.utils.Coroutines.ioSafe import com.lagradost.cloudstream3.utils.DataStoreHelper.currentAccount import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job +import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -43,6 +44,11 @@ class SearchViewModel : ViewModel() { private val _currentHistory: MutableLiveData> = MutableLiveData() val currentHistory: LiveData> get() = _currentHistory + private val _searchSuggestions: MutableLiveData> = MutableLiveData() + val searchSuggestions: LiveData> get() = _searchSuggestions + + private var suggestionJob: Job? = null + private var repos = synchronized(apis) { apis.map { APIRepository(it) } } fun clearSearch() { @@ -83,6 +89,35 @@ class SearchViewModel : ViewModel() { _currentHistory.postValue(items) } + /** + * Fetches search suggestions with debouncing. + * Waits 300ms before making the API call to avoid too many requests. + * + * @param query The search query to get suggestions for + */ + fun fetchSuggestions(query: String) { + suggestionJob?.cancel() + + if (query.isBlank() || query.length < 2) { + _searchSuggestions.postValue(emptyList()) + return + } + + suggestionJob = ioSafe { + delay(300) // Debounce + val suggestions = SearchSuggestionApi.getSuggestions(query) + _searchSuggestions.postValue(suggestions) + } + } + + /** + * Clears the current search suggestions. + */ + fun clearSuggestions() { + suggestionJob?.cancel() + _searchSuggestions.postValue(emptyList()) + } + private val lock: MutableSet = mutableSetOf() // ExpandableHomepageList because the home adapter is reused in the search fragment diff --git a/app/src/main/res/drawable/ic_baseline_north_west_24.xml b/app/src/main/res/drawable/ic_baseline_north_west_24.xml new file mode 100644 index 000000000..c46eb4b0c --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_north_west_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/layout/fragment_search.xml b/app/src/main/res/layout/fragment_search.xml index 88dedca5f..408460d41 100644 --- a/app/src/main/res/layout/fragment_search.xml +++ b/app/src/main/res/layout/fragment_search.xml @@ -1,5 +1,5 @@ - + + - + android:layout_height="match_parent" + android:background="?attr/primaryBlackBackground" + android:descendantFocusability="afterDescendants" + android:nextFocusLeft="@id/nav_rail_view" + android:nextFocusUp="@id/tvtypes_chips" + android:visibility="visible" + tools:listitem="@layout/search_history_item" /> + + + - - - - - \ No newline at end of file + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_search_tv.xml b/app/src/main/res/layout/fragment_search_tv.xml index 4d45594cf..ed2f3b639 100644 --- a/app/src/main/res/layout/fragment_search_tv.xml +++ b/app/src/main/res/layout/fragment_search_tv.xml @@ -1,5 +1,5 @@ - + + - + android:layout_marginStart="@dimen/navbar_width" + android:background="?attr/primaryBlackBackground" + android:descendantFocusability="afterDescendants" + android:nextFocusLeft="@id/navigation_search" + android:nextFocusUp="@id/tvtypes_chips" + android:tag = "@string/tv_no_focus_tag" + android:visibility="visible" + tools:listitem="@layout/search_history_item" /> + - + - android:descendantFocusability="afterDescendants" - android:nextFocusLeft="@id/navigation_search" - android:nextFocusUp="@id/tvtypes_chips" - android:nextFocusDown="@id/search_clear_call_history" - android:tag = "@string/tv_no_focus_tag" - android:paddingBottom="50dp" - android:visibility="visible" - tools:listitem="@layout/search_history_item" /> - - - - \ No newline at end of file + \ No newline at end of file diff --git a/app/src/main/res/layout/search_history_footer.xml b/app/src/main/res/layout/search_history_footer.xml new file mode 100644 index 000000000..d8f0d933b --- /dev/null +++ b/app/src/main/res/layout/search_history_footer.xml @@ -0,0 +1,14 @@ + + diff --git a/app/src/main/res/layout/search_suggestion_footer.xml b/app/src/main/res/layout/search_suggestion_footer.xml new file mode 100644 index 000000000..929fd3b04 --- /dev/null +++ b/app/src/main/res/layout/search_suggestion_footer.xml @@ -0,0 +1,14 @@ + + diff --git a/app/src/main/res/layout/search_suggestion_item.xml b/app/src/main/res/layout/search_suggestion_item.xml new file mode 100644 index 000000000..d07f7b06d --- /dev/null +++ b/app/src/main/res/layout/search_suggestion_item.xml @@ -0,0 +1,43 @@ + + + + + + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 8ad0ec423..5958cea19 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -181,6 +181,9 @@ Info Advanced Search Gives you the search results separated by provider + Search Suggestions + Show search suggestions while typing + Clear Suggestions Show filler episode for anime Show trailers Show posters from Kitsu diff --git a/app/src/main/res/xml/settings_ui.xml b/app/src/main/res/xml/settings_ui.xml index 08c0fba72..ba438269b 100644 --- a/app/src/main/res/xml/settings_ui.xml +++ b/app/src/main/res/xml/settings_ui.xml @@ -58,6 +58,12 @@ android:title="@string/advanced_search" app:defaultValue="true" app:key="advanced_search" /> + Date: Mon, 26 Jan 2026 19:01:16 +0000 Subject: [PATCH 010/236] Bump to 4.6.2 --- app/build.gradle.kts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index e69720e15..8b2ed7436 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -63,7 +63,7 @@ android { minSdk = libs.versions.minSdk.get().toInt() targetSdk = libs.versions.targetSdk.get().toInt() versionCode = 67 - versionName = "4.6.1" + versionName = "4.6.2" resValue("string", "commit_hash", getGitCommitHash()) From 7ecb7785c23f26802d27d4dcafdf781d45e38917 Mon Sep 17 00:00:00 2001 From: firelight <147925818+fire-light42@users.noreply.github.com> Date: Mon, 26 Jan 2026 22:11:12 +0000 Subject: [PATCH 011/236] Fix(UI): Move voice actor view behind actor view for better visibility --- .../main/res/drawable/outline_big_35_gray.xml | 10 +++++ app/src/main/res/layout/cast_item.xml | 44 ++++++++++--------- 2 files changed, 34 insertions(+), 20 deletions(-) create mode 100644 app/src/main/res/drawable/outline_big_35_gray.xml diff --git a/app/src/main/res/drawable/outline_big_35_gray.xml b/app/src/main/res/drawable/outline_big_35_gray.xml new file mode 100644 index 000000000..ab18a1354 --- /dev/null +++ b/app/src/main/res/drawable/outline_big_35_gray.xml @@ -0,0 +1,10 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/cast_item.xml b/app/src/main/res/layout/cast_item.xml index 99a9750b2..4f7bdf74d 100644 --- a/app/src/main/res/layout/cast_item.xml +++ b/app/src/main/res/layout/cast_item.xml @@ -7,9 +7,9 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_margin="5dp" + android:focusable="true" android:foreground="@drawable/outline_drawable" app:cardBackgroundColor="@color/transparent" - android:focusable="true" app:cardCornerRadius="@dimen/rounded_image_radius" app:cardElevation="0dp"> @@ -25,38 +25,42 @@ android:layout_gravity="center_horizontal"> - - - - - - + + + + + + From 290283dc1562813ade21f67d6d53db5b11abe381 Mon Sep 17 00:00:00 2001 From: firelight <147925818+fire-light42@users.noreply.github.com> Date: Mon, 26 Jan 2026 22:17:31 +0000 Subject: [PATCH 012/236] Chore: nicehttp -> 0.4.16 --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 2ffddc862..f0119613c 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -31,7 +31,7 @@ media3 = "1.8.0" navigationKtx = "2.9.6" newpipeextractor = "v0.24.8" nextlibMedia3 = "1.8.0-0.9.0" -nicehttp = "0.4.13" +nicehttp = "0.4.16" overlappingpanels = "0.1.5" paletteKtx = "1.0.0" preferenceKtx = "1.2.1" From cbad2cfdaf3a45d1016878368879517ec13bcf5c Mon Sep 17 00:00:00 2001 From: Phisher98 <153359846+phisher98@users.noreply.github.com> Date: Tue, 27 Jan 2026 22:36:27 +0530 Subject: [PATCH 013/236] Adding VideoInfo on Player (#2454) Co-authored-by: Bnyro --- .../cloudstream3/ui/player/CS3IPlayer.kt | 49 +++++++++++++++++-- .../ui/player/FullScreenPlayer.kt | 8 +++ .../cloudstream3/ui/player/GeneratorPlayer.kt | 25 +++++++++- .../cloudstream3/ui/player/IPlayer.kt | 4 ++ .../main/res/layout/player_custom_layout.xml | 12 +++++ .../res/layout/player_custom_layout_tv.xml | 15 ++++++ .../main/res/layout/trailer_custom_layout.xml | 10 ++++ 7 files changed, 118 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/CS3IPlayer.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/CS3IPlayer.kt index f6df94b1b..dcdeca97f 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/CS3IPlayer.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/CS3IPlayer.kt @@ -40,12 +40,15 @@ import androidx.media3.datasource.cache.LeastRecentlyUsedCacheEvictor import androidx.media3.datasource.cache.SimpleCache import androidx.media3.datasource.cronet.CronetDataSource import androidx.media3.datasource.okhttp.OkHttpDataSource +import androidx.media3.exoplayer.DecoderCounters +import androidx.media3.exoplayer.DecoderReuseEvaluation import androidx.media3.exoplayer.DefaultLoadControl import androidx.media3.exoplayer.DefaultRenderersFactory import androidx.media3.exoplayer.ExoPlayer import androidx.media3.exoplayer.Renderer.STATE_ENABLED import androidx.media3.exoplayer.Renderer.STATE_STARTED import androidx.media3.exoplayer.SeekParameters +import androidx.media3.exoplayer.analytics.AnalyticsListener import androidx.media3.exoplayer.dash.DashMediaSource import androidx.media3.exoplayer.drm.DefaultDrmSessionManager import androidx.media3.exoplayer.drm.FrameworkMediaDrm @@ -104,6 +107,7 @@ import kotlinx.coroutines.delay import okhttp3.Interceptor import org.chromium.net.CronetEngine import java.io.File +import java.security.SecureRandom import java.util.UUID import java.util.concurrent.Executors import javax.net.ssl.HttpsURLConnection @@ -434,7 +438,8 @@ class CS3IPlayer : IPlayer { return AudioTrack( this.id?.stripTrackId(), this.label, - this.language + this.language, + this.sampleMimeType ) } @@ -443,7 +448,8 @@ class CS3IPlayer : IPlayer { this.id?.stripTrackId(), this.label, this.language, - this.sampleMimeType + this.sampleMimeType, + null ) } @@ -454,6 +460,7 @@ class CS3IPlayer : IPlayer { this.language, this.width, this.height, + this.sampleMimeType ) } @@ -1358,6 +1365,7 @@ class CS3IPlayer : IPlayer { ) setHandleAudioBecomingNoisy(true) setPlaybackSpeed(playBackSpeed) + this.addAnalyticsListener(tracksAnalyticsListener) } } @@ -1843,7 +1851,7 @@ class CS3IPlayer : IPlayer { if (ignoreSSL) { // Disables ssl check val sslContext: SSLContext = SSLContext.getInstance("TLS") - sslContext.init(null, arrayOf(SSLTrustManager()), java.security.SecureRandom()) + sslContext.init(null, arrayOf(SSLTrustManager()), SecureRandom()) sslContext.createSSLEngine() HttpsURLConnection.setDefaultHostnameVerifier { _: String, _: SSLSession -> true @@ -1929,4 +1937,39 @@ class CS3IPlayer : IPlayer { loadOfflinePlayer(context, it) } } + + private val tracksAnalyticsListener = object : AnalyticsListener { + + override fun onVideoInputFormatChanged( + eventTime: AnalyticsListener.EventTime, + format: Format, + decoderReuseEvaluation: DecoderReuseEvaluation? + ) { + event(TracksChangedEvent()) + } + + override fun onAudioInputFormatChanged( + eventTime: AnalyticsListener.EventTime, + format: Format, + decoderReuseEvaluation: DecoderReuseEvaluation? + ) { + event(TracksChangedEvent()) + } + + override fun onVideoDisabled( + eventTime: AnalyticsListener.EventTime, + decoderCounters: DecoderCounters + ) { + event(TracksChangedEvent()) + } + + override fun onAudioDisabled( + eventTime: AnalyticsListener.EventTime, + decoderCounters: DecoderCounters + ) { + event(TracksChangedEvent()) + } + } + } + diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/FullScreenPlayer.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/FullScreenPlayer.kt index ceb2a9d7e..97860e5a9 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/FullScreenPlayer.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/FullScreenPlayer.kt @@ -381,6 +381,13 @@ open class FullScreenPlayer : AbstractPlayerFragment() { start() } } + playerBinding?.playerVideoInfo?.let { + ObjectAnimator.ofFloat(it, "translationY", titleMove).apply { + duration = 200 + start() + } + } + val playerBarMove = if (isShowing) 0f else 50.toPx.toFloat() playerBinding?.bottomPlayerBar?.let { ObjectAnimator.ofFloat(it, "translationY", playerBarMove).apply { @@ -924,6 +931,7 @@ open class FullScreenPlayer : AbstractPlayerFragment() { // TITLE playerVideoTitleRez.startAnimation(fadeAnimation) + playerVideoInfo.startAnimation(fadeAnimation) playerEpisodeFiller.startAnimation(fadeAnimation) playerVideoTitleHolder.startAnimation(fadeAnimation) playerTopHolder.startAnimation(fadeAnimation) 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 1bd0b158f..172330b3b 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 @@ -215,6 +215,7 @@ class GeneratorPlayer : FullScreenPlayer() { if (tracks.allAudioTracks.any { it.language == preferredAudioTrackLanguage }) { player.setPreferredAudioTrack(preferredAudioTrackLanguage) } + updatePlayerInfo() } override fun playerStatusChanged() { @@ -1494,7 +1495,6 @@ class GeneratorPlayer : FullScreenPlayer() { if (width != NO_VALUE && height != NO_VALUE) { player.setMaxVideoSize(width, height, currentVideo?.id) } - trackDialog.dismissSafe(activity) } } @@ -1815,6 +1815,7 @@ class GeneratorPlayer : FullScreenPlayer() { val source = currentSelectedLink?.first?.name ?: currentSelectedLink?.second?.name ?: "NULL" val headerName = getHeaderName().orEmpty() + val title = when (titleRez) { 0 -> "" 1 -> extra @@ -1843,6 +1844,26 @@ class GeneratorPlayer : FullScreenPlayer() { isVisible = title.isNotBlank() } } + private fun updatePlayerInfo() { + val tracks = player.getVideoTracks() + + val videoTrack = tracks.currentVideoTrack + val audioTrack = tracks.currentAudioTrack + + val videoCodec = videoTrack?.sampleMimeType?.substringAfterLast('/')?.uppercase() + val audioCodec = audioTrack?.sampleMimeType?.substringAfterLast('/')?.uppercase() + val language = listOfNotNull( + audioTrack?.label, + fromTagToLanguageName(audioTrack?.language)?.let { "[$it]" } + ).joinToString(" ") + + val stats = arrayOf(videoCodec, audioCodec, language).filterNotNull().joinToString(" • ") + + playerBinding?.playerVideoInfo?.apply { + text = stats + isVisible = stats.isNotBlank() + } + } override fun playerDimensionsLoaded(width: Int, height: Int) { super.playerDimensionsLoaded(width, height) @@ -2039,7 +2060,6 @@ class GeneratorPlayer : FullScreenPlayer() { titleRez = settingsManager.getInt(ctx.getString(R.string.prefer_limit_title_rez_key), 3) limitTitle = settingsManager.getInt(ctx.getString(R.string.prefer_limit_title_key), 0) updateForcedEncoding(ctx) - filterSubByLang = settingsManager.getBoolean(getString(R.string.filter_sub_lang_key), false) if (filterSubByLang) { @@ -2164,6 +2184,7 @@ class GeneratorPlayer : FullScreenPlayer() { } } } + } @Suppress("DEPRECATION") diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/IPlayer.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/IPlayer.kt index 2ac484648..a7ce4f784 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/IPlayer.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/IPlayer.kt @@ -182,6 +182,7 @@ interface Track { val id: String? val label: String? val language: String? + val sampleMimeType : String? } data class VideoTrack( @@ -190,12 +191,14 @@ data class VideoTrack( override val language: String?, val width: Int?, val height: Int?, + override val sampleMimeType: String?, ) : Track data class AudioTrack( override val id: String?, override val label: String?, override val language: String?, + override val sampleMimeType: String?, ) : Track data class TextTrack( @@ -203,6 +206,7 @@ data class TextTrack( override val label: String?, override val language: String?, val mimeType: String?, + override val sampleMimeType: String?, ) : Track diff --git a/app/src/main/res/layout/player_custom_layout.xml b/app/src/main/res/layout/player_custom_layout.xml index a70508da9..d95c92e01 100644 --- a/app/src/main/res/layout/player_custom_layout.xml +++ b/app/src/main/res/layout/player_custom_layout.xml @@ -146,6 +146,18 @@ android:gravity="center" android:textColor="@color/white" tools:text="1920x1080" /> + + + + + + + + + + Date: Tue, 27 Jan 2026 17:12:49 +0000 Subject: [PATCH 014/236] Fix: Minor fixes to #2454 --- .../java/com/lagradost/cloudstream3/ui/player/CS3IPlayer.kt | 1 - .../com/lagradost/cloudstream3/ui/player/FullScreenPlayer.kt | 2 +- .../com/lagradost/cloudstream3/ui/player/GeneratorPlayer.kt | 2 +- .../main/java/com/lagradost/cloudstream3/ui/player/IPlayer.kt | 1 - 4 files changed, 2 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/CS3IPlayer.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/CS3IPlayer.kt index dcdeca97f..eb92ee9d1 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/CS3IPlayer.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/CS3IPlayer.kt @@ -449,7 +449,6 @@ class CS3IPlayer : IPlayer { this.label, this.language, this.sampleMimeType, - null ) } diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/FullScreenPlayer.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/FullScreenPlayer.kt index 97860e5a9..94d4a53be 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/FullScreenPlayer.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/FullScreenPlayer.kt @@ -453,7 +453,7 @@ open class FullScreenPlayer : AbstractPlayerFragment() { override fun subtitlesChanged() { val tracks = player.getVideoTracks() val isBuiltinSubtitles = tracks.currentTextTracks.all { track -> - track.mimeType == MimeTypes.APPLICATION_MEDIA3_CUES + track.sampleMimeType == MimeTypes.APPLICATION_MEDIA3_CUES } // Subtitle offset is not possible on built-in media3 tracks playerBinding?.playerSubtitleOffsetBtt?.isGone = 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 172330b3b..8b9c9aea8 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 @@ -1857,7 +1857,7 @@ class GeneratorPlayer : FullScreenPlayer() { fromTagToLanguageName(audioTrack?.language)?.let { "[$it]" } ).joinToString(" ") - val stats = arrayOf(videoCodec, audioCodec, language).filterNotNull().joinToString(" • ") + val stats = arrayOf(videoCodec, audioCodec, language).filter { !it.isNullOrBlank() }.joinToString(" • ") playerBinding?.playerVideoInfo?.apply { text = stats diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/IPlayer.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/IPlayer.kt index a7ce4f784..b095df24b 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/IPlayer.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/IPlayer.kt @@ -205,7 +205,6 @@ data class TextTrack( override val id: String?, override val label: String?, override val language: String?, - val mimeType: String?, override val sampleMimeType: String?, ) : Track From 5e039a80ba84aea991aa17d4cd4c5af864fd3d00 Mon Sep 17 00:00:00 2001 From: CranberrySoup <142951702+CranberrySoup@users.noreply.github.com> Date: Wed, 28 Jan 2026 17:05:19 +0000 Subject: [PATCH 015/236] Fix subtitle selection (#2449) * Fix subtitle selection --- .../cloudstream3/ui/player/GeneratorPlayer.kt | 35 ++--- .../ui/player/PlayerSubtitleHelper.kt | 11 ++ .../cloudstream3/SubtitleSelectionTest.kt | 140 ++++++++++++++++++ .../cloudstream3/utils/SubtitleHelper.kt | 68 ++++++--- 4 files changed, 217 insertions(+), 37 deletions(-) create mode 100644 app/src/test/java/com/lagradost/cloudstream3/SubtitleSelectionTest.kt 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 8b9c9aea8..78655590b 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 @@ -181,17 +181,17 @@ class GeneratorPlayer : FullScreenPlayer() { binding?.playerLoadingOverlay?.isVisible = true } - private fun setSubtitles(subtitle: SubtitleData?): Boolean { - // If subtitle is changed -> Save the language - if (subtitle != currentSelectedSubtitles) { + 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 { - fromCodeToLangTagIETF(subtitle.languageCode) - ?: fromLanguageToTagIETF(subtitle.languageCode, halfMatch = true) + subtitle.getIETF_tag() } if (subtitleLanguageTagIETF != null) { + Log.i(TAG, "Set SUBTITLE_AUTO_SELECT_KEY to '$subtitleLanguageTagIETF'") setKey(SUBTITLE_AUTO_SELECT_KEY, subtitleLanguageTagIETF) preferredAutoSelectSubtitles = subtitleLanguageTagIETF } @@ -226,7 +226,7 @@ class GeneratorPlayer : FullScreenPlayer() { } private fun noSubtitles(): Boolean { - return setSubtitles(null) + return setSubtitles(null, true) } private fun getPos(): Long { @@ -910,7 +910,7 @@ class GeneratorPlayer : FullScreenPlayer() { player.saveData() player.reloadPlayer(ctx) - setSubtitles(selectedSubtitle) + setSubtitles(selectedSubtitle, false) viewModel.addSubtitles(subtitleData.toSet()) selectSourceDialog?.dismissSafe() @@ -1363,7 +1363,7 @@ class GeneratorPlayer : FullScreenPlayer() { subtitlesGroupedList.getOrNull(subtitleGroupIndex - 1)?.value?.getOrNull( subtitleOptionIndex )?.let { - setSubtitles(it) + setSubtitles(it, true) } ?: false } } @@ -1659,23 +1659,17 @@ class GeneratorPlayer : FullScreenPlayer() { } } - private fun SubtitleData.matchesLanguage(langCode: String): Boolean { - val langName = fromTagToEnglishLanguageName(langCode) ?: return false - val cleanedName = originalName.replace(Regex("[^\\p{L}\\p{Mn}\\p{Mc}\\p{Me} ]"), "").trim() - return languageCode == langCode || cleanedName == langName || cleanedName.contains(langName) || cleanedName == langCode - } - private fun getAutoSelectSubtitle( subtitles: Set, settings: Boolean, downloads: Boolean ): SubtitleData? { val langCode = preferredAutoSelectSubtitles ?: return null if (downloads) { - return sortSubs(subtitles).firstOrNull { it.origin == SubtitleOrigin.DOWNLOADED_FILE && it.matchesLanguage(langCode) } + return sortSubs(subtitles).firstOrNull { it.origin == SubtitleOrigin.DOWNLOADED_FILE && it.matchesLanguageCode(langCode) } } if (!settings) return null - return sortSubs(subtitles).firstOrNull { it.matchesLanguage(langCode) } + return sortSubs(subtitles).firstOrNull { it.matchesLanguageCode(langCode) } } private fun autoSelectFromSettings(): Boolean { @@ -1684,8 +1678,9 @@ class GeneratorPlayer : FullScreenPlayer() { val current = player.getCurrentPreferredSubtitle() Log.i(TAG, "autoSelectFromSettings = $current") context?.let { ctx -> - if (current != null) { - if (setSubtitles(current)) { + // Only use the player preferred subtitle if it matches the available language + if (current != null && (langCode == null || current.matchesLanguageCode(langCode))) { + if (setSubtitles(current, false)) { player.saveData() player.reloadPlayer(ctx) player.handleEvent(CSPlayerEvent.Play) @@ -1695,7 +1690,7 @@ class GeneratorPlayer : FullScreenPlayer() { getAutoSelectSubtitle( currentSubs, settings = true, downloads = false )?.let { sub -> - if (setSubtitles(sub)) { + if (setSubtitles(sub, false)) { player.saveData() player.reloadPlayer(ctx) player.handleEvent(CSPlayerEvent.Play) @@ -1711,7 +1706,7 @@ class GeneratorPlayer : FullScreenPlayer() { if (player.getCurrentPreferredSubtitle() == null) { getAutoSelectSubtitle(currentSubs, settings = false, downloads = true)?.let { sub -> context?.let { ctx -> - if (setSubtitles(sub)) { + if (setSubtitles(sub, false)) { player.saveData() player.reloadPlayer(ctx) player.handleEvent(CSPlayerEvent.Play) diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerSubtitleHelper.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerSubtitleHelper.kt index d9e8963e4..ee6170aa5 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerSubtitleHelper.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerSubtitleHelper.kt @@ -11,6 +11,7 @@ import androidx.media3.ui.SubtitleView import com.lagradost.cloudstream3.SubtitleFile import com.lagradost.cloudstream3.ui.subtitles.SaveCaptionStyle import com.lagradost.cloudstream3.ui.subtitles.SubtitlesFragment.Companion.setSubtitleViewStyle +import com.lagradost.cloudstream3.utils.SubtitleHelper.fromLanguageToTagIETF import com.lagradost.cloudstream3.utils.UIHelper.toPx enum class SubtitleStatus { @@ -47,6 +48,16 @@ data class SubtitleData( else "$url|$name" } + /** Returns true if langCode is the same as the IETF tag */ + fun matchesLanguageCode(langCode: String): Boolean { + return getIETF_tag() == langCode + } + + /** Tries hard to figure out a valid IETF tag based on language code and name. Will return null if not found. */ + fun getIETF_tag(): String? { + return fromLanguageToTagIETF(this.languageCode) ?: fromLanguageToTagIETF(this.originalName, halfMatch = true) + } + val name = "$originalName $nameSuffix" /** diff --git a/app/src/test/java/com/lagradost/cloudstream3/SubtitleSelectionTest.kt b/app/src/test/java/com/lagradost/cloudstream3/SubtitleSelectionTest.kt new file mode 100644 index 000000000..93dc9dc0c --- /dev/null +++ b/app/src/test/java/com/lagradost/cloudstream3/SubtitleSelectionTest.kt @@ -0,0 +1,140 @@ +package com.lagradost.cloudstream3 + +import com.lagradost.cloudstream3.ui.player.SubtitleData +import com.lagradost.cloudstream3.ui.player.SubtitleOrigin +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +/** Ensure partial subtitle language finding is reliable. */ +class SubtitleLanguageTagTest { + fun getQuickSubtitle(originalName: String, languageCode: String?): SubtitleData { + return SubtitleData( + originalName = originalName, + nameSuffix = "1", + url = "https://example.com/test.vtt", + origin = SubtitleOrigin.URL, + mimeType = "text/vtt", + headers = emptyMap(), + languageCode = languageCode + ) + } + + @Test + fun `returns languageCode directly if already valid IETF tag`() { + val subtitle = getQuickSubtitle( + originalName = "Anything", + languageCode = "en" + ) + + assertEquals("en", subtitle.getIETF_tag()) + } + + @Test + fun `matches exact language name`() { + val subtitle = getQuickSubtitle( + originalName = "English", + languageCode = null + ) + + assertEquals("en", subtitle.getIETF_tag()) + } + + @Test + fun `matches native language name`() { + val subtitle = getQuickSubtitle( + originalName = "Español", + languageCode = null + ) + + assertEquals("es", subtitle.getIETF_tag()) + } + + @Test + fun `matches fuzzy partial language name`() { + val subtitle = getQuickSubtitle( + originalName = "English [SUB]", + languageCode = null + ) + + assertEquals("en", subtitle.getIETF_tag()) + } + + @Test + fun `returns null when no language matches`() { + val subtitle = getQuickSubtitle( + originalName = "Klingon", + languageCode = null + ) + + assertNull(subtitle.getIETF_tag()) + } + + + @Test + fun `returns the correct language variant`() { + val subtitle1 = getQuickSubtitle( + originalName = "Chinese", + languageCode = null + ) + val subtitle2 = getQuickSubtitle( + originalName = "Chinese (subtitle)", + languageCode = null + ) + val subtitleSimplified1 = getQuickSubtitle( + originalName = "Chinese (simplified)", + languageCode = null + ) + val subtitleSimplified2 = getQuickSubtitle( + originalName = "Chinese - simplified", + languageCode = null + ) + val subtitleSimplified3 = getQuickSubtitle( + originalName = "Chinese simplified", + languageCode = "zhh" + ) + val subtitleSimplified4 = getQuickSubtitle( + originalName = "Chinese (simplified)2", + languageCode = "zh-hans" + ) + val subtitleSimplified5 = getQuickSubtitle( + originalName = "汉语", + languageCode = null + ) + val subtitleSimplified6 = getQuickSubtitle( + originalName = "", + languageCode = "zh-hans" + ) + assertEquals("zh", subtitle1.getIETF_tag()) + assertEquals("zh", subtitle2.getIETF_tag()) + assertEquals("zh-hans", subtitleSimplified1.getIETF_tag()) + assertEquals("zh-hans", subtitleSimplified2.getIETF_tag()) + assertEquals("zh-hans", subtitleSimplified3.getIETF_tag()) + assertEquals("zh-hans", subtitleSimplified4.getIETF_tag()) + assertEquals("zh-hans", subtitleSimplified5.getIETF_tag()) + assertEquals("zh-hans", subtitleSimplified6.getIETF_tag()) + } + + + @Test + fun `returns exact language matches`() { + val subtitle = getQuickSubtitle( + originalName = "en", + languageCode = null + ) + + assertEquals("en", subtitle.getIETF_tag()) + } + + + @Test + fun `returns partial language matches`() { + val subtitle = getQuickSubtitle( + originalName = "Englis", + languageCode = null + ) + + assertEquals("en", subtitle.getIETF_tag()) + } +} + diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/SubtitleHelper.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/SubtitleHelper.kt index cdfb6e9d7..8d5479cc0 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/SubtitleHelper.kt +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/SubtitleHelper.kt @@ -1,5 +1,6 @@ package com.lagradost.cloudstream3.utils +import me.xdrop.fuzzywuzzy.FuzzySearch import java.util.Locale // If you find a way to use SettingsGeneral getCurrentLocale() @@ -37,7 +38,7 @@ object SubtitleHelper { * https://www.iana.org/assignments/language-subtag-registry/language-subtag-registry * https://android.googlesource.com/platform/frameworks/base/+/android-16.0.0_r2/core/res/res/values/locale_config.xml * https://iso639-3.sil.org/code_tables/639/data/all - */ + */ data class LanguageMetadata( val languageName: String, val nativeName: String, @@ -75,32 +76,65 @@ object SubtitleHelper { /** * Language name (english or native) -> [LanguageMetadata] - * @param languageName language name - * @param halfMatch match with `contains()` instead of `equals()` - */ - private fun getLanguageDataFromName(languageName: String?, halfMatch: Boolean? = false): LanguageMetadata? { + * @param languageName language name or language tag + * @param halfMatch match with `contains()` instead of `equals()`. Also uses fuzzy matching to get approximate matches. + */ + private fun getLanguageDataFromName( + languageName: String?, + halfMatch: Boolean? = false + ): LanguageMetadata? { if (languageName.isNullOrBlank() || languageName.length < 2) return null // Workaround to avoid junk like "English (original audio)" or "Spanish 123" // or "اَلْعَرَبِيَّةُ (Original Audio) 1" or "English (hindi sub)"… + // Will still keep "-" to be compatible with language tags such as pr-bt val garbage = Regex( "\\([^)]*(?:dub|sub|original|audio|code)[^)]*\\)|" + // junk words in parenthesis - "[\\u064B-\\u065B]|" + // arabic diacritics - "\\d|" + // numbers - "[^\\p{L}\\p{Mn}\\p{Mc}\\p{Me} ()]" // non-letter (from any language) + "[\\u064B-\\u065B]|" + // arabic diacritics + "\\d|" + // numbers + "[^\\p{L}\\p{Mn}\\p{Mc}\\p{Me} ()-]" // non-letter (from any language) ) + + val lowLangName = languageName.lowercase().replace(garbage, "").trim() - val index = - indexMapLanguageName[lowLangName] ?: - indexMapNativeName[lowLangName] ?: -1 + + val index = indexMapLanguageName[lowLangName] + ?: indexMapNativeName[lowLangName] + ?: indexMapIETF_tag[lowLangName] + ?: -1 + val langMetadata = languages.getOrNull(index) - if (halfMatch == true && langMetadata == null) { - for (lang in languages) - if (lang.languageName.contains(lowLangName, ignoreCase = true) || - lang.nativeName.contains(lowLangName, ignoreCase = true)) - return lang + if (langMetadata != null) { + return langMetadata + } else if (halfMatch == true) { + // Go for partial matches but only use the best match + var closestMatch: Pair = null to 0 + + for (lang in languages) { + val score = maxOf( + FuzzySearch.ratio(lowLangName, lang.languageName.lowercase()), + FuzzySearch.ratio( + lowLangName, lang.nativeName.lowercase() + ) + ) + + // Usually the languageName or nativeName is a substring of the entered name, for example in "English Subtitle" + if (lowLangName.contains(lang.languageName, ignoreCase = true) || + lowLangName.contains(lang.nativeName, ignoreCase = true) || + // Arbitrary cutoff at 80. + score > 80 + ) { + // First detected language gets priority in equal scores. + if (score > closestMatch.second) { + closestMatch = lang to score + } + } + } + + return closestMatch.first } - return langMetadata + + return null } @Deprecated( From 4271b8104e66fcc8c257751c69e70c70c8924776 Mon Sep 17 00:00:00 2001 From: firelight <147925818+fire-light42@users.noreply.github.com> Date: Wed, 28 Jan 2026 19:37:55 +0000 Subject: [PATCH 016/236] Fix(UI): Made outline consistent --- .../main/res/drawable/outline_big_15_gray.xml | 11 ++ .../main/res/drawable/outline_big_25_gray.xml | 11 ++ .../main/res/layout/fragment_home_head.xml | 102 +++++++++--------- app/src/main/res/layout/main_settings.xml | 8 +- 4 files changed, 76 insertions(+), 56 deletions(-) create mode 100644 app/src/main/res/drawable/outline_big_15_gray.xml create mode 100644 app/src/main/res/drawable/outline_big_25_gray.xml diff --git a/app/src/main/res/drawable/outline_big_15_gray.xml b/app/src/main/res/drawable/outline_big_15_gray.xml new file mode 100644 index 000000000..b94500279 --- /dev/null +++ b/app/src/main/res/drawable/outline_big_15_gray.xml @@ -0,0 +1,11 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/outline_big_25_gray.xml b/app/src/main/res/drawable/outline_big_25_gray.xml new file mode 100644 index 000000000..ea5f31a1f --- /dev/null +++ b/app/src/main/res/drawable/outline_big_25_gray.xml @@ -0,0 +1,11 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_home_head.xml b/app/src/main/res/layout/fragment_home_head.xml index e57990dc4..c57c32cee 100644 --- a/app/src/main/res/layout/fragment_home_head.xml +++ b/app/src/main/res/layout/fragment_home_head.xml @@ -30,8 +30,8 @@ android:id="@+id/home_padding" android:layout_width="match_parent" android:layout_height="50dp" - android:orientation="horizontal" android:gravity="center_vertical" + android:orientation="horizontal" android:paddingHorizontal="0dp"> @@ -39,8 +39,8 @@ android:id="@+id/home_search" android:layout_width="0dp" android:layout_height="match_parent" - android:layout_weight="1" android:layout_marginEnd="8dp" + android:layout_weight="1" android:editTextColor="@color/white" android:gravity="center_vertical" android:iconifiedByDefault="true" @@ -59,12 +59,12 @@ android:id="@+id/home_head_profile_padding" android:layout_width="50dp" android:layout_height="50dp" - android:gravity="center" - android:orientation="horizontal" android:clickable="true" android:focusable="true" android:foreground="@drawable/rounded_select_ripple" - android:nextFocusLeft="@id/home_search"> + android:gravity="center" + android:nextFocusLeft="@id/home_search" + android:orientation="horizontal"> + android:focusable="true" + android:foreground="@drawable/outline_big_15_gray" + app:cardCornerRadius="20dp"> @@ -114,43 +113,43 @@ android:gravity="center" android:orientation="horizontal"> - + - + - + @@ -160,20 +159,20 @@ android:id="@+id/alternative_account_padding" android:layout_width="match_parent" android:layout_height="50dp" - android:orientation="horizontal" android:gravity="end" + android:orientation="horizontal" android:paddingHorizontal="0dp"> + android:gravity="center" + android:nextFocusLeft="@id/home_search" + android:orientation="horizontal"> diff --git a/app/src/main/res/layout/main_settings.xml b/app/src/main/res/layout/main_settings.xml index 4a41759e0..ba3774554 100644 --- a/app/src/main/res/layout/main_settings.xml +++ b/app/src/main/res/layout/main_settings.xml @@ -29,16 +29,16 @@ + tools:src="@drawable/profile_bg_orange" /> @@ -150,7 +150,7 @@ android:layout_height="wrap_content" android:padding="10dp" android:textColor="?attr/textColor" - tools:text="21/03/2024 09:02 pm"/> + tools:text="21/03/2024 09:02 pm" /> From af1e0757f45d4b9adde35f217dbd9418dbe955a4 Mon Sep 17 00:00:00 2001 From: firelight <147925818+fire-light42@users.noreply.github.com> Date: Wed, 28 Jan 2026 22:45:32 +0000 Subject: [PATCH 017/236] Feat: Zoom (#2456) * Feat: Zoom --- .../ui/player/FullScreenPlayer.kt | 375 +++++++++++++++++- app/src/main/res/drawable/video_outline.xml | 4 + .../main/res/layout/player_custom_layout.xml | 8 + .../res/layout/player_custom_layout_tv.xml | 8 + .../main/res/layout/trailer_custom_layout.xml | 8 + 5 files changed, 382 insertions(+), 21 deletions(-) create mode 100644 app/src/main/res/drawable/video_outline.xml diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/FullScreenPlayer.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/FullScreenPlayer.kt index 94d4a53be..1a6337290 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/FullScreenPlayer.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/FullScreenPlayer.kt @@ -1,6 +1,7 @@ package com.lagradost.cloudstream3.ui.player import android.animation.ObjectAnimator +import android.animation.ValueAnimator import android.annotation.SuppressLint import android.app.Activity import android.app.Dialog @@ -10,6 +11,7 @@ import android.content.pm.ActivityInfo import android.content.res.ColorStateList import android.content.res.Configuration import android.graphics.Color +import android.graphics.Matrix import android.media.AudioManager import android.media.audiofx.LoudnessEnhancer import android.os.Build @@ -22,6 +24,7 @@ import android.text.format.DateUtils import android.view.KeyEvent import android.view.LayoutInflater import android.view.MotionEvent +import android.view.ScaleGestureDetector import android.view.Surface import android.view.View import android.view.ViewGroup @@ -47,13 +50,14 @@ import androidx.core.widget.doOnTextChanged import androidx.media3.common.MimeTypes import androidx.media3.common.util.UnstableApi import androidx.media3.exoplayer.ExoPlayer +import androidx.media3.ui.AspectRatioFrameLayout import androidx.preference.PreferenceManager import androidx.recyclerview.widget.SimpleItemAnimator import com.daasuu.gpuv.egl.filter.GlBrightnessFilter import com.daasuu.gpuv.player.GPUPlayerView import com.daasuu.gpuv.player.PlayerScaleType import com.google.android.material.button.MaterialButton -import com.lagradost.api.Log +import com.lagradost.api.BuildConfig import com.lagradost.cloudstream3.CommonActivity.keyEventListener import com.lagradost.cloudstream3.CommonActivity.playerEventListener import com.lagradost.cloudstream3.CommonActivity.screenHeightWithOrientation @@ -90,12 +94,21 @@ import com.lagradost.cloudstream3.utils.Vector2 import com.lagradost.cloudstream3.utils.setText import com.lagradost.cloudstream3.utils.txt import kotlin.math.abs +import kotlin.math.absoluteValue import kotlin.math.ceil import kotlin.math.max import kotlin.math.min import kotlin.math.round import kotlin.math.roundToInt +// You can zoom out more than 100%, but it will zoom back into 100% +const val MINIMUM_ZOOM = 0.95f + +// How sensitive the auto zoom is to center at the min zoom +const val ZOOM_SNAP_SENSITIVITY = 0.07f + +// Maximum zoom to avoid getting lost +const val MAXIMUM_ZOOM = 4.0f const val MINIMUM_SEEK_TIME = 7000L // when swipe seeking const val MINIMUM_VERTICAL_SWIPE = 2.0f // in percentage @@ -259,19 +272,6 @@ open class FullScreenPlayer : AbstractPlayerFragment() { super.onDestroyView() } - override fun resize(resize: PlayerResize, showToast: Boolean) { - super.resize(resize, showToast) - safe { - gpuPlayerView?.setPlayerScaleType( - when (resize) { - PlayerResize.Fit -> PlayerScaleType.RESIZE_FIT - PlayerResize.Fill -> PlayerScaleType.RESIZE_FILL - PlayerResize.Zoom -> PlayerScaleType.RESIZE_ZOOM - } - ) - } - } - open fun showMirrorsDialogue() { throw NotImplementedError() } @@ -625,7 +625,8 @@ open class FullScreenPlayer : AbstractPlayerFragment() { } dialog.show() - val isPortrait = ctx.resources.configuration.orientation == Configuration.ORIENTATION_PORTRAIT + val isPortrait = + ctx.resources.configuration.orientation == Configuration.ORIENTATION_PORTRAIT fixSystemBarsPadding(binding.root, fixIme = isPortrait) var currentOffset = subtitleDelay @@ -1066,6 +1067,34 @@ open class FullScreenPlayer : AbstractPlayerFragment() { } companion object { + /** + * Gets the translationXY + scale form a matrix with no rotation. + * + * @return (translationX, translationY, scale) + * */ + fun matrixToTranslationAndScale(matrix: Matrix): Triple { + val points = floatArrayOf(0.0f, 0.0f, 1.0f, 1.0f) + matrix.mapPoints(points) + + // A linear matrix will map (0,0) to the translation + val translationX = points[0] + val translationY = points[1] + + // The unit vectors (1,0) and (0,1) will map to the scale if you remove the translation + // As this assumes a uniform scaling, only a single vector is needed + val scaleX = points[2] - translationX + val scaleY = points[3] - translationY + + // The matrix should have the same scaleX and scaleY + if (BuildConfig.DEBUG) { + assert((scaleX - scaleY).absoluteValue < 0.1f) { + "$scaleY != $scaleX" + } + } + + return Triple(translationX, translationY, scaleX) + } + private fun forceLetters(inp: Long, letters: Int = 2): String { val added: Int = letters - inp.toString().length return if (added > 0) { @@ -1150,7 +1179,8 @@ open class FullScreenPlayer : AbstractPlayerFragment() { Settings.System.putInt( context?.contentResolver, - Settings.System.SCREEN_BRIGHTNESS, min(1, (brightness.coerceIn(0.0f, 1.0f) * 255).toInt()) + Settings.System.SCREEN_BRIGHTNESS, + min(1, (brightness.coerceIn(0.0f, 1.0f) * 255).toInt()) ) } catch (e: Exception) { useTrueSystemBrightness = false @@ -1229,15 +1259,303 @@ open class FullScreenPlayer : AbstractPlayerFragment() { } } + override fun onConfigurationChanged(newConfig: Configuration) { + super.onConfigurationChanged(newConfig) + + // If we rotate the device we need to recalculate the zoom + val matrix = zoomMatrix + val animation = matrixAnimation + if ((animation == null || !animation.isRunning) && matrix != null) { + // Ignore if we have no zoom or mid animation + playerView?.post { + applyZoomMatrix(matrix, true) + } + } + } + + private var scaleGestureDetector: ScaleGestureDetector? = null + private var lastPan: Vector2? = null + + /** + * Gets the non-null zoom matrix, + * this is different from `zoomMatrix ?: Matrix()` + * because it allows used to start zooming at different resizeModes. + * + * The main issue is that RESIZE_MODE_FIT = 100% zoom, but if you are in RESIZE_MODE_ZOOM + * 100% will make the zoom snap to less zoomed in then you already are. + * */ + fun currentZoomMatrix(): Matrix { + val current = zoomMatrix + if (current != null) { + // Already assigned + return current + } + + val playerView = playerView + val videoView = playerView?.videoSurfaceView + + if (playerView == null || videoView == null || playerView.resizeMode != AspectRatioFrameLayout.RESIZE_MODE_ZOOM) { + // This is a fit or fill resize mode so start at 100% zoom + return Matrix() + } + + val videoWidth = videoView.width.toFloat() + val videoHeight = videoView.height.toFloat() + val playerWidth = screenWidthWithOrientation + val playerHeight = screenHeightWithOrientation + + // Sanity check + if (videoWidth <= 1.0f || videoHeight <= 1.0f || playerWidth <= 1.0f || playerHeight <= 1.0f) { + // Something is wrong with the video, return the default 100% zoom + return Matrix() + } + + val initAspect = + (playerHeight * videoWidth) / (playerWidth * videoHeight) + val aspect = max(initAspect, 1.0f / initAspect) + + // Return the matrix with the correct zoom, as it is already zoomed in + return Matrix().apply { postScale(aspect, aspect) } + } + + /** A Matrix encoding the translation and scale of the current zoom */ + private var zoomMatrix: Matrix? = null + + /** A Matrix encoding the translation and scale of the desired zoom, + * aka after you release the zoom */ + private var desiredMatrix: Matrix? = null + + /** The animation of zooming to the desiredMatrix */ + private var matrixAnimation: ValueAnimator? = null + + @SuppressLint("UnsafeOptInUsageError") + override fun resize(resize: PlayerResize, showToast: Boolean) { + // Clear all zoom stuff if we resize + matrixAnimation?.cancel() + matrixAnimation = null + zoomMatrix = null + desiredMatrix = null + playerView?.videoSurfaceView?.apply { + scaleX = 1.0f + scaleY = 1.0f + translationX = 0.0f + translationY = 0.0f + } + + safe { + gpuPlayerView?.setPlayerScaleType( + when (resize) { + PlayerResize.Fit -> PlayerScaleType.RESIZE_FIT + PlayerResize.Fill -> PlayerScaleType.RESIZE_FILL + PlayerResize.Zoom -> PlayerScaleType.RESIZE_ZOOM + } + ) + } + + super.resize(resize, showToast) + } + + /** + * Applies a new zoom matrix to the screen. Matrix should only contain a scale + translation. + * + * @param newMatrix The new zoom matrix + * @param animation If this zoom is part of an animation, + * as then it will not auto zoom after we are done + */ + @OptIn(UnstableApi::class) + fun applyZoomMatrix(newMatrix: Matrix, animation: Boolean) { + if (!animation) { + matrixAnimation?.cancel() + matrixAnimation = null + } + val (translationX, translationY, scale) = matrixToTranslationAndScale(newMatrix) + + playerView?.let { player -> + if (player.resizeMode == AspectRatioFrameLayout.RESIZE_MODE_FIT) { + player.resizeMode = AspectRatioFrameLayout.RESIZE_MODE_ZOOM + } + + val videoView = player.videoSurfaceView ?: return@let + + val videoWidth = videoView.width.toFloat() + val videoHeight = videoView.height.toFloat() + val playerWidth = screenWidthWithOrientation + val playerHeight = screenHeightWithOrientation + + // Sanity check + if (videoWidth <= 1.0f || videoHeight <= 1.0f || playerWidth <= 1.0f || playerHeight <= 1.0f || scale <= 0.01f) { + return + } + + // Calculate the scaled aspect ratio as the view height is not real, check the debugger + // and you will see videoView.height > screen.heigh + val initAspect = + (playerHeight * videoWidth) / (playerWidth * videoHeight) + val aspect = min(initAspect, 1.0f / initAspect) + val scaledAspect = scale * aspect + + // Calculate clamp, this is very weird because we need to use aspect here as videoHeight > playerHeight + val maxTransX = max(0.0f, videoWidth * scaledAspect - playerWidth) * 0.5f + val maxTransY = max(0.0f, videoHeight * scaledAspect - playerHeight) * 0.5f + + // Correct the translation to clamp within the viewing area + val expectedTranslationX = translationX.coerceIn(-maxTransX, maxTransX) + val expectedTranslationY = translationY.coerceIn(-maxTransY, maxTransY) + + // Set the transform to the correct x and y + newMatrix.postTranslate( + expectedTranslationX - translationX, + expectedTranslationY - translationY + ) + zoomMatrix = newMatrix + + if (!animation) { + // If we are not in an animation, set up the values for the animation + if ((scaledAspect - 1.0f).absoluteValue < ZOOM_SNAP_SENSITIVITY) { + // We are within the correct scaling, so center and fit it + playerBinding?.videoOutline?.isVisible = true + val desired = Matrix() + desired.setScale(1.0f / aspect, 1.0f / aspect) + desiredMatrix = desired + } else if (scale < 1.0f) { + // We have zoomed too far, zoom to 100% + playerBinding?.videoOutline?.isVisible = false + desiredMatrix = Matrix() + } else { + // Keep the same scaling after zoom + playerBinding?.videoOutline?.isVisible = false + desiredMatrix = null + } + } + + // Finally set the actual scale + translation + videoView.scaleX = scaledAspect + videoView.scaleY = scaledAspect + videoView.translationX = expectedTranslationX + videoView.translationY = expectedTranslationY + } + } + + fun createScaleGestureDetector(context: Context) { + scaleGestureDetector = ScaleGestureDetector( + context, + object : ScaleGestureDetector.SimpleOnScaleGestureListener() { + override fun onScale(detector: ScaleGestureDetector): Boolean { + val matrix = currentZoomMatrix() + val (_, _, scale) = matrixToTranslationAndScale(matrix) + // Clamp scale of the zoom, do it here as it is easier then doing it within applyZoomMatrix + val newScale = (scale * detector.scaleFactor).coerceIn( + MINIMUM_ZOOM, + MAXIMUM_ZOOM + ) + // How much we should scale it with to prevent inf scaling + val actualScaleFactor = newScale / scale + + // Scale around the focus point, this is more natural than just zoom + val pivotX = detector.focusX - screenWidthWithOrientation.toFloat() * 0.5f + val pivotY = detector.focusY - screenHeightWithOrientation.toFloat() * 0.5f + matrix.postScale( + actualScaleFactor, + actualScaleFactor, + pivotX, + pivotY + ) + applyZoomMatrix(matrix, false) + return true + } + }) + } + @SuppressLint("SetTextI18n") private fun handleMotionEvent(view: View?, event: MotionEvent?): Boolean { if (event == null || view == null) return false val currentTouch = Vector2(event.x, event.y) val startTouch = currentTouchStart - playerBinding?.apply { - playerIntroPlay.isGone = true + playerBinding?.playerIntroPlay?.isGone = true + // Handle pan with two fingers + if (event.pointerCount == 2 && !isLocked && isFullScreenPlayer && !hasTriggeredSpeedUp && currentTouchAction == null) { + holdhandler.removeCallbacks(holdRunnable) // remove 2x speed + + // Gesture detectors for zoom & pan + if (scaleGestureDetector == null) { + createScaleGestureDetector(view.context) + } + + isCurrentTouchValid = false // Prevent other touches + scaleGestureDetector?.onTouchEvent(event) + + when (event.actionMasked) { + MotionEvent.ACTION_POINTER_DOWN -> { + // Hide UI + if (isShowing) { + onClickChange() + } + } + + MotionEvent.ACTION_MOVE -> { + val newPan = Vector2( + (event.getX(0) + event.getX(1)) / 2f, + (event.getY(0) + event.getY(1)) / 2f + ) + val oldPan = lastPan + if (oldPan != null) { + val matrix = currentZoomMatrix() + // Delta move + matrix.postTranslate(newPan.x - oldPan.x, newPan.y - oldPan.y) + applyZoomMatrix(matrix, false) + } + lastPan = newPan + } + + MotionEvent.ACTION_POINTER_UP, MotionEvent.ACTION_UP -> { + // Reset touch + lastPan = null + currentTouchStart = null + currentLastTouchAction = null + currentTouchAction = null + currentTouchStartPlayerTime = null + currentTouchLast = null + currentTouchStartTime = null + + // Reset views + playerBinding?.videoOutline?.isVisible = false + matrixAnimation?.cancel() + matrixAnimation = null + + // After we have zoomed in, snap to + matrixAnimation = ValueAnimator.ofFloat(0.0f, 1.0f).apply { + startDelay = 0 + duration = 200 + + val startMatrix = currentZoomMatrix() + val endMatrix = desiredMatrix ?: return@apply + + val (startX, startY, startScale) = matrixToTranslationAndScale(startMatrix) + val (endX, endY, endScale) = matrixToTranslationAndScale(endMatrix) + + addUpdateListener { animation -> + val value = animation.animatedValue as Float // ValueAnimator.ofFloat + + // Linear interpolation of scale and translation between startMatrix and endMatrix + val valueInv = 1.0f - value + val x = startX * valueInv + endX * value + val y = startY * valueInv + endY * value + val s = startScale * valueInv + endScale * value + val m = Matrix() + m.setScale(s, s) + m.postTranslate(x, y) + applyZoomMatrix(m, true) + } + start() + } + } + } + return true + } + + playerBinding?.apply { when (event.action) { MotionEvent.ACTION_DOWN -> { // validates if the touch is inside of the player area @@ -1472,7 +1790,11 @@ open class FullScreenPlayer : AbstractPlayerFragment() { } val lastRequested = currentRequestedBrightness - val nextBrightness = (currentRequestedBrightness + verticalAddition).coerceIn(0.0f, 1.0f) // !!! Removed due to HDR conflict !!! + val nextBrightness = + (currentRequestedBrightness + verticalAddition).coerceIn( + 0.0f, + 1.0f + ) // !!! Removed due to HDR conflict !!! // // Log.e("Brightness", "Current: $currentRequestedBrightness, Next: $nextBrightness") // show toast @@ -1492,7 +1814,13 @@ open class FullScreenPlayer : AbstractPlayerFragment() { // max is set high to make it smooth level1ProgressBar.max = 100_000 level1ProgressBar.progress = - max(2_000, (min(1.0f, currentRequestedBrightness) * 100_000f).toInt()) + max( + 2_000, + (min( + 1.0f, + currentRequestedBrightness + ) * 100_000f).toInt() + ) // !!! Removed due to HDR conflict !!! /*if (!isBrightnessLocked) { @@ -1557,7 +1885,12 @@ open class FullScreenPlayer : AbstractPlayerFragment() { brightnessIcons.size - 1, max( 0, - round(max(currentRequestedBrightness, 1.0f) * (brightnessIcons.size - 1)).toInt() + round( + max( + currentRequestedBrightness, + 1.0f + ) * (brightnessIcons.size - 1) + ).toInt() ) )] ) diff --git a/app/src/main/res/drawable/video_outline.xml b/app/src/main/res/drawable/video_outline.xml new file mode 100644 index 000000000..558c4ec3e --- /dev/null +++ b/app/src/main/res/drawable/video_outline.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/player_custom_layout.xml b/app/src/main/res/layout/player_custom_layout.xml index d95c92e01..7974159c4 100644 --- a/app/src/main/res/layout/player_custom_layout.xml +++ b/app/src/main/res/layout/player_custom_layout.xml @@ -7,6 +7,14 @@ android:orientation="vertical" tools:orientation="vertical"> + + + \\s*($SUBRIP_TIMECODE)\\s*") + + // NOTE: Android Studio's suggestion to simplify '\\}' is incorrect [internal: b/144480183]. + private val SUBRIP_TAG_PATTERN: Pattern = Pattern.compile("\\{\\\\.*?\\}") + private const val SUBRIP_ALIGNMENT_TAG = "\\{\\\\an[1-9]\\}" + + // Alignment tags for SSA V4+. + private const val ALIGN_BOTTOM_LEFT = "{\\an1}" + private const val ALIGN_BOTTOM_MID = "{\\an2}" + private const val ALIGN_BOTTOM_RIGHT = "{\\an3}" + private const val ALIGN_MID_LEFT = "{\\an4}" + private const val ALIGN_MID_MID = "{\\an5}" + private const val ALIGN_MID_RIGHT = "{\\an6}" + private const val ALIGN_TOP_LEFT = "{\\an7}" + private const val ALIGN_TOP_MID = "{\\an8}" + private const val ALIGN_TOP_RIGHT = "{\\an9}" + + private fun parseTimecode(matcher: Matcher, groupOffset: Int): Long { + val hours = matcher.group(groupOffset + 1) + var timestampMs = if (hours != null) hours.toLong() * 60 * 60 * 1000 else 0 + timestampMs += + Assertions.checkNotNull(matcher.group(groupOffset + 2)) + .toLong() * 60 * 1000 + timestampMs += Assertions.checkNotNull(matcher.group(groupOffset + 3)) + .toLong() * 1000 + val millis = matcher.group(groupOffset + 4) + if (millis != null) { + timestampMs += millis.toLong() + } + return timestampMs * 1000 + } + + // TODO(b/289983417): Make package-private again, once it is no longer needed in + // DelegatingSubtitleDecoderWithSubripParserTest.java (i.e. legacy subtitle flow is removed) + @VisibleForTesting(otherwise = VisibleForTesting.Companion.PRIVATE) + fun getFractionalPositionForAnchorType(anchorType: @AnchorType Int): Float { + return when (anchorType) { + Cue.ANCHOR_TYPE_START -> START_FRACTION + Cue.ANCHOR_TYPE_MIDDLE -> MID_FRACTION + Cue.ANCHOR_TYPE_END -> END_FRACTION + Cue.TYPE_UNSET -> // Should never happen. + throw IllegalArgumentException() + + else -> + throw IllegalArgumentException() + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/CustomSubtitleDecoderFactory.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/CustomSubtitleDecoderFactory.kt index ffcd83664..61d6f5564 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/CustomSubtitleDecoderFactory.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/CustomSubtitleDecoderFactory.kt @@ -18,7 +18,6 @@ import androidx.media3.extractor.text.SubtitleParser import androidx.media3.extractor.text.dvb.DvbParser import androidx.media3.extractor.text.pgs.PgsParser import androidx.media3.extractor.text.ssa.SsaParser -import androidx.media3.extractor.text.subrip.SubripParser import androidx.media3.extractor.text.ttml.TtmlParser import androidx.media3.extractor.text.tx3g.Tx3gParser import androidx.media3.extractor.text.webvtt.Mp4WebvttParser @@ -251,14 +250,14 @@ class CustomDecoder(private val fallbackFormat: Format?) : SubtitleParser { ignoreCase = true )) -> SsaParser(fallbackFormat?.initializationData) - trimmedText.startsWith("1", ignoreCase = true) -> SubripParser() + trimmedText.startsWith("1", ignoreCase = true) -> CustomSubripParser() fallbackFormat != null -> { - when (val mimeType = fallbackFormat.sampleMimeType) { + when (fallbackFormat.sampleMimeType) { MimeTypes.TEXT_VTT -> WebvttParser() MimeTypes.TEXT_SSA -> SsaParser(fallbackFormat.initializationData) MimeTypes.APPLICATION_MP4VTT -> Mp4WebvttParser() MimeTypes.APPLICATION_TTML -> TtmlParser() - MimeTypes.APPLICATION_SUBRIP -> SubripParser() + MimeTypes.APPLICATION_SUBRIP -> CustomSubripParser() MimeTypes.APPLICATION_TX3G -> Tx3gParser(fallbackFormat.initializationData) // These decoders are not converted to parsers yet // TODO From a45593283d0d5a5b60861ec714792e6876347cb0 Mon Sep 17 00:00:00 2001 From: Pawloland <59684145+Pawloland@users.noreply.github.com> Date: Tue, 3 Feb 2026 20:19:25 +0100 Subject: [PATCH 027/236] Readd extra brightness feature as optional setting (#2469) --- app/build.gradle.kts | 2 - .../ui/player/FullScreenPlayer.kt | 238 ++++++++++-------- app/src/main/res/drawable/sun_7_24.xml | 111 ++++++++ .../res/layout/extra_brightness_overlay.xml | 8 + app/src/main/res/values/strings.xml | 3 + app/src/main/res/xml/settings_player.xml | 16 +- gradle/libs.versions.toml | 2 - 7 files changed, 262 insertions(+), 118 deletions(-) create mode 100644 app/src/main/res/drawable/sun_7_24.xml create mode 100644 app/src/main/res/layout/extra_brightness_overlay.xml diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 8b2ed7436..41e8fc0a0 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -228,8 +228,6 @@ dependencies { this.extra.set("isDebug", isDebug) }) - // Extra brightness video filters - implementation(libs.gpuv) } tasks.register("androidSourcesJar") { diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/FullScreenPlayer.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/FullScreenPlayer.kt index 1a6337290..be94d9659 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/FullScreenPlayer.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/FullScreenPlayer.kt @@ -53,9 +53,6 @@ import androidx.media3.exoplayer.ExoPlayer import androidx.media3.ui.AspectRatioFrameLayout import androidx.preference.PreferenceManager import androidx.recyclerview.widget.SimpleItemAnimator -import com.daasuu.gpuv.egl.filter.GlBrightnessFilter -import com.daasuu.gpuv.player.GPUPlayerView -import com.daasuu.gpuv.player.PlayerScaleType import com.google.android.material.button.MaterialButton import com.lagradost.api.BuildConfig import com.lagradost.cloudstream3.CommonActivity.keyEventListener @@ -127,9 +124,7 @@ open class FullScreenPlayer : AbstractPlayerFragment() { protected open var lockRotation = true protected open var isFullScreenPlayer = true protected var playerBinding: PlayerCustomLayoutBinding? = null - private var gpuPlayerView: GPUPlayerView? = null - private var gpuBrightnessFilter: GlBrightnessFilter? = null - private var hasBrightnessBoostError: Boolean = false + protected var brightnessOverlay: View? = null private var durationMode: Boolean by UserPreferenceDelegate("duration_mode", false) @@ -155,6 +150,7 @@ open class FullScreenPlayer : AbstractPlayerFragment() { // protected var currentPrefQuality = // Qualities.P2160.value // preferred maximum quality, used for ppl w bad internet or on cell + protected var extraBrightnessEnabled = false protected var fastForwardTime = 10000L protected var androidTVInterfaceOffSeekTime = 10000L protected var androidTVInterfaceOnSeekTime = 30000L @@ -196,7 +192,7 @@ open class FullScreenPlayer : AbstractPlayerFragment() { R.drawable.sun_4, R.drawable.sun_5, R.drawable.sun_6, - // R.drawable.sun_7, + R.drawable.sun_7, // R.drawable.ic_baseline_brightness_1_24, // R.drawable.ic_baseline_brightness_2_24, // R.drawable.ic_baseline_brightness_3_24, @@ -222,56 +218,129 @@ open class FullScreenPlayer : AbstractPlayerFragment() { val root = super.onCreateView(inflater, container, savedInstanceState) ?: return null playerBinding = PlayerCustomLayoutBinding.bind(root.findViewById(R.id.player_holder)) - // Create GPUPlayerView dynamically and attach it to the PlayerView's content frame - // !!! Removed due to HDR conflict !!! - /*safe { + // Inject the overlay from a separate XML into the PlayerView content frame + safe { val pv = root.findViewById(R.id.player_view) val packageName = context?.packageName ?: return@safe val contentId = resources.getIdentifier("exo_content_frame", "id", packageName) - val contentFrame = pv?.findViewById(contentId) + val contentFrame = pv?.findViewById(contentId) if (contentFrame != null) { - val gpu = GPUPlayerView(context) - val lp = android.widget.FrameLayout.LayoutParams( - android.view.ViewGroup.LayoutParams.MATCH_PARENT, - android.view.ViewGroup.LayoutParams.MATCH_PARENT + brightnessOverlay = contentFrame.findViewById(R.id.extra_brightness_overlay) + brightnessOverlay = LayoutInflater.from(context).inflate( + R.layout.extra_brightness_overlay, + contentFrame, + false ) - // Insert as first child so it sits behind any controls inside content frame - contentFrame.addView(gpu, 0, lp) - gpuPlayerView = gpu + contentFrame.addView(brightnessOverlay) + requestUpdateBrightnessOverlayOnNextLayout() } - }*/ + } + return root } - - fun setGpuExtraBrightness(extra: Float) { - gpuBrightnessFilter?.setBrightness(extra) - } - @SuppressLint("UnsafeOptInUsageError") override fun playerUpdated(player: Any?) { super.playerUpdated(player) - if (player is ExoPlayer) { - // attach GL renderer filter if available - gpuPlayerView?.setExoPlayer(player) - } } override fun onDestroyView() { - // Clean up dynamic GPUPlayerView if created + // Clean up brightness overlay if created safe { - gpuPlayerView?.onPause() - gpuPlayerView?.setGlFilter(null) - gpuBrightnessFilter = null - val parent = gpuPlayerView?.parent as? android.view.ViewGroup - parent?.removeView(gpuPlayerView) + // remove overlay if present + brightnessOverlay?.let { overlay -> + val oParent = overlay.parent as? ViewGroup + oParent?.removeView(overlay) + } } - - gpuPlayerView = null + brightnessOverlay = null playerBinding = null super.onDestroyView() } + /** + * Resize/position the brightness overlay to exactly match the visible video surface. + * This copies the video surface size, scale and translation so the overlay won't cover + * letterbox/pillarbox areas when zooming or panning. + */ + private fun updateBrightnessOverlayBounds() { + val overlay = brightnessOverlay ?: return + val pv = playerView ?: return + val video = pv.videoSurfaceView ?: return + + // Compute accurate transformed bounding box of the video view after scale+translation + val vw = video.width.toFloat() + val vh = video.height.toFloat() + val sx = video.scaleX + val sy = video.scaleY + if (vw > 0f && vh > 0f) { + // pivot defaults to center if not set + val pivotX = if (video.pivotX != 0f) video.pivotX else vw * 0.5f + val pivotY = if (video.pivotY != 0f) video.pivotY else vh * 0.5f + // Use view position (includes translation) as base; avoid double-counting translation + val tx = video.x + val ty = video.y + + // transform function for a local point (lx,ly) + fun transform(lx: Float, ly: Float): Pair { + val gx = tx + pivotX + (lx - pivotX) * sx + val gy = ty + pivotY + (ly - pivotY) * sy + return Pair(gx, gy) + } + + val p0 = transform(0f, 0f) + val p1 = transform(vw, 0f) + val p2 = transform(0f, vh) + val p3 = transform(vw, vh) + + val minX = min(min(p0.first, p1.first), min(p2.first, p3.first)) + val maxX = max(max(p0.first, p1.first), max(p2.first, p3.first)) + val minY = min(min(p0.second, p1.second), min(p2.second, p3.second)) + val maxY = max(max(p0.second, p1.second), max(p2.second, p3.second)) + + val newW = ceil(maxX - minX).toInt().coerceAtLeast(0) + val newH = ceil(maxY - minY).toInt().coerceAtLeast(0) + + val lp = overlay.layoutParams + if (lp == null) { + overlay.layoutParams = ViewGroup.LayoutParams(newW, newH) + } else { + if (lp.width != newW || lp.height != newH) { + lp.width = newW + lp.height = newH + overlay.layoutParams = lp + } + } + + overlay.scaleX = 1.0f + overlay.scaleY = 1.0f + overlay.x = minX + overlay.y = minY + } + } + + /** + * Ensure the overlay is updated once the next layout pass completes. + * Adds a one-time global layout listener (PiP/resizing/rotation frames). + */ + private fun requestUpdateBrightnessOverlayOnNextLayout() { + val pv = playerView ?: return + safe { + val obs = pv.viewTreeObserver + val listener = object : android.view.ViewTreeObserver.OnGlobalLayoutListener { + override fun onGlobalLayout() { + safe { + updateBrightnessOverlayBounds() + } + if (obs.isAlive) { + obs.removeOnGlobalLayoutListener(this) + } + } + } + if (obs.isAlive) obs.addOnGlobalLayoutListener(listener) + } + } + open fun showMirrorsDialogue() { throw NotImplementedError() } @@ -578,6 +647,7 @@ open class FullScreenPlayer : AbstractPlayerFragment() { activity?.popCurrentPage("FullScreenPlayer") } } + requestUpdateBrightnessOverlayOnNextLayout() super.onResume() } @@ -1269,6 +1339,7 @@ open class FullScreenPlayer : AbstractPlayerFragment() { // Ignore if we have no zoom or mid animation playerView?.post { applyZoomMatrix(matrix, true) + requestUpdateBrightnessOverlayOnNextLayout() } } } @@ -1342,17 +1413,8 @@ open class FullScreenPlayer : AbstractPlayerFragment() { translationY = 0.0f } - safe { - gpuPlayerView?.setPlayerScaleType( - when (resize) { - PlayerResize.Fit -> PlayerScaleType.RESIZE_FIT - PlayerResize.Fill -> PlayerScaleType.RESIZE_FILL - PlayerResize.Zoom -> PlayerScaleType.RESIZE_ZOOM - } - ) - } - super.resize(resize, showToast) + requestUpdateBrightnessOverlayOnNextLayout() } /** @@ -1433,6 +1495,7 @@ open class FullScreenPlayer : AbstractPlayerFragment() { videoView.scaleY = scaledAspect videoView.translationX = expectedTranslationX videoView.translationY = expectedTranslationY + updateBrightnessOverlayBounds() } } @@ -1505,6 +1568,7 @@ open class FullScreenPlayer : AbstractPlayerFragment() { // Delta move matrix.postTranslate(newPan.x - oldPan.x, newPan.y - oldPan.y) applyZoomMatrix(matrix, false) + updateBrightnessOverlayBounds() } lastPan = newPan } @@ -1790,16 +1854,18 @@ open class FullScreenPlayer : AbstractPlayerFragment() { } val lastRequested = currentRequestedBrightness - val nextBrightness = + val nextBrightness = if (extraBrightnessEnabled) { + currentRequestedBrightness + verticalAddition + } else { (currentRequestedBrightness + verticalAddition).coerceIn( 0.0f, 1.0f - ) // !!! Removed due to HDR conflict !!! - // + ) + } // Log.e("Brightness", "Current: $currentRequestedBrightness, Next: $nextBrightness") // show toast - if (nextBrightness > 1.0f && isBrightnessLocked && !hasShownBrightnessToast) { - //showToast(R.string.slide_up_again_to_exceed_100) + if (extraBrightnessEnabled && nextBrightness > 1.0f && isBrightnessLocked && !hasShownBrightnessToast) { + showToast(R.string.slide_up_again_to_exceed_100) hasShownBrightnessToast = true } currentRequestedBrightness = nextBrightness @@ -1809,7 +1875,6 @@ open class FullScreenPlayer : AbstractPlayerFragment() { setBrightness(currentRequestedBrightness) val level1ProgressBar = playerProgressbarRightLevel1 - //val level2ProgressBar = playerProgressbarRightLevel2 // max is set high to make it smooth level1ProgressBar.max = 100_000 @@ -1822,75 +1887,26 @@ open class FullScreenPlayer : AbstractPlayerFragment() { ) * 100_000f).toInt() ) - // !!! Removed due to HDR conflict !!! - /*if (!isBrightnessLocked) { + if (extraBrightnessEnabled && !isBrightnessLocked) { + val level2ProgressBar = playerProgressbarRightLevel2 + currentExtraBrightness = if (currentRequestedBrightness > 1.0f) min(2.0f, currentRequestedBrightness) - 1.0f else 0.0f level2ProgressBar.max = 100_000 level2ProgressBar.progress = (currentExtraBrightness * 100_000f).toInt().coerceIn(2_000, 100_000) level2ProgressBar.isVisible = currentRequestedBrightness > 1.0f - - // Only create/remove the GL filter when crossing the 1.0 threshold - val wasExtra = lastRequested > 1.0f - val willExtra = currentRequestedBrightness > 1.0f - - if (willExtra && !wasExtra) { - // crossed from <=1.0 to >1.0: initialize filter - try { - if (gpuBrightnessFilter == null) { - gpuBrightnessFilter = GlBrightnessFilter() - gpuPlayerView?.setGlFilter(gpuBrightnessFilter) - } - setGpuExtraBrightness(currentExtraBrightness) - hasBrightnessBoostError = false - } catch (t: Throwable) { - logError(t) - hasBrightnessBoostError = true - } - } else if (willExtra) { - // still >1.0: only update brightness - try { - setGpuExtraBrightness(currentExtraBrightness) - } catch (t: Throwable) { - logError(t) - hasBrightnessBoostError = true - } - } else if (wasExtra) { - // crossed from >1.0 to <=1.0: remove filter - try { - gpuPlayerView?.setGlFilter(null) - gpuBrightnessFilter = null - } catch (t: Throwable) { - logError(t) - hasBrightnessBoostError = true - } + brightnessOverlay?.let { + it.alpha = currentExtraBrightness } - - if (willExtra) { - level2ProgressBar.progressTintList = ColorStateList.valueOf( - ContextCompat.getColor( - level2ProgressBar.context, if (hasBrightnessBoostError) { - R.color.colorPrimaryRed - } else { - R.color.colorPrimaryOrange - } - ) - ) - } - }*/ + } // Log.i("Brightness", "current: $currentRequestedBrightness, ce: $currentExtraBrightness L1: ${level1ProgressBar.progress}, L2: ${level2ProgressBar.progress}") playerProgressbarRightIcon.setImageResource( - brightnessIcons[min( // clamp the value just in case + brightnessIcons[min( // clamp the value in case of extra brightness brightnessIcons.size - 1, max( 0, - round( - max( - currentRequestedBrightness, - 1.0f - ) * (brightnessIcons.size - 1) - ).toInt() + round(currentRequestedBrightness * (brightnessIcons.size - 1)).toInt() ) )] ) @@ -2345,6 +2361,10 @@ open class FullScreenPlayer : AbstractPlayerFragment() { false ) + extraBrightnessEnabled = settingsManager.getBoolean( + ctx.getString(R.string.extra_brightness_key), + false + ) val profiles = QualityDataHelper.getProfiles() val type = if (ctx.isUsingMobileData()) diff --git a/app/src/main/res/drawable/sun_7_24.xml b/app/src/main/res/drawable/sun_7_24.xml new file mode 100644 index 000000000..26e3f43e8 --- /dev/null +++ b/app/src/main/res/drawable/sun_7_24.xml @@ -0,0 +1,111 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/extra_brightness_overlay.xml b/app/src/main/res/layout/extra_brightness_overlay.xml new file mode 100644 index 000000000..8f82121bb --- /dev/null +++ b/app/src/main/res/layout/extra_brightness_overlay.xml @@ -0,0 +1,8 @@ + + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c5a6bc3b7..4f3a4f5d8 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -164,6 +164,9 @@ Use system brightness in the app player instead of a dark overlay + Extra brightness + Enable brightness filter when 100% display brightness is exceeded + extra_brightness_enabled Update watch progress Automatically sync your current episode progress Restore data from backup diff --git a/app/src/main/res/xml/settings_player.xml b/app/src/main/res/xml/settings_player.xml index 341f95b50..10a51f3c4 100644 --- a/app/src/main/res/xml/settings_player.xml +++ b/app/src/main/res/xml/settings_player.xml @@ -112,11 +112,17 @@ app:defaultValue="true" app:key="@string/preview_seekbar_key" /> + android:icon="@drawable/ic_baseline_extension_24" + android:summary="@string/software_decoding_desc" + android:title="@string/software_decoding" + app:defaultValue="true" + app:key="@string/software_decoding_key" /> + Date: Fri, 6 Feb 2026 01:05:09 +0100 Subject: [PATCH 028/236] Translated using Weblate (Belarusian) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Currently translated at 46.7% (334 of 715 strings) Translated using Weblate (Czech) Currently translated at 100.0% (715 of 715 strings) Translated using Weblate (German) Currently translated at 99.1% (709 of 715 strings) Translated using Weblate (Italian) Currently translated at 100.0% (715 of 715 strings) Translated using Weblate (Turkish) Currently translated at 100.0% (715 of 715 strings) Translated using Weblate (Ukrainian) Currently translated at 100.0% (715 of 715 strings) Translated using Weblate (Vietnamese) Currently translated at 99.8% (714 of 715 strings) Translated using Weblate (Polish) Currently translated at 100.0% (715 of 715 strings) Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 99.8% (714 of 715 strings) Translated using Weblate (Japanese) Currently translated at 100.0% (715 of 715 strings) Translated using Weblate (Belarusian) Currently translated at 40.1% (286 of 712 strings) Translated using Weblate (Japanese) Currently translated at 100.0% (712 of 712 strings) Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 100.0% (712 of 712 strings) Translated using Weblate (Russian) Currently translated at 100.0% (712 of 712 strings) Translated using Weblate (Italian) Currently translated at 100.0% (712 of 712 strings) Translated using Weblate (Polish) Currently translated at 100.0% (712 of 712 strings) Translated using Weblate (Ukrainian) Currently translated at 99.8% (709 of 710 strings) Translated using Weblate (Ukrainian) Currently translated at 99.8% (709 of 710 strings) Merge remote-tracking branch 'origin/master' Translated using Weblate (Spanish) Currently translated at 100.0% (710 of 710 strings) Translated using Weblate (Spanish) Currently translated at 100.0% (710 of 710 strings) Translated using Weblate (Japanese) Currently translated at 100.0% (710 of 710 strings) Translated using Weblate (Turkish) Currently translated at 100.0% (710 of 710 strings) Translated using Weblate (Italian) Currently translated at 100.0% (710 of 710 strings) Translated using Weblate (Czech) Currently translated at 100.0% (710 of 710 strings) Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 100.0% (710 of 710 strings) Translated using Weblate (Polish) Currently translated at 100.0% (710 of 710 strings) Merge remote-tracking branch 'origin/master' Merge remote-tracking branch 'origin/master' Translated using Weblate (German) Currently translated at 100.0% (709 of 709 strings) Merge remote-tracking branch 'origin/master' Merge remote-tracking branch 'origin/master' Translated using Weblate (Russian) Currently translated at 100.0% (709 of 709 strings) Translated using Weblate (Russian) Currently translated at 100.0% (709 of 709 strings) Translated using Weblate (Russian) Currently translated at 99.5% (706 of 709 strings) Translated using Weblate (Spanish) Currently translated at 100.0% (709 of 709 strings) Translated using Weblate (French) Currently translated at 100.0% (709 of 709 strings) Translated using Weblate (Czech) Currently translated at 100.0% (709 of 709 strings) Merge remote-tracking branch 'origin/master' Translated using Weblate (Polish) Currently translated at 100.0% (709 of 709 strings) Translated using Weblate (Japanese) Currently translated at 100.0% (709 of 709 strings) Translated using Weblate (Turkish) Currently translated at 100.0% (709 of 709 strings) Translated using Weblate (Vietnamese) Currently translated at 100.0% (709 of 709 strings) Translated using Weblate (Italian) Currently translated at 100.0% (709 of 709 strings) Translated using Weblate (Ukrainian) Currently translated at 100.0% (709 of 709 strings) Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 100.0% (709 of 709 strings) Translated using Weblate (Spanish) Currently translated at 99.7% (707 of 709 strings) Co-authored-by: AlaxLima Co-authored-by: Ardev Prisec Co-authored-by: Artem Co-authored-by: BruttoDiego Co-authored-by: Bryan Tank Co-authored-by: Christopher Allen Co-authored-by: Dan Co-authored-by: Ettore Atalan Co-authored-by: Fjuro Co-authored-by: Haru Ijima Co-authored-by: Hosted Weblate Co-authored-by: Kehribar <103407696+dpentx@users.noreply.github.com> Co-authored-by: Kraptor123 Co-authored-by: Massimo Pissarello Co-authored-by: Matthaiks Co-authored-by: Mioki Co-authored-by: Nguyễn Tiến Đạt Co-authored-by: Sasha Glazko Co-authored-by: ShowhyT Co-authored-by: Takeru Mikenu Co-authored-by: Theumis Co-authored-by: sam Co-authored-by: Дейв Рандом (MVboss1190) Co-authored-by: Максим Горпиніч Co-authored-by: 大王叫我来巡山 Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/be/ Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/cs/ Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/de/ Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/es/ Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/fr/ Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/it/ Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/ja/ Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/pl/ Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/ru/ Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/tr/ Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/uk/ Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/vi/ Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/zh_Hans/ Translation: Cloudstream/App --- app/src/main/res/values-b+cs/strings.xml | 11 +- app/src/main/res/values-b+de/strings.xml | 4 + app/src/main/res/values-b+es/strings.xml | 36 +++--- app/src/main/res/values-b+fr/strings.xml | 3 + app/src/main/res/values-b+it/strings.xml | 29 +++-- app/src/main/res/values-b+ja/strings.xml | 11 +- app/src/main/res/values-b+pl/strings.xml | 11 +- app/src/main/res/values-b+ru/strings.xml | 20 ++- app/src/main/res/values-b+tr/strings.xml | 11 +- app/src/main/res/values-b+uk/strings.xml | 85 ++++++------ app/src/main/res/values-b+vi/strings.xml | 18 ++- app/src/main/res/values-b+zh/strings.xml | 10 +- app/src/main/res/values-be/strings.xml | 156 ++++++++++++++++++++++- 13 files changed, 323 insertions(+), 82 deletions(-) diff --git a/app/src/main/res/values-b+cs/strings.xml b/app/src/main/res/values-b+cs/strings.xml index df5895639..aa2c8cc11 100644 --- a/app/src/main/res/values-b+cs/strings.xml +++ b/app/src/main/res/values-b+cs/strings.xml @@ -237,7 +237,7 @@ Aktualizovat Upřednostněná kvalita sledování (WiFi) Maximální počet znaků v názvu přehrávače - Rozlišení přehrávače + Zobrazit informace o přehrávači Velikost vyrovnávací paměti videa Délka vyrovnávací paměti videa Mezipaměť videa na disku @@ -749,4 +749,13 @@ Předběžné vydání je již nainstalováno. Nepodařilo se nainstalovat předběžné vydání. Text epizody + Návrhy vyhledávání + Zobrazit návrhy vyhledávání během psaní + Vymazat návrhy + Zobrazit panel vysílání + Extra jas + Povolit filtr jasu při překročení 100 % jasu obrazovky + extra_brightness_enabled + Informace o médiu + Název zdroje diff --git a/app/src/main/res/values-b+de/strings.xml b/app/src/main/res/values-b+de/strings.xml index d1430c9e5..0c3b9a363 100644 --- a/app/src/main/res/values-b+de/strings.xml +++ b/app/src/main/res/values-b+de/strings.xml @@ -734,4 +734,8 @@ Die Installation der Vorabversion ist fehlgeschlagen. Spiel Mirror ab" Episodentext + Empfehlungen suchen + Beim Tippen Suchempfehlungen anzeigen + Empfehlungen löschen + Zusätzliche Helligkeit diff --git a/app/src/main/res/values-b+es/strings.xml b/app/src/main/res/values-b+es/strings.xml index 9748c9d1c..88cb6749d 100644 --- a/app/src/main/res/values-b+es/strings.xml +++ b/app/src/main/res/values-b+es/strings.xml @@ -82,7 +82,7 @@ /%d Esto eliminará %s permanentemente \nEstá seguro? - Está seguro que quiere salir? + ¿Seguro que quieres salir? Continuar Descarga Código de idioma (es_ES) Póster principal @@ -272,11 +272,9 @@ Se aplicarán los cambios al reiniciar la App. Reproductor interno Idioma - Legacy (método antiguo) - Instalador de paquetes - CloudStream no tiene sitios instalados por defecto. Necesitas instalar los sitios desde los repositorios. -\n -\nÚnase a nuestro Discord o busque en línea. + Tradicional + Instalador de programas + CloudStream no tiene sitios instalados por defecto. Necesitas instalar los sitios desde los repositorios. \n \nÚnete a nuestro Discord o busca en línea. Advertencia: ¡CloudStream 3 no asume ninguna responsabilidad por el uso de extensiones de terceros y no brinda ningún soporte para ellas! Mostrar actualizaciones de la aplicación Instalador de APK @@ -432,7 +430,7 @@ Hecho Plugin cargado 18+ - Comenzó la descarga de %1$d %2$s… + Iniciada descarga de %1$d %2$s… Descarga por lotes plugin plugins @@ -587,7 +585,7 @@ Eliminar de favoritos %s \nrestante - Nombre del repositorio y su URL + Nombre y URL del repositorio ¡Copiado! Error al copiar. Por favor, copie el logcat y comuníquese con el soporte de la aplicación. Error al acceder al portapapeles. Inténtelo de nuevo. @@ -598,7 +596,7 @@ No se puede abrir la información de la aplicación CloudStream. Media Audiolibro - Para garantizar descargas y notificaciones ininterrumpidas para programas de televisión suscritos, CloudStream necesita permiso para ejecutarse en segundo plano. Al presionar Aceptar, se le dirigirá a información de la aplicación. Presione «Permitir».\n\nTenga en cuenta que este permiso no significa que CS3 agotará su batería. Solo funcionará en segundo plano cuando sea necesario, como cuando reciba notificaciones o descargue vídeos desde extensiones oficiales. + Para garantizar las notificaciones y descargas sin interrupciones de programas de TV suscritos, CloudStream necesita permiso para ejecutarse en segundo plano. Al presionar Aceptar, se abrirá la información de la aplicación. Presione «Permitir».\n\nTenga en cuenta que este permiso no significa que CS3 consumirá la batería. Solo funcionará en segundo plano cuando es necesario, como cuando se reciben notificaciones o se descargan vídeos desde extensiones oficiales. Reset Próximamente en %s La temporada %1$d y el episodio %2$d se estrenarán en @@ -610,7 +608,7 @@ Autenticación local Imagen del código QR Descartar - Repositorio abierto + Abrir repositorio Visita %s en tu smartphone o ordenador e introduce el código anterior ¡El código PIN ya ha caducado! El código caduca en %1$d mín y %2$d s @@ -665,14 +663,14 @@ Puntuación (Más alta) Episodio (Descendente) Puntuación (Más baja) - Fecha aérea (más nueva) + Fecha de emisión (más nueva) ¡Iniciando el proceso de actualización del plugin! Actualizar complementos manualmente Notificaciones del reproductor Actualizar complementos - Fecha aérea (más antigua) + Fecha de emisión (más antigua) Puntuar %s - %d complemento(s) actualizados correctamente. + ¡%d complemento(s) actualizados correctamente! No se actualizó ningún complemento. Episodio (Ascendente) Reconocimiento de habla no disponible @@ -685,16 +683,16 @@ Poner todos los subtítulos en negrita Radio del fondo El volumen ha excedido 100% - Deslice de nuevo hacia arriba para ir más allá del 100% + Desliza hacia arriba otra vez para sobrepasar 100% Cuántos elementos diferentes pueden descargarse en paralelo Preguntar siempre Descargas en paralelo - Conexiones concurrentes - Cuántas conexiones concurrentes para cada descarga se pueden usar durante un proceso de descarga + Conexiones simultáneas + Cuántas conexiones simultáneas puede usar cada descarga Ir a Descargas Sin conexión a internet. \n\nConéctese a internet y vuelva a intentarlo, o mire sus descargas mientras está sin conexión. Cambios en los límites de la pantalla - Overscan + Sobreexploración Cambios en el tamaño de los pósteres Tamaño del póster %1$d h %2$d m %3$d s @@ -731,4 +729,8 @@ Medio centro Medio derecha Arriba a la izquierda + Sugerencias de Búsqueda + Mostrar sugerencias de búsqueda mientras escribe + Borrar Sugerencias + Mostrar panel de reparto diff --git a/app/src/main/res/values-b+fr/strings.xml b/app/src/main/res/values-b+fr/strings.xml index 32df1d4b7..0511d1cd1 100644 --- a/app/src/main/res/values-b+fr/strings.xml +++ b/app/src/main/res/values-b+fr/strings.xml @@ -726,4 +726,7 @@ La version préliminaire est déjà installée. L\'installation de la version préliminaire a échouée. Texte de l\'épisode + Suggestions de recherche + Afficher des suggestions de recherche pendant la saisie + Effacer les suggestions diff --git a/app/src/main/res/values-b+it/strings.xml b/app/src/main/res/values-b+it/strings.xml index c0de82320..49ba2ec2c 100644 --- a/app/src/main/res/values-b+it/strings.xml +++ b/app/src/main/res/values-b+it/strings.xml @@ -83,7 +83,7 @@ Chiudi Cancella Salva - Velocità video + Velocità lettore Impostazioni sottotitoli Colore testo Colore contorno @@ -114,7 +114,7 @@ Nessuna descrizione trovata Mostra Logcat 🐈 Picture-in-Picture - Continua la riproduzione in un player in miniatura sopra altre applicazioni + Continua la riproduzione in un lettore in miniatura sopra altre applicazioni Pulsante di ridimensionamento del video Rimuovi bordi neri Sottotitoli @@ -256,8 +256,8 @@ Salta questo aggiornamento Aggiorna Qualità di visualizzazione preferita (WiFi) - Limita i caratteri del titolo nel player - Risoluzione video player + Limita i caratteri del titolo nel lettore + Mostra informazioni sul lettore Dimensione cache video Lunghezza buffer video Dimensione cache video su disco @@ -437,8 +437,8 @@ Lingua Prima installa l\'estensione Playlist HLS - Video player preferito - Player interno + Video lettore preferito + Lettore interno App non trovata Tutte le lingue Salta %s @@ -511,11 +511,11 @@ Questo elenco è vuoto. Prova a passare a un altro. File \"safe mode\" trovato! \nAll\'avvio non sarà caricata alcuna estensione finchè il file non verrà rimosso. - Quantità di avanzamento usata quando il player è nascosto + Intervallo di ricerca utilizzato quando il lettore è nascosto TV Android - Quantità di avanzamento usata quando il player è visibile - Player visibile - Quantità di secondi da avanzare - Player nascosto - Quantità di secondi da avanzare + Intervallo di ricerca utilizzato quando il lettore è visibile + Lettore visibile - Intervallo di ricerca + Lettore nascosto - Intervallo di ricerca Registro Avvia Test del provider @@ -756,4 +756,13 @@ La versione pre-release è già installata. Impossibile installare la versione pre-release. Testo dell\'episodio + Suggerimenti per la ricerca + Mostra suggerimenti di ricerca durante la digitazione + Cancella suggerimenti + Mostra pannello cast + Info sui media + Nome sorgente + Luminosità extra + Attiva il filtro di luminosità quando viene superato il 100% della luminosità dello schermo + extra_brightness_enabled diff --git a/app/src/main/res/values-b+ja/strings.xml b/app/src/main/res/values-b+ja/strings.xml index af8f0b54b..a075b8bff 100644 --- a/app/src/main/res/values-b+ja/strings.xml +++ b/app/src/main/res/values-b+ja/strings.xml @@ -298,7 +298,7 @@ コピーされました! オーディオブック メディア - ビデオプレーヤーの解像度 + プレイヤー情報を表示 プレイヤーに速度オプションを追加 削除する項目を選択 オフラインで視聴可能 @@ -708,4 +708,13 @@ プレリリース版は既にインストールされています。 プレリリース版のインストールに失敗しました。 エピソードのテキスト + 検索候補 + 入力中に検索候補を表示する + 候補を消去 + キャストパネルを表示 + ソース名 + メディア情報 + 追加の輝度設定 + 画面の輝度が100%を超えた場合に輝度フィルターを有効にします + 追加の輝度を有効化 diff --git a/app/src/main/res/values-b+pl/strings.xml b/app/src/main/res/values-b+pl/strings.xml index 92097cddc..7ae964085 100644 --- a/app/src/main/res/values-b+pl/strings.xml +++ b/app/src/main/res/values-b+pl/strings.xml @@ -246,7 +246,7 @@ Aktualizacja Domyślna jakość (WiFi) Maksymalna liczba znaków w tytule odtwarzacza - Rozdzielczość odtwarzacza wideo + Pokaż informacje o odtwarzaczu Rozmiar bufora wideo Długość bufora wideo Pamięć podręczna wideo na dysku @@ -737,4 +737,13 @@ Wersja przedpremierowa jest już zainstalowana. Nie udało się zainstalować wersji przedpremierowej. Tekst odcinka + Sugestie wyszukiwania + Pokaż sugestie wyszukiwania podczas pisania + Wyczyść sugestie + Pokaż panel obsady + Nazwa źródła + Informacje o multimediach + Dodatkowa jasność + Włącz filtr jasności, gdy jasność wyświetlacza przekroczy 100% + Włączono dodatkową jasność diff --git a/app/src/main/res/values-b+ru/strings.xml b/app/src/main/res/values-b+ru/strings.xml index a2d50009d..280787438 100644 --- a/app/src/main/res/values-b+ru/strings.xml +++ b/app/src/main/res/values-b+ru/strings.xml @@ -52,7 +52,7 @@ Следующая серия Жанры Поделиться - Открыть в веб обозревателе + Открыть в Браузере Пропустить загрузку Просмотр Приостановлено @@ -305,7 +305,7 @@ Вступление Титры Отметить как просмотренное - Разрешение видеоплеера + Показывать информацию об игроке Предпочтительное качество видео (WiFi) Максимум символов Длинна буфера @@ -655,7 +655,7 @@ Оценка %s Дата %s Дата выпуска (Новейшие) - Программное декодирование позволяет проигрователю воспроизводить видео, которые не поддерживаются вашим устройством, но может быть с задержками или нестабильным воспроизведением при высоком разрешении + Программное декодирование позволяет проигрователю воспроизводить видео, которые не поддерживаются вашим устройством, но может быть с задержками или нестабильным воспроизведением при высоком разрешении. Программное декодирование Уведомление проигрывателя для управления воспроизведением в фоновом режиме Обновить дополнения @@ -707,17 +707,23 @@ Резолюция и название Выравнивание Субтитров Нижний левый - Нижний центральный + Нижний центр Нижний правый Средний левый Средний центр Средний правый - Вверху слева - Вверху центр - Вверху правый + Верхний слева + Верхний центр + Верхний справа Смотреть полностью Установить предварительный выпуск Предварительный выпуск уже установлен. Не удалось установить предварительный выпуск. Текст эпизода + Поиск предложений + Показывать подсказки поиска при вводе текста + Очистить подсказки + Показать панель приведения + Информация о средствах массовой информации + Имя источника diff --git a/app/src/main/res/values-b+tr/strings.xml b/app/src/main/res/values-b+tr/strings.xml index b3fc671b9..ec5d0fb2c 100644 --- a/app/src/main/res/values-b+tr/strings.xml +++ b/app/src/main/res/values-b+tr/strings.xml @@ -257,7 +257,7 @@ Güncelle Tercih edilen görüntü kalitesi (WiFi) Video oynatıcı başlığı karakter üst sınırı - Oynatıcı çözünürlüğü + Oynatıcı bilgisini göster Video arabellek boyutu Video arabellek uzunluğu Diskteki video önbelleği @@ -746,4 +746,13 @@ Ön sürüm zaten indirilmiş. Ön sürüm indirmesi başarısız oldu. Bölüm Başlığı + Arama Önerileri + Yazarken arama önerilerini göster + Önerileri Temizle + Yayın panelini göster + Ekstra parlaklık + Görüntü parlaklığı %100\'ü geçerse parlaklık filtresini aktifleştir + Ekstra parlaklık aktifleştirildi + Medya bilgisi + Kaynağın adı diff --git a/app/src/main/res/values-b+uk/strings.xml b/app/src/main/res/values-b+uk/strings.xml index 5ece00de5..c26077014 100644 --- a/app/src/main/res/values-b+uk/strings.xml +++ b/app/src/main/res/values-b+uk/strings.xml @@ -1,9 +1,9 @@ Плакат - Плакат епізоду - Завантаження скасовано - Змінити постачальника + Постер Епізоду + Завантаження Скасовано + Змінити Постачальника Назад Рейтинг: %.1f Актори: %s @@ -13,9 +13,9 @@ %1$dд %2$dгод %3$dхв %1$dгод %2$dхв %dхв - Головний плакат - Наступний випадковий - Попередній перегляд тла + Головний Постер + Наступний Випадковий + Попередній Перегляд Заднього Фону Швидкість (%.2fx) Знайдено нове оновлення!\n%1$s –> %2$s Пошук @@ -24,25 +24,25 @@ Налаштування Пошук… Пошук на %s… - Немає даних - Більше налаштувань + Немає Даних + Більше Опцій Наступний епізод Жанри - Відкрити в браузері - Пропустити завантаження + Відкрити в Браузері + Пропустити Завантаження Завантаження… Завершено - Заплановано - Покинуто - Переглянути фільм - Переглянути трейлер - Переглянути торент + Планую Дивитися + Скинуто + Відтворити Фільм + Відтворити Трейлер + Транслювати Торрент Повторити з’єднання… Назад - Переглянути епізод + Відтворити Епізод Завантажено Завантаження - Завантаження завершено + Завантаження Завершено Дуб. Суб. Видалити файл @@ -89,23 +89,23 @@ Відтворювати наступний епізод після закінчення поточного Головна CloudStream - Заповнювач + Філлер Відтворити в CloudStream Мережева трансляція - Переглядаю + Переглядання Поділитися Відкладено - Повторно переглядаю + Переглядаю Повторно Завантажити - Переглянути трансляцію + Відтворити Трансляцію Джерела Субтитри - Внутрішнє сховище - Завантаження призупинено - Завантаження розпочато - Не вдалося завантажити - Оновлення розпочато - Помилка завантаження посилань + Внутрішнє Сховище + Завантаження Призупинено + Завантаження Розпочато + Завантаження не Вдалося + Оновлення Розпочато + Помилка Завантаження Посилань Призупинити завантаження Переглянути файл Докладніше @@ -332,7 +332,7 @@ Видалити сайт URL-адреса сервера NGINX Покликання - Роздільність відеопрогравача + Показувати інформацію про програвач Довжина буфера відео Очистити кеш відео та зображень Спричиняє збої, якщо встановлено занадто високе значення на пристроях із малим об’ємом пам’яті, таких як Android TV. @@ -551,7 +551,7 @@ Редагувати обліковий запис Показувати кнопку перемикання орієнтації екрана Обернути - Покликання перезавантажено + Посилання Перезавантажені Автообертання Увімкнути автоматичну зміну орієнтації екрана відповідно до відео Додати налаштування швидкості до програвача @@ -584,7 +584,7 @@ Медіа Скинути Наступний через %s - Сезон %1$d епізод %2$d вийде через + Сезон %1$d Епізод %2$d вийде через Оберіть пристрій для трансляції Трансляція через дзеркало Довідник CloudStream @@ -598,7 +598,7 @@ Термін дії коду закінчується через %1$dхв %2$dс Локальна автентифікація Відхилити - Відтворити з початку + Відтворити з Початку Попередження Видалити розширення Наразі завантажень немає. @@ -606,13 +606,13 @@ Відкрити локальне відео Датою випуску (від нових до старих) Датою випуску (від старих до нових) - Оберіть елементи для видалення - Обрати все - Зняти вибір + Оберіть Елементи для Видалення + Обрати Все + Зняти Вибір Всіх Видалити (%1$d | %2$s) Ви впевнені, що хочете назавжди видалити такі епізоди «%1$s»?\n\n%2$s Ви також назавжди видалите всі епізоди в такому серіалі:\n\n%s - Доступно для перегляду в автономному режимі + Доступно для перегляду в оффлайн режимі Видалити файли Ви впевнені, що хочете назавжди видалити такі елементи?\n\n%s Ви впевнені, що хочете назавжди видалити всі епізоди в такому серіалі?\n\n%s @@ -625,7 +625,7 @@ Не показувати Розташування теки для резервних копій Власний - Це відео – торент, це означає, що ваша діяльність у відео може відстежуватися.\nПереконайтеся, що розумієте, що таке торент, перш ніж продовжити. + Це відео – Торрент, це означає, що ваша відео діяльність може відстежуватися.\nПереконайтеся, що розумієте, що таке Торрент, перед тим як продовжити. Розмір обведення Аудіо Подкаст @@ -653,7 +653,7 @@ Сповіщення програвача для керування відтворенням у фоновому режимі Сповіщення програвача Розпізнавання мовлення недоступне - Говоріть… + Почніть Говорити… Вбудовані Мережеві Радіус тла @@ -701,9 +701,18 @@ Угорі ліворуч Верхній центр Угорі праворуч - Відтворити повну серію + Відтворити Повні Серії Встановити передрелізну версію Попередня версія вже встановлена. Не вдалося встановити попередню версію. Текст епізоду + Пропозиції пошуку + Показувати підказки пошуку під час введення тексту + Очистити пропозиції + Додаткова яскравість + Увімкнути фільтр яскравості при перевищенні 100% яскравості дисплея + extra_brightness_enabled + Показати панель трансляції + Інформація про медіа + Назва джерела diff --git a/app/src/main/res/values-b+vi/strings.xml b/app/src/main/res/values-b+vi/strings.xml index b26c715f3..a4804c9d4 100644 --- a/app/src/main/res/values-b+vi/strings.xml +++ b/app/src/main/res/values-b+vi/strings.xml @@ -257,7 +257,7 @@ Cập nhật Chất lượng xem ưu tiên (WiFi) Kí tự tối đa trên tiêu đề - Nội dung trình phát video + Hiện thông tin trình phát Kích thước bộ nhớ đệm video Thời lượng bộ nhớ đệm Lưu bộ nhớ đệm video trên ổ cứng @@ -417,7 +417,7 @@ Xem kho lưu trữ của cộng đồng Danh sách công khai In hoa toàn bộ phụ đề - Cảnh báo: CloudStream 3 không chịu trách nhiệm về các tiện ích bên thứ ba và không cung cấp bất kỳ sự hỗ trợ nào! + Cảnh báo: CloudStream không chịu trách nhiệm về các tiện ích bên thứ ba và không cung cấp bất kỳ sự hỗ trợ nào! %s (Đã vô hiệu hoá) Âm thanh & Chất lượng Âm thanh @@ -673,7 +673,7 @@ Lỗi mã hóa Giải mã phần mềm cho phép phát các tệp video không được thiết bị của bạn hỗ trợ, nhưng có thể gây ra phản hồi chậm hoặc phát lại không ổn định ở độ phân giải cao. Bộ giải mã ứng dụng - Khởi động lại ứng dụng và chấp nhận cửa sổ Stream Torrent để tiếp tục + Khởi động lại ứng dụng và chấp nhận cửa sổ Stream Torrent để tiếp tục. Kích hoạt torrent trong Cài đặt/Nguồn phim/Thể loại ưu tiên Tải phụ đề đầu tiên có sẵn Âm thanh @@ -745,4 +745,16 @@ Giữa trái Giữa phải Phát trọn bộ loạt phim + Gợi ý tìm kiếm + Hiển thị gợi ý tìm kiếm khi đang nhập + Xóa gợi ý + Cài đặt phiên bản phát hành trước + Bản phát hành trước đã được cài đặt. + Cài đặt bản phát hành trước thất bại. + Chữ của tập + Kích hoạt bộ lọc độ sáng khi độ sáng màn hình vượt quá 100% + Hiển thị bảng điều khiển + Thông tin âm thanh + Độ sáng bổ sung + Tên nguồn diff --git a/app/src/main/res/values-b+zh/strings.xml b/app/src/main/res/values-b+zh/strings.xml index 3c5a78d08..9db07479d 100644 --- a/app/src/main/res/values-b+zh/strings.xml +++ b/app/src/main/res/values-b+zh/strings.xml @@ -259,7 +259,7 @@ 更新 首选播放画质(WiFi) 视频播放器标题最多字符 - 视频播放器标题 + 显示播放器信息 视频缓冲大小 视频缓冲长度 视频缓存存储 @@ -748,4 +748,12 @@ 已安装预发行版。 安装预发行版失败。 剧集文本 + 搜索建议 + 输入时显示搜索建议 + 清除建议 + 显示投屏面板 + 媒体信息 + 源名称 + 额外亮度 + 超过 100% 亮度时启用亮度过滤器 diff --git a/app/src/main/res/values-be/strings.xml b/app/src/main/res/values-be/strings.xml index 64eeaffae..e409f8190 100644 --- a/app/src/main/res/values-be/strings.xml +++ b/app/src/main/res/values-be/strings.xml @@ -12,7 +12,7 @@ %1$d с Плакат Плакат - Плакат эпізода + Плакат серыі Асноўны плакат Наступны выпадковы Назад @@ -128,7 +128,7 @@ \@string/home_play Для карэктнай працы гэтага пастаўшчыка можа спатрэбіцца VPN Гэты пастаўшчык — Torrent, рэкамендуецца VPN - Сайт не пастаўляе метаданых, загрузіць відэа не ўдасца, калі на сайце яго няма. + Вэб-сайт не пастаўляе метаданых, загрузіць відэа не ўдасца, калі на сайце яго няма. Апісанне Сюжэту не знойдзена Апісання не знойдзена @@ -183,4 +183,156 @@ Некаторыя прылады не падтрымліваюць новы ўсталёўшчык пакетаў. Калі абнаўленні не ўсталёўваюцца, паспрабуйце ранейшую версію. Github Выберыце рэжым фільтравання спампоўвання убудоваў + Прапановы пошуку + Паказваць прапановы пошуку падчас уводу тэксту + Ачысціць прапановы + Паказваць склад акцёраў + Аўтаматычна ўсталёўваць усе яшчэ не ўсталяваныя ўбудовы з даданых рэпазіторыяў. + Паказваць абнаўленні праграмы + Аўтаматычна правяраць на новыя абнаўленні пасля адкрыцця праграмы. + Паўтарыць наладжванне + Усталяваць перадфінальную версію + Перадфінальная версія ўжо ўсталявана. + Не ўдалося ўсталяваць перадфінальную версію. + Усталёўшчык APK + Лёгкая праграма для раманаў ад тых жа распрацоўшчыкаў + Праграма для анімэ ад тых жа распрацоўшчыкаў + Далучайцеся да Discord + Даць распрацоўшчыкам бенен + Дадзена бененаў + Мова праграмы + У гэтага пастаўшчыка няма падтрымкі Chromecast + Спасылак не знойдзена + Спасылка скапіравана да буфера абмену + Прайграць серыю + Скінуць да пачатковага значэння + Сезон + %1$s %2$d%3$s + Сезона няма + Серыя + Серый + %1$d-%2$d + %1$d %2$s + Наступны праз %s + Сез + Сер + Серый не знойдзена + Выдаліць + Выдаліць файл + Выдаліць файлы + Выдаліць (%1$d | %2$s) + Скасаваць + Прыпыніць + Пачаць + Няўдала + Пройдзена + Увага + Узнавіць + -30 + +30 + Гэта выдаліць %s назаўсёды\nВы ўпэўнены? + Вы ўпэўнены, што хочаце назаўсёды выдаліць наступныя элементы?\n\n%s + Вы ўпэўнены, што хочаце назаўсёды выдаліць наступныя серыі «%1$s»?\n\n%2$s + Вы таксама назаўсёды выдаліце ўсе серыі гэтага серыяла:\n\n%s + Вы ўпэўнены, што хочаце назаўсёды выдаліце ўсе серыі гэтага серыяла:\n\n%s + %dхв\nзасталося + %s\nзасталося + Бягучы + Завершана + Стан + Год + Рэйтынг + Працягласць + Вэб-сайт + Сціслы агляд + у чарзе + Субцітраў няма + Прадвызначанае + Свабодна + Ужыта + Праграма + Фільмы + Тэлесерыялы + Мультфільмы + Анімэ + Торренты + Дакументальныя фільмы + OVA + Азіяцкія драмы + Прамыя трансляцыі + NSFW + Іншыя + Фільм + Серыял + Мультфільм + Анімэ + OVA + Торрэнт + Дакументальны фільм + Азіяцкая драма + Прамая трансляцыя + NSFW + Відэа + Музыка + Аўдыякніга + Медыя + Аўдыя + Падкаст + Памылка крыніцы + Памылка аддаленага элемента + Памылка паказу + Памылка кадзіравання + Непадтрыманая памылка + Нечаканая памылка прайгравальніка + Памылка спампоўвання, праверце дазвол на сховішча + Глядзець праз Chromecast + Люстэрка Chromecast + Дадатковая яркасць + Уключыць фільтр яркасці калі яркасць дысплэя больш за 100% + extra_brightness_enabled + Трансляцыя праз люстэрку + Глядзець у праграме + Глядзець праз люстэрку" + Глядзець у %s + Аўтаспампоўка + Спампаваць люстэрку + Абнавіць спасылкі + Спампаваць субцітры + Метка якасці + Метка дубляжу + Метка субцітраў + Метка рэйтынгу + Загаловак + Тэкст серыі + Пераключэнне элементаў інтэрфейсу на плакаце + Абнаўленняў не знойдзена + Праверыць на абнаўленні + Блакіроўка + Змена памеру + Крыніца + Прапусціць ОП + Не паказваць зноў + Прапусціць гэта абнаўленне + Абнавіць + Прыярытэтная якасць прагляду (WiFi) + Прыярытэтная якасць прагляду (Мабільная перадача даных) + Максімальная колькасць сімвалаў у загалоўку праглядальніка + Паказваць інфармацыю ў прайгравальніку + Памер буфера відэа + Даўжыня буфера відэа + Кэш відэа на дыску + Ачысціць кэш відэа і відарысаў + Прайгравальнік паказаны — крок перамоткі + Крок перамоткі калі прайгравальнік бачны + Прайгравальнік схаваны — крок перамоткі + Крок перамоткі калі прайгравальнік схаваны + Выклікае збоі пры высокіх значэннях на прыладах з маленькім аб\'ёмам памяці, такіх як Android TV. + Выклікае праблемы пры высокіх значэннях на прыладах з маленькім аб\'ёмам сховішча, такіх як Android TV. + DNS праз HTTPS + Карысна для абходу блакіровак + Проксі GitHub + Не ўдалося дасягнуць GitHub. Уключэнне проксі jsDelivr… + Абыходзьце блакіроўкі спасылак GitHub з дапамогай jsDelivr. Можа затрымаць абнаўленні на некалькі дзён. + Кланіраваць вэб-сайт + Выдаліць вэб-сайт From b370b5b9e7025f75cc4d9e26b23790529bf43ae1 Mon Sep 17 00:00:00 2001 From: Luna712 <142361265+Luna712@users.noreply.github.com> Date: Thu, 5 Feb 2026 18:18:05 -0700 Subject: [PATCH 029/236] Update gradle to 9.3.1 (#2477) --- gradle/wrapper/gradle-wrapper.properties | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 0cbba65dd..0e68c05b4 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,8 +1,8 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists # Binary-only ZIP Checksum: https://gradle.org/release-checksums/ -distributionSha256Sum=0d585f69da091fc5b2beced877feab55a3064d43b8a1d46aeb07996b0915e0e0 -distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.0-bin.zip +distributionSha256Sum=b266d5ff6b90eada6dc3b20cb090e3731302e553a27c5d3e4df1f0d76beaff06 +distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME From 4530c00a71711b4d5c2ebba24fc6ecbb0e0e0b43 Mon Sep 17 00:00:00 2001 From: Phisher98 Date: Mon, 9 Feb 2026 19:28:54 +0530 Subject: [PATCH 030/236] Improvement --- .../com/lagradost/cloudstream3/extractors/GDMirrorbot.kt | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/GDMirrorbot.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/GDMirrorbot.kt index 095add00d..e0fefe8aa 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/GDMirrorbot.kt +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/GDMirrorbot.kt @@ -38,7 +38,13 @@ open class GDMirrorbot : ExtractorApi() { val hostUrl = baseUrl?.let { getBaseUrl(it) } if (finalId != null && myKey != null) { - val apiUrl = "$mainUrl/mymovieapi?$idType=$finalId&key=$myKey" + val apiUrl = if (url.contains("/tv/")) { + val season = Regex("""/tv/\d+/(\d+)/""").find(url)?.groupValues?.get(1) ?: "1" + val episode = Regex("""/tv/\d+/\d+/(\d+)""").find(url)?.groupValues?.get(1) ?: "1" + "$mainUrl/myseriesapi?tmdbid=$finalId&season=$season&epname=$episode&key=$myKey" + } else { + "$mainUrl/mymovieapi?$idType=$finalId&key=$myKey" + } pageText = app.get(apiUrl).text } From 2c62f3fa467d49343a21005f6c74b5d5e5131d0b Mon Sep 17 00:00:00 2001 From: Mioki <22417711+okibcn@users.noreply.github.com> Date: Tue, 10 Feb 2026 16:53:15 -0800 Subject: [PATCH 031/236] Merge pull request #2478 from okibcn/0K_UqloadFixPR --- .../cloudstream3/extractors/Uqload.kt | 41 ++++++++++++++----- .../cloudstream3/utils/ExtractorApi.kt | 8 ++++ 2 files changed, 38 insertions(+), 11 deletions(-) diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Uqload.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Uqload.kt index a267c87ee..803973ef4 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Uqload.kt +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Uqload.kt @@ -2,6 +2,12 @@ package com.lagradost.cloudstream3.extractors import com.lagradost.cloudstream3.utils.* import com.lagradost.cloudstream3.app +import com.lagradost.cloudstream3.SubtitleFile +import com.lagradost.cloudstream3.USER_AGENT +import com.lagradost.cloudstream3.newSubtitleFile +import com.lagradost.cloudstream3.utils.* + +// import android.util.Log class Uqload1 : Uqload() { override var mainUrl = "https://uqload.com" @@ -11,27 +17,40 @@ class Uqload2 : Uqload() { override var mainUrl = "https://uqload.co" } +class Uqloadcx : Uqload() { + override var mainUrl = "https://uqload.cx" +} + +class Uqloadbz : Uqload() { + override var mainUrl = "https://uqload.bz" +} + open class Uqload : ExtractorApi() { - override val name: String = "Uqload" - override val mainUrl: String = "https://www.uqload.com" - private val srcRegex = Regex("""sources:.\[(.*?)\]""") // would be possible to use the parse and find src attribute + override var name: String = "Uqload" + override var mainUrl: String = "https://www.uqload.com" override val requiresReferer = true + private val srcRegex = Regex("""sources:.*"(.*?)".*""") // would be possible to use the parse and find src attribute - override suspend fun getUrl(url: String, referer: String?): List? { + override suspend fun getUrl( + url: String, + referer: String?, + subtitleCallback: (SubtitleFile) -> Unit, + callback: (ExtractorLink) -> Unit + ) { with(app.get(url)) { // raised error ERROR_CODE_PARSING_CONTAINER_UNSUPPORTED (3003) is due to the response: "error_nofile" - srcRegex.find(this.text)?.groupValues?.get(1)?.replace("\"", "")?.let { link -> - return listOf( + srcRegex.find(this.text)?.groupValues?.get(1)?.let { link -> + // Log.d("CS3debugUQload","decoded URL: $link") + callback.invoke( newExtractorLink( - name, - name, - link + source = name, + name = name, + url = link ) { - this.referer = url + this.referer = "$mainUrl/" } ) } } - return null } } diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/ExtractorApi.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/ExtractorApi.kt index bc9eb6df7..a05479966 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/ExtractorApi.kt +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/ExtractorApi.kt @@ -254,6 +254,8 @@ import com.lagradost.cloudstream3.extractors.UpstreamExtractor import com.lagradost.cloudstream3.extractors.Uqload import com.lagradost.cloudstream3.extractors.Uqload1 import com.lagradost.cloudstream3.extractors.Uqload2 +import com.lagradost.cloudstream3.extractors.Uqloadcx +import com.lagradost.cloudstream3.extractors.Uqloadbz import com.lagradost.cloudstream3.extractors.UqloadsXyz import com.lagradost.cloudstream3.extractors.Urochsunloath import com.lagradost.cloudstream3.extractors.Userload @@ -283,6 +285,8 @@ import com.lagradost.cloudstream3.extractors.Vidguardto3 import com.lagradost.cloudstream3.extractors.VidhideExtractor import com.lagradost.cloudstream3.extractors.Vidmoly import com.lagradost.cloudstream3.extractors.Vidmolyme +import com.lagradost.cloudstream3.extractors.Vidmolyto +import com.lagradost.cloudstream3.extractors.Vidmolybiz import com.lagradost.cloudstream3.extractors.Vidnest import com.lagradost.cloudstream3.extractors.Vido import com.lagradost.cloudstream3.extractors.Vidoza @@ -996,6 +1000,8 @@ val extractorApis: MutableList = arrayListOf( Uqload(), Uqload1(), Uqload2(), + Uqloadcx(), + Uqloadbz(), Evoload(), Evoload1(), UpstreamExtractor(), @@ -1129,6 +1135,8 @@ val extractorApis: MutableList = arrayListOf( Streamplay(), Vidmoly(), Vidmolyme(), + Vidmolyto(), + Vidmolybiz(), Voe(), Voe1(), Tubeless(), From cf084ac2eb726e2a1bcbebd50657e38b599f0e1d Mon Sep 17 00:00:00 2001 From: CranberrySoup <142951702+CranberrySoup@users.noreply.github.com> Date: Wed, 11 Feb 2026 18:30:48 +0000 Subject: [PATCH 032/236] Download rework (#2037) --- app/src/main/AndroidManifest.xml | 6 + .../lagradost/cloudstream3/MainActivity.kt | 37 +- .../cloudstream3/plugins/PluginManager.kt | 8 +- .../services/DownloadQueueService.kt | 262 ++++ .../services/SubscriptionWorkManager.kt | 2 +- .../services/VideoDownloadService.kt | 19 +- .../ui/download/DownloadAdapter.kt | 19 +- .../ui/download/DownloadButtonSetup.kt | 24 +- .../ui/download/DownloadFragment.kt | 19 +- .../ui/download/DownloadViewModel.kt | 161 ++- .../ui/download/button/BaseFetchButton.kt | 6 +- .../ui/download/button/DownloadButton.kt | 5 +- .../ui/download/button/PieFetchButton.kt | 44 +- .../ui/download/queue/DownloadQueueAdapter.kt | 274 ++++ .../download/queue/DownloadQueueFragment.kt | 79 ++ .../download/queue/DownloadQueueViewModel.kt | 43 + .../cloudstream3/ui/home/HomeViewModel.kt | 6 +- .../ui/player/DownloadFileGenerator.kt | 6 +- .../cloudstream3/ui/player/GeneratorPlayer.kt | 2 +- .../cloudstream3/ui/result/EpisodeAdapter.kt | 17 +- .../ui/result/ResultFragmentPhone.kt | 61 +- .../ui/result/ResultViewModel2.kt | 322 ++--- .../cloudstream3/ui/search/SearchHelper.kt | 4 +- .../ui/settings/SettingsGeneral.kt | 15 +- .../ui/settings/SettingsUpdates.kt | 3 +- .../cloudstream3/utils/AppContextUtils.kt | 6 +- .../cloudstream3/utils/BackupUtils.kt | 31 +- .../lagradost/cloudstream3/utils/DataStore.kt | 2 + .../cloudstream3/utils/DataStoreHelper.kt | 7 +- .../utils/DownloadFileWorkManager.kt | 104 -- .../cloudstream3/utils/SubtitleUtils.kt | 5 +- .../cloudstream3/utils/VideoDownloadHelper.kt | 55 - .../downloader/DownloadFileManagement.kt | 132 ++ .../DownloadManager.kt} | 1185 ++++++++--------- .../utils/downloader/DownloadObjects.kt | 222 +++ .../utils/downloader/DownloadQueueManager.kt | 250 ++++ .../utils/downloader/DownloadUtils.kt | 164 +++ app/src/main/res/drawable/clear_all_24px.xml | 12 + .../res/drawable/dashed_line_horizontal.xml | 10 + .../res/drawable/netflix_download_batch.xml | 19 + .../drawable/round_keyboard_arrow_up_24.xml | 13 + .../main/res/layout/download_queue_item.xml | 122 ++ .../res/layout/fragment_download_queue.xml | 64 + .../main/res/layout/fragment_downloads.xml | 94 +- app/src/main/res/layout/fragment_result.xml | 177 +-- app/src/main/res/menu/download_queue.xml | 10 + .../main/res/navigation/mobile_navigation.xml | 25 +- app/src/main/res/values/strings.xml | 16 + 48 files changed, 2914 insertions(+), 1255 deletions(-) create mode 100644 app/src/main/java/com/lagradost/cloudstream3/services/DownloadQueueService.kt create mode 100644 app/src/main/java/com/lagradost/cloudstream3/ui/download/queue/DownloadQueueAdapter.kt create mode 100644 app/src/main/java/com/lagradost/cloudstream3/ui/download/queue/DownloadQueueFragment.kt create mode 100644 app/src/main/java/com/lagradost/cloudstream3/ui/download/queue/DownloadQueueViewModel.kt create mode 100644 app/src/main/java/com/lagradost/cloudstream3/utils/downloader/DownloadFileManagement.kt rename app/src/main/java/com/lagradost/cloudstream3/utils/{VideoDownloadManager.kt => downloader/DownloadManager.kt} (69%) create mode 100644 app/src/main/java/com/lagradost/cloudstream3/utils/downloader/DownloadObjects.kt create mode 100644 app/src/main/java/com/lagradost/cloudstream3/utils/downloader/DownloadQueueManager.kt create mode 100644 app/src/main/java/com/lagradost/cloudstream3/utils/downloader/DownloadUtils.kt create mode 100644 app/src/main/res/drawable/clear_all_24px.xml create mode 100644 app/src/main/res/drawable/dashed_line_horizontal.xml create mode 100644 app/src/main/res/drawable/netflix_download_batch.xml create mode 100644 app/src/main/res/drawable/round_keyboard_arrow_up_24.xml create mode 100644 app/src/main/res/layout/download_queue_item.xml create mode 100644 app/src/main/res/layout/fragment_download_queue.xml create mode 100644 app/src/main/res/menu/download_queue.xml diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 9e1bc9ac9..56622aab9 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -216,6 +216,12 @@ android:foregroundServiceType="dataSync" android:exported="false" /> + + { + 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 } @@ -1164,13 +1174,7 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa app.initClient(this) val settingsManager = PreferenceManager.getDefaultSharedPreferences(this) - val errorFile = filesDir.resolve("last_error") - if (errorFile.exists() && errorFile.isFile) { - lastError = errorFile.readText(Charset.defaultCharset()) - errorFile.delete() - } else { - lastError = null - } + setLastError(this) val settingsForProvider = SettingsJson() settingsForProvider.enableAdult = @@ -2032,6 +2036,9 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa updateLocale() runDefault() } + + // Start the download queue + DownloadQueueManager.init(this) } /** Biometric stuff **/ diff --git a/app/src/main/java/com/lagradost/cloudstream3/plugins/PluginManager.kt b/app/src/main/java/com/lagradost/cloudstream3/plugins/PluginManager.kt index 1b5d2909c..ba3357102 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/plugins/PluginManager.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/plugins/PluginManager.kt @@ -29,6 +29,7 @@ import com.lagradost.cloudstream3.CommonActivity.showToast import com.lagradost.cloudstream3.MainAPI import com.lagradost.cloudstream3.MainAPI.Companion.settingsForProvider import com.lagradost.cloudstream3.MainActivity.Companion.afterPluginsLoadedEvent +import com.lagradost.cloudstream3.MainActivity.Companion.lastError import com.lagradost.cloudstream3.PROVIDER_STATUS_DOWN import com.lagradost.cloudstream3.PROVIDER_STATUS_OK import com.lagradost.cloudstream3.R @@ -51,7 +52,7 @@ import com.lagradost.cloudstream3.utils.Coroutines.main import com.lagradost.cloudstream3.utils.ExtractorApi import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute import com.lagradost.cloudstream3.utils.UiText -import com.lagradost.cloudstream3.utils.VideoDownloadManager.sanitizeFilename +import com.lagradost.cloudstream3.utils.downloader.DownloadFileManagement.sanitizeFilename import com.lagradost.cloudstream3.utils.extractorApis import com.lagradost.cloudstream3.utils.txt import dalvik.system.PathClassLoader @@ -572,6 +573,11 @@ object PluginManager { afterPluginsLoadedEvent.invoke(forceReload) } + /** @return true if safe mode is enabled in any possible way. */ + fun isSafeMode(): Boolean { + return checkSafeModeFile() || lastError != null + } + /** * This can be used to override any extension loading to fix crashes! * @return true if safe mode file is present diff --git a/app/src/main/java/com/lagradost/cloudstream3/services/DownloadQueueService.kt b/app/src/main/java/com/lagradost/cloudstream3/services/DownloadQueueService.kt new file mode 100644 index 000000000..37b9a1002 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/services/DownloadQueueService.kt @@ -0,0 +1,262 @@ +package com.lagradost.cloudstream3.services + +import android.app.Service +import android.content.Context +import android.content.Intent +import android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC +import android.os.Build.VERSION.SDK_INT +import android.os.IBinder +import android.util.Log +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import androidx.core.app.PendingIntentCompat +import com.lagradost.cloudstream3.CloudStreamApp.Companion.removeKey +import com.lagradost.cloudstream3.MainActivity +import com.lagradost.cloudstream3.MainActivity.Companion.lastError +import com.lagradost.cloudstream3.MainActivity.Companion.setLastError +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.mvvm.debugAssert +import com.lagradost.cloudstream3.mvvm.debugWarning +import com.lagradost.cloudstream3.mvvm.logError +import com.lagradost.cloudstream3.mvvm.safe +import com.lagradost.cloudstream3.plugins.PluginManager +import com.lagradost.cloudstream3.utils.AppContextUtils.createNotificationChannel +import com.lagradost.cloudstream3.utils.Coroutines.ioSafe +import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute +import com.lagradost.cloudstream3.utils.downloader.DownloadQueueManager +import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager +import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager.KEY_RESUME_IN_QUEUE +import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager.KEY_RESUME_PACKAGES +import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager.downloadEvent +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.takeWhile +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.flow.updateAndGet +import kotlinx.coroutines.withTimeoutOrNull +import kotlin.system.measureTimeMillis +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.seconds + +class DownloadQueueService : Service() { + companion object { + const val TAG = "DownloadQueueService" + const val DOWNLOAD_QUEUE_CHANNEL_ID = "cloudstream3.download.queue" + const val DOWNLOAD_QUEUE_CHANNEL_NAME = "Download queue service" + const val DOWNLOAD_QUEUE_CHANNEL_DESCRIPTION = "App download queue notification." + const val DOWNLOAD_QUEUE_NOTIFICATION_ID = 917194232 // Random unique + @Volatile + var isRunning = false + + fun getIntent( + context: Context, + ): Intent { + return Intent(context, DownloadQueueService::class.java) + } + + private val _downloadInstances: MutableStateFlow> = + MutableStateFlow(emptyList()) + + /** Flow of all active downloads, not queued. May temporarily contain completed or failed EpisodeDownloadInstances. + * Completed or failed instances are automatically removed by the download queue service. + * + */ + val downloadInstances: StateFlow> = + _downloadInstances + + private val totalDownloadFlow = + downloadInstances.combine(DownloadQueueManager.queue) { instances, queue -> + instances to queue + } + .combine(VideoDownloadManager.currentDownloads) { (instances, queue), currentDownloads -> + Triple(instances, queue, currentDownloads) + } + } + + + private val baseNotification by lazy { + val intent = Intent(this, MainActivity::class.java) + val pendingIntent = + PendingIntentCompat.getActivity(this, 0, intent, 0, false) + + val activeDownloads = resources.getQuantityString(R.plurals.downloads_active, 0).format(0) + val activeQueue = resources.getQuantityString(R.plurals.downloads_queued, 0).format(0) + + NotificationCompat.Builder(this, DOWNLOAD_QUEUE_CHANNEL_ID) + .setOngoing(true) // Make it persistent + .setAutoCancel(false) + .setColorized(false) + .setOnlyAlertOnce(true) + .setSilent(true) + .setShowWhen(false) + // If low priority then the notification might not show :( + .setPriority(NotificationCompat.PRIORITY_DEFAULT) + .setColor(this.colorFromAttribute(R.attr.colorPrimary)) + .setContentText(activeDownloads) + .setSubText(activeQueue) + .setContentIntent(pendingIntent) + .setSmallIcon(R.drawable.download_icon_load) + } + + + private fun updateNotification(context: Context, downloads: Int, queued: Int) { + val activeDownloads = + resources.getQuantityString(R.plurals.downloads_active, downloads).format(downloads) + val activeQueue = + resources.getQuantityString(R.plurals.downloads_queued, queued).format(queued) + + val newNotification = baseNotification + .setContentText(activeDownloads) + .setSubText(activeQueue) + .build() + + safe { + NotificationManagerCompat.from(context) + .notify(DOWNLOAD_QUEUE_NOTIFICATION_ID, newNotification) + } + } + + // We always need to listen to events, even before the download is launched. + // Stopping link loading is an event which can trigger before downloading. + val downloadEventListener = { event: Pair -> + when (event.second) { + VideoDownloadManager.DownloadActionType.Stop -> { + removeKey(KEY_RESUME_PACKAGES, event.first.toString()) + removeKey(KEY_RESUME_IN_QUEUE, event.first.toString()) + DownloadQueueManager.cancelDownload(event.first) + } + + else -> {} + } + } + + @OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class) + override fun onCreate() { + isRunning = true + val context: Context = this // To make code more readable + + Log.d(TAG, "Download queue service started.") + this.createNotificationChannel( + DOWNLOAD_QUEUE_CHANNEL_ID, + DOWNLOAD_QUEUE_CHANNEL_NAME, + DOWNLOAD_QUEUE_CHANNEL_DESCRIPTION + ) + if (SDK_INT >= 29) { + startForeground( + DOWNLOAD_QUEUE_NOTIFICATION_ID, + baseNotification.build(), + FOREGROUND_SERVICE_TYPE_DATA_SYNC + ) + } else { + startForeground(DOWNLOAD_QUEUE_NOTIFICATION_ID, baseNotification.build()) + } + + downloadEvent += downloadEventListener + + val queueJob = ioSafe { + // Ensure this is up to date to prevent race conditions with MainActivity launches + setLastError(context) + // Early return, to prevent waiting for plugins in safe mode + if (lastError != null) return@ioSafe + + // Try to ensure all plugins are loaded before starting the downloader. + // To prevent infinite stalls we use a timeout of 15 seconds, it is judged as long enough + val timeout = 15.seconds + val timeTaken = withTimeoutOrNull(timeout) { + measureTimeMillis { + while (!(PluginManager.loadedOnlinePlugins && PluginManager.loadedLocalPlugins)) { + delay(100.milliseconds) + } + } + } + + debugWarning({ timeTaken == null || timeTaken > 3_000 }, { + "Abnormally long downloader startup time of: ${timeTaken ?: timeout.inWholeMilliseconds}ms" + }) + debugAssert({ timeTaken == null }, { "Downloader startup should not time out" }) + + totalDownloadFlow + .takeWhile { (instances, queue) -> + // Stop if destroyed + isRunning + // Run as long as there is a queue to process + && (instances.isNotEmpty() || queue.isNotEmpty()) + // Run as long as there are no app crashes + && lastError == null + } + .collect { (_, queue, currentDownloads) -> + // Remove completed or failed + val newInstances = _downloadInstances.updateAndGet { currentInstances -> + currentInstances.filterNot { it.isCompleted || it.isFailed || it.isCancelled } + } + + val maxDownloads = VideoDownloadManager.maxConcurrentDownloads(context) + val currentInstanceCount = newInstances.size + + val newDownloads = minOf( + // Cannot exceed the max downloads + maxOf(0, maxDownloads - currentInstanceCount), + // Cannot start more downloads than the queue size + queue.size + ) + + // Cant start multiple downloads at once. If this is rerun it may start too many downloads. + if (newDownloads > 0) { + _downloadInstances.update { instances -> + val downloadInstance = DownloadQueueManager.popQueue(context) + if (downloadInstance != null) { + downloadInstance.startDownload() + instances + downloadInstance + } else { + instances + } + } + } + + // The downloads actually displayed to the user with a notification + val currentVisualDownloads = + currentDownloads.size + newInstances.count { + currentDownloads.contains(it.downloadQueueWrapper.id) + .not() + } + // Just the queue + val currentVisualQueue = queue.size + + updateNotification(context, currentVisualDownloads, currentVisualQueue) + } + } + + // Stop self regardless of job outcome + queueJob.invokeOnCompletion { throwable -> + if (throwable != null) { + logError(throwable) + } + safe { + stopSelf() + } + } + } + + override fun onDestroy() { + Log.d(TAG, "Download queue service stopped.") + downloadEvent -= downloadEventListener + isRunning = false + super.onDestroy() + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + return START_STICKY // We want the service restarted if its killed + } + + override fun onBind(intent: Intent?): IBinder? = null + + override fun onTimeout(reason: Int) { + stopSelf() + Log.e(TAG, "Service stopped due to timeout: $reason") + } + +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/services/SubscriptionWorkManager.kt b/app/src/main/java/com/lagradost/cloudstream3/services/SubscriptionWorkManager.kt index fc31c1f3e..242f08129 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/services/SubscriptionWorkManager.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/services/SubscriptionWorkManager.kt @@ -22,7 +22,7 @@ import com.lagradost.cloudstream3.utils.DataStoreHelper import com.lagradost.cloudstream3.utils.DataStoreHelper.getAllSubscriptions import com.lagradost.cloudstream3.utils.DataStoreHelper.getDub import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute -import com.lagradost.cloudstream3.utils.VideoDownloadManager.getImageBitmapFromUrl +import com.lagradost.cloudstream3.utils.downloader.DownloadUtils.getImageBitmapFromUrl import kotlinx.coroutines.withTimeoutOrNull import java.util.concurrent.TimeUnit diff --git a/app/src/main/java/com/lagradost/cloudstream3/services/VideoDownloadService.kt b/app/src/main/java/com/lagradost/cloudstream3/services/VideoDownloadService.kt index 6151a0edd..d63b18cdc 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/services/VideoDownloadService.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/services/VideoDownloadService.kt @@ -2,12 +2,13 @@ package com.lagradost.cloudstream3.services import android.app.Service import android.content.Intent import android.os.IBinder -import com.lagradost.cloudstream3.utils.VideoDownloadManager +import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.cancel import kotlinx.coroutines.launch +/** Handle notification actions such as pause/resume downloads */ class VideoDownloadService : Service() { private val downloadScope = CoroutineScope(Dispatchers.Default) @@ -42,19 +43,3 @@ class VideoDownloadService : Service() { super.onDestroy() } } -// override fun onHandleIntent(intent: Intent?) { -// if (intent != null) { -// val id = intent.getIntExtra("id", -1) -// val type = intent.getStringExtra("type") -// if (id != -1 && type != null) { -// val state = when (type) { -// "resume" -> VideoDownloadManager.DownloadActionType.Resume -// "pause" -> VideoDownloadManager.DownloadActionType.Pause -// "stop" -> VideoDownloadManager.DownloadActionType.Stop -// else -> return -// } -// VideoDownloadManager.downloadEvent.invoke(Pair(id, state)) -// } -// } -// } -//} diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadAdapter.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadAdapter.kt index d0740f66a..1b48143a6 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadAdapter.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadAdapter.kt @@ -19,7 +19,7 @@ import com.lagradost.cloudstream3.ui.download.button.DownloadStatusTell import com.lagradost.cloudstream3.utils.AppContextUtils.getNameFull import com.lagradost.cloudstream3.utils.DataStoreHelper.getViewPos import com.lagradost.cloudstream3.utils.ImageLoader.loadImage -import com.lagradost.cloudstream3.utils.VideoDownloadHelper +import com.lagradost.cloudstream3.utils.downloader.DownloadObjects const val DOWNLOAD_ACTION_PLAY_FILE = 0 const val DOWNLOAD_ACTION_DELETE_FILE = 1 @@ -27,6 +27,7 @@ const val DOWNLOAD_ACTION_RESUME_DOWNLOAD = 2 const val DOWNLOAD_ACTION_PAUSE_DOWNLOAD = 3 const val DOWNLOAD_ACTION_DOWNLOAD = 4 const val DOWNLOAD_ACTION_LONG_CLICK = 5 +const val DOWNLOAD_ACTION_CANCEL_PENDING = 6 const val DOWNLOAD_ACTION_GO_TO_CHILD = 0 const val DOWNLOAD_ACTION_LOAD_RESULT = 1 @@ -34,22 +35,22 @@ const val DOWNLOAD_ACTION_LOAD_RESULT = 1 sealed class VisualDownloadCached { abstract val currentBytes: Long abstract val totalBytes: Long - abstract val data: VideoDownloadHelper.DownloadCached + abstract val data: DownloadObjects.DownloadCached abstract var isSelected: Boolean data class Child( override val currentBytes: Long, override val totalBytes: Long, - override val data: VideoDownloadHelper.DownloadEpisodeCached, + override val data: DownloadObjects.DownloadEpisodeCached, override var isSelected: Boolean, ) : VisualDownloadCached() data class Header( override val currentBytes: Long, override val totalBytes: Long, - override val data: VideoDownloadHelper.DownloadHeaderCached, + override val data: DownloadObjects.DownloadHeaderCached, override var isSelected: Boolean, - val child: VideoDownloadHelper.DownloadEpisodeCached?, + val child: DownloadObjects.DownloadEpisodeCached?, val currentOngoingDownloads: Int, val totalDownloads: Int, ) : VisualDownloadCached() @@ -57,12 +58,12 @@ sealed class VisualDownloadCached { data class DownloadClickEvent( val action: Int, - val data: VideoDownloadHelper.DownloadEpisodeCached + val data: DownloadObjects.DownloadEpisodeCached ) data class DownloadHeaderClickEvent( val action: Int, - val data: VideoDownloadHelper.DownloadHeaderCached + val data: DownloadObjects.DownloadHeaderCached ) class DownloadAdapter( @@ -170,6 +171,7 @@ class DownloadAdapter( } } + downloadButton.resetView() val status = downloadButton.getStatus(card.child.id, card.currentBytes, card.totalBytes) if (status == DownloadStatusTell.IsDone) { // We do this here instead if we are finished downloading @@ -187,7 +189,6 @@ class DownloadAdapter( } else { // We need to make sure we restore the correct progress // when we refresh data in the adapter. - downloadButton.resetView() val drawable = downloadButton.getDrawableFromStatus(status)?.let { ContextCompat.getDrawable(downloadButton.context, it) } @@ -277,6 +278,7 @@ class DownloadAdapter( } } + downloadButton.resetView() val status = downloadButton.getStatus(data.id, card.currentBytes, card.totalBytes) if (status == DownloadStatusTell.IsDone) { // We do this here instead if we are finished downloading @@ -295,7 +297,6 @@ class DownloadAdapter( } else { // We need to make sure we restore the correct progress // when we refresh data in the adapter. - downloadButton.resetView() val drawable = downloadButton.getDrawableFromStatus(status)?.let { ContextCompat.getDrawable(downloadButton.context, it) } 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 295feffe8..884eebd62 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 @@ -18,8 +18,9 @@ import com.lagradost.cloudstream3.utils.DOWNLOAD_EPISODE_CACHE import com.lagradost.cloudstream3.utils.DOWNLOAD_HEADER_CACHE import com.lagradost.cloudstream3.utils.SnackbarHelper.showSnackbar import com.lagradost.cloudstream3.utils.UIHelper.navigate -import com.lagradost.cloudstream3.utils.VideoDownloadHelper -import com.lagradost.cloudstream3.utils.VideoDownloadManager +import com.lagradost.cloudstream3.utils.downloader.DownloadObjects +import com.lagradost.cloudstream3.utils.downloader.DownloadQueueManager +import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager import kotlinx.coroutines.MainScope object DownloadButtonSetup { @@ -82,7 +83,7 @@ object DownloadButtonSetup { } else { val pkg = VideoDownloadManager.getDownloadResumePackage(ctx, id) if (pkg != null) { - VideoDownloadManager.downloadFromResumeUsingWorker(ctx, pkg) + DownloadQueueManager.addToQueue(pkg.toWrapper()) } else { VideoDownloadManager.downloadEvent.invoke( Pair(click.data.id, VideoDownloadManager.DownloadActionType.Resume) @@ -95,7 +96,7 @@ object DownloadButtonSetup { DOWNLOAD_ACTION_LONG_CLICK -> { activity?.let { act -> val length = - VideoDownloadManager.getDownloadFileInfoAndUpdateSettings( + VideoDownloadManager.getDownloadFileInfo( act, click.data.id )?.fileLength @@ -110,24 +111,31 @@ object DownloadButtonSetup { } } + DOWNLOAD_ACTION_CANCEL_PENDING -> { + DownloadQueueManager.cancelDownload(id) + } + DOWNLOAD_ACTION_PLAY_FILE -> { activity?.let { act -> - val parent = getKey( + val parent = getKey( DOWNLOAD_HEADER_CACHE, click.data.parentId.toString() ) ?: return val episodes = getKeys(DOWNLOAD_EPISODE_CACHE) ?.mapNotNull { - getKey(it) + getKey(it) } ?.filter { it.parentId == click.data.parentId } val items = mutableListOf() - val allRelevantEpisodes = episodes?.sortedWith(compareBy { it.season ?: 0 }.thenBy { it.episode }) + val allRelevantEpisodes = + episodes?.sortedWith(compareBy { + it.season ?: 0 + }.thenBy { it.episode }) allRelevantEpisodes?.forEach { - val keyInfo = getKey( + val keyInfo = getKey( VideoDownloadManager.KEY_DOWNLOAD_INFO, it.id.toString() ) ?: return@forEach 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 3bd424640..be9f768a8 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 @@ -29,6 +29,7 @@ import com.lagradost.cloudstream3.mvvm.observe import com.lagradost.cloudstream3.mvvm.observeNullable import com.lagradost.cloudstream3.ui.BaseFragment import com.lagradost.cloudstream3.ui.download.DownloadButtonSetup.handleDownloadClick +import com.lagradost.cloudstream3.ui.download.queue.DownloadQueueViewModel import com.lagradost.cloudstream3.ui.player.BasicLink import com.lagradost.cloudstream3.ui.player.GeneratorPlayer import com.lagradost.cloudstream3.ui.player.LinkGenerator @@ -58,6 +59,7 @@ class DownloadFragment : BaseFragment( ) { private val downloadViewModel: DownloadViewModel by activityViewModels() + private val downloadQueueViewModel: DownloadQueueViewModel by activityViewModels() private fun View.setLayoutWidth(weight: Long) { val param = LinearLayout.LayoutParams( @@ -142,6 +144,17 @@ class DownloadFragment : BaseFragment( binding.downloadApp ) } + observe(downloadQueueViewModel.childCards) { cards -> + 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) { + "$baseText (${cards.currentDownloads.size}/$size)" + } else { + baseText + } + } + observe(downloadViewModel.selectedBytes) { updateDeleteButton(downloadViewModel.selectedItemIds.value?.count() ?: 0, it) } @@ -213,7 +226,7 @@ class DownloadFragment : BaseFragment( setLinearListLayout( isHorizontal = false, nextRight = FOCUS_SELF, - nextDown = FOCUS_SELF, + nextDown = R.id.download_queue_button, ) } @@ -227,6 +240,10 @@ class DownloadFragment : BaseFragment( setOnClickListener { showStreamInputDialog(it.context) } } + downloadQueueButton.setOnClickListener { + activity?.navigate(R.id.action_navigation_global_to_navigation_download_queue) + } + downloadStreamButtonTv.isFocusableInTouchMode = isLayout(TV) downloadAppbar.isFocusableInTouchMode = isLayout(TV) diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadViewModel.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadViewModel.kt index ee69390ff..b7c8d98da 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadViewModel.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadViewModel.kt @@ -5,30 +5,44 @@ import android.content.DialogInterface import android.os.Environment import android.os.StatFs import androidx.appcompat.app.AlertDialog +import androidx.core.content.edit import androidx.lifecycle.LiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.lagradost.api.Log import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.isEpisodeBased import com.lagradost.cloudstream3.mvvm.Resource import com.lagradost.cloudstream3.mvvm.launchSafe import com.lagradost.cloudstream3.mvvm.logError +import com.lagradost.cloudstream3.services.DownloadQueueService +import com.lagradost.cloudstream3.services.DownloadQueueService.Companion.downloadInstances import com.lagradost.cloudstream3.utils.AppContextUtils.getNameFull import com.lagradost.cloudstream3.utils.AppContextUtils.setDefaultFocus import com.lagradost.cloudstream3.utils.ConsistentLiveData +import com.lagradost.cloudstream3.utils.Coroutines.ioSafe +import com.lagradost.cloudstream3.utils.Coroutines.ioWork import com.lagradost.cloudstream3.utils.DOWNLOAD_EPISODE_CACHE +import com.lagradost.cloudstream3.utils.DOWNLOAD_EPISODE_CACHE_BACKUP import com.lagradost.cloudstream3.utils.DOWNLOAD_HEADER_CACHE +import com.lagradost.cloudstream3.utils.DOWNLOAD_HEADER_CACHE_BACKUP import com.lagradost.cloudstream3.utils.DataStore.getFolderName import com.lagradost.cloudstream3.utils.DataStore.getKey import com.lagradost.cloudstream3.utils.DataStore.getKeys +import com.lagradost.cloudstream3.utils.DataStore.getSharedPrefs import com.lagradost.cloudstream3.utils.ResourceLiveData -import com.lagradost.cloudstream3.utils.VideoDownloadHelper -import com.lagradost.cloudstream3.utils.VideoDownloadManager.deleteFilesAndUpdateSettings -import com.lagradost.cloudstream3.utils.VideoDownloadManager.getDownloadFileInfoAndUpdateSettings +import com.lagradost.cloudstream3.utils.downloader.DownloadObjects +import com.lagradost.cloudstream3.utils.downloader.DownloadQueueManager +import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager.deleteFilesAndUpdateSettings +import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager.getDownloadFileInfo import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext class DownloadViewModel : ViewModel() { + companion object { + const val TAG = "DownloadViewModel" + } + private val _headerCards = ResourceLiveData>(Resource.Loading()) val headerCards: LiveData>> = _headerCards @@ -111,23 +125,103 @@ class DownloadViewModel : ViewModel() { } + fun removeRedundantEpisodeKeys(context: Context, keys: List>) { + val settingsManager = context.getSharedPrefs() + ioSafe { + settingsManager.edit { + keys.forEach { (parentId, childId) -> + Log.i(TAG, "Removing download episode key: ${parentId}/${childId}") + val oldPath = getFolderName( + getFolderName( + DOWNLOAD_EPISODE_CACHE, + parentId.toString() + ), + childId.toString() + ) + val newPath = getFolderName( + getFolderName( + DOWNLOAD_EPISODE_CACHE_BACKUP, + parentId.toString() + ), + childId.toString() + ) + + val oldPref = settingsManager.getString(oldPath, null) + // Cowardly future backup solution in case the key removal fails in some edge case. + // This and all backup keys may be removed in a future update if the key removal is proven to be robust. + this.putString(newPath, oldPref) + this.remove(oldPath) + } + } + } + } + + fun removeRedundantHeaderKeys( + context: Context, + cached: List, + totalBytesUsedByChild: Map, + totalDownloads: Map + ) { + val settingsManager = context.getSharedPrefs() + ioSafe { + settingsManager.edit { + cached.forEach { header -> + val downloads = totalDownloads[header.id] ?: 0 + val bytes = totalBytesUsedByChild[header.id] ?: 0 + + if (downloads <= 0 || bytes <= 0) { + Log.i(TAG, "Removing download header key: ${header.id}") + val oldPAth = getFolderName(DOWNLOAD_HEADER_CACHE, header.id.toString()) + val newPath = + getFolderName(DOWNLOAD_HEADER_CACHE_BACKUP, header.id.toString()) + val oldPref = settingsManager.getString(oldPAth, null) + // Cowardly future backup solution in case the key removal fails in some edge case. + // This and all backup keys may be removed in a future update if the key removal is proven to be robust. + this.putString(newPath, oldPref) + this.remove(oldPAth) + } + } + } + } + } + fun updateHeaderList(context: Context) = viewModelScope.launchSafe { // Do not push loading as it interrupts the UI //_headerCards.postValue(Resource.Loading()) - val visual = withContext(Dispatchers.IO) { + val visual = ioWork { val children = context.getKeys(DOWNLOAD_EPISODE_CACHE) - .mapNotNull { context.getKey(it) } + .mapNotNull { context.getKey(it) } .distinctBy { it.id } // Remove duplicates - val (totalBytesUsedByChild, currentBytesUsedByChild, totalDownloads) = + val isCurrentlyDownloading = + DownloadQueueService.isRunning || downloadInstances.value.isNotEmpty() || DownloadQueueManager.queue.value.isNotEmpty() + + val downloadStats = calculateDownloadStats(context, children) val cached = context.getKeys(DOWNLOAD_HEADER_CACHE) - .mapNotNull { context.getKey(it) } + .mapNotNull { context.getKey(it) } + + // Download stats and header keys may change when downloading. + // To prevent the downloader and key removal from colliding, simply do not prune keys when downloading. + if (!isCurrentlyDownloading) { + removeRedundantHeaderKeys( + context, + cached, + downloadStats.totalBytesUsedByChild, + downloadStats.totalDownloads + ) + } + // calculateDownloadStats already performed checks when creating redundantDownloads, no extra check required + removeRedundantEpisodeKeys(context, downloadStats.redundantDownloads) createVisualDownloadList( - context, cached, totalBytesUsedByChild, currentBytesUsedByChild, totalDownloads + context, + cached, + downloadStats.totalBytesUsedByChild, + downloadStats.currentBytesUsedByChild, + downloadStats.totalDownloads ) } @@ -159,20 +253,38 @@ class DownloadViewModel : ViewModel() { })) } + private data class DownloadStats( + val totalBytesUsedByChild: Map, + val currentBytesUsedByChild: Map, + val totalDownloads: Map, + /** Parent ID to child ID. Keys to be removed. */ + val redundantDownloads: List> + ) + private fun calculateDownloadStats( context: Context, - children: List - ): Triple, Map, Map> { + children: List + ): DownloadStats { // parentId : bytes val totalBytesUsedByChild = mutableMapOf() // parentId : bytes val currentBytesUsedByChild = mutableMapOf() // parentId : downloadsCount val totalDownloads = mutableMapOf() + val redundantDownloads = mutableListOf>() children.forEach { child -> - val childFile = - getDownloadFileInfoAndUpdateSettings(context, child.id) ?: return@forEach + val childFile = getDownloadFileInfo(context, child.id) + + if (childFile == null) { + // It may not be a redundant child if something is currently downloading. + // DOWNLOAD_EPISODE_CACHE gets created before KEY_DOWNLOAD_INFO in the downloader + // leading to valid situations where getDownloadFileInfo is null, but we do not want to remove DOWNLOAD_EPISODE_CACHE + if (!DownloadQueueService.isRunning && downloadInstances.value.isEmpty() && DownloadQueueManager.queue.value.isEmpty()) { + redundantDownloads.add(child.parentId to child.id) + } + return@forEach + } if (childFile.fileLength <= 1) return@forEach val len = childFile.totalBytes @@ -182,12 +294,17 @@ class DownloadViewModel : ViewModel() { currentBytesUsedByChild.merge(child.parentId, flen, Long::plus) totalDownloads.merge(child.parentId, 1, Int::plus) } - return Triple(totalBytesUsedByChild, currentBytesUsedByChild, totalDownloads) + return DownloadStats( + totalBytesUsedByChild, + currentBytesUsedByChild, + totalDownloads, + redundantDownloads + ) } private fun createVisualDownloadList( context: Context, - cached: List, + cached: List, totalBytesUsedByChild: Map, currentBytesUsedByChild: Map, totalDownloads: Map @@ -196,11 +313,14 @@ class DownloadViewModel : ViewModel() { val downloads = totalDownloads[it.id] ?: 0 val bytes = totalBytesUsedByChild[it.id] ?: 0 val currentBytes = currentBytesUsedByChild[it.id] ?: 0 - if (bytes <= 0 || downloads <= 0) return@mapNotNull null + + if (bytes <= 0 || downloads <= 0) { + return@mapNotNull null + } val isSelected = selectedItemIds.value?.contains(it.id) ?: false val movieEpisode = - if (it.type.isEpisodeBased()) null else context.getKey( + if (it.type.isEpisodeBased()) null else context.getKey( DOWNLOAD_EPISODE_CACHE, getFolderName(it.id.toString(), it.id.toString()) ) @@ -233,11 +353,10 @@ class DownloadViewModel : ViewModel() { val visual = withContext(Dispatchers.IO) { context.getKeys(folder).mapNotNull { key -> - context.getKey(key) + context.getKey(key) }.mapNotNull { val isSelected = selectedItemIds.value?.contains(it.id) ?: false - val info = - getDownloadFileInfoAndUpdateSettings(context, it.id) ?: return@mapNotNull null + val info = getDownloadFileInfo(context, it.id) ?: return@mapNotNull null VisualDownloadCached.Child( currentBytes = info.fileLength, totalBytes = info.totalBytes, @@ -313,7 +432,7 @@ class DownloadViewModel : ViewModel() { if (item.data.type.isEpisodeBased()) { val episodes = context.getKeys(DOWNLOAD_EPISODE_CACHE) .mapNotNull { - context.getKey( + context.getKey( it ) } @@ -337,7 +456,7 @@ class DownloadViewModel : ViewModel() { is VisualDownloadCached.Child -> { ids.add(item.data.id) - val parent = context.getKey( + val parent = context.getKey( DOWNLOAD_HEADER_CACHE, item.data.parentId.toString() ) diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/download/button/BaseFetchButton.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/download/button/BaseFetchButton.kt index 36a84d9f3..82c4dcc3b 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/download/button/BaseFetchButton.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/download/button/BaseFetchButton.kt @@ -11,7 +11,7 @@ import androidx.core.widget.ContentLoadingProgressBar import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.utils.Coroutines.ioSafe import com.lagradost.cloudstream3.utils.Coroutines.mainWork -import com.lagradost.cloudstream3.utils.VideoDownloadManager +import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager typealias DownloadStatusTell = VideoDownloadManager.DownloadType @@ -78,7 +78,7 @@ abstract class BaseFetchButton(context: Context, attributeSet: AttributeSet) : if (!doSetProgress) return ioSafe { - val savedData = VideoDownloadManager.getDownloadFileInfoAndUpdateSettings(context, id) + val savedData = VideoDownloadManager.getDownloadFileInfo(context, id) mainWork { if (savedData != null) { @@ -87,7 +87,7 @@ abstract class BaseFetchButton(context: Context, attributeSet: AttributeSet) : setProgress(downloadedBytes, totalBytes) applyMetaData(id, downloadedBytes, totalBytes) - } else run { resetView() } + } } } } diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/download/button/DownloadButton.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/download/button/DownloadButton.kt index 20a444611..91c5dd72c 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/download/button/DownloadButton.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/download/button/DownloadButton.kt @@ -8,7 +8,7 @@ import androidx.core.view.isVisible import com.google.android.material.button.MaterialButton import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.ui.download.DownloadClickEvent -import com.lagradost.cloudstream3.utils.VideoDownloadHelper +import com.lagradost.cloudstream3.utils.downloader.DownloadObjects class DownloadButton(context: Context, attributeSet: AttributeSet) : PieFetchButton(context, attributeSet) { @@ -18,6 +18,7 @@ class DownloadButton(context: Context, attributeSet: AttributeSet) : super.onAttachedToWindow() progressText = findViewById(R.id.result_movie_download_text_precentage) mainText = findViewById(R.id.result_movie_download_text) + setStatus(null) } override fun setStatus(status: DownloadStatusTell?) { @@ -35,7 +36,7 @@ class DownloadButton(context: Context, attributeSet: AttributeSet) : } override fun setDefaultClickListener( - card: VideoDownloadHelper.DownloadEpisodeCached, + card: DownloadObjects.DownloadEpisodeCached, textView: TextView?, callback: (DownloadClickEvent) -> Unit ) { diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/download/button/PieFetchButton.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/download/button/PieFetchButton.kt index 3181a1bcd..2fdc94e9c 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/download/button/PieFetchButton.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/download/button/PieFetchButton.kt @@ -15,6 +15,8 @@ import androidx.core.view.isVisible import com.lagradost.cloudstream3.CloudStreamApp.Companion.removeKey import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.mvvm.logError +import com.lagradost.cloudstream3.services.DownloadQueueService.Companion.downloadInstances +import com.lagradost.cloudstream3.ui.download.DOWNLOAD_ACTION_CANCEL_PENDING import com.lagradost.cloudstream3.ui.download.DOWNLOAD_ACTION_DELETE_FILE import com.lagradost.cloudstream3.ui.download.DOWNLOAD_ACTION_DOWNLOAD import com.lagradost.cloudstream3.ui.download.DOWNLOAD_ACTION_LONG_CLICK @@ -23,9 +25,10 @@ import com.lagradost.cloudstream3.ui.download.DOWNLOAD_ACTION_PLAY_FILE import com.lagradost.cloudstream3.ui.download.DOWNLOAD_ACTION_RESUME_DOWNLOAD import com.lagradost.cloudstream3.ui.download.DownloadClickEvent import com.lagradost.cloudstream3.utils.UIHelper.popupMenuNoIcons -import com.lagradost.cloudstream3.utils.VideoDownloadHelper -import com.lagradost.cloudstream3.utils.VideoDownloadManager -import com.lagradost.cloudstream3.utils.VideoDownloadManager.KEY_RESUME_PACKAGES +import com.lagradost.cloudstream3.utils.downloader.DownloadObjects +import com.lagradost.cloudstream3.utils.downloader.DownloadQueueManager.queue +import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager +import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager.KEY_RESUME_PACKAGES open class PieFetchButton(context: Context, attributeSet: AttributeSet) : BaseFetchButton(context, attributeSet) { @@ -138,10 +141,18 @@ open class PieFetchButton(context: Context, attributeSet: AttributeSet) : recycle() } - resetView() + // resetView() onInflate() } + + override fun onAttachedToWindow() { + super.onAttachedToWindow() + // Re-run all animations when the view gets visible. + // Otherwise views may run without animations after recycled + setStatusInternal(currentStatus) + } + private var currentStatus: DownloadStatusTell? = null /*private fun getActivity(): Activity? { var context = context @@ -162,16 +173,31 @@ open class PieFetchButton(context: Context, attributeSet: AttributeSet) : }*/ protected fun setDefaultClickListener( - view: View, textView: TextView?, card: VideoDownloadHelper.DownloadEpisodeCached, + view: View, textView: TextView?, card: DownloadObjects.DownloadEpisodeCached, callback: (DownloadClickEvent) -> Unit ) { this.progressText = textView this.setPersistentId(card.id) view.setOnClickListener { if (isZeroBytes) { - removeKey(KEY_RESUME_PACKAGES, card.id.toString()) - callback(DownloadClickEvent(DOWNLOAD_ACTION_DOWNLOAD, card)) - // callback.invoke(DownloadClickEvent(DOWNLOAD_ACTION_DOWNLOAD, data)) + val localQueue = queue.value + val localInstances = downloadInstances.value + val id = card.id + + // If the download is already in queue or active downloads, provide an option to cancel it + if (localQueue.any { q -> q.id == id } || localInstances.any { i -> i.downloadQueueWrapper.id == id }) { + it.popupMenuNoIcons( + arrayListOf( + Pair(DOWNLOAD_ACTION_CANCEL_PENDING, R.string.cancel), + ) + ) { + callback(DownloadClickEvent(itemId, card)) + } + } else { + // Otherwise just start a download instantly + removeKey(KEY_RESUME_PACKAGES, card.id.toString()) + callback(DownloadClickEvent(DOWNLOAD_ACTION_DOWNLOAD, card)) + } } else { val list = arrayListOf( Pair(DOWNLOAD_ACTION_PLAY_FILE, R.string.popup_play_file), @@ -212,7 +238,7 @@ open class PieFetchButton(context: Context, attributeSet: AttributeSet) : } open fun setDefaultClickListener( - card: VideoDownloadHelper.DownloadEpisodeCached, + card: DownloadObjects.DownloadEpisodeCached, textView: TextView?, callback: (DownloadClickEvent) -> Unit ) { diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/download/queue/DownloadQueueAdapter.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/download/queue/DownloadQueueAdapter.kt new file mode 100644 index 000000000..877fcfea8 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/download/queue/DownloadQueueAdapter.kt @@ -0,0 +1,274 @@ +package com.lagradost.cloudstream3.ui.download.queue + + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.core.view.isGone +import androidx.fragment.app.Fragment +import androidx.recyclerview.widget.ItemTouchHelper +import androidx.recyclerview.widget.RecyclerView +import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.databinding.DownloadQueueItemBinding +import com.lagradost.cloudstream3.ui.BaseAdapter +import com.lagradost.cloudstream3.ui.BaseDiffCallback +import com.lagradost.cloudstream3.ui.ViewHolderState +import com.lagradost.cloudstream3.ui.download.DOWNLOAD_ACTION_CANCEL_PENDING +import com.lagradost.cloudstream3.ui.download.DOWNLOAD_ACTION_DELETE_FILE +import com.lagradost.cloudstream3.ui.download.DOWNLOAD_ACTION_PAUSE_DOWNLOAD +import com.lagradost.cloudstream3.ui.download.DOWNLOAD_ACTION_RESUME_DOWNLOAD +import com.lagradost.cloudstream3.ui.download.DownloadButtonSetup.handleDownloadClick +import com.lagradost.cloudstream3.ui.download.DownloadClickEvent +import com.lagradost.cloudstream3.ui.download.queue.DownloadQueueAdapter.Companion.DOWNLOAD_SEPARATOR_TAG +import com.lagradost.cloudstream3.utils.AppContextUtils.getNameFull +import com.lagradost.cloudstream3.utils.DOWNLOAD_EPISODE_CACHE +import com.lagradost.cloudstream3.utils.DataStore.getFolderName +import com.lagradost.cloudstream3.utils.DataStore.getKey +import com.lagradost.cloudstream3.utils.UIHelper.popupMenuNoIcons +import com.lagradost.cloudstream3.utils.downloader.DownloadObjects +import com.lagradost.cloudstream3.utils.downloader.DownloadObjects.DownloadQueueWrapper +import com.lagradost.cloudstream3.utils.downloader.DownloadQueueManager +import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager +import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager.KEY_DOWNLOAD_INFO + +/** An item in the adapter can either be a separator or a real item. + * isCurrentlyDownloading is used to fully update items as opposed to just moving them. */ +class DownloadAdapterItem(val item: DownloadQueueWrapper?) { + val isSeparator = item == null +} + + +class DownloadQueueAdapter(val fragment: Fragment) : BaseAdapter( + diffCallback = BaseDiffCallback( + itemSame = { a, b -> a.item?.id == b.item?.id }, + contentSame = { a, b -> + a.item == b.item + }) +) { + var currentDownloads = 0 + + companion object { + val DOWNLOAD_SEPARATOR_TAG = "DOWNLOAD_SEPARATOR_TAG" + } + + override fun onCreateContent(parent: ViewGroup): ViewHolderState { + val inflater = LayoutInflater.from(parent.context) + val binding = DownloadQueueItemBinding.inflate(inflater, parent, false) + return ViewHolderState(binding) + } + + override fun onBindContent( + holder: ViewHolderState, + item: DownloadAdapterItem, + position: Int + ) { + when (val binding = holder.view) { + is DownloadQueueItemBinding -> { + if (item.item == null) { + holder.itemView.tag = DOWNLOAD_SEPARATOR_TAG + bindSeparator(binding) + } else { + holder.itemView.tag = null + bind(binding, item.item) + } + } + } + } + + fun submitQueue(newQueue: DownloadAdapterQueue) { + val index = newQueue.currentDownloads.size + val current = newQueue.currentDownloads + val queue = newQueue.queue + currentDownloads = current.size + + val newList = + (current + queue).distinctBy { it.id }.map { DownloadAdapterItem(it) }.toMutableList() + .apply { + // Only add the separator if it actually separates something + if (index < this.size) { + add(index, DownloadAdapterItem(null)) + } + } + submitList(newList) + } + + fun bindSeparator(binding: DownloadQueueItemBinding) { + binding.apply { + separatorHolder.isGone = false + downloadChildEpisodeHolder.isGone = true + } + } + + fun bind( + binding: DownloadQueueItemBinding, + queueWrapper: DownloadQueueWrapper, + ) { + val context = binding.root.context + + binding.apply { + separatorHolder.isGone = true + downloadChildEpisodeHolder.isGone = false + + // Only set the child-text if child and parent are not the same + // This prevents setting movie titles twice + if (queueWrapper.id != queueWrapper.parentId) { + val mainName = queueWrapper.downloadItem?.resultName ?: queueWrapper.resumePackage?.item?.ep?.mainName + downloadChildEpisodeTextExtra.text = mainName + } else { + downloadChildEpisodeTextExtra.text = null + } + + downloadChildEpisodeTextExtra.isGone = downloadChildEpisodeTextExtra.text.isNullOrBlank() + + val status = VideoDownloadManager.downloadStatus[queueWrapper.id] + + downloadButton.setOnClickListener { view -> + val episodeCached = + getKey( + DOWNLOAD_EPISODE_CACHE, + getFolderName(queueWrapper.parentId.toString(), queueWrapper.id.toString()) + ) + + val downloadInfo = context.getKey( + KEY_DOWNLOAD_INFO, + queueWrapper.id.toString() + ) + + val isCurrentlyDownloading = queueWrapper.isCurrentlyDownloading() + + val actionList = arrayListOf>() + + if (isCurrentlyDownloading && episodeCached != null) { + // KEY_DOWNLOAD_INFO is used in the file deletion, and is required to exist to delete anything + if (downloadInfo != null) { + actionList.add(Pair(DOWNLOAD_ACTION_DELETE_FILE, R.string.popup_delete_file)) + } else { + actionList.add(Pair(DOWNLOAD_ACTION_CANCEL_PENDING, R.string.cancel)) + } + + val currentStatus = VideoDownloadManager.downloadStatus[queueWrapper.id] + + when (currentStatus) { + VideoDownloadManager.DownloadType.IsDownloading -> { + actionList.add( + Pair( + DOWNLOAD_ACTION_PAUSE_DOWNLOAD, + R.string.popup_pause_download + ) + ) + } + + VideoDownloadManager.DownloadType.IsPaused -> { + actionList.add( + Pair( + DOWNLOAD_ACTION_RESUME_DOWNLOAD, + R.string.popup_resume_download + ) + ) + } + + else -> {} + } + + view.popupMenuNoIcons( + actionList + ) { + handleDownloadClick(DownloadClickEvent(itemId, episodeCached)) + } + } else { + actionList.add(Pair(DOWNLOAD_ACTION_CANCEL_PENDING, R.string.cancel)) + + view.popupMenuNoIcons( + actionList + ) { + when (itemId) { + DOWNLOAD_ACTION_CANCEL_PENDING -> { + DownloadQueueManager.cancelDownload(queueWrapper.id) + } + } + } + } + } + + downloadButton.resetView() + downloadButton.setStatus(status) + downloadButton.setPersistentId(queueWrapper.id) + + downloadChildEpisodeText.apply { + val name = queueWrapper.downloadItem?.episode?.name + ?: queueWrapper.resumePackage?.item?.ep?.name + val episode = + queueWrapper.downloadItem?.episode?.episode + ?: queueWrapper.resumePackage?.item?.ep?.episode + val season = + queueWrapper.downloadItem?.episode?.season + ?: queueWrapper.resumePackage?.item?.ep?.season + text = context.getNameFull(name, episode, season) + isSelected = true // Needed for text repeating + } + } + } +} + + +class DragAndDropTouchHelper(adapter: DownloadQueueAdapter) : + ItemTouchHelper( + DragAndDropTouchHelperCallback(adapter) + ) + +private class DragAndDropTouchHelperCallback(private val adapter: DownloadQueueAdapter) : + ItemTouchHelper.Callback() { + override fun getMovementFlags( + recyclerView: RecyclerView, + viewHolder: RecyclerView.ViewHolder + ): Int { + val item = adapter.getItem(viewHolder.absoluteAdapterPosition) + val isDownloading = item.item?.isCurrentlyDownloading() == true + val dragFlags = if (item.isSeparator || isDownloading) { + 0 + } else { + ItemTouchHelper.UP or ItemTouchHelper.DOWN // Allow drag up/down + } + + val swipeFlags = 0 // Disable swipe functionality + return makeMovementFlags(dragFlags, swipeFlags) + } + + override fun onMove( + recyclerView: RecyclerView, + source: RecyclerView.ViewHolder, + target: RecyclerView.ViewHolder + ): Boolean { + val fromPosition = source.absoluteAdapterPosition + val toPosition = target.absoluteAdapterPosition + val separatorPosition = adapter.currentDownloads + + val toPositionNoSeparator = + if (separatorPosition < toPosition) toPosition - separatorPosition else toPosition + + if (source.itemView.tag == DOWNLOAD_SEPARATOR_TAG) { + return false + } else { + adapter.getItem(fromPosition).item?.let { downloadQueueInfo -> + DownloadQueueManager.reorderItem( + downloadQueueInfo, + toPositionNoSeparator - 1 + ) + } + } + + return true + } + + override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) { + + } + + override fun isLongPressDragEnabled(): Boolean { + return true // Enable drag with long press + } + + override fun isItemViewSwipeEnabled(): Boolean { + return false // Disable swipe by default + } +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/download/queue/DownloadQueueFragment.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/download/queue/DownloadQueueFragment.kt new file mode 100644 index 000000000..071d8913d --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/download/queue/DownloadQueueFragment.kt @@ -0,0 +1,79 @@ +package com.lagradost.cloudstream3.ui.download.queue + +import android.view.View +import androidx.appcompat.app.AlertDialog +import androidx.core.view.isGone +import androidx.fragment.app.activityViewModels +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.databinding.FragmentDownloadQueueBinding +import com.lagradost.cloudstream3.mvvm.observe +import com.lagradost.cloudstream3.ui.BaseFragment +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.isLandscape +import com.lagradost.cloudstream3.ui.settings.Globals.isLayout +import com.lagradost.cloudstream3.utils.UIHelper.fixSystemBarsPadding +import com.lagradost.cloudstream3.utils.UIHelper.setAppBarNoScrollFlagsOnTV +import com.lagradost.cloudstream3.utils.downloader.DownloadQueueManager +import com.lagradost.cloudstream3.utils.txt + + +class DownloadQueueFragment : + BaseFragment(BindingCreator.Inflate(FragmentDownloadQueueBinding::inflate)) { + private val queueViewModel: DownloadQueueViewModel by activityViewModels() + + override fun onBindingCreated(binding: FragmentDownloadQueueBinding) { + val adapter = DownloadQueueAdapter(this@DownloadQueueFragment) + val clearQueueItem = binding.downloadQueueToolbar.menu?.findItem(R.id.cancel_all) + + observe(queueViewModel.childCards) { cards -> + val size = cards.queue.size + cards.currentDownloads.size + val isEmptyQueue = size == 0 + binding.downloadQueueList.isGone = isEmptyQueue + binding.textNoQueue.isGone = !isEmptyQueue + clearQueueItem?.isVisible = !isEmptyQueue + + adapter.submitQueue(cards) + } + + binding.apply { + downloadQueueToolbar.apply { + title = txt(R.string.download_queue).asString(context) + if (isLayout(PHONE or EMULATOR)) { + setNavigationIcon(R.drawable.ic_baseline_arrow_back_24) + setNavigationOnClickListener { + dispatchBackPressed() + } + } + setAppBarNoScrollFlagsOnTV() + clearQueueItem?.setOnMenuItemClickListener { + AlertDialog.Builder(context, R.style.AlertDialogCustom) + .setTitle(R.string.cancel_all) + .setMessage(R.string.cancel_queue_message) + .setPositiveButton(R.string.yes) { _, _ -> + DownloadQueueManager.removeAllFromQueue() + } + .setNegativeButton(R.string.no) { _, _ -> + }.show() + + true + } + } + + downloadQueueList.adapter = adapter + + // Drag and drop + val helper = DragAndDropTouchHelper(adapter) + helper.attachToRecyclerView(downloadQueueList) + } + } + + override fun fixLayout(view: View) { + fixSystemBarsPadding( + view, + padBottom = isLandscape(), + padLeft = isLayout(TV or EMULATOR) + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/download/queue/DownloadQueueViewModel.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/download/queue/DownloadQueueViewModel.kt new file mode 100644 index 000000000..fc384cb4e --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/download/queue/DownloadQueueViewModel.kt @@ -0,0 +1,43 @@ +package com.lagradost.cloudstream3.ui.download.queue + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.lagradost.cloudstream3.mvvm.launchSafe +import com.lagradost.cloudstream3.services.DownloadQueueService.Companion.downloadInstances +import com.lagradost.cloudstream3.utils.downloader.DownloadObjects +import com.lagradost.cloudstream3.utils.downloader.DownloadQueueManager +import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.launch + +data class DownloadAdapterQueue( + val currentDownloads: List, + val queue: List, +) + +class DownloadQueueViewModel : ViewModel() { + private val _childCards = MutableLiveData() + val childCards: LiveData = _childCards + private val totalDownloadFlow = + downloadInstances.combine(DownloadQueueManager.queue) { instances, queue -> + val current = instances.map { it.downloadQueueWrapper } + DownloadAdapterQueue(current, queue.toList()) + }.combine(VideoDownloadManager.currentDownloads) { total, _ -> + // We want to update the flow when currentDownloads updates, but we do not care about its value + total + } + + init { + viewModelScope.launch { + totalDownloadFlow.collect { queue -> + updateChildList(queue) + } + } + } + + fun updateChildList(downloads: DownloadAdapterQueue) { + _childCards.postValue(downloads) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeViewModel.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeViewModel.kt index 6df5bbbef..e0b7f7024 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeViewModel.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeViewModel.kt @@ -49,7 +49,7 @@ import com.lagradost.cloudstream3.utils.DataStoreHelper.getCurrentAccount import com.lagradost.cloudstream3.utils.DataStoreHelper.getLastWatched import com.lagradost.cloudstream3.utils.DataStoreHelper.getResultWatchState import com.lagradost.cloudstream3.utils.DataStoreHelper.getViewPos -import com.lagradost.cloudstream3.utils.VideoDownloadHelper +import com.lagradost.cloudstream3.utils.downloader.DownloadObjects import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.withContext @@ -68,7 +68,7 @@ class HomeViewModel : ViewModel() { val resumeWatchingResult = withContext(Dispatchers.IO) { resumeWatching?.mapNotNull { resume -> - val data = getKey( + val data = getKey( DOWNLOAD_HEADER_CACHE, resume.parentId.toString() ) ?: return@mapNotNull null @@ -523,7 +523,7 @@ class HomeViewModel : ViewModel() { } else if (api == null) { // API is not found aka not loaded or removed, post the loading // progress if waiting for plugins, otherwise nothing - if (PluginManager.loadedOnlinePlugins || PluginManager.checkSafeModeFile() || lastError != null) { + if (PluginManager.loadedOnlinePlugins || PluginManager.isSafeMode()) { loadAndCancel(noneApi) } else { _page.postValue(Resource.Loading()) 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 4c27dbc97..eb1bcd00d 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 @@ -10,8 +10,8 @@ import com.lagradost.cloudstream3.utils.ExtractorLinkType import com.lagradost.cloudstream3.utils.SubtitleHelper.fromLanguageToTagIETF import com.lagradost.cloudstream3.utils.SubtitleUtils.cleanDisplayName import com.lagradost.cloudstream3.utils.SubtitleUtils.isMatchingSubtitle -import com.lagradost.cloudstream3.utils.VideoDownloadManager.getDownloadFileInfoAndUpdateSettings -import com.lagradost.cloudstream3.utils.VideoDownloadManager.getFolder +import com.lagradost.cloudstream3.utils.downloader.DownloadFileManagement.getFolder +import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager.getDownloadFileInfo class DownloadFileGenerator( episodes: List, @@ -35,7 +35,7 @@ class DownloadFileGenerator( // we actually need it as it can be more expensive. val info = meta.id?.let { id -> activity?.let { act -> - getDownloadFileInfoAndUpdateSettings(act, id) + getDownloadFileInfo(act, id) } } 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 933d5de32..e8d58563d 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 @@ -116,7 +116,7 @@ import com.lagradost.cloudstream3.utils.UIHelper.fixSystemBarsPadding import com.lagradost.cloudstream3.utils.UIHelper.hideSystemUI import com.lagradost.cloudstream3.utils.UIHelper.popCurrentPage import com.lagradost.cloudstream3.utils.UIHelper.toPx -import com.lagradost.cloudstream3.utils.VideoDownloadManager.getImageBitmapFromUrl +import com.lagradost.cloudstream3.utils.downloader.DownloadUtils.getImageBitmapFromUrl import com.lagradost.cloudstream3.utils.setText import com.lagradost.cloudstream3.utils.txt import com.lagradost.safefile.SafeFile diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/result/EpisodeAdapter.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/result/EpisodeAdapter.kt index 818e79d74..7ff3904d8 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/result/EpisodeAdapter.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/result/EpisodeAdapter.kt @@ -32,7 +32,8 @@ import com.lagradost.cloudstream3.utils.AppContextUtils.html import com.lagradost.cloudstream3.utils.Coroutines.main import com.lagradost.cloudstream3.utils.ImageLoader.loadImage import com.lagradost.cloudstream3.utils.UIHelper.toPx -import com.lagradost.cloudstream3.utils.VideoDownloadHelper +import com.lagradost.cloudstream3.utils.downloader.DownloadObjects +import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager import com.lagradost.cloudstream3.utils.setText import com.lagradost.cloudstream3.utils.txt import java.text.DateFormat @@ -160,7 +161,7 @@ class EpisodeAdapter( downloadButton.isVisible = hasDownloadSupport downloadButton.setDefaultClickListener( - VideoDownloadHelper.DownloadEpisodeCached( + DownloadObjects.DownloadEpisodeCached( name = item.name, poster = item.poster, episode = item.episode, @@ -199,6 +200,11 @@ class EpisodeAdapter( } } + val status = VideoDownloadManager.downloadStatus[item.id] + downloadButton.resetView() + downloadButton.setPersistentId(item.id) + downloadButton.setStatus(status) + val name = if (item.name == null) "${episodeText.context.getString(R.string.episode)} ${item.episode}" else "${item.episode}. ${item.name}" episodeFiller.isVisible = item.isFiller == true @@ -376,7 +382,7 @@ class EpisodeAdapter( binding.apply { downloadButton.isVisible = hasDownloadSupport downloadButton.setDefaultClickListener( - VideoDownloadHelper.DownloadEpisodeCached( + DownloadObjects.DownloadEpisodeCached( name = item.name, poster = item.poster, episode = item.episode, @@ -415,6 +421,11 @@ class EpisodeAdapter( } } + val status = VideoDownloadManager.downloadStatus[item.id] + downloadButton.resetView() + downloadButton.setPersistentId(item.id) + downloadButton.setStatus(status) + val name = if (item.name == null) "${episodeText.context.getString(R.string.episode)} ${item.episode}" else "${item.episode}. ${item.name}" episodeFiller.isVisible = item.isFiller == true diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultFragmentPhone.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultFragmentPhone.kt index b0fb9d037..af8d229a9 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultFragmentPhone.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultFragmentPhone.kt @@ -18,6 +18,7 @@ import android.view.animation.DecelerateInterpolator import android.widget.AbsListView import android.widget.ArrayAdapter import android.widget.Toast +import androidx.appcompat.app.AlertDialog import androidx.core.view.isGone import androidx.core.view.isInvisible import androidx.core.view.isVisible @@ -72,6 +73,7 @@ import com.lagradost.cloudstream3.utils.AppContextUtils.updateHasTrailers import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.attachBackPressedCallback import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.detachBackPressedCallback import com.lagradost.cloudstream3.utils.BatteryOptimizationChecker.openBatteryOptimizationSettings +import com.lagradost.cloudstream3.utils.Coroutines.ioSafe import com.lagradost.cloudstream3.utils.ExtractorLink import com.lagradost.cloudstream3.utils.ImageLoader.loadImage import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showBottomDialog @@ -85,12 +87,14 @@ import com.lagradost.cloudstream3.utils.UIHelper.hideKeyboard import com.lagradost.cloudstream3.utils.UIHelper.popCurrentPage import com.lagradost.cloudstream3.utils.UIHelper.populateChips import com.lagradost.cloudstream3.utils.UIHelper.popupMenuNoIconsAndNoStringRes +import com.lagradost.cloudstream3.utils.downloader.DownloadObjects import com.lagradost.cloudstream3.utils.UIHelper.setListViewHeightBasedOnItems import com.lagradost.cloudstream3.utils.UIHelper.setNavigationBarColorCompat -import com.lagradost.cloudstream3.utils.VideoDownloadHelper +import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager import com.lagradost.cloudstream3.utils.getImageFromDrawable import com.lagradost.cloudstream3.utils.setText import com.lagradost.cloudstream3.utils.setTextHtml +import com.lagradost.cloudstream3.utils.txt import java.net.URLEncoder import kotlin.math.roundToInt @@ -700,10 +704,60 @@ open class ResultFragmentPhone : FullScreenPlayer() { // no failure? resultEpisodeLoading.isVisible = episodes is Resource.Loading resultEpisodes.isVisible = episodes is Resource.Success + resultBatchDownloadButton.isVisible = + episodes is Resource.Success && episodes.value.isNotEmpty() + if (episodes is Resource.Success) { (resultEpisodes.adapter as? EpisodeAdapter)?.submitList(episodes.value) + resultBatchDownloadButton.setOnClickListener { view -> + val episodeStart = + episodes.value.firstOrNull()?.episode ?: return@setOnClickListener + val episodeEnd = + episodes.value.lastOrNull()?.episode ?: return@setOnClickListener + + val episodeRange = if (episodeStart == episodeEnd) { + episodeStart.toString() + } else { + txt( + R.string.episodes_range, + episodeStart, + episodeEnd + ).asString(view.context) + } + + val rangeMessage = txt( + R.string.download_episode_range, + episodeRange + ).asString(view.context) + + AlertDialog.Builder(view.context, R.style.AlertDialogCustom) + .setTitle(R.string.download_all) + .setMessage(rangeMessage) + .setPositiveButton(R.string.yes) { _, _ -> + ioSafe { + episodes.value.forEach { episode -> + viewModel.handleAction( + EpisodeClickEvent( + ACTION_DOWNLOAD_EPISODE, + episode + ) + ) + // Join to make the episodes ordered + .join() + } + } + } + .setNegativeButton(R.string.cancel) { _, _ -> + + }.show() + + } + } + + } + } observeNullable(viewModel.movie) { data -> @@ -731,8 +785,11 @@ open class ResultFragmentPhone : FullScreenPlayer() { ) return@setOnLongClickListener true } + + val status = VideoDownloadManager.downloadStatus[ep.id] + downloadButton.setStatus(status) downloadButton.setDefaultClickListener( - VideoDownloadHelper.DownloadEpisodeCached( + DownloadObjects.DownloadEpisodeCached( name = ep.name, poster = ep.poster, episode = 0, 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 4b4d0b5fa..6eab987fc 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 @@ -29,13 +29,22 @@ 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.metaproviders.SyncRedirector -import com.lagradost.cloudstream3.mvvm.* +import com.lagradost.cloudstream3.mvvm.Resource +import com.lagradost.cloudstream3.mvvm.debugAssert +import com.lagradost.cloudstream3.mvvm.debugException +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.runAllAsync +import com.lagradost.cloudstream3.sortUrls import com.lagradost.cloudstream3.syncproviders.AccountManager import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.secondsToReadable -import com.lagradost.cloudstream3.syncproviders.providers.Kitsu import com.lagradost.cloudstream3.syncproviders.SyncAPI +import com.lagradost.cloudstream3.syncproviders.providers.Kitsu import com.lagradost.cloudstream3.ui.APIRepository -import com.lagradost.cloudstream3.ui.download.DOWNLOAD_NAVIGATE_TO +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 @@ -45,9 +54,6 @@ import com.lagradost.cloudstream3.ui.player.LOADTYPE_INAPP_DOWNLOAD import com.lagradost.cloudstream3.ui.player.RepoLinkGenerator import com.lagradost.cloudstream3.ui.player.SubtitleData import com.lagradost.cloudstream3.ui.result.EpisodeAdapter.Companion.getPlayerAction -import com.lagradost.cloudstream3.ui.subtitles.SubtitlesFragment -import com.lagradost.cloudstream3.ui.WatchType -import com.lagradost.cloudstream3.utils.* import com.lagradost.cloudstream3.utils.AppContextUtils.getNameFull import com.lagradost.cloudstream3.utils.AppContextUtils.isConnectedToChromecast import com.lagradost.cloudstream3.utils.AppContextUtils.setDefaultFocus @@ -57,9 +63,12 @@ import com.lagradost.cloudstream3.utils.Coroutines.ioSafe import com.lagradost.cloudstream3.utils.Coroutines.ioWork import com.lagradost.cloudstream3.utils.Coroutines.ioWorkSafe import com.lagradost.cloudstream3.utils.Coroutines.main +import com.lagradost.cloudstream3.utils.DOWNLOAD_HEADER_CACHE +import com.lagradost.cloudstream3.utils.DataStore import com.lagradost.cloudstream3.utils.DataStore.editor import com.lagradost.cloudstream3.utils.DataStore.getFolderName import com.lagradost.cloudstream3.utils.DataStore.setKey +import com.lagradost.cloudstream3.utils.DataStoreHelper import com.lagradost.cloudstream3.utils.DataStoreHelper.currentAccount import com.lagradost.cloudstream3.utils.DataStoreHelper.deleteBookmarkedData import com.lagradost.cloudstream3.utils.DataStoreHelper.getAllBookmarkedData @@ -86,10 +95,31 @@ import com.lagradost.cloudstream3.utils.DataStoreHelper.setResultWatchState import com.lagradost.cloudstream3.utils.DataStoreHelper.setSubscribedData import com.lagradost.cloudstream3.utils.DataStoreHelper.setVideoWatchState import com.lagradost.cloudstream3.utils.DataStoreHelper.updateSubscribedData -import com.lagradost.cloudstream3.utils.SubtitleHelper.fromTagToEnglishLanguageName +import com.lagradost.cloudstream3.utils.Editor +import com.lagradost.cloudstream3.utils.ExtractorLink +import com.lagradost.cloudstream3.utils.ExtractorLinkType +import com.lagradost.cloudstream3.utils.FillerEpisodeCheck +import com.lagradost.cloudstream3.utils.INFER_TYPE +import com.lagradost.cloudstream3.utils.Qualities 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.DownloadUtils.downloadSubtitle +import com.lagradost.cloudstream3.utils.loadExtractor +import com.lagradost.cloudstream3.utils.newExtractorLink +import com.lagradost.cloudstream3.utils.txt +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.cancelChildren +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.isActive +import kotlinx.coroutines.job +import kotlinx.coroutines.launch import java.util.concurrent.TimeUnit -import kotlinx.coroutines.* /** This starts at 1 */ data class EpisodeRange( @@ -668,228 +698,6 @@ class ResultViewModel2 : ViewModel() { index to list }.toMap() } - - private fun downloadSubtitle( - context: Context?, - link: ExtractorSubtitleLink, - fileName: String, - folder: String - ) { - ioSafe { - VideoDownloadManager.downloadThing( - context ?: return@ioSafe, - link, - "$fileName ${link.name}", - folder, - if (link.url.contains(".srt")) "srt" else "vtt", - false, - null, createNotificationCallback = {} - ) - } - } - - private fun getFolder(currentType: TvType, titleName: String): String { - return if (currentType.isEpisodeBased()) { - val sanitizedFileName = VideoDownloadManager.sanitizeFilename(titleName) - "${currentType.getFolderPrefix()}/$sanitizedFileName" - } else currentType.getFolderPrefix() - } - - private fun downloadSubtitle( - context: Context?, - link: SubtitleData, - meta: VideoDownloadManager.DownloadEpisodeMetadata, - ) { - context?.let { ctx -> - val fileName = VideoDownloadManager.getFileName(ctx, meta) - val folder = getFolder(meta.type ?: return, meta.mainName) - downloadSubtitle( - ctx, - ExtractorSubtitleLink(link.name, link.url, "", link.headers), - fileName, - folder - ) - } - } - - fun startDownload( - context: Context?, - episode: ResultEpisode, - currentIsMovie: Boolean, - currentHeaderName: String, - currentType: TvType, - currentPoster: String?, - apiName: String, - parentId: Int, - url: String, - links: List, - subs: List? - ) { - try { - if (context == null) return - - val meta = - getMeta( - episode, - currentHeaderName, - apiName, - currentPoster, - currentIsMovie, - currentType - ) - - val folder = getFolder(currentType, currentHeaderName) - - val src = "$DOWNLOAD_NAVIGATE_TO/$parentId" // url ?: return@let - - // SET VISUAL KEYS - setKey( - DOWNLOAD_HEADER_CACHE, - parentId.toString(), - VideoDownloadHelper.DownloadHeaderCached( - apiName = apiName, - url = url, - type = currentType, - name = currentHeaderName, - poster = currentPoster, - id = parentId, - cacheTime = System.currentTimeMillis(), - ) - ) - - setKey( - DataStore.getFolderName( - DOWNLOAD_EPISODE_CACHE, - parentId.toString() - ), // 3 deep folder for faster acess - episode.id.toString(), - VideoDownloadHelper.DownloadEpisodeCached( - name = episode.name, - poster = episode.poster, - episode = episode.episode, - season = episode.season, - id = episode.id, - parentId = parentId, - score = episode.score, - description = episode.description, - cacheTime = System.currentTimeMillis(), - ) - ) - - // DOWNLOAD VIDEO - VideoDownloadManager.downloadEpisodeUsingWorker( - context, - src,//url ?: return, - folder, - meta, - links - ) - - // 1. Checks if the lang should be downloaded - // 2. Makes it into the download format - // 3. Downloads it as a .vtt file - val downloadList = SubtitlesFragment.getDownloadSubsLanguageTagIETF() - - subs?.filter { subtitle -> - downloadList.any { langTagIETF -> - subtitle.languageCode == langTagIETF || - subtitle.originalName.contains( - fromTagToEnglishLanguageName( - langTagIETF - ) ?: langTagIETF - ) - } - } - ?.map { ExtractorSubtitleLink(it.name, it.url, "", it.headers) } - ?.take(3) // max subtitles download hardcoded (?_?) - ?.forEach { link -> - val fileName = VideoDownloadManager.getFileName(context, meta) - downloadSubtitle(context, link, fileName, folder) - } - } catch (e: Exception) { - logError(e) - } - } - - suspend fun downloadEpisode( - activity: Activity?, - episode: ResultEpisode, - currentIsMovie: Boolean, - currentHeaderName: String, - currentType: TvType, - currentPoster: String?, - apiName: String, - parentId: Int, - url: String, - ) { - ioSafe { - val generator = RepoLinkGenerator(listOf(episode)) - val currentLinks = mutableSetOf() - val currentSubs = mutableSetOf() - generator.generateLinks( - clearCache = false, - sourceTypes = LOADTYPE_INAPP_DOWNLOAD, - callback = { - it.first?.let { link -> - currentLinks.add(link) - } - }, - subtitleCallback = { sub -> - currentSubs.add(sub) - }) - - if (currentLinks.isEmpty()) { - main { - showToast( - R.string.no_links_found_toast, - Toast.LENGTH_SHORT - ) - } - return@ioSafe - } else { - main { - showToast( - R.string.download_started, - Toast.LENGTH_SHORT - ) - } - } - - startDownload( - activity, - episode, - currentIsMovie, - currentHeaderName, - currentType, - currentPoster, - apiName, - parentId, - url, - sortUrls(currentLinks), - sortSubs(currentSubs), - ) - } - } - - private fun getMeta( - episode: ResultEpisode, - titleName: String, - apiName: String, - currentPoster: String?, - currentIsMovie: Boolean, - tvType: TvType, - ): VideoDownloadManager.DownloadEpisodeMetadata { - return VideoDownloadManager.DownloadEpisodeMetadata( - episode.id, - VideoDownloadManager.sanitizeFilename(titleName), - apiName, - episode.poster ?: currentPoster, - episode.name, - if (currentIsMovie) null else episode.season, - if (currentIsMovie) null else episode.episode, - tvType, - ) - } } private val _watchStatus: MutableLiveData = MutableLiveData(WatchType.NONE) @@ -1068,6 +876,28 @@ class ResultViewModel2 : ViewModel() { } } + private fun getMeta( + episode: ResultEpisode, + titleName: String, + apiName: String, + currentPoster: String?, + currentIsMovie: Boolean, + tvType: TvType, + ): DownloadObjects.DownloadEpisodeMetadata { + return DownloadObjects.DownloadEpisodeMetadata( + episode.id, + episode.parentId, + sanitizeFilename(titleName), + apiName, + episode.poster ?: currentPoster, + episode.name, + if (currentIsMovie) null else episode.season, + if (currentIsMovie) null else episode.episode, + tvType, + ) + } + + /** * Toggles the favorite status of an item. * @@ -1613,16 +1443,17 @@ class ResultViewModel2 : ViewModel() { ACTION_DOWNLOAD_EPISODE -> { val response = currentResponse ?: return - downloadEpisode( - activity, - click.data, - response.isMovie(), - response.name, - response.type, - response.posterUrl, - response.apiName, - response.getId(), - response.url + DownloadQueueManager.addToQueue( + DownloadObjects.DownloadQueueItem( + click.data, + response.isMovie(), + response.name, + response.type, + response.posterUrl, + response.apiName, + response.getId(), + response.url, + ).toWrapper() ) } @@ -1633,9 +1464,8 @@ class ResultViewModel2 : ViewModel() { LOADTYPE_INAPP_DOWNLOAD, txt(R.string.episode_action_download_mirror) ) { (result, index) -> - ioSafe { - startDownload( - activity, + DownloadQueueManager.addToQueue( + DownloadObjects.DownloadQueueItem( click.data, response.isMovie(), response.name, @@ -1646,8 +1476,8 @@ class ResultViewModel2 : ViewModel() { response.url, listOf(result.links[index]), result.subs, - ) - } + ).toWrapper() + ) showToast( R.string.download_started, Toast.LENGTH_SHORT @@ -2816,7 +2646,7 @@ class ResultViewModel2 : ViewModel() { setKey( DOWNLOAD_HEADER_CACHE, mainId.toString(), - VideoDownloadHelper.DownloadHeaderCached( + DownloadObjects.DownloadHeaderCached( apiName = apiName, url = validUrl, type = loadResponse.type, diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchHelper.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchHelper.kt index e176d6c9b..449a04bf8 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchHelper.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchHelper.kt @@ -11,7 +11,7 @@ import com.lagradost.cloudstream3.ui.download.DownloadClickEvent import com.lagradost.cloudstream3.ui.result.START_ACTION_LOAD_EP import com.lagradost.cloudstream3.utils.AppContextUtils.loadSearchResult import com.lagradost.cloudstream3.utils.DataStoreHelper -import com.lagradost.cloudstream3.utils.VideoDownloadHelper +import com.lagradost.cloudstream3.utils.downloader.DownloadObjects object SearchHelper { fun handleSearchClickCallback(callback: SearchClickCallback) { @@ -31,7 +31,7 @@ object SearchHelper { handleDownloadClick( DownloadClickEvent( DOWNLOAD_ACTION_PLAY_FILE, - VideoDownloadHelper.DownloadEpisodeCached( + DownloadObjects.DownloadEpisodeCached( name = card.name, poster = card.posterUrl, episode = card.episode ?: 0, diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsGeneral.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsGeneral.kt index 4c64b175b..b13987f28 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsGeneral.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsGeneral.kt @@ -44,8 +44,9 @@ import com.lagradost.cloudstream3.utils.UIHelper.dismissSafe import com.lagradost.cloudstream3.utils.UIHelper.hideKeyboard import com.lagradost.cloudstream3.utils.UIHelper.navigate import com.lagradost.cloudstream3.utils.USER_PROVIDER_API -import com.lagradost.cloudstream3.utils.VideoDownloadManager -import com.lagradost.cloudstream3.utils.VideoDownloadManager.getBasePath +import com.lagradost.cloudstream3.utils.downloader.DownloadFileManagement +import com.lagradost.cloudstream3.utils.downloader.DownloadFileManagement.getBasePath +import com.lagradost.cloudstream3.utils.downloader.DownloadQueueManager import java.util.Locale // Change local language settings in the app. @@ -328,7 +329,7 @@ class SettingsGeneral : BasePreferenceFragmentCompat() { fun getDownloadDirs(): List { return safe { context?.let { ctx -> - val defaultDir = VideoDownloadManager.getDefaultDir(ctx)?.filePath() + val defaultDir = DownloadFileManagement.getDefaultDir(ctx)?.filePath() val first = listOf(defaultDir) (try { @@ -350,12 +351,18 @@ class SettingsGeneral : BasePreferenceFragmentCompat() { return@setOnPreferenceChangeListener true } + getPref(R.string.download_parallel_key)?.setOnPreferenceChangeListener { _, _ -> + // Notify that the queue logic has been changed + DownloadQueueManager.forceRefreshQueue() + return@setOnPreferenceChangeListener true + } + getPref(R.string.download_path_key)?.setOnPreferenceClickListener { val dirs = getDownloadDirs() val currentDir = settingsManager.getString(getString(R.string.download_path_key_visual), null) - ?: context?.let { ctx -> VideoDownloadManager.getDefaultDir(ctx)?.filePath() } + ?: context?.let { ctx -> DownloadFileManagement.getDefaultDir(ctx)?.filePath() } activity?.showBottomDialog( dirs + listOf(getString(R.string.custom)), diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsUpdates.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsUpdates.kt index 148f0ee5b..2b74eab4c 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsUpdates.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsUpdates.kt @@ -23,7 +23,6 @@ import com.lagradost.cloudstream3.plugins.PluginManager import com.lagradost.cloudstream3.services.BackupWorkManager import com.lagradost.cloudstream3.ui.BasePreferenceFragmentCompat import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR -import com.lagradost.cloudstream3.ui.settings.Globals.TV import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.getPref import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.hideOn import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setPaddingBottom @@ -40,7 +39,7 @@ import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showDialog import com.lagradost.cloudstream3.utils.UIHelper.clipboardHelper import com.lagradost.cloudstream3.utils.UIHelper.dismissSafe import com.lagradost.cloudstream3.utils.UIHelper.hideKeyboard -import com.lagradost.cloudstream3.utils.VideoDownloadManager +import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager import com.lagradost.cloudstream3.utils.txt import java.io.BufferedReader import java.io.InputStreamReader diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/AppContextUtils.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/AppContextUtils.kt index 65f928cc4..6a9ab28d8 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/AppContextUtils.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/AppContextUtils.kt @@ -40,7 +40,6 @@ import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentActivity import androidx.navigation.fragment.findNavController import androidx.preference.PreferenceManager -import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import androidx.tvprovider.media.tv.PreviewChannelHelper @@ -86,6 +85,7 @@ import com.lagradost.cloudstream3.utils.DataStoreHelper.getLastWatched import com.lagradost.cloudstream3.utils.FillerEpisodeCheck.toClassDir import com.lagradost.cloudstream3.utils.JsUnpacker.Companion.load import com.lagradost.cloudstream3.utils.UIHelper.navigate +import com.lagradost.cloudstream3.utils.downloader.DownloadObjects import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import okhttp3.Cache @@ -152,7 +152,7 @@ object AppContextUtils { private fun buildWatchNextProgramUri( context: Context, card: DataStoreHelper.ResumeWatchingResult, - resumeWatching: VideoDownloadHelper.ResumeWatching? + resumeWatching: DownloadObjects.ResumeWatching? ): WatchNextProgram { val isSeries = card.type?.isMovieType() == false val title = if (isSeries) { @@ -319,7 +319,7 @@ object AppContextUtils { val context = this continueWatchingLock.withLock { // A way to get all last watched timestamps - val timeStampHashMap = HashMap() + val timeStampHashMap = HashMap() getAllResumeStateIds()?.forEach { id -> val lastWatched = getLastWatched(id) ?: return@forEach timeStampHashMap[lastWatched.parentId] = lastWatched diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/BackupUtils.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/BackupUtils.kt index 96171aa90..a444ef3d0 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/BackupUtils.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/BackupUtils.kt @@ -28,9 +28,12 @@ import com.lagradost.cloudstream3.utils.DataStore.getSharedPrefs import com.lagradost.cloudstream3.utils.DataStore.mapper import com.lagradost.cloudstream3.utils.UIHelper.checkWrite import com.lagradost.cloudstream3.utils.UIHelper.requestRW -import com.lagradost.cloudstream3.utils.VideoDownloadManager.StreamData -import com.lagradost.cloudstream3.utils.VideoDownloadManager.getBasePath -import com.lagradost.cloudstream3.utils.VideoDownloadManager.setupStream +import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager.setupStream +import com.lagradost.cloudstream3.utils.downloader.DownloadObjects +import com.lagradost.cloudstream3.utils.downloader.DownloadQueueManager.QUEUE_KEY +import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager.KEY_DOWNLOAD_INFO +import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager.KEY_RESUME_IN_QUEUE +import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager.KEY_RESUME_PACKAGES import com.lagradost.safefile.MediaFileContentType import com.lagradost.safefile.SafeFile import okhttp3.internal.closeQuietly @@ -79,6 +82,24 @@ object BackupUtils { "open_subtitles_user", "subdl_user", "simkl_token", + + + // Downloads can not be restored from backups. + // The download path URI can not be transferred. + // In the future we may potentially write metadata to files in the download directory + // and make it possible to restore download folders using that metadata. + DOWNLOAD_HEADER_CACHE_BACKUP, + DOWNLOAD_HEADER_CACHE, + DOWNLOAD_EPISODE_CACHE_BACKUP, + DOWNLOAD_EPISODE_CACHE, + + // This may overwrite valid local data with invalid data + KEY_DOWNLOAD_INFO, + + // Prevent backups from automatically starting downloads + KEY_RESUME_IN_QUEUE, + KEY_RESUME_PACKAGES, + QUEUE_KEY ) /** false if key should not be contained in backup */ @@ -208,7 +229,7 @@ object BackupUtils { } @Throws(IOException::class) - private fun setupBackupStream(context: Context, name: String, ext: String = "txt"): StreamData { + private fun setupBackupStream(context: Context, name: String, ext: String = "txt"): DownloadObjects.StreamData { return setupStream( baseFile = getCurrentBackupDir(context).first ?: getDefaultBackupDir(context) ?: throw IOException("Bad config"), @@ -290,7 +311,7 @@ object BackupUtils { } /** - * Copy of [VideoDownloadManager.basePathToFile], [VideoDownloadManager.getDefaultDir] and [VideoDownloadManager.getBasePath] + * Copy of [com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager.basePathToFile], [com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager.getDefaultDir] and [com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager.getBasePath] * modded for backup specific paths * */ diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/DataStore.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/DataStore.kt index 20d33c112..58d77bfe8 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/DataStore.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/DataStore.kt @@ -15,9 +15,11 @@ import kotlin.reflect.KProperty import androidx.core.content.edit const val DOWNLOAD_HEADER_CACHE = "download_header_cache" +const val DOWNLOAD_HEADER_CACHE_BACKUP = "BACKUP_download_header_cache" //const val WATCH_HEADER_CACHE = "watch_header_cache" const val DOWNLOAD_EPISODE_CACHE = "download_episode_cache" +const val DOWNLOAD_EPISODE_CACHE_BACKUP = "BACKUP_download_episode_cache" const val VIDEO_PLAYER_BRIGHTNESS = "video_player_alpha_key" const val USER_SELECTED_HOMEPAGE_API = "home_api_used" const val USER_PROVIDER_API = "user_custom_sites" diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/DataStoreHelper.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/DataStoreHelper.kt index 217dc2a52..19caead21 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/DataStoreHelper.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/DataStoreHelper.kt @@ -30,6 +30,7 @@ import com.lagradost.cloudstream3.ui.result.EpisodeSortType import com.lagradost.cloudstream3.ui.result.ResultEpisode import com.lagradost.cloudstream3.ui.result.VideoWatchState import com.lagradost.cloudstream3.utils.AppContextUtils.filterProviderByPreferredMedia +import com.lagradost.cloudstream3.utils.downloader.DownloadObjects import java.util.Calendar import java.util.Date import java.util.GregorianCalendar @@ -529,7 +530,7 @@ object DataStoreHelper { setKey( "$currentAccount/$RESULT_RESUME_WATCHING", parentId.toString(), - VideoDownloadHelper.ResumeWatching( + DownloadObjects.ResumeWatching( parentId, episodeId, episode, @@ -550,7 +551,7 @@ object DataStoreHelper { removeKey("$currentAccount/$RESULT_RESUME_WATCHING", parentId.toString()) } - fun getLastWatched(id: Int?): VideoDownloadHelper.ResumeWatching? { + fun getLastWatched(id: Int?): DownloadObjects.ResumeWatching? { if (id == null) return null return getKey( "$currentAccount/$RESULT_RESUME_WATCHING", @@ -558,7 +559,7 @@ object DataStoreHelper { ) } - private fun getLastWatchedOld(id: Int?): VideoDownloadHelper.ResumeWatching? { + private fun getLastWatchedOld(id: Int?): DownloadObjects.ResumeWatching? { if (id == null) return null return getKey( "$currentAccount/$RESULT_RESUME_WATCHING_OLD", diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/DownloadFileWorkManager.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/DownloadFileWorkManager.kt index 0b9b81e40..e69de29bb 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/DownloadFileWorkManager.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/DownloadFileWorkManager.kt @@ -1,104 +0,0 @@ -package com.lagradost.cloudstream3.utils - -import android.app.Notification -import android.content.Context -import android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC -import android.os.Build.VERSION.SDK_INT -import androidx.work.CoroutineWorker -import androidx.work.ForegroundInfo -import androidx.work.WorkerParameters -import com.lagradost.cloudstream3.CloudStreamApp.Companion.removeKey -import com.lagradost.cloudstream3.mvvm.logError -import com.lagradost.cloudstream3.utils.Coroutines.main -import com.lagradost.cloudstream3.utils.DataStore.getKey -import com.lagradost.cloudstream3.utils.VideoDownloadManager.WORK_KEY_INFO -import com.lagradost.cloudstream3.utils.VideoDownloadManager.WORK_KEY_PACKAGE -import com.lagradost.cloudstream3.utils.VideoDownloadManager.downloadCheck -import com.lagradost.cloudstream3.utils.VideoDownloadManager.downloadEpisode -import com.lagradost.cloudstream3.utils.VideoDownloadManager.downloadFromResume -import com.lagradost.cloudstream3.utils.VideoDownloadManager.downloadStatusEvent -import com.lagradost.cloudstream3.utils.VideoDownloadManager.getDownloadResumePackage -import kotlinx.coroutines.delay - -const val DOWNLOAD_CHECK = "DownloadCheck" - -class DownloadFileWorkManager(val context: Context, private val workerParams: WorkerParameters) : - CoroutineWorker(context, workerParams) { - - override suspend fun doWork(): Result { - val key = workerParams.inputData.getString("key") - try { - if (key == DOWNLOAD_CHECK) { - downloadCheck(applicationContext, ::handleNotification) - } else if (key != null) { - val info = - applicationContext.getKey(WORK_KEY_INFO, key) - val pkg = - applicationContext.getKey( - WORK_KEY_PACKAGE, - key - ) - - if (info != null) { - getDownloadResumePackage(applicationContext, info.ep.id)?.let { dpkg -> - downloadFromResume(applicationContext, dpkg, ::handleNotification) - } ?: run { - downloadEpisode( - applicationContext, - info.source, - info.folder, - info.ep, - info.links, - ::handleNotification - ) - } - } else if (pkg != null) { - downloadFromResume(applicationContext, pkg, ::handleNotification) - } - removeKeys(key) - } - return Result.success() - } catch (e: Exception) { - logError(e) - if (key != null) { - removeKeys(key) - } - return Result.failure() - } - } - - private fun removeKeys(key: String) { - removeKey(WORK_KEY_INFO, key) - removeKey(WORK_KEY_PACKAGE, key) - } - - private suspend fun awaitDownload(id: Int) { - var isDone = false - val listener = { (localId, localType): Pair -> - if (id == localId) { - when (localType) { - VideoDownloadManager.DownloadType.IsDone, VideoDownloadManager.DownloadType.IsFailed, VideoDownloadManager.DownloadType.IsStopped -> { - isDone = true - } - - else -> Unit - } - } - } - downloadStatusEvent += listener - while (!isDone) { - println("AWAITING $id") - delay(1000) - } - downloadStatusEvent -= listener - } - - private fun handleNotification(id: Int, notification: Notification) { - main { - if (SDK_INT >= 29) - setForegroundAsync(ForegroundInfo(id, notification, FOREGROUND_SERVICE_TYPE_DATA_SYNC)) - else setForegroundAsync(ForegroundInfo(id, notification)) - - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/SubtitleUtils.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/SubtitleUtils.kt index 97be98aea..18d465e3c 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/SubtitleUtils.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/SubtitleUtils.kt @@ -2,7 +2,8 @@ package com.lagradost.cloudstream3.utils import android.content.Context import com.lagradost.api.Log -import com.lagradost.cloudstream3.utils.VideoDownloadManager.basePathToFile +import com.lagradost.cloudstream3.utils.downloader.DownloadFileManagement.basePathToFile +import com.lagradost.cloudstream3.utils.downloader.DownloadObjects object SubtitleUtils { @@ -12,7 +13,7 @@ object SubtitleUtils { ".ttml", ".sbv", ".dfxp" ) - fun deleteMatchingSubtitles(context: Context, info: VideoDownloadManager.DownloadedFileInfo) { + fun deleteMatchingSubtitles(context: Context, info: DownloadObjects.DownloadedFileInfo) { val cleanDisplay = cleanDisplayName(info.displayName) val base = basePathToFile(context, info.basePath) diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/VideoDownloadHelper.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/VideoDownloadHelper.kt index fcee1e45a..e69de29bb 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/VideoDownloadHelper.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/VideoDownloadHelper.kt @@ -1,55 +0,0 @@ -package com.lagradost.cloudstream3.utils - -import com.fasterxml.jackson.annotation.JsonProperty -import com.lagradost.cloudstream3.Score -import com.lagradost.cloudstream3.TvType -object VideoDownloadHelper { - abstract class DownloadCached( - @JsonProperty("id") open val id: Int, - ) - - data class DownloadEpisodeCached( - @JsonProperty("name") val name: String?, - @JsonProperty("poster") val poster: String?, - @JsonProperty("episode") val episode: Int, - @JsonProperty("season") val season: Int?, - @JsonProperty("parentId") val parentId: Int, - @JsonProperty("score") var score: Score? = null, - @JsonProperty("description") val description: String?, - @JsonProperty("cacheTime") val cacheTime: Long, - override val id: Int, - ): DownloadCached(id) { - @JsonProperty("rating", access = JsonProperty.Access.WRITE_ONLY) - @Deprecated( - "`rating` is the old scoring system, use score instead", - replaceWith = ReplaceWith("score"), - level = DeprecationLevel.ERROR - ) - var rating: Int? = null - set(value) { - if (value != null) { - @Suppress("DEPRECATION_ERROR") - score = Score.fromOld(value) - } - } - } - - data class DownloadHeaderCached( - @JsonProperty("apiName") val apiName: String, - @JsonProperty("url") val url: String, - @JsonProperty("type") val type: TvType, - @JsonProperty("name") val name: String, - @JsonProperty("poster") val poster: String?, - @JsonProperty("cacheTime") val cacheTime: Long, - override val id: Int, - ): DownloadCached(id) - - data class ResumeWatching( - @JsonProperty("parentId") val parentId: Int, - @JsonProperty("episodeId") val episodeId: Int?, - @JsonProperty("episode") val episode: Int?, - @JsonProperty("season") val season: Int?, - @JsonProperty("updateTime") val updateTime: Long, - @JsonProperty("isFromDownload") val isFromDownload: Boolean, - ) -} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/downloader/DownloadFileManagement.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/downloader/DownloadFileManagement.kt new file mode 100644 index 000000000..898c30a1c --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/downloader/DownloadFileManagement.kt @@ -0,0 +1,132 @@ +package com.lagradost.cloudstream3.utils.downloader + +import android.content.Context +import android.net.Uri +import androidx.core.net.toUri +import androidx.preference.PreferenceManager +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.TvType +import com.lagradost.cloudstream3.getFolderPrefix +import com.lagradost.cloudstream3.isEpisodeBased +import com.lagradost.safefile.MediaFileContentType +import com.lagradost.safefile.SafeFile + +object DownloadFileManagement { + private const val RESERVED_CHARS = "|\\?*<\":>+[]/\'" + internal fun sanitizeFilename(name: String, removeSpaces: Boolean = false): String { + var tempName = name + for (c in RESERVED_CHARS) { + tempName = tempName.replace(c, ' ') + } + if (removeSpaces) tempName = tempName.replace(" ", "") + return tempName.replace(" ", " ").trim(' ') + } + + /** + * Used for getting video player subs. + * @return List of pairs for the files in this format: + * */ + internal fun getFolder( + context: Context, + relativePath: String, + basePath: String? + ): List>? { + val base = basePathToFile(context, basePath) + val folder = + base?.gotoDirectory(relativePath, createMissingDirectories = false) ?: return null + + //if (folder.isDirectory() != false) return null + + return folder.listFiles() + ?.mapNotNull { (it.name() ?: "") to (it.uri() ?: return@mapNotNull null) } + } + + /** + * Turns a string to an UniFile. Used for stored string paths such as settings. + * Should only be used to get a download path. + * */ + internal fun basePathToFile(context: Context, path: String?): SafeFile? { + return when { + path.isNullOrBlank() -> getDefaultDir(context) + path.startsWith("content://") -> SafeFile.fromUri(context, path.toUri()) + else -> SafeFile.fromFilePath(context, path) + } + } + + /** + * Base path where downloaded things should be stored, changes depending on settings. + * Returns the file and a string to be stored for future file retrieval. + * UniFile.filePath is not sufficient for storage. + * */ + internal fun Context.getBasePath(): Pair { + val settingsManager = PreferenceManager.getDefaultSharedPreferences(this) + val basePathSetting = settingsManager.getString(getString(R.string.download_path_key), null) + return basePathToFile(this, basePathSetting) to basePathSetting + } + + internal fun getFileName( + context: Context, + metadata: DownloadObjects.DownloadEpisodeMetadata + ): String { + return getFileName(context, metadata.name, metadata.episode, metadata.season) + } + + internal fun getFileName( + context: Context, + epName: String?, + episode: Int?, + season: Int? + ): String { + // kinda ugly ik + return sanitizeFilename( + if (epName == null) { + if (season != null) { + "${context.getString(R.string.season)} $season ${context.getString(R.string.episode)} $episode" + } else { + "${context.getString(R.string.episode)} $episode" + } + } else { + if (episode != null) { + if (season != null) { + "${context.getString(R.string.season)} $season ${context.getString(R.string.episode)} $episode - $epName" + } else { + "${context.getString(R.string.episode)} $episode - $epName" + } + } else { + epName + } + } + ) + } + + + internal fun DownloadObjects.DownloadedFileInfo.toFile(context: Context): SafeFile? { + return basePathToFile(context, this.basePath)?.gotoDirectory( + relativePath, + createMissingDirectories = false + ) + ?.findFile(displayName) + } + + internal fun getFolder(currentType: TvType, titleName: String): String { + return if (currentType.isEpisodeBased()) { + val sanitizedFileName = sanitizeFilename(titleName) + "${currentType.getFolderPrefix()}/$sanitizedFileName" + } else currentType.getFolderPrefix() + } + + /** + * Gets the default download path as an UniFile. + * Vital for legacy downloads, be careful about changing anything here. + * + * As of writing UniFile is used for everything but download directory on scoped storage. + * Special ContentResolver fuckery is needed for that as UniFile doesn't work. + * */ + fun getDefaultDir(context: Context): SafeFile? { + // See https://www.py4u.net/discuss/614761 + return SafeFile.fromMedia( + context, MediaFileContentType.Downloads + ) + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/VideoDownloadManager.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/downloader/DownloadManager.kt similarity index 69% rename from app/src/main/java/com/lagradost/cloudstream3/utils/VideoDownloadManager.kt rename to app/src/main/java/com/lagradost/cloudstream3/utils/downloader/DownloadManager.kt index cdda11868..01dc53017 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/VideoDownloadManager.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/downloader/DownloadManager.kt @@ -1,38 +1,27 @@ -package com.lagradost.cloudstream3.utils +package com.lagradost.cloudstream3.utils.downloader + import android.Manifest import android.annotation.SuppressLint import android.app.Notification -import android.app.NotificationChannel -import android.app.NotificationManager import android.app.PendingIntent -import android.content.* +import android.content.Context +import android.content.Intent import android.content.pm.PackageManager -import android.graphics.Bitmap -import android.net.Uri import android.os.Build import android.os.Build.VERSION.SDK_INT import android.util.Log +import android.widget.Toast import androidx.annotation.DrawableRes import androidx.core.app.ActivityCompat import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat import androidx.core.app.PendingIntentCompat -import androidx.core.graphics.drawable.toBitmap import androidx.core.net.toUri import androidx.preference.PreferenceManager -import androidx.work.Data -import androidx.work.ExistingWorkPolicy -import androidx.work.OneTimeWorkRequest -import androidx.work.WorkManager -import coil3.Extras -import coil3.SingletonImageLoader -import coil3.asDrawable -import coil3.request.ImageRequest -import coil3.request.SuccessResult -import com.fasterxml.jackson.annotation.JsonProperty import com.lagradost.cloudstream3.APIHolder.getApiFromNameNull import com.lagradost.cloudstream3.BuildConfig +import com.lagradost.cloudstream3.CommonActivity.showToast import com.lagradost.cloudstream3.CloudStreamApp.Companion.removeKey import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey import com.lagradost.cloudstream3.IDownloadableMinimum @@ -42,14 +31,56 @@ import com.lagradost.cloudstream3.TvType import com.lagradost.cloudstream3.app import com.lagradost.cloudstream3.mvvm.launchSafe import com.lagradost.cloudstream3.mvvm.logError +import com.lagradost.cloudstream3.mvvm.safe import com.lagradost.cloudstream3.services.VideoDownloadService +import com.lagradost.cloudstream3.sortUrls +import com.lagradost.cloudstream3.ui.download.DOWNLOAD_NAVIGATE_TO +import com.lagradost.cloudstream3.ui.player.LOADTYPE_INAPP_DOWNLOAD +import com.lagradost.cloudstream3.ui.player.RepoLinkGenerator +import com.lagradost.cloudstream3.ui.player.SubtitleData +import com.lagradost.cloudstream3.ui.result.ExtractorSubtitleLink +import com.lagradost.cloudstream3.ui.result.ResultEpisode +import com.lagradost.cloudstream3.ui.subtitles.SubtitlesFragment +import com.lagradost.cloudstream3.utils.AppContextUtils.createNotificationChannel +import com.lagradost.cloudstream3.utils.AppContextUtils.sortSubs import com.lagradost.cloudstream3.utils.Coroutines.ioSafe import com.lagradost.cloudstream3.utils.Coroutines.main +import com.lagradost.cloudstream3.utils.DOWNLOAD_EPISODE_CACHE +import com.lagradost.cloudstream3.utils.DOWNLOAD_HEADER_CACHE +import com.lagradost.cloudstream3.utils.DataStore.getFolderName import com.lagradost.cloudstream3.utils.DataStore.getKey import com.lagradost.cloudstream3.utils.DataStore.removeKey +import com.lagradost.cloudstream3.utils.Event +import com.lagradost.cloudstream3.utils.ExtractorLink +import com.lagradost.cloudstream3.utils.ExtractorLinkType +import com.lagradost.cloudstream3.utils.M3u8Helper +import com.lagradost.cloudstream3.utils.M3u8Helper2 +import com.lagradost.cloudstream3.utils.SubtitleHelper.fromTagToEnglishLanguageName import com.lagradost.cloudstream3.utils.SubtitleUtils.deleteMatchingSubtitles import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute -import com.lagradost.safefile.MediaFileContentType +import com.lagradost.cloudstream3.utils.downloader.DownloadFileManagement.getBasePath +import com.lagradost.cloudstream3.utils.downloader.DownloadFileManagement.getDefaultDir +import com.lagradost.cloudstream3.utils.downloader.DownloadFileManagement.getFileName +import com.lagradost.cloudstream3.utils.downloader.DownloadFileManagement.getFolder +import com.lagradost.cloudstream3.utils.downloader.DownloadFileManagement.sanitizeFilename +import com.lagradost.cloudstream3.utils.downloader.DownloadFileManagement.toFile +import com.lagradost.cloudstream3.utils.downloader.DownloadObjects.CreateNotificationMetadata +import com.lagradost.cloudstream3.utils.downloader.DownloadObjects.DownloadEpisodeMetadata +import com.lagradost.cloudstream3.utils.downloader.DownloadObjects.DownloadItem +import com.lagradost.cloudstream3.utils.downloader.DownloadObjects.DownloadResumePackage +import com.lagradost.cloudstream3.utils.downloader.DownloadObjects.DownloadStatus +import com.lagradost.cloudstream3.utils.downloader.DownloadObjects.DownloadedFileInfo +import com.lagradost.cloudstream3.utils.downloader.DownloadObjects.DownloadedFileInfoResult +import com.lagradost.cloudstream3.utils.downloader.DownloadObjects.LazyStreamDownloadResponse +import com.lagradost.cloudstream3.utils.downloader.DownloadObjects.StreamData +import com.lagradost.cloudstream3.utils.downloader.DownloadObjects.DownloadQueueWrapper +import com.lagradost.cloudstream3.utils.downloader.DownloadUtils.appendAndDontOverride +import com.lagradost.cloudstream3.utils.downloader.DownloadUtils.cancel +import com.lagradost.cloudstream3.utils.downloader.DownloadUtils.downloadSubtitle +import com.lagradost.cloudstream3.utils.downloader.DownloadUtils.getEstimatedTimeLeft +import com.lagradost.cloudstream3.utils.downloader.DownloadUtils.getImageBitmapFromUrl +import com.lagradost.cloudstream3.utils.downloader.DownloadUtils.join +import com.lagradost.cloudstream3.utils.txt import com.lagradost.safefile.SafeFile import com.lagradost.safefile.closeQuietly import kotlinx.coroutines.CancellationException @@ -60,23 +91,24 @@ import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.cancel import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update import kotlinx.coroutines.isActive import kotlinx.coroutines.launch -import kotlinx.coroutines.runBlocking import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import java.io.Closeable import java.io.IOException import java.io.OutputStream -import java.util.* const val DOWNLOAD_CHANNEL_ID = "cloudstream3.general" const val DOWNLOAD_CHANNEL_NAME = "Downloads" const val DOWNLOAD_CHANNEL_DESCRIPT = "The download notification channel" object VideoDownloadManager { - private fun maxConcurrentDownloads(context: Context): Int = + fun maxConcurrentDownloads(context: Context): Int = PreferenceManager.getDefaultSharedPreferences(context) ?.getInt(context.getString(R.string.download_parallel_key), 3) ?: 3 @@ -84,8 +116,11 @@ object VideoDownloadManager { PreferenceManager.getDefaultSharedPreferences(context) ?.getInt(context.getString(R.string.download_concurrent_key), 3) ?: 3 - private var currentDownloads = mutableListOf() + private val _currentDownloads: MutableStateFlow> = MutableStateFlow(emptySet()) + val currentDownloads: StateFlow> = _currentDownloads + const val TAG = "VDM" + private const val DOWNLOAD_NOTIFICATION_TAG = "FROM_DOWNLOADER" private const val USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.0.0 Safari/537.36" @@ -129,56 +164,6 @@ object VideoDownloadManager { Stop, } - data class DownloadEpisodeMetadata( - @JsonProperty("id") val id: Int, - @JsonProperty("mainName") val mainName: String, - @JsonProperty("sourceApiName") val sourceApiName: String?, - @JsonProperty("poster") val poster: String?, - @JsonProperty("name") val name: String?, - @JsonProperty("season") val season: Int?, - @JsonProperty("episode") val episode: Int?, - @JsonProperty("type") val type: TvType?, - ) - - data class DownloadItem( - @JsonProperty("source") val source: String?, - @JsonProperty("folder") val folder: String?, - @JsonProperty("ep") val ep: DownloadEpisodeMetadata, - @JsonProperty("links") val links: List, - ) - - data class DownloadResumePackage( - @JsonProperty("item") val item: DownloadItem, - @JsonProperty("linkIndex") val linkIndex: Int?, - ) - - data class DownloadedFileInfo( - @JsonProperty("totalBytes") val totalBytes: Long, - @JsonProperty("relativePath") val relativePath: String, - @JsonProperty("displayName") val displayName: String, - @JsonProperty("extraInfo") val extraInfo: String? = null, - @JsonProperty("basePath") val basePath: String? = null // null is for legacy downloads. See getDefaultPath() - ) - - data class DownloadedFileInfoResult( - @JsonProperty("fileLength") val fileLength: Long, - @JsonProperty("totalBytes") val totalBytes: Long, - @JsonProperty("path") val path: Uri, - ) - - data class DownloadQueueResumePackage( - @JsonProperty("index") val index: Int, - @JsonProperty("pkg") val pkg: DownloadResumePackage, - ) - - data class DownloadStatus( - /** if you should retry with the same args and hope for a better result */ - val retrySame: Boolean, - /** if you should try the next mirror */ - val tryNext: Boolean, - /** if the result is what the user intended */ - val success: Boolean, - ) /** Invalid input, just skip to the next one as the same args will give the same error */ private val DOWNLOAD_INVALID_INPUT = @@ -199,113 +184,49 @@ object VideoDownloadManager { private val DOWNLOAD_BAD_CONFIG = DownloadStatus(retrySame = false, tryNext = false, success = false) - const val KEY_RESUME_PACKAGES = "download_resume" + const val KEY_RESUME_PACKAGES = "download_resume_2" const val KEY_DOWNLOAD_INFO = "download_info" - private const val KEY_RESUME_QUEUE_PACKAGES = "download_q_resume" + + /** A key to save all the downloads which have not yet started and those currently running, using [DownloadQueueWrapper] + * [KEY_RESUME_PACKAGES] can store keys which should not be automatically queued, unlike this key. + */ + const val KEY_RESUME_IN_QUEUE = "download_resume_queue_key" +// private const val KEY_RESUME_QUEUE_PACKAGES = "download_q_resume" val downloadStatus = HashMap() val downloadStatusEvent = Event>() val downloadDeleteEvent = Event() val downloadEvent = Event>() val downloadProgressEvent = Event>() - val downloadQueue = LinkedList() +// val downloadQueue = LinkedList() - private var hasCreatedNotChanel = false + private var hasCreatedNotChannel = false private fun Context.createNotificationChannel() { - hasCreatedNotChanel = true - // Create the NotificationChannel, but only on API 26+ because - // the NotificationChannel class is new and not in the support library - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - val name = DOWNLOAD_CHANNEL_NAME //getString(R.string.channel_name) - val descriptionText = DOWNLOAD_CHANNEL_DESCRIPT//getString(R.string.channel_description) - val importance = NotificationManager.IMPORTANCE_DEFAULT - val channel = NotificationChannel(DOWNLOAD_CHANNEL_ID, name, importance).apply { - description = descriptionText + hasCreatedNotChannel = true + + this.createNotificationChannel( + DOWNLOAD_CHANNEL_ID, + DOWNLOAD_CHANNEL_NAME, + DOWNLOAD_CHANNEL_DESCRIPT + ) + } + + fun cancelAllDownloadNotifications(context: Context) { + val manager = NotificationManagerCompat.from(context) + manager.activeNotifications.forEach { notification -> + if (notification.tag == DOWNLOAD_NOTIFICATION_TAG) { + manager.cancel(DOWNLOAD_NOTIFICATION_TAG, notification.id) } - // Register the channel with the system - val notificationManager: NotificationManager = - this.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager - notificationManager.createNotificationChannel(channel) } } - ///** Will return IsDone if not found or error */ - //fun getDownloadState(id: Int): DownloadType { - // return try { - // downloadStatus[id] ?: DownloadType.IsDone - // } catch (e: Exception) { - // logError(e) - // DownloadType.IsDone - // } - //} - private val cachedBitmaps = hashMapOf() - fun Context.getImageBitmapFromUrl(url: String, headers: Map? = null): Bitmap? { - try { - if (cachedBitmaps.containsKey(url)) { - return cachedBitmaps[url] - } - - val imageLoader = SingletonImageLoader.get(this) - - val request = ImageRequest.Builder(this) - .data(url) - .apply { - headers?.forEach { (key, value) -> - extras[Extras.Key(key)] = value - } - } - .build() - - val bitmap = runBlocking { - val result = imageLoader.execute(request) - (result as? SuccessResult)?.image?.asDrawable(applicationContext.resources) - ?.toBitmap() - } - - bitmap?.let { - cachedBitmaps[url] = it - } - - return bitmap - } catch (e: Exception) { - logError(e) - return null - } - } - //calculate the time - private fun getEstimatedTimeLeft(context:Context,bytesPerSecond: Long, progress: Long, total: Long):String{ - if(bytesPerSecond <= 0 ) return "" - val timeInSec = (total - progress)/bytesPerSecond - val hrs = timeInSec/3600 - val mins = (timeInSec%3600)/ 60 - val secs = timeInSec % 60 - val timeFormated:UiText? = when{ - hrs>0 -> txt( - R.string.download_time_left_hour_min_sec_format, - hrs, - mins, - secs - ) - mins>0 -> txt( - R.string.download_time_left_min_sec_format, - mins, - secs - ) - secs>0 -> txt( - R.string.download_time_left_sec_format, - secs - ) - else -> null - } - return timeFormated?.asString(context) ?: "" - } /** * @param hlsProgress will together with hlsTotal display another notification if used, to lessen the confusion about estimated size. * */ @SuppressLint("StringFormatInvalid") - private suspend fun createNotification( + private suspend fun createDownloadNotification( context: Context, source: String?, linkName: String?, @@ -321,7 +242,6 @@ object VideoDownloadManager { try { if (total <= 0) return null// crash, invalid data -// main { // DON'T WANT TO SLOW IT DOWN val builder = NotificationCompat.Builder(context, DOWNLOAD_CHANNEL_ID) .setAutoCancel(true) .setColorized(true) @@ -405,9 +325,9 @@ object VideoDownloadManager { } else "" val remainingTime = - if(state == DownloadType.IsDownloading){ - getEstimatedTimeLeft(context,bytesPerSecond, progress, total) - }else "" + if (state == DownloadType.IsDownloading) { + getEstimatedTimeLeft(context, bytesPerSecond, progress, total) + } else "" val bigText = when (state) { @@ -470,7 +390,7 @@ object VideoDownloadManager { builder.setContentText(txt) } - if ((state == DownloadType.IsDownloading || state == DownloadType.IsPaused) && SDK_INT >= Build.VERSION_CODES.O) { + if ((state == DownloadType.IsDownloading || state == DownloadType.IsPaused || state == DownloadType.IsPending) && SDK_INT >= Build.VERSION_CODES.O) { val actionTypes: MutableList = ArrayList() // INIT if (state == DownloadType.IsDownloading) { @@ -482,6 +402,9 @@ object VideoDownloadManager { actionTypes.add(DownloadActionType.Resume) actionTypes.add(DownloadActionType.Stop) } + if (state == DownloadType.IsPending) { + actionTypes.add(DownloadActionType.Stop) + } // ADD ACTIONS for ((index, i) in actionTypes.withIndex()) { @@ -520,7 +443,7 @@ object VideoDownloadManager { } } - if (!hasCreatedNotChanel) { + if (!hasCreatedNotChannel) { context.createNotificationChannel() } @@ -535,7 +458,7 @@ object VideoDownloadManager { ) { return null } - notify(ep.id, notification) + notify(DOWNLOAD_NOTIFICATION_TAG, ep.id, notification) } return notification } catch (e: Exception) { @@ -544,69 +467,6 @@ object VideoDownloadManager { } } - private const val RESERVED_CHARS = "|\\?*<\":>+[]/\'" - fun sanitizeFilename(name: String, removeSpaces: Boolean = false): String { - var tempName = name - for (c in RESERVED_CHARS) { - tempName = tempName.replace(c, ' ') - } - if (removeSpaces) tempName = tempName.replace(" ", "") - return tempName.replace(" ", " ").trim(' ') - } - - /** - * Used for getting video player subs. - * @return List of pairs for the files in this format: - * */ - fun getFolder( - context: Context, - relativePath: String, - basePath: String? - ): List>? { - val base = basePathToFile(context, basePath) - val folder = - base?.gotoDirectory(relativePath, createMissingDirectories = false) ?: return null - - //if (folder.isDirectory() != false) return null - - return folder.listFiles() - ?.mapNotNull { (it.name() ?: "") to (it.uri() ?: return@mapNotNull null) } - } - - - data class CreateNotificationMetadata( - val type: DownloadType, - val bytesDownloaded: Long, - val bytesTotal: Long, - val hlsProgress: Long? = null, - val hlsTotal: Long? = null, - val bytesPerSecond: Long - ) - - data class StreamData( - private val fileLength: Long, - val file: SafeFile, - //val fileStream: OutputStream, - ) { - @Throws(IOException::class) - fun open(): OutputStream { - return file.openOutputStreamOrThrow(resume) - } - - @Throws(IOException::class) - fun openNew(): OutputStream { - return file.openOutputStreamOrThrow(false) - } - - fun delete(): Boolean { - return file.delete() == true - } - - val resume: Boolean get() = fileLength > 0L - val startAt: Long get() = if (resume) fileLength else 0L - val exists: Boolean get() = file.exists() == true - } - @Throws(IOException::class) fun setupStream( @@ -628,7 +488,7 @@ object VideoDownloadManager { /** * Sets up the appropriate file and creates a data stream from the file. - * Used for initializing downloads. + * Used for initializing downloads and backups. * */ @Throws(IOException::class) fun setupStream( @@ -717,8 +577,6 @@ object VideoDownloadManager { DownloadActionType.Stop -> { type = DownloadType.IsStopped - removeKey(KEY_RESUME_PACKAGES, event.first.toString()) - saveQueue() stopListener?.invoke() stopListener = null } @@ -880,34 +738,12 @@ object VideoDownloadManager { } } - /** bytes have the size end-start where the byte range is [start,end) - * note that ByteArray is a pointer and therefore cant be stored without cloning it */ - data class LazyStreamDownloadResponse( - val bytes: ByteArray, - val startByte: Long, - val endByte: Long, - ) { - val size get() = endByte - startByte - - override fun toString(): String { - return "$startByte->$endByte" - } - - override fun equals(other: Any?): Boolean { - if (other !is LazyStreamDownloadResponse) return false - return other.startByte == startByte && other.endByte == endByte - } - - override fun hashCode(): Int { - return Objects.hash(startByte, endByte) - } - } data class LazyStreamDownloadData( private val url: String, private val headers: Map, private val referer: String, - /** This specifies where chunck i starts and ends, + /** This specifies where chunk i starts and ends, * bytes=${chuckStartByte[ i ]}-${chuckStartByte[ i+1 ] -1} * where out of bounds => bytes=${chuckStartByte[ i ]}- */ private val chuckStartByte: LongArray, @@ -994,11 +830,11 @@ object VideoDownloadManager { if (end == null) return true // we have download more or exactly what we needed if (start >= end) return true - } catch (e: IllegalStateException) { + } catch (_: IllegalStateException) { return false - } catch (e: CancellationException) { + } catch (_: CancellationException) { return false - } catch (t: Throwable) { + } catch (_: Throwable) { continue } } @@ -1117,38 +953,6 @@ object VideoDownloadManager { ) } - /** Helper function to make sure duplicate attributes don't get overriden or inserted without lowercase cmp - * example: map("a" to 1) appendAndDontOverride map("A" to 2, "a" to 3, "c" to 4) = map("a" to 1, "c" to 4) - * */ - private fun Map.appendAndDontOverride(rhs: Map): Map { - val out = this.toMutableMap() - val current = this.keys.map { it.lowercase() } - for ((key, value) in rhs) { - if (current.contains(key.lowercase())) continue - out[key] = value - } - return out - } - - private fun List.cancel() { - forEach { job -> - try { - job.cancel() - } catch (t: Throwable) { - logError(t) - } - } - } - - private suspend fun List.join() { - forEach { job -> - try { - job.join() - } catch (t: Throwable) { - logError(t) - } - } - } /** download a file that consist of a single stream of data*/ suspend fun downloadThing( @@ -1444,6 +1248,7 @@ object VideoDownloadManager { // push the metadata metadata.setResumeLength(stream.startAt) metadata.hlsProgress = startAt + metadata.hlsWrittenProgress = startAt metadata.type = DownloadType.IsPending metadata.setDownloadFileInfoTemplate( DownloadedFileInfo( @@ -1520,50 +1325,45 @@ object VideoDownloadManager { return@launch } - try { - fileMutex.lock() - // user pause - while (metadata.type == DownloadType.IsPaused) delay(100) - // if stopped then break to delete - if (metadata.type == DownloadType.IsStopped || metadata.type == DownloadType.IsFailed || !isActive) return@launch - - val segmentLength = bytes.size.toLong() - // send notification, no matter the actual write order - metadata.addSegment(segmentLength) - - // directly write the bytes if you are first - if (metadata.hlsWrittenProgress == index) { - fileStream.write(bytes) - - metadata.addBytesWritten(segmentLength) - metadata.setWrittenSegment(index) - } else { - // no need to clone as there will be no modification of this bytearray - pendingData[index] = bytes - } - - // write the cached bytes submitted by other threads - while (true) { - val cache = pendingData.remove(metadata.hlsWrittenProgress) ?: break - val cacheLength = cache.size.toLong() - - fileStream.write(cache) - - metadata.addBytesWritten(cacheLength) - metadata.setWrittenSegment(metadata.hlsWrittenProgress) - } - } catch (t: Throwable) { - // this is in case of write fail - logError(t) - if (metadata.type != DownloadType.IsStopped) { - metadata.type = DownloadType.IsFailed - } - } finally { + fileMutex.withLock { try { - // may cause java.lang.IllegalStateException: Mutex is not locked because of cancelling - fileMutex.unlock() + // user pause + while (metadata.type == DownloadType.IsPaused) delay(100) + // if stopped then break to delete + if (metadata.type == DownloadType.IsStopped || metadata.type == DownloadType.IsFailed || !isActive) return@launch + + val segmentLength = bytes.size.toLong() + // send notification, no matter the actual write order + metadata.addSegment(segmentLength) + + // directly write the bytes if you are first + if (metadata.hlsWrittenProgress == index) { + fileStream.write(bytes) + + metadata.addBytesWritten(segmentLength) + metadata.setWrittenSegment(index) + } else { + // no need to clone as there will be no modification of this bytearray + pendingData[index] = bytes + } + + // write the cached bytes submitted by other threads + while (true) { + val cache = + pendingData.remove(metadata.hlsWrittenProgress) ?: break + val cacheLength = cache.size.toLong() + + fileStream.write(cache) + + metadata.addBytesWritten(cacheLength) + metadata.setWrittenSegment(metadata.hlsWrittenProgress) + } } catch (t: Throwable) { + // this is in case of write fail logError(t) + if (metadata.type != DownloadType.IsStopped) { + metadata.type = DownloadType.IsFailed + } } } } @@ -1610,75 +1410,6 @@ object VideoDownloadManager { return "$name.$extension" } - /** - * Gets the default download path as an UniFile. - * Vital for legacy downloads, be careful about changing anything here. - * - * As of writing UniFile is used for everything but download directory on scoped storage. - * Special ContentResolver fuckery is needed for that as UniFile doesn't work. - * */ - fun getDefaultDir(context: Context): SafeFile? { - // See https://www.py4u.net/discuss/614761 - return SafeFile.fromMedia( - context, MediaFileContentType.Downloads - ) - } - - /** - * Turns a string to an UniFile. Used for stored string paths such as settings. - * Should only be used to get a download path. - * */ - fun basePathToFile(context: Context, path: String?): SafeFile? { - return when { - path.isNullOrBlank() -> getDefaultDir(context) - path.startsWith("content://") -> SafeFile.fromUri(context, path.toUri()) - else -> SafeFile.fromFilePath(context, path) - } - } - - /** - * Base path where downloaded things should be stored, changes depending on settings. - * Returns the file and a string to be stored for future file retrieval. - * UniFile.filePath is not sufficient for storage. - * */ - fun Context.getBasePath(): Pair { - val settingsManager = PreferenceManager.getDefaultSharedPreferences(this) - val basePathSetting = settingsManager.getString(getString(R.string.download_path_key), null) - return basePathToFile(this, basePathSetting) to basePathSetting - } - - fun getFileName(context: Context, metadata: DownloadEpisodeMetadata): String { - return getFileName(context, metadata.name, metadata.episode, metadata.season) - } - - private fun getFileName( - context: Context, - epName: String?, - episode: Int?, - season: Int? - ): String { - // kinda ugly ik - return sanitizeFilename( - if (epName == null) { - if (season != null) { - "${context.getString(R.string.season)} $season ${context.getString(R.string.episode)} $episode" - } else { - "${context.getString(R.string.episode)} $episode" - } - } else { - if (episode != null) { - if (season != null) { - "${context.getString(R.string.season)} $season ${context.getString(R.string.episode)} $episode - $epName" - } else { - "${context.getString(R.string.episode)} $episode - $epName" - } - } else { - epName - } - } - ) - } - private suspend fun downloadSingleEpisode( context: Context, source: String?, @@ -1704,7 +1435,7 @@ object VideoDownloadManager { val callback: (CreateNotificationMetadata) -> Unit = { meta -> main { - createNotification( + createDownloadNotification( context, source, link.name, @@ -1758,100 +1489,17 @@ object VideoDownloadManager { ) } - else -> throw IllegalArgumentException("unsuported download type") + else -> throw IllegalArgumentException("Unsupported download type") } - } catch (t: Throwable) { + } catch (_: Throwable) { return DOWNLOAD_FAILED } finally { extractorJob.cancel() } } - suspend fun downloadCheck( - context: Context, notificationCallback: (Int, Notification) -> Unit, - ) { - if (!(currentDownloads.size < maxConcurrentDownloads(context) && downloadQueue.size > 0)) return - val pkg = downloadQueue.removeAt(0) - val item = pkg.item - val id = item.ep.id - if (currentDownloads.contains(id)) { // IF IT IS ALREADY DOWNLOADING, RESUME IT - downloadEvent.invoke(id to DownloadActionType.Resume) - return - } - - currentDownloads.add(id) - try { - for (index in (pkg.linkIndex ?: 0) until item.links.size) { - val link = item.links[index] - val resume = pkg.linkIndex == index - - setKey( - KEY_RESUME_PACKAGES, - id.toString(), - DownloadResumePackage(item, index) - ) - - var connectionResult = - downloadSingleEpisode( - context, - item.source, - item.folder, - item.ep, - link, - notificationCallback, - resume - ) - - if (connectionResult.retrySame) { - connectionResult = downloadSingleEpisode( - context, - item.source, - item.folder, - item.ep, - link, - notificationCallback, - true - ) - } - - if (connectionResult.success) { // SUCCESS - removeKey(KEY_RESUME_PACKAGES, id.toString()) - break - } else if (!connectionResult.tryNext || index >= item.links.lastIndex) { - downloadStatusEvent.invoke(Pair(id, DownloadType.IsFailed)) - break - } - } - } catch (e: Exception) { - logError(e) - } finally { - currentDownloads.remove(id) - // Because otherwise notifications will not get caught by the work manager - downloadCheckUsingWorker(context) - } - - // return id - } - - /* fun getDownloadFileInfoAndUpdateSettings(context: Context, id: Int): DownloadedFileInfoResult? { - val res = getDownloadFileInfo(context, id) - if (res == null) context.removeKey(KEY_DOWNLOAD_INFO, id.toString()) - return res - } - */ - fun getDownloadFileInfoAndUpdateSettings(context: Context, id: Int): DownloadedFileInfoResult? = - getDownloadFileInfo(context, id) - - private fun DownloadedFileInfo.toFile(context: Context): SafeFile? { - return basePathToFile(context, this.basePath)?.gotoDirectory( - relativePath, - createMissingDirectories = false - ) - ?.findFile(displayName) - } - - private fun getDownloadFileInfo( + fun getDownloadFileInfo( context: Context, id: Int, ): DownloadedFileInfoResult? { @@ -1861,8 +1509,7 @@ object VideoDownloadManager { val file = info.toFile(context) // only delete the key if the file is not found - if (file == null || !file.existsOrThrow()) { - //if (removeKeys) context.removeKey(KEY_DOWNLOAD_INFO, id.toString()) // TODO READD + if (file == null || file.exists() == false) { return null } @@ -1913,35 +1560,20 @@ object VideoDownloadManager { return success } - /*private fun deleteFile( - context: Context, - folder: SafeFile?, - relativePath: String, - displayName: String - ): Boolean { - val file = folder?.gotoDirectory(relativePath)?.findFile(displayName) ?: return false - if (file.exists() == false) return true - return try { - file.delete() - } catch (e: Exception) { - logError(e) - (context.contentResolver?.delete(file.uri() ?: return true, null, null) - ?: return false) > 0 - } - }*/ - private fun deleteFile(context: Context, id: Int): Boolean { val info = context.getKey(KEY_DOWNLOAD_INFO, id.toString()) ?: return false val file = info.toFile(context) - downloadEvent.invoke(id to DownloadActionType.Stop) - downloadProgressEvent.invoke(Triple(id, 0, 0)) - downloadStatusEvent.invoke(id to DownloadType.IsStopped) - downloadDeleteEvent.invoke(id) - val isFileDeleted = file?.delete() == true || file?.exists() == false - if (isFileDeleted) deleteMatchingSubtitles(context, info) + + if (isFileDeleted) { + deleteMatchingSubtitles(context, info) + downloadEvent.invoke(id to DownloadActionType.Stop) + downloadProgressEvent.invoke(Triple(id, 0, 0)) + downloadStatusEvent.invoke(id to DownloadType.IsStopped) + downloadDeleteEvent.invoke(id) + } return isFileDeleted } @@ -1950,119 +1582,434 @@ object VideoDownloadManager { return context.getKey(KEY_RESUME_PACKAGES, id.toString()) } - suspend fun downloadFromResume( - context: Context, - pkg: DownloadResumePackage, - notificationCallback: (Int, Notification) -> Unit, - setKey: Boolean = true + fun getDownloadQueuePackage(context: Context, id: Int): DownloadQueueWrapper? { + return context.getKey(KEY_RESUME_IN_QUEUE, id.toString()) + } + + fun getDownloadEpisodeMetadata( + episode: ResultEpisode, + titleName: String, + apiName: String, + currentPoster: String?, + currentIsMovie: Boolean, + tvType: TvType, + ): DownloadEpisodeMetadata { + return DownloadEpisodeMetadata( + episode.id, + episode.parentId, + sanitizeFilename(titleName), + apiName, + episode.poster ?: currentPoster, + episode.name, + if (currentIsMovie) null else episode.season, + if (currentIsMovie) null else episode.episode, + tvType, + ) + } + + class EpisodeDownloadInstance( + val context: Context, + val downloadQueueWrapper: DownloadQueueWrapper ) { - if (!currentDownloads.any { it == pkg.item.ep.id } && !downloadQueue.any { it.item.ep.id == pkg.item.ep.id }) { - downloadQueue.addLast(pkg) - downloadCheck(context, notificationCallback) - if (setKey) saveQueue() - //ret - } else { - downloadEvent( - pkg.item.ep.id to DownloadActionType.Resume - ) - //null - } - } + private val TAG = "EpisodeDownloadInstance" + private var subtitleDownloadJob: Job? = null + private var downloadJob: Job? = null + private var linkLoadingJob: Job? = null - private fun saveQueue() { - try { - val dQueue = - downloadQueue.toList() - .mapIndexed { index, any -> DownloadQueueResumePackage(index, any) } - .toTypedArray() - setKey(KEY_RESUME_QUEUE_PACKAGES, dQueue) - } catch (t: Throwable) { - logError(t) - } - } + /** isCompleted just means the download should not be retried. + * It includes stopped by user AND completion of file download. + * */ + var isCompleted = false + set(value) { + field = value + if (value) { + removeKey(KEY_RESUME_IN_QUEUE, downloadQueueWrapper.id.toString()) + // Do not emit events when completed as it may also trigger on cancellation. - /*fun isMyServiceRunning(context: Context, serviceClass: Class<*>): Boolean { - val manager = context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager? - for (service in manager!!.getRunningServices(Int.MAX_VALUE)) { - if (serviceClass.name == service.service.className) { - return true + + // Force refresh the queue when completed. + // May lead to some redundant calls, but ensures that the queue is always up to date. + DownloadQueueManager.forceRefreshQueue() + } + } + + /** Cancels all active jobs and sets instance to failed. */ + fun cancelDownload() { + val cause = "Cancel call from cancelDownload" + this.subtitleDownloadJob?.cancel(cause) + this.linkLoadingJob?.cancel(cause) + + // Should not cancel the download job, it may need to clean up itself. + // Better to send a status event using isStopped and let it cancel itself. + isCancelled = true + } + + // Run to cancel ongoing work, delete partial work and refresh queue + private fun cleanup(status: DownloadType) { + removeKey(KEY_RESUME_IN_QUEUE, downloadQueueWrapper.id.toString()) + val id = downloadQueueWrapper.id + + // Delete subtitles on cancel + safe { + val info = context.getKey(KEY_DOWNLOAD_INFO, id.toString()) + if (info != null) { + deleteMatchingSubtitles(context, info) + } + } + + downloadStatusEvent.invoke(Pair(id, status)) + downloadStatus[id] = status + downloadEvent.invoke(Pair(id, DownloadActionType.Stop)) + + // Force refresh the queue when failed. + // May lead to some redundant calls, but ensures that the queue is always up to date. + DownloadQueueManager.forceRefreshQueue() + } + + var isCancelled = false + set(value) { + val oldField = field + field = value + + // Clean up cancelled work, but only once + if (value && !oldField) { + cleanup(DownloadType.IsStopped) + } + } + + + /** This failure can be both downloader and user initiated. + * Do not automatically retry in case of failure. */ + var isFailed = false + set(value) { + val oldField = field + field = value + + // Clean up failed work, but only once + if (value && !oldField) { + cleanup(DownloadType.IsFailed) + } + } + + companion object { + private fun displayNotification(context: Context, id: Int, notification: Notification) { + safe { + NotificationManagerCompat.from(context) + .notify(DOWNLOAD_NOTIFICATION_TAG, id, notification) + } } } - return false - }*/ - suspend fun downloadEpisode( - context: Context?, - source: String?, - folder: String?, - ep: DownloadEpisodeMetadata, - links: List, - notificationCallback: (Int, Notification) -> Unit, - ) { - if (context == null) return - if (links.isEmpty()) return - downloadFromResume( - context, - DownloadResumePackage(DownloadItem(source, folder, ep, links), null), - notificationCallback - ) - } + private suspend fun downloadFromResume( + downloadResumePackage: DownloadResumePackage, + notificationCallback: (Int, Notification) -> Unit, + ) { + val item = downloadResumePackage.item + val id = item.ep.id + if (currentDownloads.value.contains(id)) { // IF IT IS ALREADY DOWNLOADING, RESUME IT + downloadEvent.invoke(id to DownloadActionType.Resume) + return + } - /** Worker stuff */ - private fun startWork(context: Context, key: String) { - val req = OneTimeWorkRequest.Builder(DownloadFileWorkManager::class.java) - .setInputData( - Data.Builder() - .putString("key", key) - .build() + _currentDownloads.update { downloads -> + downloads + id + } + + try { + for (index in (downloadResumePackage.linkIndex ?: 0) until item.links.size) { + val link = item.links[index] + val resume = downloadResumePackage.linkIndex == index + + setKey( + KEY_RESUME_PACKAGES, + id.toString(), + DownloadResumePackage(item, index) + ) + + var connectionResult = + downloadSingleEpisode( + context, + item.source, + item.folder, + item.ep, + link, + notificationCallback, + resume + ) + + if (connectionResult.retrySame) { + connectionResult = downloadSingleEpisode( + context, + item.source, + item.folder, + item.ep, + link, + notificationCallback, + true + ) + } + + if (connectionResult.success) { // SUCCESS + isCompleted = true + break + } else if (!connectionResult.tryNext || index >= item.links.lastIndex) { + isFailed = true + break + } + } + } catch (e: Exception) { + isFailed = true + logError(e) + } finally { + isFailed = !isCompleted + _currentDownloads.update { downloads -> + downloads - id + } + } + } + + private suspend fun startDownload( + info: DownloadItem?, + pkg: DownloadResumePackage? + ) { + try { + if (info != null) { + getDownloadResumePackage(context, info.ep.id)?.let { dpkg -> + downloadFromResume(dpkg) { id, notification -> + displayNotification(context, id, notification) + } + } ?: run { + if (info.links.isEmpty()) return + downloadFromResume( + DownloadResumePackage(info, null) + ) { id, notification -> + displayNotification(context, id, notification) + } + } + } else if (pkg != null) { + downloadFromResume(pkg) { id, notification -> + displayNotification(context, id, notification) + } + } + return + } catch (e: Exception) { + isFailed = true + logError(e) + return + } + } + + private suspend fun downloadFromResume() { + val resumePackage = downloadQueueWrapper.resumePackage ?: return + downloadFromResume(resumePackage) { id, notification -> + displayNotification(context, id, notification) + } + } + + fun startDownload() { + Log.d(TAG, "Starting download ${downloadQueueWrapper.id}") + setKey(KEY_RESUME_IN_QUEUE, downloadQueueWrapper.id.toString(), downloadQueueWrapper) + + ioSafe { + if (downloadQueueWrapper.resumePackage != null) { + downloadFromResume() + // Load links if they are not already loaded + } else if (downloadQueueWrapper.downloadItem != null && downloadQueueWrapper.downloadItem.links.isNullOrEmpty()) { + downloadEpisodeWithoutLinks() + } else if (downloadQueueWrapper.downloadItem?.links != null) { + downloadEpisodeWithLinks( + sortUrls(downloadQueueWrapper.downloadItem.links.toSet()), + downloadQueueWrapper.downloadItem.subs + ) + } + } + } + + private fun downloadEpisodeWithLinks( + links: List, + subs: List? + ) { + val downloadItem = downloadQueueWrapper.downloadItem ?: return + try { + // Prepare visual keys + setKey( + DOWNLOAD_HEADER_CACHE, + downloadItem.resultId.toString(), + DownloadObjects.DownloadHeaderCached( + apiName = downloadItem.apiName, + url = downloadItem.resultUrl, + type = downloadItem.resultType, + name = downloadItem.resultName, + poster = downloadItem.resultPoster, + id = downloadItem.resultId, + cacheTime = System.currentTimeMillis(), + ) + ) + setKey( + getFolderName( + DOWNLOAD_EPISODE_CACHE, + downloadItem.resultId.toString() + ), // 3 deep folder for faster access + downloadItem.episode.id.toString(), + DownloadObjects.DownloadEpisodeCached( + name = downloadItem.episode.name, + poster = downloadItem.episode.poster, + episode = downloadItem.episode.episode, + season = downloadItem.episode.season, + id = downloadItem.episode.id, + parentId = downloadItem.resultId, + score = downloadItem.episode.score, + description = downloadItem.episode.description, + cacheTime = System.currentTimeMillis(), + ) + ) + + val meta = + getDownloadEpisodeMetadata( + downloadItem.episode, + downloadItem.resultName, + downloadItem.apiName, + downloadItem.resultPoster, + downloadItem.isMovie, + downloadItem.resultType + ) + + val folder = + getFolder(downloadItem.resultType, downloadItem.resultName) + val src = "$DOWNLOAD_NAVIGATE_TO/${downloadItem.resultId}" + + // DOWNLOAD VIDEO + val info = DownloadItem(src, folder, meta, links) + + this.downloadJob = ioSafe { + startDownload(info, null) + } + + // 1. Checks if the lang should be downloaded + // 2. Makes it into the download format + // 3. Downloads it as a .vtt file + this.subtitleDownloadJob = ioSafe { + try { + val downloadList = SubtitlesFragment.getDownloadSubsLanguageTagIETF() + + subs?.filter { subtitle -> + downloadList.any { langTagIETF -> + subtitle.languageCode == langTagIETF || + subtitle.originalName.contains( + fromTagToEnglishLanguageName( + langTagIETF + ) ?: langTagIETF + ) + } + } + ?.map { ExtractorSubtitleLink(it.name, it.url, "", it.headers) } + ?.take(3) // max subtitles download hardcoded (?_?) + ?.forEach { link -> + val fileName = getFileName(context, meta) + downloadSubtitle(context, link, fileName, folder) + } + + } catch (_: CancellationException) { + val fileName = getFileName(context, meta) + + val info = DownloadedFileInfo( + totalBytes = 0, + relativePath = folder, + displayName = fileName, + basePath = context.getBasePath().second + ) + + deleteMatchingSubtitles(context, info) + } + } + } catch (e: Exception) { + // The work is only failed if the job did not get started + if (this.downloadJob == null) { + isFailed = true + } + logError(e) + } + } + + private suspend fun downloadEpisodeWithoutLinks() { + val downloadItem = downloadQueueWrapper.downloadItem ?: return + + val generator = RepoLinkGenerator(listOf(downloadItem.episode)) + val currentLinks = mutableSetOf() + val currentSubs = mutableSetOf() + val meta = + getDownloadEpisodeMetadata( + downloadItem.episode, + downloadItem.resultName, + downloadItem.apiName, + downloadItem.resultPoster, + downloadItem.isMovie, + downloadItem.resultType + ) + + createDownloadNotification( + context, + downloadItem.apiName, + txt(R.string.loading).asString(context), + meta, + DownloadType.IsPending, + 0, + 1, + { _, _ -> }, + null, + null, + 0 + )?.let { linkLoadingNotification -> + displayNotification(context, downloadItem.episode.id, linkLoadingNotification) + } + + linkLoadingJob = ioSafe { + generator.generateLinks( + clearCache = false, + sourceTypes = LOADTYPE_INAPP_DOWNLOAD, + callback = { + it.first?.let { link -> + currentLinks.add(link) + } + }, + subtitleCallback = { sub -> + currentSubs.add(sub) + }) + } + + // Wait for link loading completion + linkLoadingJob?.join() + + // Remove link loading notification + NotificationManagerCompat.from(context).cancel(DOWNLOAD_NOTIFICATION_TAG, downloadItem.episode.id) + + if (linkLoadingJob?.isCancelled == true) { + // Same as if no links, but no toast. + // Cancelled link loading is presumed to be user initiated + isCancelled = true + return + } else if (currentLinks.isEmpty()) { + main { + showToast( + R.string.no_links_found_toast, + Toast.LENGTH_SHORT + ) + } + isFailed = true + return + } else { + main { + showToast( + R.string.download_started, + Toast.LENGTH_SHORT + ) + } + } + + downloadEpisodeWithLinks( + sortUrls(currentLinks), + sortSubs(currentSubs), ) - .build() - (WorkManager.getInstance(context)).enqueueUniqueWork( - key, - ExistingWorkPolicy.KEEP, - req - ) + } } - - fun downloadCheckUsingWorker( - context: Context, - ) { - startWork(context, DOWNLOAD_CHECK) - } - - fun downloadFromResumeUsingWorker( - context: Context, - pkg: DownloadResumePackage, - ) { - val key = pkg.item.ep.id.toString() - setKey(WORK_KEY_PACKAGE, key, pkg) - startWork(context, key) - } - - // Keys are needed to transfer the data to the worker reliably and without exceeding the data limit - const val WORK_KEY_PACKAGE = "work_key_package" - const val WORK_KEY_INFO = "work_key_info" - - fun downloadEpisodeUsingWorker( - context: Context, - source: String?, - folder: String?, - ep: DownloadEpisodeMetadata, - links: List, - ) { - val info = DownloadInfo( - source, folder, ep, links - ) - - val key = info.ep.id.toString() - setKey(WORK_KEY_INFO, key, info) - startWork(context, key) - } - - data class DownloadInfo( - @JsonProperty("source") val source: String?, - @JsonProperty("folder") val folder: String?, - @JsonProperty("ep") val ep: DownloadEpisodeMetadata, - @JsonProperty("links") val links: List - ) } diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/downloader/DownloadObjects.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/downloader/DownloadObjects.kt new file mode 100644 index 000000000..1d945a6b4 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/downloader/DownloadObjects.kt @@ -0,0 +1,222 @@ +package com.lagradost.cloudstream3.utils.downloader + +import android.net.Uri +import com.fasterxml.jackson.annotation.JsonProperty +import com.lagradost.cloudstream3.Score +import com.lagradost.cloudstream3.TvType +import com.lagradost.cloudstream3.services.DownloadQueueService +import com.lagradost.cloudstream3.ui.player.SubtitleData +import com.lagradost.cloudstream3.ui.result.ResultEpisode +import com.lagradost.cloudstream3.utils.ExtractorLink +import com.lagradost.safefile.SafeFile +import java.io.IOException +import java.io.OutputStream +import java.util.Objects + +object DownloadObjects { + /** An item can either be something to resume or something new to start */ + data class DownloadQueueWrapper( + @JsonProperty("resumePackage") val resumePackage: DownloadResumePackage?, + @JsonProperty("downloadItem") val downloadItem: DownloadQueueItem?, + ) { + init { + assert(resumePackage != null || downloadItem != null) { + "ResumeID and downloadItem cannot both be null at the same time!" + } + } + + /** Loop through the current download instances to see if it is currently downloading. Also includes link loading. */ + fun isCurrentlyDownloading(): Boolean { + return DownloadQueueService.downloadInstances.value.any { it.downloadQueueWrapper.id == this.id } + } + + @JsonProperty("id") + val id = resumePackage?.item?.ep?.id ?: downloadItem!!.episode.id + + @JsonProperty("parentId") + val parentId = resumePackage?.item?.ep?.parentId ?: downloadItem!!.episode.parentId + } + + /** General data about the episode and show to start a download from. */ + data class DownloadQueueItem( + @JsonProperty("episode") val episode: ResultEpisode, + @JsonProperty("isMovie") val isMovie: Boolean, + @JsonProperty("resultName") val resultName: String, + @JsonProperty("resultType") val resultType: TvType, + @JsonProperty("resultPoster") val resultPoster: String?, + @JsonProperty("apiName") val apiName: String, + @JsonProperty("resultId") val resultId: Int, + @JsonProperty("resultUrl") val resultUrl: String, + @JsonProperty("links") val links: List? = null, + @JsonProperty("subs") val subs: List? = null, + ) { + fun toWrapper(): DownloadQueueWrapper { + return DownloadQueueWrapper(null, this) + } + } + + + abstract class DownloadCached( + @JsonProperty("id") open val id: Int, + ) + + data class DownloadEpisodeCached( + @JsonProperty("name") val name: String?, + @JsonProperty("poster") val poster: String?, + @JsonProperty("episode") val episode: Int, + @JsonProperty("season") val season: Int?, + @JsonProperty("parentId") val parentId: Int, + @JsonProperty("score") var score: Score? = null, + @JsonProperty("description") val description: String?, + @JsonProperty("cacheTime") val cacheTime: Long, + override val id: Int, + ) : DownloadCached(id) { + @JsonProperty("rating", access = JsonProperty.Access.WRITE_ONLY) + @Deprecated( + "`rating` is the old scoring system, use score instead", + replaceWith = ReplaceWith("score"), + level = DeprecationLevel.ERROR + ) + var rating: Int? = null + set(value) { + if (value != null) { + @Suppress("DEPRECATION_ERROR") + score = Score.fromOld(value) + } + } + } + + /** What to display to the user for a downloaded show/movie. Includes info such as name, poster and url */ + data class DownloadHeaderCached( + @JsonProperty("apiName") val apiName: String, + @JsonProperty("url") val url: String, + @JsonProperty("type") val type: TvType, + @JsonProperty("name") val name: String, + @JsonProperty("poster") val poster: String?, + @JsonProperty("cacheTime") val cacheTime: Long, + override val id: Int, + ) : DownloadCached(id) + + data class DownloadResumePackage( + @JsonProperty("item") val item: DownloadItem, + /** Tills which link should get resumed */ + @JsonProperty("linkIndex") val linkIndex: Int?, + ) { + fun toWrapper(): DownloadQueueWrapper { + return DownloadQueueWrapper(this, null) + } + } + + data class DownloadItem( + @JsonProperty("source") val source: String?, + @JsonProperty("folder") val folder: String?, + @JsonProperty("ep") val ep: DownloadEpisodeMetadata, + @JsonProperty("links") val links: List, + ) + + /** Metadata for a specific episode and how to display it. */ + data class DownloadEpisodeMetadata( + @JsonProperty("id") val id: Int, + @JsonProperty("parentId") val parentId: Int, + @JsonProperty("mainName") val mainName: String, + @JsonProperty("sourceApiName") val sourceApiName: String?, + @JsonProperty("poster") val poster: String?, + @JsonProperty("name") val name: String?, + @JsonProperty("season") val season: Int?, + @JsonProperty("episode") val episode: Int?, + @JsonProperty("type") val type: TvType?, + ) + + + data class DownloadedFileInfo( + @JsonProperty("totalBytes") val totalBytes: Long, + @JsonProperty("relativePath") val relativePath: String, + @JsonProperty("displayName") val displayName: String, + @JsonProperty("extraInfo") val extraInfo: String? = null, + @JsonProperty("basePath") val basePath: String? = null // null is for legacy downloads. See getBasePath() + ) + + data class DownloadedFileInfoResult( + @JsonProperty("fileLength") val fileLength: Long, + @JsonProperty("totalBytes") val totalBytes: Long, + @JsonProperty("path") val path: Uri, + ) + + + data class ResumeWatching( + @JsonProperty("parentId") val parentId: Int, + @JsonProperty("episodeId") val episodeId: Int?, + @JsonProperty("episode") val episode: Int?, + @JsonProperty("season") val season: Int?, + @JsonProperty("updateTime") val updateTime: Long, + @JsonProperty("isFromDownload") val isFromDownload: Boolean, + ) + + + data class DownloadStatus( + /** if you should retry with the same args and hope for a better result */ + val retrySame: Boolean, + /** if you should try the next mirror */ + val tryNext: Boolean, + /** if the result is what the user intended */ + val success: Boolean, + ) + + + data class CreateNotificationMetadata( + val type: VideoDownloadManager.DownloadType, + val bytesDownloaded: Long, + val bytesTotal: Long, + val hlsProgress: Long? = null, + val hlsTotal: Long? = null, + val bytesPerSecond: Long + ) + + data class StreamData( + private val fileLength: Long, + val file: SafeFile, + //val fileStream: OutputStream, + ) { + @Throws(IOException::class) + fun open(): OutputStream { + return file.openOutputStreamOrThrow(resume) + } + + @Throws(IOException::class) + fun openNew(): OutputStream { + return file.openOutputStreamOrThrow(false) + } + + fun delete(): Boolean { + return file.delete() == true + } + + val resume: Boolean get() = fileLength > 0L + val startAt: Long get() = if (resume) fileLength else 0L + val exists: Boolean get() = file.exists() == true + } + + + /** bytes have the size end-start where the byte range is [start,end) + * note that ByteArray is a pointer and therefore cant be stored without cloning it */ + data class LazyStreamDownloadResponse( + val bytes: ByteArray, + val startByte: Long, + val endByte: Long, + ) { + val size get() = endByte - startByte + + override fun toString(): String { + return "$startByte->$endByte" + } + + override fun equals(other: Any?): Boolean { + if (other !is LazyStreamDownloadResponse) return false + return other.startByte == startByte && other.endByte == endByte + } + + override fun hashCode(): Int { + return Objects.hash(startByte, endByte) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/downloader/DownloadQueueManager.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/downloader/DownloadQueueManager.kt new file mode 100644 index 000000000..f38664088 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/downloader/DownloadQueueManager.kt @@ -0,0 +1,250 @@ +package com.lagradost.cloudstream3.utils.downloader + +import android.content.Context +import android.util.Log +import androidx.core.content.ContextCompat +import com.lagradost.cloudstream3.CloudStreamApp +import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey +import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKeys +import com.lagradost.cloudstream3.CloudStreamApp.Companion.removeKeys +import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey +import com.lagradost.cloudstream3.MainActivity.Companion.lastError +import com.lagradost.cloudstream3.mvvm.safe +import com.lagradost.cloudstream3.plugins.PluginManager +import com.lagradost.cloudstream3.services.DownloadQueueService +import com.lagradost.cloudstream3.services.DownloadQueueService.Companion.downloadInstances +import com.lagradost.cloudstream3.utils.Coroutines.ioSafe +import com.lagradost.cloudstream3.utils.downloader.DownloadObjects.DownloadQueueWrapper +import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager.KEY_RESUME_IN_QUEUE +import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager.downloadStatus +import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager.downloadStatusEvent +import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager.getDownloadFileInfo +import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager.getDownloadQueuePackage +import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager.getDownloadResumePackage +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.getAndUpdate +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.flow.updateAndGet + +// 1. Put a download on the queue +// 2. The queue manager starts a foreground service to handle the queue +// 3. The service starts work manager jobs to handle the downloads? +object DownloadQueueManager { + private const val TAG = "DownloadQueueManager" + const val QUEUE_KEY = "download_queue_key" + + /** Flow of all active queued download, no active downloads. + * This flow may see many changes, do not place expensive observers. + * downloadInstances is the flow keeping track of active downloads. + * @see com.lagradost.cloudstream3.services.DownloadQueueService.Companion.downloadInstances + */ + private val _queue: MutableStateFlow> by lazy { + /** Persistent queue */ + val currentValue = getKey>(QUEUE_KEY) ?: emptyArray() + MutableStateFlow(currentValue) + } + + val queue: StateFlow> by lazy { _queue } + + /** Start the queue, marks all queue objects as in progress. + * Note that this may run twice without the service restarting + * because MainActivity may be recreated. */ + fun init(context: Context) { + ioSafe { + _queue.collect { queue -> + setKey(QUEUE_KEY, queue) + } + } + + ioSafe startQueue@{ + // Do not automatically start the queue if safe mode is activated. + if (PluginManager.isSafeMode()) { + // Prevent misleading UI + VideoDownloadManager.cancelAllDownloadNotifications(context) + return@startQueue + } + + val resumeQueue = + getPreResumeIds().filterNot { + VideoDownloadManager.currentDownloads.value.contains(it) + } + .mapNotNull { id -> + getDownloadResumePackage(context, id)?.toWrapper() + ?: getDownloadQueuePackage(context, id) + } + + val newQueue = _queue.updateAndGet { localQueue -> + // Add resume packages to the first part of the queue, since they may have been removed from the queue when they started + (resumeQueue + localQueue).distinctBy { it.id }.toTypedArray() + } + + // Once added to the queue they can be safely removed + removeKeys(KEY_RESUME_IN_QUEUE) + + // Make sure the download buttons display a pending status + newQueue.forEach { obj -> + setQueueStatus(obj.id, VideoDownloadManager.DownloadType.IsPending) + } + + if (newQueue.any()) { + startQueueService(context) + } + } + } + + /** Downloads not yet started or in progress. */ + private fun getPreResumeIds(): Set { + return getKeys(KEY_RESUME_IN_QUEUE)?.mapNotNull { + it.substringAfter("$KEY_RESUME_IN_QUEUE/").toIntOrNull() + }?.toSet() + ?: emptySet() + } + + /** Adds an object to the internal persistent queue. It does not re-add an existing item. @return true if successfully added */ + private fun add(downloadQueueWrapper: DownloadQueueWrapper): Boolean { + Log.d(TAG, "Download added to queue: $downloadQueueWrapper") + val newQueue = _queue.updateAndGet { localQueue -> + // Do not add the same episode twice + if (downloadQueueWrapper.isCurrentlyDownloading() || localQueue.any { it.id == downloadQueueWrapper.id }) { + return@updateAndGet localQueue + } + localQueue + downloadQueueWrapper + } + return newQueue.any { it.id == downloadQueueWrapper.id } + } + + /** Removes all objects with the same id from the internal persistent queue */ + private fun remove(id: Int) { + Log.d(TAG, "Download removed from the queue: $id") + _queue.update { localQueue -> + // The check is to prevent unnecessary updates + if (!localQueue.any { it.id == id }) { + return@update localQueue + } + + localQueue.filter { it.id != id }.toTypedArray() + } + } + + /** Removes all items and returns the previous queue */ + private fun removeAll(): Array { + Log.d(TAG, "Removed everything from queue") + return _queue.getAndUpdate { + emptyArray() + } + } + + private fun reorder(downloadQueueWrapper: DownloadQueueWrapper, newPosition: Int) { + _queue.update { localQueue -> + val newIndex = newPosition.coerceIn(0, localQueue.size) + val id = downloadQueueWrapper.id + + val newQueue = localQueue.filter { it.id != id }.toMutableList().apply { + this.add(newIndex, downloadQueueWrapper) + }.toTypedArray() + + newQueue + } + } + + /** Start a real download from the first item in the queue */ + fun popQueue(context: Context): VideoDownloadManager.EpisodeDownloadInstance? { + val first = queue.value.firstOrNull() ?: return null + + remove(first.id) + + val downloadInstance = VideoDownloadManager.EpisodeDownloadInstance(context, first) + + return downloadInstance + } + + /** Marks the item as in queue for the download button */ + private fun setQueueStatus(id: Int, status: VideoDownloadManager.DownloadType) { + downloadStatusEvent.invoke( + Pair( + id, + status + ) + ) + downloadStatus[id] = status + } + + private fun startQueueService(context: Context?) { + if (context == null) { + Log.d(TAG, "Cannot start download queue service, null context.") + return + } + // Do not restart the download queue service + if (DownloadQueueService.isRunning) { + return + } + ioSafe { + val intent = DownloadQueueService.getIntent(context) + ContextCompat.startForegroundService(context, intent) + } + } + + /** Cancels an active download or removes it from queue depending on where it is. */ + fun cancelDownload(id: Int) { + Log.d(TAG, "Cancelling download: $id") + + val currentInstance = downloadInstances.value.find { it.downloadQueueWrapper.id == id } + + if (currentInstance != null) { + currentInstance.cancelDownload() + } else { + removeFromQueue(id) + } + } + + /** Removes all queued items */ + fun removeAllFromQueue() { + removeAll().forEach { wrapper -> + setQueueStatus(wrapper.id, VideoDownloadManager.DownloadType.IsStopped) + } + } + + /** Removes all objects with the same id from the internal persistent queue */ + fun removeFromQueue(id: Int) { + ioSafe { + remove(id) + setQueueStatus(id, VideoDownloadManager.DownloadType.IsStopped) + } + } + + /** Will move the download queue wrapper to a new position in the queue. + * If the item does not exist it will also insert it. */ + fun reorderItem(downloadQueueWrapper: DownloadQueueWrapper, newPosition: Int) { + ioSafe { + reorder(downloadQueueWrapper, newPosition) + } + } + + /** Add a new object to the queue. Will not queue completed downloads or current downloads. */ + fun addToQueue(downloadQueueWrapper: DownloadQueueWrapper) = safe { + val context = CloudStreamApp.context ?: return@safe + val fileInfo = getDownloadFileInfo(context, downloadQueueWrapper.id) + val isComplete = fileInfo != null && + // Assure no division by 0 + fileInfo.totalBytes > 0 && + // If more than 98% downloaded then do not add to queue + (fileInfo.fileLength.toFloat() / fileInfo.totalBytes.toFloat()) > 0.98f + // Do not queue completed files! + if (isComplete) return@safe + + if (add(downloadQueueWrapper)) { + setQueueStatus(downloadQueueWrapper.id, VideoDownloadManager.DownloadType.IsPending) + startQueueService(context) + } + } + + + /** Refreshes the queue flow with the same value, but copied. + * Good to run if the downloads are affected by some outside value change. */ + fun forceRefreshQueue() { + _queue.update { localQueue -> + localQueue.copyOf() + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/downloader/DownloadUtils.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/downloader/DownloadUtils.kt new file mode 100644 index 000000000..b436bb49c --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/downloader/DownloadUtils.kt @@ -0,0 +1,164 @@ +package com.lagradost.cloudstream3.utils.downloader + +import android.content.Context +import android.graphics.Bitmap +import androidx.core.graphics.drawable.toBitmap +import coil3.Extras +import coil3.SingletonImageLoader +import coil3.asDrawable +import coil3.request.ImageRequest +import coil3.request.SuccessResult +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.mvvm.logError +import com.lagradost.cloudstream3.mvvm.safe +import com.lagradost.cloudstream3.ui.player.SubtitleData +import com.lagradost.cloudstream3.ui.result.ExtractorSubtitleLink +import com.lagradost.cloudstream3.utils.Coroutines.ioSafe +import com.lagradost.cloudstream3.utils.UiText +import com.lagradost.cloudstream3.utils.downloader.DownloadFileManagement.getFileName +import com.lagradost.cloudstream3.utils.downloader.DownloadFileManagement.getFolder +import com.lagradost.cloudstream3.utils.txt +import kotlinx.coroutines.Job +import kotlinx.coroutines.runBlocking + +/** Separate object with helper functions for the downloader */ +object DownloadUtils { + private val cachedBitmaps = hashMapOf() + internal fun Context.getImageBitmapFromUrl( + url: String, + headers: Map? = null + ): Bitmap? = safe { + if (cachedBitmaps.containsKey(url)) { + return@safe cachedBitmaps[url] + } + + val imageLoader = SingletonImageLoader.get(this) + + val request = ImageRequest.Builder(this) + .data(url) + .apply { + headers?.forEach { (key, value) -> + extras[Extras.Key(key)] = value + } + } + .build() + + val bitmap = runBlocking { + val result = imageLoader.execute(request) + (result as? SuccessResult)?.image?.asDrawable(applicationContext.resources) + ?.toBitmap() + } + + bitmap?.let { + cachedBitmaps[url] = it + } + + return@safe bitmap + } + + //calculate the time + internal fun getEstimatedTimeLeft( + context: Context, + bytesPerSecond: Long, + progress: Long, + total: Long + ): String { + if (bytesPerSecond <= 0) return "" + val timeInSec = (total - progress) / bytesPerSecond + val hrs = timeInSec / 3600 + val mins = (timeInSec % 3600) / 60 + val secs = timeInSec % 60 + val timeFormated: UiText? = when { + hrs > 0 -> txt( + R.string.download_time_left_hour_min_sec_format, + hrs, + mins, + secs + ) + + mins > 0 -> txt( + R.string.download_time_left_min_sec_format, + mins, + secs + ) + + secs > 0 -> txt( + R.string.download_time_left_sec_format, + secs + ) + + else -> null + } + return timeFormated?.asString(context) ?: "" + } + + internal fun downloadSubtitle( + context: Context?, + link: ExtractorSubtitleLink, + fileName: String, + folder: String + ) { + ioSafe { + VideoDownloadManager.downloadThing( + context ?: return@ioSafe, + link, + "$fileName ${link.name}", + folder, + if (link.url.contains(".srt")) "srt" else "vtt", + false, + null, createNotificationCallback = {} + ) + } + } + + fun downloadSubtitle( + context: Context?, + link: SubtitleData, + meta: DownloadObjects.DownloadEpisodeMetadata, + ) { + context?.let { ctx -> + val fileName = getFileName(ctx, meta) + val folder = getFolder(meta.type ?: return, meta.mainName) + downloadSubtitle( + ctx, + ExtractorSubtitleLink(link.name, link.url, "", link.headers), + fileName, + folder + ) + } + } + + + /** Helper function to make sure duplicate attributes don't get overridden or inserted without lowercase cmp + * example: map("a" to 1) appendAndDontOverride map("A" to 2, "a" to 3, "c" to 4) = map("a" to 1, "c" to 4) + * */ + internal fun Map.appendAndDontOverride(rhs: Map): Map { + val out = this.toMutableMap() + val current = this.keys.map { it.lowercase() } + for ((key, value) in rhs) { + if (current.contains(key.lowercase())) continue + out[key] = value + } + return out + } + + internal fun List.cancel() { + forEach { job -> + try { + job.cancel() + } catch (t: Throwable) { + logError(t) + } + } + } + + internal suspend fun List.join() { + forEach { job -> + try { + job.join() + } catch (t: Throwable) { + logError(t) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/res/drawable/clear_all_24px.xml b/app/src/main/res/drawable/clear_all_24px.xml new file mode 100644 index 000000000..dbbc7dc9f --- /dev/null +++ b/app/src/main/res/drawable/clear_all_24px.xml @@ -0,0 +1,12 @@ + + + + + diff --git a/app/src/main/res/drawable/dashed_line_horizontal.xml b/app/src/main/res/drawable/dashed_line_horizontal.xml new file mode 100644 index 000000000..737ff1959 --- /dev/null +++ b/app/src/main/res/drawable/dashed_line_horizontal.xml @@ -0,0 +1,10 @@ + + + + + diff --git a/app/src/main/res/drawable/netflix_download_batch.xml b/app/src/main/res/drawable/netflix_download_batch.xml new file mode 100644 index 000000000..8ef633fd2 --- /dev/null +++ b/app/src/main/res/drawable/netflix_download_batch.xml @@ -0,0 +1,19 @@ + + + + + diff --git a/app/src/main/res/drawable/round_keyboard_arrow_up_24.xml b/app/src/main/res/drawable/round_keyboard_arrow_up_24.xml new file mode 100644 index 000000000..d1360f948 --- /dev/null +++ b/app/src/main/res/drawable/round_keyboard_arrow_up_24.xml @@ -0,0 +1,13 @@ + + + + + diff --git a/app/src/main/res/layout/download_queue_item.xml b/app/src/main/res/layout/download_queue_item.xml new file mode 100644 index 000000000..86562a513 --- /dev/null +++ b/app/src/main/res/layout/download_queue_item.xml @@ -0,0 +1,122 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/fragment_download_queue.xml b/app/src/main/res/layout/fragment_download_queue.xml new file mode 100644 index 000000000..c562940f9 --- /dev/null +++ b/app/src/main/res/layout/fragment_download_queue.xml @@ -0,0 +1,64 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/fragment_downloads.xml b/app/src/main/res/layout/fragment_downloads.xml index d6f41d6b0..96e1e50ab 100644 --- a/app/src/main/res/layout/fragment_downloads.xml +++ b/app/src/main/res/layout/fragment_downloads.xml @@ -74,24 +74,24 @@ + android:layout_height="match_parent" + android:background="?attr/primaryGrayBackground" + android:baselineAligned="false" + android:focusable="true" + android:foreground="@drawable/outline_drawable" + android:nextFocusLeft="@id/navigation_downloads" + android:nextFocusRight="@id/download_stream_button_tv" + android:nextFocusDown="@id/download_list" + android:orientation="horizontal"> @@ -112,8 +112,8 @@ app:shimmer_base_alpha="0.2" app:shimmer_duration="@integer/loading_time" app:shimmer_highlight_alpha="0.3" - tools:visibility="gone" - tools:ignore="MissingClass"> + tools:ignore="MissingClass" + tools:visibility="gone"> + + android:layout_height="wrap_content" + android:orientation="vertical" + android:visibility="gone" + tools:visibility="visible"> @@ -248,20 +249,19 @@ - + app:tint="?attr/textColor"> @@ -308,9 +308,38 @@ android:descendantFocusability="afterDescendants" android:nextFocusUp="@id/download_appbar" android:nextFocusLeft="@id/navigation_downloads" + android:nextFocusDown="@id/download_queue_button" android:tag="@string/tv_no_focus_tag" tools:listitem="@layout/download_header_episode" /> - + + + + + + + + + + android:layout_gravity="bottom|end" + android:layout_marginBottom="50dp" + android:orientation="vertical"> + android:tooltipText="@string/open_local_video" /> + app:icon="@drawable/ic_network_stream" /> \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_result.xml b/app/src/main/res/layout/fragment_result.xml index c6de59542..a5c933d6a 100644 --- a/app/src/main/res/layout/fragment_result.xml +++ b/app/src/main/res/layout/fragment_result.xml @@ -586,75 +586,81 @@ android:paddingVertical="5dp" android:visibility="gone" tools:visibility="visible"> + + android:visibility="visible" /> + android:gravity="center" + android:orientation="horizontal"> - - - - - - - + + + + + + + + + + + + + + app:icon="@drawable/cast_ic_mini_controller_skip_next" + tools:visibility="visible" /> + + - - + - + + + diff --git a/app/src/main/res/values/donottranslate-strings.xml b/app/src/main/res/values/donottranslate-strings.xml index 56c223e69..acc5a2127 100644 --- a/app/src/main/res/values/donottranslate-strings.xml +++ b/app/src/main/res/values/donottranslate-strings.xml @@ -29,6 +29,7 @@ swipe_enabled_key playback_speed_enabled_key player_resize_enabled_key + player_source_priority_key pip_enabled_key double_tap_enabled_key double_tap_pause_enabled_key diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index af1ea37cb..a80b9120c 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -624,6 +624,8 @@ Set default Use Edit + Source priority + Decide how video sources should be sorted in the player Profiles Help diff --git a/app/src/main/res/xml/settings_player.xml b/app/src/main/res/xml/settings_player.xml index 10a51f3c4..6e1367474 100644 --- a/app/src/main/res/xml/settings_player.xml +++ b/app/src/main/res/xml/settings_player.xml @@ -57,6 +57,11 @@ + + android:icon="@drawable/ic_baseline_extension_24" + android:summary="@string/software_decoding_desc" + android:title="@string/software_decoding" + app:defaultValue="true" + app:key="@string/software_decoding_key" /> Date: Wed, 18 Feb 2026 01:58:03 +0530 Subject: [PATCH 037/236] Remove hubcloud download link (#2502) Sometimes it is making a get request to a video file --- .../cloudstream3/extractors/HubCloud.kt | 39 +------------------ 1 file changed, 2 insertions(+), 37 deletions(-) diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/HubCloud.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/HubCloud.kt index d8a3fb1ec..3ee950256 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/HubCloud.kt +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/HubCloud.kt @@ -148,42 +148,7 @@ class HubCloud : ExtractorApi() { ) { this.quality = quality } ) } - - text.contains("10Gbps", ignoreCase = true) -> { - var currentLink = link - var redirectUrl: String? - var redirectCount = 0 - val maxRedirects = 3 - - while (redirectCount < maxRedirects) { - val response = app.get(currentLink, allowRedirects = false) - redirectUrl = response.headers["location"] - - if (redirectUrl == null) { - Log.e(tag, "10Gbps: No redirect") - return@amap - } - - if ("link=" in redirectUrl) { - val finalLink = redirectUrl.substringAfter("link=") - callback.invoke( - newExtractorLink( - "10Gbps [Download]", - "10Gbps [Download] $labelExtras", - finalLink - ) { this.quality = quality } - ) - return@amap - } - - currentLink = redirectUrl - redirectCount++ - } - - Log.e(tag, "10Gbps: Redirect limit reached ($maxRedirects)") - return@amap - } - + else -> { loadExtractor(link, "", subtitleCallback, callback) } @@ -243,4 +208,4 @@ class HubCloud : ExtractorApi() { parts.takeLast(3).joinToString(".") } } -} \ No newline at end of file +} From 8796a73f067abb0136af250528a9869233d7d879 Mon Sep 17 00:00:00 2001 From: Swapnil Kuwar Date: Wed, 18 Feb 2026 02:14:22 +0530 Subject: [PATCH 038/236] Search result provider pining feature added. (#2501) --- .../cloudstream3/ui/search/SearchFragment.kt | 26 +++++++++++++------ 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchFragment.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchFragment.kt index c24e81882..e8d5b80d5 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchFragment.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchFragment.kt @@ -494,24 +494,34 @@ class SearchFragment : BaseFragment( try { // https://stackoverflow.com/questions/6866238/concurrent-modification-exception-adding-to-an-arraylist listLock.lock() + + val pinnedOrder = DataStoreHelper.pinnedProviders.toList().reversed() + + val sortedList = list.toList().sortedWith(compareBy { pair -> + val index = pinnedOrder.indexOf(pair.first) + if (index == -1) Int.MAX_VALUE else index + }) + (binding.searchMasterRecycler.adapter as? ParentItemAdapter)?.apply { - val newItems = list.map { ongoing -> - val dataList = ongoing.value.list + val newItems = sortedList.map { ongoing -> + + val providerName = ongoing.first + val providerData = ongoing.second + + val dataList = providerData.list val dataListFiltered = context?.filterSearchResultByFilmQuality(dataList) ?: dataList val homePageList = HomePageList( - ongoing.key, + providerName, dataListFiltered ) - val expandableList = HomeViewModel.ExpandableHomepageList( + HomeViewModel.ExpandableHomepageList( homePageList, - ongoing.value.currentPage, - ongoing.value.hasNext + providerData.currentPage, + providerData.hasNext ) - - expandableList } submitList(newItems) From 443c1c81c9b24ed4ad38b91c21aef0e9488be2e0 Mon Sep 17 00:00:00 2001 From: firelight <147925818+fire-light42@users.noreply.github.com> Date: Tue, 17 Feb 2026 20:45:39 +0000 Subject: [PATCH 039/236] Refactor: Minor code cleanup for #2501 --- .../cloudstream3/ui/search/SearchFragment.kt | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchFragment.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchFragment.kt index e8d5b80d5..6bbd569b7 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchFragment.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchFragment.kt @@ -495,19 +495,15 @@ class SearchFragment : BaseFragment( // https://stackoverflow.com/questions/6866238/concurrent-modification-exception-adding-to-an-arraylist listLock.lock() - val pinnedOrder = DataStoreHelper.pinnedProviders.toList().reversed() + val pinnedOrder = DataStoreHelper.pinnedProviders.reversedArray() - val sortedList = list.toList().sortedWith(compareBy { pair -> - val index = pinnedOrder.indexOf(pair.first) + val sortedList = list.toList().sortedWith(compareBy { (providerName, _) -> + val index = pinnedOrder.indexOf(providerName) if (index == -1) Int.MAX_VALUE else index }) (binding.searchMasterRecycler.adapter as? ParentItemAdapter)?.apply { - val newItems = sortedList.map { ongoing -> - - val providerName = ongoing.first - val providerData = ongoing.second - + val newItems = sortedList.map { (providerName, providerData) -> val dataList = providerData.list val dataListFiltered = context?.filterSearchResultByFilmQuality(dataList) ?: dataList From c862d119fb2fe425f01ecc65dca81045c374cecc Mon Sep 17 00:00:00 2001 From: Mohd Kaif Shaikh <63385299+KaifTaufiq@users.noreply.github.com> Date: Wed, 18 Feb 2026 02:18:29 +0530 Subject: [PATCH 040/236] add Videa.hu extractor (#2491) * add Videa.hu extractor * replace import android.util.Base64 * Refactor Videa extractor for improved URL handling --- .../cloudstream3/extractors/Videa.kt | 205 ++++++++++++++++++ .../cloudstream3/utils/ExtractorApi.kt | 3 +- 2 files changed, 207 insertions(+), 1 deletion(-) create mode 100644 library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Videa.kt diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Videa.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Videa.kt new file mode 100644 index 000000000..121e221ad --- /dev/null +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Videa.kt @@ -0,0 +1,205 @@ +// Adapted for CloudStream - taken from https://github.com/vargalex/ResolveURL/blob/fix/videa-resolver-add-cookie/script.module.resolveurl/lib/resolveurl/plugins/videa.py +package com.lagradost.cloudstream3.extractors + +import com.lagradost.cloudstream3.app +import com.lagradost.cloudstream3.SubtitleFile +import com.lagradost.cloudstream3.utils.* +import com.lagradost.cloudstream3.base64DecodeArray + +/** + * Extractor for Videa.hu video hosting service + * Handles encrypted XML responses and redirect chains + */ +class Videa : ExtractorApi() { + override val name = "Videa" + override val mainUrl = "https://videa.hu" + override val requiresReferer = false + + private val videaSecret = "xHb0ZvME5q8CBcoQi6AngerDu3FGO9fkUlwPmLVY_RTzj2hJIS4NasXWKy1td7p" + + override suspend fun getUrl( + url: String, + referer: String?, + subtitleCallback: (SubtitleFile) -> Unit, + callback: (ExtractorLink) -> Unit + ) { + var currentUrl = url + var key = "" + var lastUrl: String? = null + // Handle redirect loop until we get valid XML + while (true) { + val webUrl = getXmlUrl(currentUrl) { cookie -> /* no-op, cookie not used */ } ?: return + val response = app.get(webUrl) + val rawBytes = response.body.bytes() + + // Check if response starts with XML declaration + val isXml = rawBytes.size >= 5 && + rawBytes[0] == 0x3C.toByte() && // '<' + rawBytes[1] == 0x3F.toByte() && // '?' + rawBytes[2] == 0x78.toByte() && // 'x' + rawBytes[3] == 0x6D.toByte() && // 'm' + rawBytes[4] == 0x6C.toByte() // 'l' + + val videaXml = if (isXml) { + String(rawBytes, Charsets.UTF_8) + } else { + // Handle encrypted XML response + val xsHeader = response.headers["X-Videa-Xs"] ?: return + key += xsHeader + rc4DecryptBytes(rawBytes, key) + } + + // Check for redirect in XML error + val redirectMatch = """(.*)""".toRegex().find(videaXml) + + if (redirectMatch != null && redirectMatch.groupValues[1] != currentUrl) { + lastUrl = currentUrl + currentUrl = redirectMatch.groupValues[1] + } else { + parseVideoSources(videaXml, callback) + break + } + } + } + + private suspend fun getXmlUrl(url: String, cookieCallback: (String) -> Unit = {}): String? { + val response = app.get(url) + val html = response.text + + // Extract sl cookie if present + response.headers["Set-Cookie"]?.let { cookieHeader -> + """sl=([^;]+)""".toRegex().find(cookieHeader)?.let { + cookieCallback(it.value) + } + } + + // Determine if this is a player URL or needs iframe extraction + val playerUrl = if ("/player" in url) { + url + } else { + val iframeMatch = """ + """sl=([^;]+)""".toRegex().find(cookieHeader)?.let { + cookieCallback(it.value) + } + } + + // Extract nonce and generate tokens + val nonceMatch = """_xt\s*=\s*"([^"]+)""".toRegex().find(playerHtml) ?: return null + val (s, t, key) = generateTokens(nonceMatch.groupValues[1]) + + // Extract video parameter + val videoParam = when { + "f=" in playerUrl -> "f=" + playerUrl.substringAfter("f=").substringBefore("&") + "v=" in playerUrl -> "v=" + playerUrl.substringAfter("v=").substringBefore("&") + else -> return null + } + + return "$mainUrl/player/xml?platform=desktop&$videoParam&_s=$s&_t=$t" + } + + private fun generateTokens(nonce: String): Triple { + val lo = nonce.take(32) + val s = nonce.substring(32) + var result = "" + + for (i in 0 until 32) { + val index = videaSecret.indexOf(lo[i]) - 31 + result += s[i - index] + } + + // Generate random seed + val chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" + val randomSeed = (1..8).map { chars.random() }.joinToString("") + + val key = result.substring(16) + randomSeed + return Triple(randomSeed, result.take(16), key) + } + + private suspend fun parseVideoSources(xml: String, callback: (ExtractorLink) -> Unit) { + val sourceRegex = """video_source\s*name="([^"]+)".*exp="([^"]+)"[^>]*>([^<]+)""".toRegex() + val sources = sourceRegex.findAll(xml).toList() + + for (sourceMatch in sources) { + val sourceName = sourceMatch.groupValues[1] + val exp = sourceMatch.groupValues[2] + var sourceUrl = sourceMatch.groupValues[3] + + // Add https if needed + if (sourceUrl.startsWith("//")) { + sourceUrl = "https:$sourceUrl" + } + + // Extract hash for this source + val hashMatch = """([^<]+)<""".toRegex().find(xml) + + hashMatch?.let { match -> + val hash = match.groupValues[1] + val finalUrl = "$sourceUrl?md5=$hash&expires=$exp".replace("&", "&") + + callback( + newExtractorLink( + name, + "$sourceName - $name", + finalUrl, + ExtractorLinkType.VIDEO + ) { + this.quality = Qualities.Unknown.value + this.referer = mainUrl + } + ) + } + } + } + + private fun rc4DecryptBytes(encryptedBytes: ByteArray, key: String): String { + // Check if data is Base64 encoded + val isBase64 = encryptedBytes.all { byte -> + val char = byte.toInt() and 0xFF + char in 32..126 || char == 10 || char == 13 + } + + val actualEncryptedBytes = if (isBase64) { + val base64String = String(encryptedBytes, Charsets.UTF_8) + .replace("\r", "") + .replace("\n", "") + .replace(" ", "") + .trim() + base64DecodeArray(base64String) + } else { + encryptedBytes + } + + val keyBytes = key.toByteArray(Charsets.UTF_8) + + // RC4 key-scheduling algorithm (KSA) + val s = IntArray(256) { it } + var j = 0 + for (i in 0..255) { + j = (j + s[i] + (keyBytes[i % keyBytes.size].toInt() and 0xFF)) % 256 + s[i] = s[j].also { s[j] = s[i] } + } + + // RC4 pseudo-random generation algorithm (PRGA) + var i = 0 + j = 0 + val result = ByteArray(actualEncryptedBytes.size) + for (k in actualEncryptedBytes.indices) { + i = (i + 1) % 256 + j = (j + s[i]) % 256 + s[i] = s[j].also { s[j] = s[i] } + val keyStreamByte = s[(s[i] + s[j]) % 256] + result[k] = ((actualEncryptedBytes[k].toInt() and 0xFF) xor keyStreamByte).toByte() + } + + return String(result, Charsets.UTF_8) + } +} diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/ExtractorApi.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/ExtractorApi.kt index a05479966..6d4c94426 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/ExtractorApi.kt +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/ExtractorApi.kt @@ -261,6 +261,7 @@ import com.lagradost.cloudstream3.extractors.Urochsunloath import com.lagradost.cloudstream3.extractors.Userload import com.lagradost.cloudstream3.extractors.Userscloud import com.lagradost.cloudstream3.extractors.Uservideo +import com.lagradost.cloudstream3.extractors.Videa import com.lagradost.cloudstream3.extractors.Vanfem import com.lagradost.cloudstream3.extractors.Vicloud import com.lagradost.cloudstream3.extractors.VidHidePro @@ -985,7 +986,7 @@ val extractorApis: MutableList = arrayListOf( Lvturbo(), Fastream(), - + Videa(), FEmbed(), FeHD(), Fplayer(), From 88d42613d3b071ef167bebef46dec1d591cb44f4 Mon Sep 17 00:00:00 2001 From: Nivin <89772187+NivinCNC@users.noreply.github.com> Date: Wed, 18 Feb 2026 02:23:07 +0530 Subject: [PATCH 041/236] Replace Google suggest API with TheMovieDB multi-search endpoint (#2500) --- .../ui/search/SearchSuggestionApi.kt | 48 ++++++++++++------- 1 file changed, 31 insertions(+), 17 deletions(-) diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchSuggestionApi.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchSuggestionApi.kt index ea2dfd30b..8dbd78178 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchSuggestionApi.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchSuggestionApi.kt @@ -1,18 +1,33 @@ package com.lagradost.cloudstream3.ui.search +import com.fasterxml.jackson.annotation.JsonProperty import com.lagradost.cloudstream3.app import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.nicehttp.NiceResponse /** * API for fetching search suggestions from external sources. - * Uses Google's suggestion API which provides movie/show related suggestions. + * Uses TheMovieDB API to provide movie/show/anime related suggestions. */ object SearchSuggestionApi { - private const val GOOGLE_SUGGESTION_URL = "https://suggestqueries.google.com/complete/search" + private const val TMDB_API_URL = "https://api.themoviedb.org/3/search/multi" + private const val TMDB_API_KEY = "e6333b32409e02a4a6eba6fb7ff866bb" + + data class TmdbSearchResult( + @JsonProperty("results") val results: List? + ) + + data class TmdbSearchItem( + @JsonProperty("media_type") val mediaType: String?, + @JsonProperty("title") val title: String?, + @JsonProperty("name") val name: String?, + @JsonProperty("original_title") val originalTitle: String?, + @JsonProperty("original_name") val originalName: String? + ) /** - * Fetches search suggestions from Google's autocomplete API. + * Fetches search suggestions from TheMovieDB multi search API. + * Returns suggestions for movies, TV series, and anime. * * @param query The search query to get suggestions for * @return List of suggestion strings, empty list on failure @@ -22,16 +37,15 @@ object SearchSuggestionApi { return try { val response = app.get( - GOOGLE_SUGGESTION_URL, + TMDB_API_URL, params = mapOf( - "client" to "firefox", // Returns JSON format - "q" to query, - "hl" to "en" // Language hint + "api_key" to TMDB_API_KEY, + "query" to query, + "language" to "en-US" ), cacheTime = 60 * 24 // Cache for 1 day (cacheUnit default is Minutes) ) - // Response format: ["query",["suggestion1","suggestion2",...]] parseSuggestions(response) } catch (e: Exception) { logError(e) @@ -40,18 +54,18 @@ object SearchSuggestionApi { } /** - * Parses the Google suggestion JSON response. - * Format: ["query",["suggestion1","suggestion2",...]] + * Parses the TMDB search response and extracts movie/TV show titles. + * Filters to only include movies, TV shows, and anime. */ private fun parseSuggestions(response: NiceResponse): List { return try { - val parsed = response.parsed>() - val suggestions = parsed.getOrNull(1) - when (suggestions) { - is List<*> -> suggestions.filterIsInstance().take(10) - is Array<*> -> suggestions.filterIsInstance().take(10) - else -> emptyList() - } + val parsed = response.parsed() + parsed.results + ?.filter { it.mediaType == "movie" || it.mediaType == "tv" } + ?.mapNotNull { it.title ?: it.name } + ?.distinct() + ?.take(10) + ?: emptyList() } catch (e: Exception) { logError(e) emptyList() From 76728d858f7ab6a0f672306a18b8c4d5eb0072f9 Mon Sep 17 00:00:00 2001 From: Luna712 <142361265+Luna712@users.noreply.github.com> Date: Tue, 17 Feb 2026 14:55:30 -0700 Subject: [PATCH 042/236] Use withStyledAttributes (#2305) --- .../cloudstream3/ui/CustomRecyclerViews.kt | 8 ++-- .../cloudstream3/ui/MiniControllerFragment.kt | 25 ++++--------- .../ui/download/button/PieFetchButton.kt | 24 +++++------- .../ui/settings/testing/TestView.kt | 8 ++-- .../utils/PercentageCropImageView.kt | 33 +++++++++-------- .../lagradost/cloudstream3/utils/UIHelper.kt | 37 ++++++++----------- .../cloudstream3/widget/FlowLayout.kt | 13 ++++--- 7 files changed, 65 insertions(+), 83 deletions(-) diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/CustomRecyclerViews.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/CustomRecyclerViews.kt index 78ad2a6bf..302358538 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/CustomRecyclerViews.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/CustomRecyclerViews.kt @@ -3,6 +3,7 @@ package com.lagradost.cloudstream3.ui import android.content.Context import android.util.AttributeSet import android.view.View +import androidx.core.content.withStyledAttributes import androidx.core.view.children import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.RecyclerView @@ -154,10 +155,9 @@ class AutofitRecyclerView @JvmOverloads constructor(context: Context, attrs: Att init { if (attrs != null) { - val attrsArray = intArrayOf(android.R.attr.columnWidth) - val array = context.obtainStyledAttributes(attrs, attrsArray) - columnWidth = array.getDimensionPixelSize(0, -1) - array.recycle() + context.withStyledAttributes(attrs, intArrayOf(android.R.attr.columnWidth)) { + columnWidth = getDimensionPixelSize(0, -1) + } } layoutManager = manager diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/MiniControllerFragment.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/MiniControllerFragment.kt index b6326eb36..bd8541e6b 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/MiniControllerFragment.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/MiniControllerFragment.kt @@ -7,12 +7,12 @@ import android.view.View import android.widget.LinearLayout import android.widget.ProgressBar import android.widget.RelativeLayout +import androidx.core.content.withStyledAttributes import com.google.android.gms.cast.framework.media.widget.MiniControllerFragment import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.utils.UIHelper.adjustAlpha import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute import com.lagradost.cloudstream3.utils.UIHelper.toPx -import java.lang.ref.WeakReference class MyMiniControllerFragment : MiniControllerFragment() { @@ -25,26 +25,15 @@ class MyMiniControllerFragment : MiniControllerFragment() { // I KNOW, KINDA SPAGHETTI SOLUTION, BUT IT WORKS override fun onInflate(context: Context, attributeSet: AttributeSet, bundle: Bundle?) { - super.onInflate(context, attributeSet, bundle) - - // somehow this leaks and I really dont know why, it seams like if you go back to a fragment with this, it leaks???? if (currentColor == 0) { - WeakReference( - context.obtainStyledAttributes( - attributeSet, - R.styleable.CustomCast - ) - ).apply { - if (get() - ?.hasValue(R.styleable.CustomCast_customCastBackgroundColor) == true - ) { - currentColor = - get() - ?.getColor(R.styleable.CustomCast_customCastBackgroundColor, 0) ?: 0 + context.withStyledAttributes(attributeSet, R.styleable.CustomCast, 0, 0) { + if (hasValue(R.styleable.CustomCast_customCastBackgroundColor)) { + currentColor = getColor(R.styleable.CustomCast_customCastBackgroundColor, 0) } - get()?.recycle() - }.clear() + } } + + super.onInflate(context, attributeSet, bundle) } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/download/button/PieFetchButton.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/download/button/PieFetchButton.kt index 2fdc94e9c..a414dedf5 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/download/button/PieFetchButton.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/download/button/PieFetchButton.kt @@ -10,6 +10,7 @@ import android.widget.ImageView import android.widget.TextView import androidx.annotation.MainThread import androidx.core.content.ContextCompat +import androidx.core.content.withStyledAttributes import androidx.core.view.isGone import androidx.core.view.isVisible import com.lagradost.cloudstream3.CloudStreamApp.Companion.removeKey @@ -66,7 +67,7 @@ open class PieFetchButton(context: Context, attributeSet: AttributeSet) : open fun onInflate() {} init { - context.obtainStyledAttributes(attributeSet, R.styleable.PieFetchButton, 0, 0).apply { + context.withStyledAttributes(attributeSet, R.styleable.PieFetchButton, 0, 0) { try { inflate( overrideLayout ?: getResourceId( @@ -75,6 +76,7 @@ open class PieFetchButton(context: Context, attributeSet: AttributeSet) : ) ) } catch (e: Exception) { + recycle() // Manually call recycle first to avoid memory leaks Log.e( "PieFetchButton", "Error inflating PieFetchButton, " + "check that you have declared the required aria2c attrs: aria2c_icon_scale aria2c_icon_color aria2c_outline_color aria2c_fill_color" @@ -82,11 +84,6 @@ open class PieFetchButton(context: Context, attributeSet: AttributeSet) : throw e } - - progressBar = findViewById(R.id.progress_downloaded) - progressBarBackground = findViewById(R.id.progress_downloaded_background) - statusView = findViewById(R.id.image_download_status) - animateWaiting = getBoolean( R.styleable.PieFetchButton_download_animate_waiting, true @@ -95,16 +92,13 @@ open class PieFetchButton(context: Context, attributeSet: AttributeSet) : R.styleable.PieFetchButton_download_hide_when_icon, true ) - waitingAnimation = getResourceId( R.styleable.PieFetchButton_download_waiting_animation, R.anim.rotate_around_center_point ) - activeOutline = getResourceId( R.styleable.PieFetchButton_download_outline_active, R.drawable.circle_shape ) - nonActiveOutline = getResourceId( R.styleable.PieFetchButton_download_outline_non_active, R.drawable.circle_shape_dotted @@ -132,15 +126,17 @@ open class PieFetchButton(context: Context, attributeSet: AttributeSet) : ) val fillIndex = getInt(R.styleable.PieFetchButton_download_fill, 0) - progressDrawable = getResourceId( R.styleable.PieFetchButton_download_fill_override, fillArray[fillIndex] ) - - progressBar.progressDrawable = ContextCompat.getDrawable(context, progressDrawable) - - recycle() } + + progressBar = findViewById(R.id.progress_downloaded) + progressBarBackground = findViewById(R.id.progress_downloaded_background) + statusView = findViewById(R.id.image_download_status) + + progressBar.progressDrawable = ContextCompat.getDrawable(context, progressDrawable) + // resetView() onInflate() } diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/testing/TestView.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/testing/TestView.kt index eea495a26..65ed47a54 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/testing/TestView.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/testing/TestView.kt @@ -9,6 +9,7 @@ import androidx.annotation.DrawableRes import androidx.annotation.StringRes import androidx.cardview.widget.CardView import androidx.core.content.ContextCompat +import androidx.core.content.withStyledAttributes import androidx.core.view.isVisible import androidx.core.widget.ContentLoadingProgressBar import com.google.android.material.button.MaterialButton @@ -59,10 +60,9 @@ class TestView @JvmOverloads constructor( playPauseButton = findViewById(R.id.tests_play_pause) attrs?.let { - val typedArray = context.obtainStyledAttributes(it, R.styleable.TestView) - val headerText = typedArray.getString(R.styleable.TestView_header_text) - mainSectionHeader?.text = headerText - typedArray.recycle() + context.withStyledAttributes(it, R.styleable.TestView) { + mainSectionHeader?.text = getString(R.styleable.TestView_header_text) + } } playPauseButton?.setOnClickListener { diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/PercentageCropImageView.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/PercentageCropImageView.kt index 46eaee655..6580182bb 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/PercentageCropImageView.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/PercentageCropImageView.kt @@ -4,7 +4,9 @@ import android.content.Context import android.graphics.Matrix import android.graphics.drawable.Drawable import android.util.AttributeSet +import androidx.core.content.withStyledAttributes import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.mvvm.logError /** * A custom [AppCompatImageView] that allows precise control over the visible crop area @@ -144,22 +146,23 @@ class PercentageCropImageView : androidx.appcompat.widget.AppCompatImageView { private fun initAttrs(context: Context, attrs: AttributeSet?) { attrs ?: return - val typedArray = context.obtainStyledAttributes(attrs, R.styleable.PercentageCropImageView) - try { - if (typedArray.hasValue(R.styleable.PercentageCropImageView_cropYCenterOffsetPct)) { - mCropYCenterOffsetPct = typedArray.getFloat( - R.styleable.PercentageCropImageView_cropYCenterOffsetPct, - 0.5f - ) + context.withStyledAttributes(attrs, R.styleable.PercentageCropImageView) { + try { + if (hasValue(R.styleable.PercentageCropImageView_cropYCenterOffsetPct)) { + mCropYCenterOffsetPct = getFloat( + R.styleable.PercentageCropImageView_cropYCenterOffsetPct, + 0.5f + ) + } + if (hasValue(R.styleable.PercentageCropImageView_cropXCenterOffsetPct)) { + mCropXCenterOffsetPct = getFloat( + R.styleable.PercentageCropImageView_cropXCenterOffsetPct, + 0.5f + ) + } + } catch (e: Exception) { + logError(e) } - if (typedArray.hasValue(R.styleable.PercentageCropImageView_cropXCenterOffsetPct)) { - mCropXCenterOffsetPct = typedArray.getFloat( - R.styleable.PercentageCropImageView_cropXCenterOffsetPct, - 0.5f - ) - } - } finally { - typedArray.recycle() } } } \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/UIHelper.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/UIHelper.kt index c901b7a45..ebd7b2988 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/UIHelper.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/UIHelper.kt @@ -43,6 +43,7 @@ import androidx.appcompat.widget.PopupMenu import androidx.core.app.ActivityCompat import androidx.core.content.ContextCompat import androidx.core.content.getSystemService +import androidx.core.content.withStyledAttributes import androidx.core.graphics.alpha import androidx.core.graphics.blue import androidx.core.graphics.green @@ -304,18 +305,25 @@ object UIHelper { @ColorInt fun Context.getResourceColor(@AttrRes resource: Int, alphaFactor: Float = 1f): Int { - val typedArray = obtainStyledAttributes(intArrayOf(resource)) - val color = typedArray.getColor(0, 0) - typedArray.recycle() + val color = colorFromAttribute(resource) + return if (alphaFactor < 1f) adjustAlpha(color, alphaFactor) else color + } - if (alphaFactor < 1f) { - val alpha = (color.alpha * alphaFactor).roundToInt() - return Color.argb(alpha, color.red, color.green, color.blue) + @ColorInt + fun Context.colorFromAttribute(@AttrRes attribute: Int): Int { + var color = 0 + withStyledAttributes(attrs = intArrayOf(attribute)) { + color = getColor(0, 0) } - return color } + @ColorInt + fun adjustAlpha(@ColorInt color: Int, factor: Float): Int { + val alpha = (color.alpha * factor).roundToInt() + return Color.argb(alpha, color.red, color.green, color.blue) + } + var createPaletteAsyncCache: HashMap = hashMapOf() fun createPaletteAsync(url: String, bitmap: Bitmap, callback: (Palette) -> Unit) { createPaletteAsyncCache[url]?.let { palette -> @@ -330,21 +338,6 @@ object UIHelper { } } - fun adjustAlpha(@ColorInt color: Int, factor: Float): Int { - val alpha = (Color.alpha(color) * factor).roundToInt() - val red = Color.red(color) - val green = Color.green(color) - val blue = Color.blue(color) - return Color.argb(alpha, red, green, blue) - } - - fun Context.colorFromAttribute(attribute: Int): Int { - val attributes = obtainStyledAttributes(intArrayOf(attribute)) - val color = attributes.getColor(0, 0) - attributes.recycle() - return color - } - fun Activity.hideSystemUI() { // Enables regular immersive mode. if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { diff --git a/app/src/main/java/com/lagradost/cloudstream3/widget/FlowLayout.kt b/app/src/main/java/com/lagradost/cloudstream3/widget/FlowLayout.kt index 624370032..c18ad39c6 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/widget/FlowLayout.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/widget/FlowLayout.kt @@ -4,6 +4,7 @@ import android.annotation.SuppressLint import android.content.Context import android.util.AttributeSet import android.view.ViewGroup +import androidx.core.content.withStyledAttributes import androidx.core.view.isVisible import androidx.core.view.marginEnd import com.lagradost.cloudstream3.R @@ -19,9 +20,9 @@ class FlowLayout : ViewGroup { @SuppressLint("CustomViewStyleable") internal constructor(c: Context, attrs: AttributeSet?) : super(c, attrs) { - val t = c.obtainStyledAttributes(attrs, R.styleable.FlowLayout_Layout) - itemSpacing = t.getDimensionPixelSize(R.styleable.FlowLayout_Layout_itemSpacing, 0) - t.recycle() + c.withStyledAttributes(attrs, R.styleable.FlowLayout_Layout) { + itemSpacing = getDimensionPixelSize(R.styleable.FlowLayout_Layout_itemSpacing, 0) + } } override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { @@ -104,9 +105,9 @@ class FlowLayout : ViewGroup { @SuppressLint("CustomViewStyleable") internal constructor(c: Context, attrs: AttributeSet?) : super(c, attrs) { - val t = c.obtainStyledAttributes(attrs, R.styleable.FlowLayout_Layout) - spacing = 0//t.getDimensionPixelSize(R.styleable.FlowLayout_Layout_itemSpacing, 0); - t.recycle() + c.withStyledAttributes(attrs, R.styleable.FlowLayout_Layout) { + spacing = 0 + } } internal constructor(width: Int, height: Int) : super(width, height) { From ad2168c5bc4d072f404feb23c20a8f3023f32613 Mon Sep 17 00:00:00 2001 From: Luna712 <142361265+Luna712@users.noreply.github.com> Date: Tue, 17 Feb 2026 16:31:01 -0700 Subject: [PATCH 043/236] Upgrade to minSdk 23 (#2078) --- app/build.gradle.kts | 8 ++++++++ gradle/libs.versions.toml | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 41e8fc0a0..5f6f55575 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -152,6 +152,14 @@ android { resValues = true } + packaging { + jniLibs { + // Enables legacy JNI packaging to reduce APK size (similar to builds before minSdk 23). + // Note: This may increase app startup time slightly. + useLegacyPackaging = true + } + } + namespace = "com.lagradost.cloudstream3" } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index afceb26dd..90ae59c07 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -49,7 +49,7 @@ zipline = "1.24.0" jvmTarget = "1.8" jdkToolchain = "17" -minSdk = "21" +minSdk = "23" compileSdk = "36" targetSdk = "36" From ea4ef5c2f327671a494f3256cac1def88b1314ab Mon Sep 17 00:00:00 2001 From: Luna712 <142361265+Luna712@users.noreply.github.com> Date: Tue, 17 Feb 2026 16:52:58 -0700 Subject: [PATCH 044/236] Update media3 to 1.9.2 (#2342) --- .../cloudstream3/ui/player/CS3IPlayer.kt | 4 +- .../player/UpdatedDefaultExtractorsFactory.kt | 60 +- .../ui/player/UpdatedMatroskaExtractor.kt | 675 ++++++++++++++---- gradle/libs.versions.toml | 4 +- 4 files changed, 593 insertions(+), 150 deletions(-) diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/CS3IPlayer.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/CS3IPlayer.kt index fdcbb044c..cfe3a069f 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/CS3IPlayer.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/CS3IPlayer.kt @@ -544,10 +544,10 @@ class CS3IPlayer : IPlayer { currentSubtitleOffset = offset CustomDecoder.subtitleOffset = offset if (currentTextRenderer?.state == STATE_ENABLED || currentTextRenderer?.state == STATE_STARTED) { - exoPlayer?.currentPosition?.let { pos -> + exoPlayer?.currentPosition?.also { pos -> // This seems to properly refresh all subtitles // It needs to be done as all subtitle cues with timings are pre-processed - currentTextRenderer?.resetPosition(pos) + currentTextRenderer?.resetPosition(pos, false) } } } diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/UpdatedDefaultExtractorsFactory.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/UpdatedDefaultExtractorsFactory.kt index 8ea0f4e61..b3873bd32 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/UpdatedDefaultExtractorsFactory.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/UpdatedDefaultExtractorsFactory.kt @@ -13,6 +13,7 @@ package com.lagradost.cloudstream3.ui.player import android.net.Uri import androidx.annotation.GuardedBy +import androidx.media3.common.C import androidx.media3.common.FileTypes import androidx.media3.common.Format import androidx.media3.common.util.TimestampAdjuster @@ -48,7 +49,6 @@ import java.lang.reflect.Constructor import java.lang.reflect.InvocationTargetException import java.util.concurrent.atomic.AtomicBoolean - /** * An [ExtractorsFactory] that provides an array of extractors for the following formats: * @@ -103,13 +103,16 @@ class UpdatedDefaultExtractorsFactory : ExtractorsFactory { private var tsTimestampSearchBytes: Int private var textTrackTranscodingEnabled: Boolean private var subtitleParserFactory: SubtitleParser.Factory + private var codecsToParseWithinGopSampleDependencies: @C.VideoCodecFlags Int private var jpegFlags: @JpegExtractor.Flags Int = 0 + private var heifFlags: @HeifExtractor.Flags Int = 0 init { tsMode = TsExtractor.MODE_SINGLE_PMT tsTimestampSearchBytes = TsExtractor.DEFAULT_TIMESTAMP_SEARCH_BYTES subtitleParserFactory = DefaultSubtitleParserFactory() textTrackTranscodingEnabled = true + codecsToParseWithinGopSampleDependencies = C.VIDEO_CODEC_FLAG_H264 or C.VIDEO_CODEC_FLAG_H265 } /** @@ -346,6 +349,14 @@ class UpdatedDefaultExtractorsFactory : ExtractorsFactory { return this } + @Synchronized + override fun experimentalSetCodecsToParseWithinGopSampleDependencies( + codecsToParseWithinGopSampleDependencies: @C.VideoCodecFlags Int + ): UpdatedDefaultExtractorsFactory { + this.codecsToParseWithinGopSampleDependencies = codecsToParseWithinGopSampleDependencies + return this + } + /** * Sets flags for [JpegExtractor] instances created by the factory. * @@ -361,6 +372,21 @@ class UpdatedDefaultExtractorsFactory : ExtractorsFactory { return this } + /** + * Sets flags for [HeifExtractor] instances created by the factory. + * + * @see HeifExtractor.HeifExtractor + * @param flags The flags to use. + * @return The factory, for convenience. + */ + @Synchronized + fun setHeifExtractorFlags( + flags: @HeifExtractor.Flags Int + ): UpdatedDefaultExtractorsFactory { + this.heifFlags = flags + return this + } + @Synchronized override fun createExtractors(): Array { return createExtractors(Uri.EMPTY, HashMap()) @@ -468,21 +494,26 @@ class UpdatedDefaultExtractorsFactory : ExtractorsFactory { extractors.add( FragmentedMp4Extractor( subtitleParserFactory, - fragmentedMp4Flags - or (if (textTrackTranscodingEnabled) - 0 - else - FragmentedMp4Extractor.FLAG_EMIT_RAW_SUBTITLE_DATA) + fragmentedMp4Flags or + FragmentedMp4Extractor + .codecsToParseWithinGopSampleDependenciesAsFlags( + codecsToParseWithinGopSampleDependencies + ) or + if (textTrackTranscodingEnabled) 0 + else FragmentedMp4Extractor.FLAG_EMIT_RAW_SUBTITLE_DATA ) ) + extractors.add( Mp4Extractor( subtitleParserFactory, - mp4Flags - or (if (textTrackTranscodingEnabled) - 0 - else - Mp4Extractor.FLAG_EMIT_RAW_SUBTITLE_DATA) + mp4Flags or + Mp4Extractor + .codecsToParseWithinGopSampleDependenciesAsFlags( + codecsToParseWithinGopSampleDependencies + ) or + if (textTrackTranscodingEnabled) 0 + else Mp4Extractor.FLAG_EMIT_RAW_SUBTITLE_DATA ) ) } @@ -524,12 +555,7 @@ class UpdatedDefaultExtractorsFactory : ExtractorsFactory { FileTypes.PNG -> extractors.add(PngExtractor()) FileTypes.WEBP -> extractors.add(WebpExtractor()) FileTypes.BMP -> extractors.add(BmpExtractor()) - FileTypes.HEIF -> if ((mp4Flags and Mp4Extractor.FLAG_READ_MOTION_PHOTO_METADATA) == 0 - && (mp4Flags and Mp4Extractor.FLAG_READ_SEF_DATA) == 0 - ) { - extractors.add(HeifExtractor()) - } - + FileTypes.HEIF -> extractors.add(HeifExtractor(heifFlags)) FileTypes.AVIF -> extractors.add(AvifExtractor()) FileTypes.WEBVTT, FileTypes.UNKNOWN -> {} else -> {} diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/UpdatedMatroskaExtractor.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/UpdatedMatroskaExtractor.kt index 6868af771..5937b1973 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/UpdatedMatroskaExtractor.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/UpdatedMatroskaExtractor.kt @@ -41,18 +41,20 @@ import androidx.media3.common.ColorInfo import androidx.media3.common.DrmInitData import androidx.media3.common.DrmInitData.SchemeData import androidx.media3.common.Format +import androidx.media3.common.Metadata import androidx.media3.common.MimeTypes import androidx.media3.common.ParserException -import androidx.media3.common.util.Assertions import androidx.media3.common.util.Log import androidx.media3.common.util.ParsableByteArray import androidx.media3.common.util.UnstableApi import androidx.media3.common.util.Util +import androidx.media3.container.DolbyVisionConfig import androidx.media3.container.NalUnitUtil import androidx.media3.extractor.AacUtil import androidx.media3.extractor.AvcConfig import androidx.media3.extractor.ChunkIndex -import androidx.media3.container.DolbyVisionConfig +import androidx.media3.extractor.ChunkIndexProvider +import androidx.media3.extractor.DtsUtil import androidx.media3.extractor.Extractor import androidx.media3.extractor.ExtractorInput import androidx.media3.extractor.ExtractorOutput @@ -61,12 +63,18 @@ import androidx.media3.extractor.HevcConfig import androidx.media3.extractor.MpegAudioUtil import androidx.media3.extractor.PositionHolder import androidx.media3.extractor.SeekMap -import androidx.media3.extractor.SeekMap.Unseekable +import androidx.media3.extractor.SeekMap.SeekPoints +import androidx.media3.extractor.SeekPoint +import androidx.media3.extractor.TrackAwareSeekMap import androidx.media3.extractor.TrackOutput import androidx.media3.extractor.TrackOutput.CryptoData import androidx.media3.extractor.TrueHdSampleRechunker +import androidx.media3.extractor.metadata.ThumbnailMetadata import androidx.media3.extractor.text.SubtitleParser import androidx.media3.extractor.text.SubtitleTranscodingExtractorOutput +import com.google.common.base.Preconditions.checkArgument +import com.google.common.base.Preconditions.checkNotNull +import com.google.common.base.Preconditions.checkState import com.google.common.collect.ImmutableList import java.io.IOException import java.nio.ByteBuffer @@ -74,6 +82,7 @@ import java.nio.ByteOrder import java.util.Arrays import java.util.Collections import java.util.Locale +import java.util.Objects import java.util.UUID import kotlin.math.max import kotlin.math.min @@ -119,6 +128,8 @@ class UpdatedMatroskaExtractor private constructor( private var timecodeScale = C.TIME_UNSET private var durationTimecode = C.TIME_UNSET private var durationUs = C.TIME_UNSET + private var isWebm: Boolean = false + private var pendingEndTracks: Boolean // The track corresponding to the current TrackEntry element, or null. private var currentTrack: Track? = null @@ -131,6 +142,13 @@ class UpdatedMatroskaExtractor private constructor( private var seekEntryPosition: Long = 0 // Cue related elements. + private val perTrackCues: SparseArray> + private var inCuesElement = false + private var currentCueTimeUs: Long = C.TIME_UNSET + private var currentCueTrackNumber: Int = C.INDEX_UNSET + private var currentCueClusterPosition: Long = C.INDEX_UNSET.toLong() + private var currentCueRelativePosition: Long = C.INDEX_UNSET.toLong() + private var primarySeekTrackNumber: Int = C.INDEX_UNSET private var seekForCues = false private var seekForSeekContent = false private var visitedSeekHeads: HashSet = HashSet() @@ -139,9 +157,6 @@ class UpdatedMatroskaExtractor private constructor( private var cuesContentPosition = C.INDEX_UNSET.toLong() private var seekPositionAfterBuildingCues = C.INDEX_UNSET.toLong() private var clusterTimecodeUs = C.TIME_UNSET - private var cueTimesUs: androidx.media3.common.util.LongArray? = null - private var cueClusterPositions: androidx.media3.common.util.LongArray? = null - private var seenClusterPositionForCurrentCuePoint = false // Reading state. private var haveOutputSample = false @@ -218,6 +233,7 @@ class UpdatedMatroskaExtractor private constructor( init { reader.init(InnerEbmlProcessor()) this.subtitleParserFactory = subtitleParserFactory + this.perTrackCues = SparseArray() seekForCuesEnabled = (flags and FLAG_DISABLE_SEEK_FOR_CUES) == 0 parseSubtitlesDuringExtraction = (flags and FLAG_EMIT_RAW_SUBTITLE_DATA) == 0 varintReader = VarintReader() @@ -233,6 +249,7 @@ class UpdatedMatroskaExtractor private constructor( encryptionSubsampleData = ParsableByteArray() supplementalData = ParsableByteArray() blockSampleSizes = IntArray(1) + pendingEndTracks = true } @Throws(IOException::class) @@ -255,6 +272,17 @@ class UpdatedMatroskaExtractor private constructor( reader.reset() varintReader.reset() resetWriteSampleData() + inCuesElement = false + currentCueTimeUs = C.TIME_UNSET + currentCueTrackNumber = C.INDEX_UNSET + currentCueClusterPosition = C.INDEX_UNSET.toLong() + currentCueRelativePosition = C.INDEX_UNSET.toLong() + // To prevent creating duplicate cue points on a re-parse, clear any existing cue data if the + // seek map has not yet been sent. Once sent, the cue data is considered final, and subsequent + // Cues elements will be ignored by the parsing logic. + if (!sentSeekMap) { + perTrackCues.clear() + } for (i in 0.. EbmlProcessor.ELEMENT_TYPE_MASTER - ID_EBML_READ_VERSION, ID_DOC_TYPE_READ_VERSION, ID_SEEK_POSITION, ID_TIMECODE_SCALE, ID_TIME_CODE, ID_BLOCK_DURATION, ID_PIXEL_WIDTH, ID_PIXEL_HEIGHT, ID_DISPLAY_WIDTH, ID_DISPLAY_HEIGHT, ID_DISPLAY_UNIT, ID_TRACK_NUMBER, ID_TRACK_TYPE, ID_FLAG_DEFAULT, ID_FLAG_FORCED, ID_DEFAULT_DURATION, ID_MAX_BLOCK_ADDITION_ID, ID_BLOCK_ADD_ID_TYPE, ID_CODEC_DELAY, ID_SEEK_PRE_ROLL, ID_DISCARD_PADDING, ID_CHANNELS, ID_AUDIO_BIT_DEPTH, ID_CONTENT_ENCODING_ORDER, ID_CONTENT_ENCODING_SCOPE, ID_CONTENT_COMPRESSION_ALGORITHM, ID_CONTENT_ENCRYPTION_ALGORITHM, ID_CONTENT_ENCRYPTION_AES_SETTINGS_CIPHER_MODE, ID_CUE_TIME, ID_CUE_CLUSTER_POSITION, ID_REFERENCE_BLOCK, ID_STEREO_MODE, ID_COLOUR_BITS_PER_CHANNEL, ID_COLOUR_RANGE, ID_COLOUR_TRANSFER, ID_COLOUR_PRIMARIES, ID_MAX_CLL, ID_MAX_FALL, ID_PROJECTION_TYPE, ID_BLOCK_ADD_ID -> EbmlProcessor.ELEMENT_TYPE_UNSIGNED_INT + ID_EBML_READ_VERSION, ID_DOC_TYPE_READ_VERSION, ID_SEEK_POSITION, ID_TIMECODE_SCALE, ID_TIME_CODE, ID_BLOCK_DURATION, ID_PIXEL_WIDTH, ID_PIXEL_HEIGHT, ID_DISPLAY_WIDTH, ID_DISPLAY_HEIGHT, ID_DISPLAY_UNIT, ID_TRACK_NUMBER, ID_TRACK_TYPE, ID_FLAG_DEFAULT, ID_FLAG_FORCED, ID_DEFAULT_DURATION, ID_MAX_BLOCK_ADDITION_ID, ID_BLOCK_ADD_ID_TYPE, ID_CODEC_DELAY, ID_SEEK_PRE_ROLL, ID_DISCARD_PADDING, ID_CHANNELS, ID_AUDIO_BIT_DEPTH, ID_CONTENT_ENCODING_ORDER, ID_CONTENT_ENCODING_SCOPE, ID_CONTENT_COMPRESSION_ALGORITHM, ID_CONTENT_ENCRYPTION_ALGORITHM, ID_CONTENT_ENCRYPTION_AES_SETTINGS_CIPHER_MODE, ID_CUE_TIME, ID_CUE_CLUSTER_POSITION, ID_CUE_RELATIVE_POSITION, ID_CUE_TRACK, ID_REFERENCE_BLOCK, ID_STEREO_MODE, ID_COLOUR_BITS_PER_CHANNEL, ID_COLOUR_RANGE, ID_COLOUR_TRANSFER, ID_COLOUR_PRIMARIES, ID_MAX_CLL, ID_MAX_FALL, ID_PROJECTION_TYPE, ID_BLOCK_ADD_ID -> EbmlProcessor.ELEMENT_TYPE_UNSIGNED_INT ID_DOC_TYPE, ID_NAME, ID_CODEC_ID, ID_LANGUAGE -> EbmlProcessor.ELEMENT_TYPE_STRING ID_SEEK_ID, ID_BLOCK_ADD_ID_EXTRA_DATA, ID_CONTENT_COMPRESSION_SETTINGS, ID_CONTENT_ENCRYPTION_KEY_ID, ID_SIMPLE_BLOCK, ID_BLOCK, ID_CODEC_PRIVATE, ID_PROJECTION_PRIVATE, ID_BLOCK_ADDITIONAL -> EbmlProcessor.ELEMENT_TYPE_BINARY @@ -341,11 +369,27 @@ class UpdatedMatroskaExtractor private constructor( } ID_CUES -> { - cueTimesUs = androidx.media3.common.util.LongArray() - cueClusterPositions = androidx.media3.common.util.LongArray() + if (!sentSeekMap) { + inCuesElement = true + } + } + + ID_CUE_POINT -> { + if (!sentSeekMap) { + assertInCues(id) + currentCueTimeUs = C.TIME_UNSET + } + } + + ID_CUE_TRACK_POSITIONS -> { + if (!sentSeekMap) { + assertInCues(id) + currentCueTrackNumber = C.INDEX_UNSET + currentCueClusterPosition = C.INDEX_UNSET.toLong() + currentCueRelativePosition = C.INDEX_UNSET.toLong() + } } - ID_CUE_POINT -> seenClusterPositionForCurrentCuePoint = false ID_CLUSTER -> if (!sentSeekMap) { // We need to build cues before parsing the cluster. if (seekForCuesEnabled && cuesContentPosition != C.INDEX_UNSET.toLong()) { @@ -358,7 +402,7 @@ class UpdatedMatroskaExtractor private constructor( } else { // We don't know where the Cues element is located. It's most likely omitted. Allow // playback, but disable seeking. - extractorOutput!!.seekMap(Unseekable(durationUs)) + extractorOutput!!.seekMap(SeekMap.Unseekable(durationUs)) sentSeekMap = true } } @@ -370,7 +414,10 @@ class UpdatedMatroskaExtractor private constructor( ID_CONTENT_ENCODING -> {} ID_CONTENT_ENCRYPTION -> getCurrentTrack(id).hasContentEncryption = true - ID_TRACK_ENTRY -> currentTrack = Track() + ID_TRACK_ENTRY -> { + currentTrack = Track() + currentTrack!!.isWebm = isWebm + } ID_MASTERING_METADATA -> getCurrentTrack(id).hasColorInfo = true else -> {} } @@ -409,7 +456,7 @@ class UpdatedMatroskaExtractor private constructor( } else { // Otherwise, if we not found any cues nor any more seek heads then we mark // this as unseekable. - extractorOutput!!.seekMap(Unseekable(durationUs)) + extractorOutput!!.seekMap(SeekMap.Unseekable(durationUs)) sentSeekMap = true } } @@ -438,13 +485,67 @@ class UpdatedMatroskaExtractor private constructor( ID_CUES -> { if (!sentSeekMap) { - extractorOutput!!.seekMap(buildSeekMap(cueTimesUs, cueClusterPositions)) + var hasAnyCues = false + for (i in 0 until perTrackCues.size()) { + if (perTrackCues.valueAt(i).isNotEmpty()) { + hasAnyCues = true + break + } + } + + if (!hasAnyCues || durationUs == C.TIME_UNSET) { + // Cues are missing, empty, or duration is unknown. + extractorOutput!!.seekMap(SeekMap.Unseekable(durationUs)) + } else { + for (i in 0 until perTrackCues.size()) { + perTrackCues.valueAt(i).sort() + } + + val seekMap = MatroskaSeekMap( + perTrackCues, + durationUs, + primarySeekTrackNumber, + segmentContentPosition, + segmentContentSize + ) + extractorOutput!!.seekMap(seekMap) + } sentSeekMap = true - } else { - // We have already built the cues. Ignore. + inCuesElement = false + for (i in 0 until tracks.size()) { + val track: Track = tracks.valueAt(i) + track.maybeAddThumbnailMetadata(perTrackCues, durationUs, segmentContentPosition, segmentContentSize) + if (!track.waitingForDtsAnalysis) { + track.assertOutputInitialized() + track.output!!.format(requireNotNull(track.format)) + } + } + maybeEndTracks() + } + } + + ID_CUE_TRACK_POSITIONS -> { + if (!sentSeekMap) { + assertInCues(id) + if (currentCueTimeUs != C.TIME_UNSET + && currentCueTrackNumber != C.INDEX_UNSET + && currentCueClusterPosition != C.INDEX_UNSET.toLong() + ) { + var trackCues = perTrackCues[currentCueTrackNumber] + if (trackCues == null) { + trackCues = ArrayList() + perTrackCues.put(currentCueTrackNumber, trackCues) + } + + trackCues.add( + MatroskaSeekMap.CuePointData( + currentCueTimeUs, + /* clusterPosition= */ segmentContentPosition + currentCueClusterPosition, + /* relativePosition= */ currentCueRelativePosition + ) + ) + } } - this.cueTimesUs = null - this.cueClusterPositions = null } ID_BLOCK_GROUP -> { @@ -520,17 +621,15 @@ class UpdatedMatroskaExtractor private constructor( } ID_TRACK_ENTRY -> { - val currentTrack = Assertions.checkStateNotNull(this.currentTrack) + val currentTrack = checkNotNull(this.currentTrack) if (currentTrack.codecId == null) { throw ParserException.createForMalformedContainer( "CodecId is missing in TrackEntry element", /* cause= */null ) } else { - if (isCodecSupported( - currentTrack.codecId!! - ) - ) { - currentTrack.initializeOutput(extractorOutput!!, currentTrack.number) + if (isCodecSupported(currentTrack.codecId!!)) { + currentTrack.initializeFormat(currentTrack.number); + currentTrack.output = extractorOutput!!.track(currentTrack.number, currentTrack.type); tracks.put(currentTrack.number, currentTrack) } } @@ -540,10 +639,63 @@ class UpdatedMatroskaExtractor private constructor( ID_TRACKS -> { if (tracks.size() == 0) { throw ParserException.createForMalformedContainer( - "No valid tracks were found", /* cause= */null + "No valid tracks were found", /* cause= */ null ) } - extractorOutput!!.endTracks() + + // Determine the track to use for default seeking. + var defaultVideoTrackNumber: Int = C.INDEX_UNSET + var firstVideoTrackNumber: Int = C.INDEX_UNSET + var defaultAudioTrackNumber: Int = C.INDEX_UNSET + var firstAudioTrackNumber: Int = C.INDEX_UNSET + + // If we're not going to seek for cues, output the formats immediately. + val mayBeSendFormatsEarly = !seekForCuesEnabled || cuesContentPosition == C.INDEX_UNSET.toLong(); + + for (i in 0 until tracks.size()) { + val trackItem: Track = tracks.valueAt(i) + + val trackType: @C.TrackType Int = trackItem.type + when (trackType) { + C.TRACK_TYPE_VIDEO -> { + if (trackItem.flagDefault) { + defaultVideoTrackNumber = trackItem.number + } + if (firstVideoTrackNumber == C.INDEX_UNSET) { + firstVideoTrackNumber = trackItem.number + } + } + + C.TRACK_TYPE_AUDIO -> { + if (trackItem.flagDefault) { + defaultAudioTrackNumber = trackItem.number + } + if (firstAudioTrackNumber == C.INDEX_UNSET) { + firstAudioTrackNumber = trackItem.number + } + } + } + + if (mayBeSendFormatsEarly) { + trackItem.assertOutputInitialized() + if (!trackItem.waitingForDtsAnalysis) { + trackItem.output!!.format(checkNotNull(trackItem.format)) + } + } + } + + primarySeekTrackNumber = when { + defaultVideoTrackNumber != C.INDEX_UNSET -> defaultVideoTrackNumber + firstVideoTrackNumber != C.INDEX_UNSET -> firstVideoTrackNumber + defaultAudioTrackNumber != C.INDEX_UNSET -> defaultAudioTrackNumber + firstAudioTrackNumber != C.INDEX_UNSET -> firstAudioTrackNumber + tracks.size() > 0 -> tracks.valueAt(0).number + else -> C.INDEX_UNSET + } + + if (mayBeSendFormatsEarly) { + maybeEndTracks() + } } else -> {} @@ -586,7 +738,16 @@ class UpdatedMatroskaExtractor private constructor( ID_TRACK_NUMBER -> getCurrentTrack(id).number = value.toInt() ID_FLAG_DEFAULT -> getCurrentTrack(id).flagDefault = value == 1L ID_FLAG_FORCED -> getCurrentTrack(id).flagForced = value == 1L - ID_TRACK_TYPE -> getCurrentTrack(id).type = value.toInt() + ID_TRACK_TYPE -> { + val matroskaTrackType = value.toInt() + getCurrentTrack(id).type = when (matroskaTrackType) { + 1 -> C.TRACK_TYPE_VIDEO // Matroska video + 2 -> C.TRACK_TYPE_AUDIO // Matroska audio + 17 -> C.TRACK_TYPE_TEXT // Matroska subtitle + 33 -> C.TRACK_TYPE_METADATA // Matroska metadata + else -> C.TRACK_TYPE_UNKNOWN + } + } ID_DEFAULT_DURATION -> getCurrentTrack(id).defaultSampleDurationNs = value.toInt() ID_MAX_BLOCK_ADDITION_ID -> getCurrentTrack(id).maxBlockAdditionId = value.toInt() ID_BLOCK_ADD_ID_TYPE -> getCurrentTrack(id).blockAddIdType = value.toInt() @@ -632,17 +793,35 @@ class UpdatedMatroskaExtractor private constructor( } ID_CUE_TIME -> { - assertInCues(id) - cueTimesUs!!.add(scaleTimecodeToUs(value)) + if (!sentSeekMap) { + assertInCues(id) + currentCueTimeUs = scaleTimecodeToUs(value) + } } - ID_CUE_CLUSTER_POSITION -> if (!seenClusterPositionForCurrentCuePoint) { - assertInCues(id) - // If there's more than one video/audio track, then there could be more than one - // CueTrackPositions within a single CuePoint. In such a case, ignore all but the first - // one (since the cluster position will be quite close for all the tracks). - cueClusterPositions!!.add(value) - seenClusterPositionForCurrentCuePoint = true + ID_CUE_TRACK -> { + if (!sentSeekMap) { + assertInCues(id) + currentCueTrackNumber = value.toInt() + } + } + + ID_CUE_CLUSTER_POSITION -> { + if (!sentSeekMap) { + assertInCues(id) + if (currentCueClusterPosition == C.INDEX_UNSET.toLong()) { + currentCueClusterPosition = value + } + } + } + + ID_CUE_RELATIVE_POSITION -> { + if (!sentSeekMap) { + assertInCues(id) + if (currentCueRelativePosition == C.INDEX_UNSET.toLong()) { + currentCueRelativePosition = value + } + } } ID_TIME_CODE -> clusterTimecodeUs = scaleTimecodeToUs(value) @@ -954,7 +1133,7 @@ class UpdatedMatroskaExtractor private constructor( (scratch.data[0].toInt() shl 8) or (scratch.data[1].toInt() and 0xFF) blockTimeUs = clusterTimecodeUs + scaleTimecodeToUs(timecode.toLong()) val isKeyframe = - track.type == TRACK_TYPE_AUDIO + track.type == C.TRACK_TYPE_AUDIO || (id == ID_SIMPLE_BLOCK && (scratch.data[2].toInt() and 0x80) == 0x80) blockFlags = if (isKeyframe) C.BUFFER_FLAG_KEY_FRAME else 0 blockState = BLOCK_STATE_DATA @@ -1046,9 +1225,7 @@ class UpdatedMatroskaExtractor private constructor( } } - @Throws( - ParserException::class - ) + @Throws(ParserException::class) private fun assertInTrackEntry(id: Int) { if (currentTrack == null) { throw ParserException.createForMalformedContainer( @@ -1057,11 +1234,9 @@ class UpdatedMatroskaExtractor private constructor( } } - @Throws( - ParserException::class - ) + @Throws(ParserException::class) private fun assertInCues(id: Int) { - if (cueTimesUs == null || cueClusterPositions == null) { + if (!inCuesElement) { throw ParserException.createForMalformedContainer( "Element $id must be in a Cues", /* cause= */null ) @@ -1090,6 +1265,7 @@ class UpdatedMatroskaExtractor private constructor( } else { if (CODEC_ID_SUBRIP == track.codecId || CODEC_ID_ASS == track.codecId + || CODEC_ID_SSA == track.codecId || CODEC_ID_VTT == track.codecId ) { if (blockSampleCount > 1) { @@ -1179,7 +1355,7 @@ class UpdatedMatroskaExtractor private constructor( if (CODEC_ID_SUBRIP == track.codecId) { writeSubtitleSampleData(input, SUBRIP_PREFIX, size) return finishWriteSampleData() - } else if (CODEC_ID_ASS == track.codecId) { + } else if (CODEC_ID_ASS == track.codecId || CODEC_ID_SSA == track.codecId) { writeSubtitleSampleData(input, SSA_PREFIX, size) return finishWriteSampleData() } else if (CODEC_ID_VTT == track.codecId) { @@ -1187,6 +1363,20 @@ class UpdatedMatroskaExtractor private constructor( return finishWriteSampleData() } + if (track.waitingForDtsAnalysis) { + checkNotNull(track.format) + if (DtsUtil.isSampleDtsHd(input, size)) { + track.format = track.format!! + .buildUpon() + .setSampleMimeType(MimeTypes.AUDIO_DTS_HD) + .build() + } + + track.output!!.format(track.format!!) + track.waitingForDtsAnalysis = false + maybeEndTracks() + } + val output = track.output if (!sampleEncodingHandled) { if (track.hasContentEncryption) { @@ -1353,7 +1543,7 @@ class UpdatedMatroskaExtractor private constructor( } } else { if (track.trueHdSampleRechunker != null) { - Assertions.checkState(sampleStrippedBytes.limit() == 0) + checkState(sampleStrippedBytes.limit() == 0) track.trueHdSampleRechunker!!.startSample(input) } while (sampleBytesRead < size) { @@ -1452,57 +1642,6 @@ class UpdatedMatroskaExtractor private constructor( return bytesWritten } - /** - * Builds a [SeekMap] from the recently gathered Cues information. - * - * @return The built [SeekMap]. The returned [SeekMap] may be unseekable if cues - * information was missing or incomplete. - */ - private fun buildSeekMap( - cueTimesUs: androidx.media3.common.util.LongArray?, - cueClusterPositions: androidx.media3.common.util.LongArray? - ): SeekMap { - if (segmentContentPosition == C.INDEX_UNSET.toLong() || durationUs == C.TIME_UNSET || cueTimesUs == null || cueTimesUs.size() == 0 || cueClusterPositions == null || cueClusterPositions.size() != cueTimesUs.size()) { - // Cues information is missing or incomplete. - return Unseekable(durationUs) - } - val cuePointsSize = cueTimesUs.size() - var sizes = IntArray(cuePointsSize) - var offsets = LongArray(cuePointsSize) - var durationsUs = LongArray(cuePointsSize) - var timesUs = LongArray(cuePointsSize) - for (i in 0.. 0 && timesUs[lastValidIndex] > durationUs) { - lastValidIndex-- - } - - // Calculate sizes and durations for the last valid index - sizes[lastValidIndex] = - (segmentContentPosition + segmentContentSize - offsets[lastValidIndex]).toInt() - durationsUs[lastValidIndex] = durationUs - timesUs[lastValidIndex] - - // If the last valid index is not the last cue point, truncate the arrays - if (lastValidIndex < cuePointsSize - 1) { - Log.w(TAG, "Discarding trailing cue points with timestamps greater than total duration") - sizes = sizes.copyOf(lastValidIndex + 1) - offsets = offsets.copyOf(lastValidIndex + 1) - durationsUs = durationsUs.copyOf(lastValidIndex + 1) - timesUs = timesUs.copyOf(lastValidIndex + 1) - } - - return ChunkIndex(sizes, offsets, durationsUs, timesUs) - } - /** * Updates the position of the holder to Cues element's position if the extractor configuration * permits use of master seek entry. After building Cues sets the holder's position back to where @@ -1522,7 +1661,7 @@ class UpdatedMatroskaExtractor private constructor( // (until cues or end of segment). However this also means that we only need to seek // back to the top once, instead seeking back in a stack like manner. if (seekForSeekContent) { - Assertions.checkArgument(pendingSeekHeads.isNotEmpty(), "Illegal value of seekForSeekContent") + checkArgument(pendingSeekHeads.isNotEmpty(), "Illegal value of seekForSeekContent") // The exact order does not really matter, but it is easiest to just do stack (FILO) val next = pendingSeekHeads.removeAt(pendingSeekHeads.size - 1) seekPosition.position = next @@ -1569,11 +1708,22 @@ class UpdatedMatroskaExtractor private constructor( } private fun assertInitialized() { - Assertions.checkStateNotNull( + checkNotNull( extractorOutput ) } + private fun maybeEndTracks() { + if (!pendingEndTracks) return + + for (i in 0 until tracks.size()) { + if (tracks.valueAt(i).waitingForDtsAnalysis) return + } + + checkNotNull(extractorOutput).endTracks() + pendingEndTracks = false + } + /** Passes events through to the outer [UpdatedMatroskaExtractor]. */ private inner class InnerEbmlProcessor : EbmlProcessor { override fun getElementType(id: Int): @EbmlProcessor.ElementType Int { @@ -1618,10 +1768,11 @@ class UpdatedMatroskaExtractor private constructor( /** Holds data corresponding to a single track. */ protected class Track { // Common elements. + var isWebm: Boolean = false var name: String? = null var codecId: String? = null var number: Int = 0 - var type: Int = 0 + var type: @C.TrackType Int = 0 var defaultSampleDurationNs: Int = 0 var maxBlockAdditionId: Int = 0 var blockAddIdType: Int = 0 @@ -1671,23 +1822,24 @@ class UpdatedMatroskaExtractor private constructor( var sampleRate: Int = 8000 var codecDelayNs: Long = 0 var seekPreRollNs: Long = 0 - var trueHdSampleRechunker: TrueHdSampleRechunker? = - null + var trueHdSampleRechunker: TrueHdSampleRechunker? = null + var waitingForDtsAnalysis: Boolean = false // Text elements. var flagForced: Boolean = false + + // Common track elements. var flagDefault: Boolean = true var language: String = "eng" // Set when the output is initialized. nalUnitLengthFieldLength is only set for H264/H265. var output: TrackOutput? = null + var format: Format? = null var nalUnitLengthFieldLength: Int = 0 - /** Initializes the track with an output. */ - @Throws( - ParserException::class - ) - fun initializeOutput(output: ExtractorOutput, trackId: Int) { + /** Builds the [Format] for the track. */ + @Throws(ParserException::class) + fun initializeFormat(trackId: Int) { var mimeType: String var maxInputSize = Format.NO_VALUE var pcmEncoding: @PcmEncoding Int = Format.NO_VALUE @@ -1695,8 +1847,20 @@ class UpdatedMatroskaExtractor private constructor( var codecs: String? = null when (codecId) { CODEC_ID_VP8 -> mimeType = MimeTypes.VIDEO_VP8 - CODEC_ID_VP9 -> mimeType = MimeTypes.VIDEO_VP9 - CODEC_ID_AV1 -> mimeType = MimeTypes.VIDEO_AV1 + CODEC_ID_VP9 -> { + mimeType = MimeTypes.VIDEO_VP9 + initializationData = + if (codecPrivate == null) null else ImmutableList.of( + codecPrivate!! + ) + } + CODEC_ID_AV1 -> { + mimeType = MimeTypes.VIDEO_AV1 + initializationData = + if (codecPrivate == null) null else ImmutableList.of( + codecPrivate!! + ) + } CODEC_ID_MPEG2 -> mimeType = MimeTypes.VIDEO_MPEG2 CODEC_ID_MPEG4_SP, CODEC_ID_MPEG4_ASP, CODEC_ID_MPEG4_AP -> { mimeType = MimeTypes.VIDEO_MP4V @@ -1808,7 +1972,10 @@ class UpdatedMatroskaExtractor private constructor( trueHdSampleRechunker = TrueHdSampleRechunker() } - CODEC_ID_DTS, CODEC_ID_DTS_EXPRESS -> mimeType = MimeTypes.AUDIO_DTS + CODEC_ID_DTS, CODEC_ID_DTS_EXPRESS -> { + mimeType = MimeTypes.AUDIO_DTS // temporary + waitingForDtsAnalysis = true + } CODEC_ID_DTS_LOSSLESS -> mimeType = MimeTypes.AUDIO_DTS_HD CODEC_ID_FLAC -> { mimeType = MimeTypes.AUDIO_FLAC @@ -1907,7 +2074,7 @@ class UpdatedMatroskaExtractor private constructor( } CODEC_ID_SUBRIP -> mimeType = MimeTypes.APPLICATION_SUBRIP - CODEC_ID_ASS -> { + CODEC_ID_ASS, CODEC_ID_SSA -> { mimeType = MimeTypes.TEXT_SSA initializationData = ImmutableList.of( SSA_DIALOGUE_FORMAT, getCodecPrivate( @@ -1953,18 +2120,15 @@ class UpdatedMatroskaExtractor private constructor( selectionFlags = selectionFlags or if (flagDefault) C.SELECTION_FLAG_DEFAULT else 0 selectionFlags = selectionFlags or if (flagForced) C.SELECTION_FLAG_FORCED else 0 - val type: Int val formatBuilder = Format.Builder() // TODO: Consider reading the name elements of the tracks and, if present, incorporating them // into the trackId passed when creating the formats. if (MimeTypes.isAudio(mimeType)) { - type = C.TRACK_TYPE_AUDIO formatBuilder .setChannelCount(channelCount) .setSampleRate(sampleRate) .setPcmEncoding(pcmEncoding) } else if (MimeTypes.isVideo(mimeType)) { - type = C.TRACK_TYPE_VIDEO if (displayUnit == DISPLAY_UNIT_PIXELS) { displayWidth = if (displayWidth == Format.NO_VALUE) width else displayWidth displayHeight = if (displayHeight == Format.NO_VALUE) height else displayHeight @@ -2025,7 +2189,6 @@ class UpdatedMatroskaExtractor private constructor( || MimeTypes.APPLICATION_PGS == mimeType || MimeTypes.APPLICATION_DVBSUBS == mimeType ) { - type = C.TRACK_TYPE_TEXT } else { throw ParserException.createForMalformedContainer( "Unexpected MIME type.", /* cause= */null @@ -2036,9 +2199,10 @@ class UpdatedMatroskaExtractor private constructor( formatBuilder.setLabel(name) } - val format = + format = formatBuilder .setId(trackId) + .setContainerMimeType(if (isWebm) MimeTypes.VIDEO_WEBM else MimeTypes.VIDEO_MATROSKA) .setSampleMimeType(mimeType) .setMaxInputSize(maxInputSize) .setLanguage(language) @@ -2047,9 +2211,6 @@ class UpdatedMatroskaExtractor private constructor( .setCodecs(codecs) .setDrmInitData(drmInitData) .build() - - this.output = output.track(number, type) - this.output!!.format(format) } /** Forces any pending sample metadata to be flushed to the output. */ @@ -2124,6 +2285,90 @@ class UpdatedMatroskaExtractor private constructor( return hdrStaticInfoData } + /** + * Finds the best thumbnail timestamp from the cue points and adds it to the track's format as + * [ThumbnailMetadata]. + */ + fun maybeAddThumbnailMetadata( + perTrackCues: SparseArray>, + durationUs: Long, + segmentContentPosition: Long, + segmentContentSize: Long + ) { + if (type != C.TRACK_TYPE_VIDEO) return + + val cuePoints = perTrackCues[number] + if (cuePoints.isNullOrEmpty()) return + + val thumbnailTimestampUs = findBestThumbnailPresentationTimeUs( + cuePoints, durationUs, segmentContentPosition, segmentContentSize + ) + + if (thumbnailTimestampUs != C.TIME_UNSET) { + val currentFormat = requireNotNull(format) + val existingMetadata = currentFormat.metadata + val thumbnailMetadata = ThumbnailMetadata(thumbnailTimestampUs) + val newMetadata = if (existingMetadata == null) { + Metadata(thumbnailMetadata) + } else { + existingMetadata.copyWithAppendedEntries(thumbnailMetadata) + } + format = currentFormat.buildUpon().setMetadata(newMetadata).build() + } + } + + /** + * Finds the best thumbnail timestamp from the provided cue points. + * + *

The heuristic seeks to find a visually interesting frame by assuming that a larger chunk + * size corresponds to a more complex and representative frame. It calculates an approximate + * bitrate for each chunk and selects the timestamp of the chunk with the highest bitrate. + */ + private fun findBestThumbnailPresentationTimeUs( + cuePoints: MutableList, + durationUs: Long, + segmentContentPosition: Long, + segmentContentSize: Long + ): Long { + if (cuePoints.isEmpty()) return C.TIME_UNSET + + var maxBitrate = 0.0 + var bestCueIndex = -1 + val scanLimit = min(cuePoints.size, MAX_CHUNKS_TO_SCAN_FOR_THUMBNAIL) + + for (i in 0 until scanLimit) { + val cue = cuePoints[i] + + if (cue.timeUs > MAX_DURATION_US_TO_SCAN_FOR_THUMBNAIL) break + + val bytesBetweenCues: Long + val durationBetweenCuesUs: Long + + if (i < cuePoints.size - 1) { + val nextCue = cuePoints[i + 1] + bytesBetweenCues = (nextCue.clusterPosition + nextCue.relativePosition) - + (cue.clusterPosition + cue.relativePosition) + durationBetweenCuesUs = nextCue.timeUs - cue.timeUs + } else { + // Last cue point + bytesBetweenCues = (segmentContentPosition + segmentContentSize) - + (cue.clusterPosition + cue.relativePosition) + durationBetweenCuesUs = durationUs - cue.timeUs + } + + if (durationBetweenCuesUs > 0) { + // This is an approximation of the bitrate for thumbnail heuristic. + val bitrate = bytesBetweenCues.toDouble() / durationBetweenCuesUs + if (bitrate > maxBitrate) { + maxBitrate = bitrate + bestCueIndex = i + } + } + } + + return if (bestCueIndex == -1) C.TIME_UNSET else cuePoints[bestCueIndex].timeUs + } + /** * Checks that the track has an output. * @@ -2133,14 +2378,12 @@ class UpdatedMatroskaExtractor private constructor( * fact at runtime. */ fun assertOutputInitialized() { - Assertions.checkNotNull( + checkNotNull( output ) } - @Throws( - ParserException::class - ) + @Throws(ParserException::class) private fun getCodecPrivate(codecId: String): ByteArray { if (codecPrivate == null) { throw ParserException.createForMalformedContainer( @@ -2379,6 +2622,7 @@ class UpdatedMatroskaExtractor private constructor( private const val CODEC_ID_PCM_FLOAT = "A_PCM/FLOAT/IEEE" private const val CODEC_ID_SUBRIP = "S_TEXT/UTF8" private const val CODEC_ID_ASS = "S_TEXT/ASS" + private const val CODEC_ID_SSA = "S_TEXT/SSA" private const val CODEC_ID_VTT = "S_TEXT/WEBVTT" private const val CODEC_ID_VOBSUB = "S_VOBSUB" private const val CODEC_ID_PGS = "S_HDMV/PGS" @@ -2455,8 +2699,10 @@ class UpdatedMatroskaExtractor private constructor( private const val ID_CUES = 0x1C53BB6B private const val ID_CUE_POINT = 0xBB private const val ID_CUE_TIME = 0xB3 + private const val ID_CUE_TRACK = 0xF7 private const val ID_CUE_TRACK_POSITIONS = 0xB7 private const val ID_CUE_CLUSTER_POSITION = 0xF1 + private const val ID_CUE_RELATIVE_POSITION = 0xF0 private const val ID_LANGUAGE = 0x22B59C private const val ID_PROJECTION = 0x7670 private const val ID_PROJECTION_TYPE = 0x7671 @@ -2511,6 +2757,12 @@ class UpdatedMatroskaExtractor private constructor( private const val FOURCC_COMPRESSION_H263 = 0x33363248 private const val FOURCC_COMPRESSION_VC1 = 0x31435657 + /** The maximum number of chunks to scan when searching for a thumbnail. */ + private const val MAX_CHUNKS_TO_SCAN_FOR_THUMBNAIL = 20 + + /** The maximum duration to scan for a thumbnail, in microseconds. */ + private const val MAX_DURATION_US_TO_SCAN_FOR_THUMBNAIL = 10_000_000L + /** * A template for the prefix that must be added to each subrip sample. * @@ -2732,8 +2984,8 @@ class UpdatedMatroskaExtractor private constructor( * See documentation on [.SSA_DIALOGUE_FORMAT] and [.SUBRIP_PREFIX] for why we use * the duration as the end timecode. * - * @param codecId The subtitle codec; must be [.CODEC_ID_SUBRIP], [.CODEC_ID_ASS] or - * [.CODEC_ID_VTT]. + * @param codecId The subtitle codec; must be [.CODEC_ID_SUBRIP], [.CODEC_ID_ASS], + * [.CODEC_ID_SSA] or [.CODEC_ID_VTT]. * @param durationUs The duration of the sample, in microseconds. * @param subtitleData The subtitle sample in which to overwrite the end timecode (output * parameter). @@ -2752,7 +3004,7 @@ class UpdatedMatroskaExtractor private constructor( endTimecodeOffset = SUBRIP_PREFIX_END_TIMECODE_OFFSET } - CODEC_ID_ASS -> { + CODEC_ID_ASS, CODEC_ID_SSA -> { endTimecode = formatSubtitleTimecode( durationUs, SSA_TIMECODE_FORMAT, SSA_TIMECODE_LAST_VALUE_SCALING_FACTOR @@ -2780,7 +3032,7 @@ class UpdatedMatroskaExtractor private constructor( timeUs: Long, timecodeFormat: String, lastTimecodeValueScalingFactor: Long ): ByteArray { var timeUs = timeUs - Assertions.checkArgument(timeUs != C.TIME_UNSET) + checkArgument(timeUs != C.TIME_UNSET) val timeCodeData: ByteArray val hours = (timeUs / (3600 * C.MICROS_PER_SECOND)).toInt() timeUs -= (hours * 3600L * C.MICROS_PER_SECOND) @@ -2798,7 +3050,7 @@ class UpdatedMatroskaExtractor private constructor( private fun isCodecSupported(codecId: String): Boolean { return when (codecId) { - CODEC_ID_VP8, CODEC_ID_VP9, CODEC_ID_AV1, CODEC_ID_MPEG2, CODEC_ID_MPEG4_SP, CODEC_ID_MPEG4_ASP, CODEC_ID_MPEG4_AP, CODEC_ID_H264, CODEC_ID_H265, CODEC_ID_FOURCC, CODEC_ID_THEORA, CODEC_ID_OPUS, CODEC_ID_VORBIS, CODEC_ID_AAC, CODEC_ID_MP2, CODEC_ID_MP3, CODEC_ID_AC3, CODEC_ID_E_AC3, CODEC_ID_TRUEHD, CODEC_ID_DTS, CODEC_ID_DTS_EXPRESS, CODEC_ID_DTS_LOSSLESS, CODEC_ID_FLAC, CODEC_ID_ACM, CODEC_ID_PCM_INT_LIT, CODEC_ID_PCM_INT_BIG, CODEC_ID_PCM_FLOAT, CODEC_ID_SUBRIP, CODEC_ID_ASS, CODEC_ID_VTT, CODEC_ID_VOBSUB, CODEC_ID_PGS, CODEC_ID_DVBSUB -> true + CODEC_ID_VP8, CODEC_ID_VP9, CODEC_ID_AV1, CODEC_ID_MPEG2, CODEC_ID_MPEG4_SP, CODEC_ID_MPEG4_ASP, CODEC_ID_MPEG4_AP, CODEC_ID_H264, CODEC_ID_H265, CODEC_ID_FOURCC, CODEC_ID_THEORA, CODEC_ID_OPUS, CODEC_ID_VORBIS, CODEC_ID_AAC, CODEC_ID_MP2, CODEC_ID_MP3, CODEC_ID_AC3, CODEC_ID_E_AC3, CODEC_ID_TRUEHD, CODEC_ID_DTS, CODEC_ID_DTS_EXPRESS, CODEC_ID_DTS_LOSSLESS, CODEC_ID_FLAC, CODEC_ID_ACM, CODEC_ID_PCM_INT_LIT, CODEC_ID_PCM_INT_BIG, CODEC_ID_PCM_FLOAT, CODEC_ID_SUBRIP, CODEC_ID_ASS, CODEC_ID_SSA, CODEC_ID_VTT, CODEC_ID_VOBSUB, CODEC_ID_PGS, CODEC_ID_DVBSUB -> true else -> false } @@ -2822,4 +3074,169 @@ class UpdatedMatroskaExtractor private constructor( } } } -} \ No newline at end of file + + class MatroskaSeekMap( + private val perTrackCues: SparseArray>, + private val durationUs: Long, + private val primarySeekTrackNumber: Int, + segmentContentPosition: Long, + segmentContentSize: Long + ) : TrackAwareSeekMap, ChunkIndexProvider { + + private val chunkIndex: ChunkIndex? = + buildChunkIndex( + perTrackCues, + durationUs, + primarySeekTrackNumber, + segmentContentPosition, + segmentContentSize + ) + + override fun isSeekable(): Boolean { + // The media is seekable overall only if the primary seek track has cue points. + return isSeekable(primarySeekTrackNumber) + } + + override fun isSeekable(trackId: Int): Boolean { + val cuePoints = perTrackCues[trackId] + return !cuePoints.isNullOrEmpty() + } + + override fun getDurationUs(): Long = durationUs + + override fun getSeekPoints(timeUs: Long): SeekPoints = + chunkIndex?.getSeekPoints(timeUs) + ?: SeekPoints(SeekPoint.START) + + override fun getSeekPoints(timeUs: Long, trackId: Int): SeekPoints { + var cuePoints = perTrackCues[trackId] + + if ((cuePoints.isNullOrEmpty()) && trackId != primarySeekTrackNumber) { + cuePoints = perTrackCues[primarySeekTrackNumber] + } + + if (cuePoints.isNullOrEmpty()) { + return SeekPoints(SeekPoint.START) + } + + val bestIndex = Util.binarySearchFloor( + cuePoints, + CuePointData(timeUs, C.INDEX_UNSET.toLong(), C.INDEX_UNSET.toLong()), + /* inclusive= */ true, + /* stayInBounds= */ false + ) + + return if (bestIndex != -1) { + val bestCue = cuePoints[bestIndex] + val firstPoint = SeekPoint(bestCue.timeUs, bestCue.clusterPosition) + + if (bestCue.timeUs < timeUs && bestIndex + 1 < cuePoints.size) { + val nextCue = cuePoints[bestIndex + 1] + val secondPoint = SeekPoint(nextCue.timeUs, nextCue.clusterPosition) + SeekPoints(firstPoint, secondPoint) + } else { + SeekPoints(firstPoint) + } + } else { + val firstCue = cuePoints[0] + SeekPoints(SeekPoint(firstCue.timeUs, firstCue.clusterPosition)) + } + } + + override fun getChunkIndex(): ChunkIndex? = chunkIndex + + private companion object { + + private fun buildChunkIndex( + perTrackCues: SparseArray>, + durationUs: Long, + primarySeekTrackNumber: Int, + segmentContentPosition: Long, + segmentContentSize: Long + ): ChunkIndex? { + + val primaryTrackCuePoints = + perTrackCues[primarySeekTrackNumber] ?: return null + + if (primaryTrackCuePoints.isEmpty()) { + return null + } + + val cuePointsSize = primaryTrackCuePoints.size + var sizes = IntArray(cuePointsSize) + var offsets = LongArray(cuePointsSize) + var durationsUs = LongArray(cuePointsSize) + var timesUs = LongArray(cuePointsSize) + + for (i in 0 until cuePointsSize) { + val cue = primaryTrackCuePoints[i] + timesUs[i] = cue.timeUs + offsets[i] = cue.clusterPosition + } + + for (i in 0 until cuePointsSize - 1) { + sizes[i] = (offsets[i + 1] - offsets[i]).toInt() + durationsUs[i] = timesUs[i + 1] - timesUs[i] + } + + // Start from the last cue point and move backward until a valid duration is found. + var lastValidIndex = cuePointsSize - 1 + while (lastValidIndex > 0 && timesUs[lastValidIndex] >= durationUs) { + lastValidIndex-- + } + + // Calculate sizes and durations for the last valid index + sizes[lastValidIndex] = + (segmentContentPosition + segmentContentSize - offsets[lastValidIndex]).toInt() + durationsUs[lastValidIndex] = durationUs - timesUs[lastValidIndex] + + // If trailing cue points were found, truncate the arrays to the last valid index. + if (lastValidIndex < cuePointsSize - 1) { + Log.w(TAG, "Discarding trailing cue points with timestamps greater than total duration.") + sizes = sizes.copyOf(lastValidIndex + 1) + offsets = offsets.copyOf(lastValidIndex + 1) + durationsUs = durationsUs.copyOf(lastValidIndex + 1) + timesUs = timesUs.copyOf(lastValidIndex + 1) + } + + return ChunkIndex(sizes, offsets, durationsUs, timesUs) + } + } + + class CuePointData( + /** The timestamp of the cue point, in microseconds. */ + val timeUs: Long, + + /** The absolute byte offset of the start of the cluster containing this cue point. */ + val clusterPosition: Long, + + /** + * The relative byte offset of the cue point's data block within its cluster. + * + *

Note: For seeking, use {@link #clusterPosition} to prevent A/V desync. + */ + val relativePosition: Long + ) : Comparable { + + override fun compareTo(other: CuePointData): Int { + return timeUs.compareTo(other.timeUs) + } + + override fun equals(other: Any?): Boolean { + if (this === other) { + return true + } + if (other !is CuePointData) { + return false + } + return this.timeUs == other.timeUs && + this.clusterPosition == other.clusterPosition && + this.relativePosition == other.relativePosition + } + + override fun hashCode(): Int { + return Objects.hash(timeUs, clusterPosition, relativePosition) + } + } + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 90ae59c07..a836a75fb 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -27,10 +27,10 @@ kotlinGradlePlugin = "2.3.0" kotlinxCoroutinesCore = "1.10.2" lifecycleKtx = "2.9.4" material = "1.14.0-alpha08" -media3 = "1.8.0" +media3 = "1.9.2" navigationKtx = "2.9.6" newpipeextractor = "v0.25.2" -nextlibMedia3 = "1.8.0-0.9.0" +nextlibMedia3 = "1.9.1-0.11.0" nicehttp = "0.4.16" overlappingpanels = "0.1.5" paletteKtx = "1.0.0" From 8baee7ee78395c580ba53730661479a4786f4d8d Mon Sep 17 00:00:00 2001 From: firelight <147925818+fire-light42@users.noreply.github.com> Date: Wed, 18 Feb 2026 00:10:36 +0000 Subject: [PATCH 045/236] Fix(UI): Minor color issue and padding on top chip bar --- app/src/main/res/layout/fragment_plugins.xml | 24 +++++++++++++------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/app/src/main/res/layout/fragment_plugins.xml b/app/src/main/res/layout/fragment_plugins.xml index 49917a547..c5fa11571 100644 --- a/app/src/main/res/layout/fragment_plugins.xml +++ b/app/src/main/res/layout/fragment_plugins.xml @@ -6,8 +6,8 @@ android:id="@+id/extensions_root" android:layout_width="match_parent" android:layout_height="match_parent" - android:orientation="vertical" - android:background="?attr/primaryGrayBackground"> + android:background="?attr/primaryGrayBackground" + android:orientation="vertical"> - + + + + From 6806a4e2e6f4abbc8f790db325f3be708b021217 Mon Sep 17 00:00:00 2001 From: firelight <147925818+fire-light42@users.noreply.github.com> Date: Wed, 18 Feb 2026 16:31:01 +0000 Subject: [PATCH 046/236] Revert "Update media3 to 1.9.2 (#2342)" (#2509) This reverts commit ea4ef5c2f327671a494f3256cac1def88b1314ab. --- .../cloudstream3/ui/player/CS3IPlayer.kt | 4 +- .../player/UpdatedDefaultExtractorsFactory.kt | 60 +- .../ui/player/UpdatedMatroskaExtractor.kt | 675 ++++-------------- gradle/libs.versions.toml | 4 +- 4 files changed, 150 insertions(+), 593 deletions(-) diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/CS3IPlayer.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/CS3IPlayer.kt index cfe3a069f..fdcbb044c 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/CS3IPlayer.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/CS3IPlayer.kt @@ -544,10 +544,10 @@ class CS3IPlayer : IPlayer { currentSubtitleOffset = offset CustomDecoder.subtitleOffset = offset if (currentTextRenderer?.state == STATE_ENABLED || currentTextRenderer?.state == STATE_STARTED) { - exoPlayer?.currentPosition?.also { pos -> + exoPlayer?.currentPosition?.let { pos -> // This seems to properly refresh all subtitles // It needs to be done as all subtitle cues with timings are pre-processed - currentTextRenderer?.resetPosition(pos, false) + currentTextRenderer?.resetPosition(pos) } } } diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/UpdatedDefaultExtractorsFactory.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/UpdatedDefaultExtractorsFactory.kt index b3873bd32..8ea0f4e61 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/UpdatedDefaultExtractorsFactory.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/UpdatedDefaultExtractorsFactory.kt @@ -13,7 +13,6 @@ package com.lagradost.cloudstream3.ui.player import android.net.Uri import androidx.annotation.GuardedBy -import androidx.media3.common.C import androidx.media3.common.FileTypes import androidx.media3.common.Format import androidx.media3.common.util.TimestampAdjuster @@ -49,6 +48,7 @@ import java.lang.reflect.Constructor import java.lang.reflect.InvocationTargetException import java.util.concurrent.atomic.AtomicBoolean + /** * An [ExtractorsFactory] that provides an array of extractors for the following formats: * @@ -103,16 +103,13 @@ class UpdatedDefaultExtractorsFactory : ExtractorsFactory { private var tsTimestampSearchBytes: Int private var textTrackTranscodingEnabled: Boolean private var subtitleParserFactory: SubtitleParser.Factory - private var codecsToParseWithinGopSampleDependencies: @C.VideoCodecFlags Int private var jpegFlags: @JpegExtractor.Flags Int = 0 - private var heifFlags: @HeifExtractor.Flags Int = 0 init { tsMode = TsExtractor.MODE_SINGLE_PMT tsTimestampSearchBytes = TsExtractor.DEFAULT_TIMESTAMP_SEARCH_BYTES subtitleParserFactory = DefaultSubtitleParserFactory() textTrackTranscodingEnabled = true - codecsToParseWithinGopSampleDependencies = C.VIDEO_CODEC_FLAG_H264 or C.VIDEO_CODEC_FLAG_H265 } /** @@ -349,14 +346,6 @@ class UpdatedDefaultExtractorsFactory : ExtractorsFactory { return this } - @Synchronized - override fun experimentalSetCodecsToParseWithinGopSampleDependencies( - codecsToParseWithinGopSampleDependencies: @C.VideoCodecFlags Int - ): UpdatedDefaultExtractorsFactory { - this.codecsToParseWithinGopSampleDependencies = codecsToParseWithinGopSampleDependencies - return this - } - /** * Sets flags for [JpegExtractor] instances created by the factory. * @@ -372,21 +361,6 @@ class UpdatedDefaultExtractorsFactory : ExtractorsFactory { return this } - /** - * Sets flags for [HeifExtractor] instances created by the factory. - * - * @see HeifExtractor.HeifExtractor - * @param flags The flags to use. - * @return The factory, for convenience. - */ - @Synchronized - fun setHeifExtractorFlags( - flags: @HeifExtractor.Flags Int - ): UpdatedDefaultExtractorsFactory { - this.heifFlags = flags - return this - } - @Synchronized override fun createExtractors(): Array { return createExtractors(Uri.EMPTY, HashMap()) @@ -494,26 +468,21 @@ class UpdatedDefaultExtractorsFactory : ExtractorsFactory { extractors.add( FragmentedMp4Extractor( subtitleParserFactory, - fragmentedMp4Flags or - FragmentedMp4Extractor - .codecsToParseWithinGopSampleDependenciesAsFlags( - codecsToParseWithinGopSampleDependencies - ) or - if (textTrackTranscodingEnabled) 0 - else FragmentedMp4Extractor.FLAG_EMIT_RAW_SUBTITLE_DATA + fragmentedMp4Flags + or (if (textTrackTranscodingEnabled) + 0 + else + FragmentedMp4Extractor.FLAG_EMIT_RAW_SUBTITLE_DATA) ) ) - extractors.add( Mp4Extractor( subtitleParserFactory, - mp4Flags or - Mp4Extractor - .codecsToParseWithinGopSampleDependenciesAsFlags( - codecsToParseWithinGopSampleDependencies - ) or - if (textTrackTranscodingEnabled) 0 - else Mp4Extractor.FLAG_EMIT_RAW_SUBTITLE_DATA + mp4Flags + or (if (textTrackTranscodingEnabled) + 0 + else + Mp4Extractor.FLAG_EMIT_RAW_SUBTITLE_DATA) ) ) } @@ -555,7 +524,12 @@ class UpdatedDefaultExtractorsFactory : ExtractorsFactory { FileTypes.PNG -> extractors.add(PngExtractor()) FileTypes.WEBP -> extractors.add(WebpExtractor()) FileTypes.BMP -> extractors.add(BmpExtractor()) - FileTypes.HEIF -> extractors.add(HeifExtractor(heifFlags)) + FileTypes.HEIF -> if ((mp4Flags and Mp4Extractor.FLAG_READ_MOTION_PHOTO_METADATA) == 0 + && (mp4Flags and Mp4Extractor.FLAG_READ_SEF_DATA) == 0 + ) { + extractors.add(HeifExtractor()) + } + FileTypes.AVIF -> extractors.add(AvifExtractor()) FileTypes.WEBVTT, FileTypes.UNKNOWN -> {} else -> {} diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/UpdatedMatroskaExtractor.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/UpdatedMatroskaExtractor.kt index 5937b1973..6868af771 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/UpdatedMatroskaExtractor.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/UpdatedMatroskaExtractor.kt @@ -41,20 +41,18 @@ import androidx.media3.common.ColorInfo import androidx.media3.common.DrmInitData import androidx.media3.common.DrmInitData.SchemeData import androidx.media3.common.Format -import androidx.media3.common.Metadata import androidx.media3.common.MimeTypes import androidx.media3.common.ParserException +import androidx.media3.common.util.Assertions import androidx.media3.common.util.Log import androidx.media3.common.util.ParsableByteArray import androidx.media3.common.util.UnstableApi import androidx.media3.common.util.Util -import androidx.media3.container.DolbyVisionConfig import androidx.media3.container.NalUnitUtil import androidx.media3.extractor.AacUtil import androidx.media3.extractor.AvcConfig import androidx.media3.extractor.ChunkIndex -import androidx.media3.extractor.ChunkIndexProvider -import androidx.media3.extractor.DtsUtil +import androidx.media3.container.DolbyVisionConfig import androidx.media3.extractor.Extractor import androidx.media3.extractor.ExtractorInput import androidx.media3.extractor.ExtractorOutput @@ -63,18 +61,12 @@ import androidx.media3.extractor.HevcConfig import androidx.media3.extractor.MpegAudioUtil import androidx.media3.extractor.PositionHolder import androidx.media3.extractor.SeekMap -import androidx.media3.extractor.SeekMap.SeekPoints -import androidx.media3.extractor.SeekPoint -import androidx.media3.extractor.TrackAwareSeekMap +import androidx.media3.extractor.SeekMap.Unseekable import androidx.media3.extractor.TrackOutput import androidx.media3.extractor.TrackOutput.CryptoData import androidx.media3.extractor.TrueHdSampleRechunker -import androidx.media3.extractor.metadata.ThumbnailMetadata import androidx.media3.extractor.text.SubtitleParser import androidx.media3.extractor.text.SubtitleTranscodingExtractorOutput -import com.google.common.base.Preconditions.checkArgument -import com.google.common.base.Preconditions.checkNotNull -import com.google.common.base.Preconditions.checkState import com.google.common.collect.ImmutableList import java.io.IOException import java.nio.ByteBuffer @@ -82,7 +74,6 @@ import java.nio.ByteOrder import java.util.Arrays import java.util.Collections import java.util.Locale -import java.util.Objects import java.util.UUID import kotlin.math.max import kotlin.math.min @@ -128,8 +119,6 @@ class UpdatedMatroskaExtractor private constructor( private var timecodeScale = C.TIME_UNSET private var durationTimecode = C.TIME_UNSET private var durationUs = C.TIME_UNSET - private var isWebm: Boolean = false - private var pendingEndTracks: Boolean // The track corresponding to the current TrackEntry element, or null. private var currentTrack: Track? = null @@ -142,13 +131,6 @@ class UpdatedMatroskaExtractor private constructor( private var seekEntryPosition: Long = 0 // Cue related elements. - private val perTrackCues: SparseArray> - private var inCuesElement = false - private var currentCueTimeUs: Long = C.TIME_UNSET - private var currentCueTrackNumber: Int = C.INDEX_UNSET - private var currentCueClusterPosition: Long = C.INDEX_UNSET.toLong() - private var currentCueRelativePosition: Long = C.INDEX_UNSET.toLong() - private var primarySeekTrackNumber: Int = C.INDEX_UNSET private var seekForCues = false private var seekForSeekContent = false private var visitedSeekHeads: HashSet = HashSet() @@ -157,6 +139,9 @@ class UpdatedMatroskaExtractor private constructor( private var cuesContentPosition = C.INDEX_UNSET.toLong() private var seekPositionAfterBuildingCues = C.INDEX_UNSET.toLong() private var clusterTimecodeUs = C.TIME_UNSET + private var cueTimesUs: androidx.media3.common.util.LongArray? = null + private var cueClusterPositions: androidx.media3.common.util.LongArray? = null + private var seenClusterPositionForCurrentCuePoint = false // Reading state. private var haveOutputSample = false @@ -233,7 +218,6 @@ class UpdatedMatroskaExtractor private constructor( init { reader.init(InnerEbmlProcessor()) this.subtitleParserFactory = subtitleParserFactory - this.perTrackCues = SparseArray() seekForCuesEnabled = (flags and FLAG_DISABLE_SEEK_FOR_CUES) == 0 parseSubtitlesDuringExtraction = (flags and FLAG_EMIT_RAW_SUBTITLE_DATA) == 0 varintReader = VarintReader() @@ -249,7 +233,6 @@ class UpdatedMatroskaExtractor private constructor( encryptionSubsampleData = ParsableByteArray() supplementalData = ParsableByteArray() blockSampleSizes = IntArray(1) - pendingEndTracks = true } @Throws(IOException::class) @@ -272,17 +255,6 @@ class UpdatedMatroskaExtractor private constructor( reader.reset() varintReader.reset() resetWriteSampleData() - inCuesElement = false - currentCueTimeUs = C.TIME_UNSET - currentCueTrackNumber = C.INDEX_UNSET - currentCueClusterPosition = C.INDEX_UNSET.toLong() - currentCueRelativePosition = C.INDEX_UNSET.toLong() - // To prevent creating duplicate cue points on a re-parse, clear any existing cue data if the - // seek map has not yet been sent. Once sent, the cue data is considered final, and subsequent - // Cues elements will be ignored by the parsing logic. - if (!sentSeekMap) { - perTrackCues.clear() - } for (i in 0.. EbmlProcessor.ELEMENT_TYPE_MASTER - ID_EBML_READ_VERSION, ID_DOC_TYPE_READ_VERSION, ID_SEEK_POSITION, ID_TIMECODE_SCALE, ID_TIME_CODE, ID_BLOCK_DURATION, ID_PIXEL_WIDTH, ID_PIXEL_HEIGHT, ID_DISPLAY_WIDTH, ID_DISPLAY_HEIGHT, ID_DISPLAY_UNIT, ID_TRACK_NUMBER, ID_TRACK_TYPE, ID_FLAG_DEFAULT, ID_FLAG_FORCED, ID_DEFAULT_DURATION, ID_MAX_BLOCK_ADDITION_ID, ID_BLOCK_ADD_ID_TYPE, ID_CODEC_DELAY, ID_SEEK_PRE_ROLL, ID_DISCARD_PADDING, ID_CHANNELS, ID_AUDIO_BIT_DEPTH, ID_CONTENT_ENCODING_ORDER, ID_CONTENT_ENCODING_SCOPE, ID_CONTENT_COMPRESSION_ALGORITHM, ID_CONTENT_ENCRYPTION_ALGORITHM, ID_CONTENT_ENCRYPTION_AES_SETTINGS_CIPHER_MODE, ID_CUE_TIME, ID_CUE_CLUSTER_POSITION, ID_CUE_RELATIVE_POSITION, ID_CUE_TRACK, ID_REFERENCE_BLOCK, ID_STEREO_MODE, ID_COLOUR_BITS_PER_CHANNEL, ID_COLOUR_RANGE, ID_COLOUR_TRANSFER, ID_COLOUR_PRIMARIES, ID_MAX_CLL, ID_MAX_FALL, ID_PROJECTION_TYPE, ID_BLOCK_ADD_ID -> EbmlProcessor.ELEMENT_TYPE_UNSIGNED_INT + ID_EBML_READ_VERSION, ID_DOC_TYPE_READ_VERSION, ID_SEEK_POSITION, ID_TIMECODE_SCALE, ID_TIME_CODE, ID_BLOCK_DURATION, ID_PIXEL_WIDTH, ID_PIXEL_HEIGHT, ID_DISPLAY_WIDTH, ID_DISPLAY_HEIGHT, ID_DISPLAY_UNIT, ID_TRACK_NUMBER, ID_TRACK_TYPE, ID_FLAG_DEFAULT, ID_FLAG_FORCED, ID_DEFAULT_DURATION, ID_MAX_BLOCK_ADDITION_ID, ID_BLOCK_ADD_ID_TYPE, ID_CODEC_DELAY, ID_SEEK_PRE_ROLL, ID_DISCARD_PADDING, ID_CHANNELS, ID_AUDIO_BIT_DEPTH, ID_CONTENT_ENCODING_ORDER, ID_CONTENT_ENCODING_SCOPE, ID_CONTENT_COMPRESSION_ALGORITHM, ID_CONTENT_ENCRYPTION_ALGORITHM, ID_CONTENT_ENCRYPTION_AES_SETTINGS_CIPHER_MODE, ID_CUE_TIME, ID_CUE_CLUSTER_POSITION, ID_REFERENCE_BLOCK, ID_STEREO_MODE, ID_COLOUR_BITS_PER_CHANNEL, ID_COLOUR_RANGE, ID_COLOUR_TRANSFER, ID_COLOUR_PRIMARIES, ID_MAX_CLL, ID_MAX_FALL, ID_PROJECTION_TYPE, ID_BLOCK_ADD_ID -> EbmlProcessor.ELEMENT_TYPE_UNSIGNED_INT ID_DOC_TYPE, ID_NAME, ID_CODEC_ID, ID_LANGUAGE -> EbmlProcessor.ELEMENT_TYPE_STRING ID_SEEK_ID, ID_BLOCK_ADD_ID_EXTRA_DATA, ID_CONTENT_COMPRESSION_SETTINGS, ID_CONTENT_ENCRYPTION_KEY_ID, ID_SIMPLE_BLOCK, ID_BLOCK, ID_CODEC_PRIVATE, ID_PROJECTION_PRIVATE, ID_BLOCK_ADDITIONAL -> EbmlProcessor.ELEMENT_TYPE_BINARY @@ -369,27 +341,11 @@ class UpdatedMatroskaExtractor private constructor( } ID_CUES -> { - if (!sentSeekMap) { - inCuesElement = true - } - } - - ID_CUE_POINT -> { - if (!sentSeekMap) { - assertInCues(id) - currentCueTimeUs = C.TIME_UNSET - } - } - - ID_CUE_TRACK_POSITIONS -> { - if (!sentSeekMap) { - assertInCues(id) - currentCueTrackNumber = C.INDEX_UNSET - currentCueClusterPosition = C.INDEX_UNSET.toLong() - currentCueRelativePosition = C.INDEX_UNSET.toLong() - } + cueTimesUs = androidx.media3.common.util.LongArray() + cueClusterPositions = androidx.media3.common.util.LongArray() } + ID_CUE_POINT -> seenClusterPositionForCurrentCuePoint = false ID_CLUSTER -> if (!sentSeekMap) { // We need to build cues before parsing the cluster. if (seekForCuesEnabled && cuesContentPosition != C.INDEX_UNSET.toLong()) { @@ -402,7 +358,7 @@ class UpdatedMatroskaExtractor private constructor( } else { // We don't know where the Cues element is located. It's most likely omitted. Allow // playback, but disable seeking. - extractorOutput!!.seekMap(SeekMap.Unseekable(durationUs)) + extractorOutput!!.seekMap(Unseekable(durationUs)) sentSeekMap = true } } @@ -414,10 +370,7 @@ class UpdatedMatroskaExtractor private constructor( ID_CONTENT_ENCODING -> {} ID_CONTENT_ENCRYPTION -> getCurrentTrack(id).hasContentEncryption = true - ID_TRACK_ENTRY -> { - currentTrack = Track() - currentTrack!!.isWebm = isWebm - } + ID_TRACK_ENTRY -> currentTrack = Track() ID_MASTERING_METADATA -> getCurrentTrack(id).hasColorInfo = true else -> {} } @@ -456,7 +409,7 @@ class UpdatedMatroskaExtractor private constructor( } else { // Otherwise, if we not found any cues nor any more seek heads then we mark // this as unseekable. - extractorOutput!!.seekMap(SeekMap.Unseekable(durationUs)) + extractorOutput!!.seekMap(Unseekable(durationUs)) sentSeekMap = true } } @@ -485,67 +438,13 @@ class UpdatedMatroskaExtractor private constructor( ID_CUES -> { if (!sentSeekMap) { - var hasAnyCues = false - for (i in 0 until perTrackCues.size()) { - if (perTrackCues.valueAt(i).isNotEmpty()) { - hasAnyCues = true - break - } - } - - if (!hasAnyCues || durationUs == C.TIME_UNSET) { - // Cues are missing, empty, or duration is unknown. - extractorOutput!!.seekMap(SeekMap.Unseekable(durationUs)) - } else { - for (i in 0 until perTrackCues.size()) { - perTrackCues.valueAt(i).sort() - } - - val seekMap = MatroskaSeekMap( - perTrackCues, - durationUs, - primarySeekTrackNumber, - segmentContentPosition, - segmentContentSize - ) - extractorOutput!!.seekMap(seekMap) - } + extractorOutput!!.seekMap(buildSeekMap(cueTimesUs, cueClusterPositions)) sentSeekMap = true - inCuesElement = false - for (i in 0 until tracks.size()) { - val track: Track = tracks.valueAt(i) - track.maybeAddThumbnailMetadata(perTrackCues, durationUs, segmentContentPosition, segmentContentSize) - if (!track.waitingForDtsAnalysis) { - track.assertOutputInitialized() - track.output!!.format(requireNotNull(track.format)) - } - } - maybeEndTracks() - } - } - - ID_CUE_TRACK_POSITIONS -> { - if (!sentSeekMap) { - assertInCues(id) - if (currentCueTimeUs != C.TIME_UNSET - && currentCueTrackNumber != C.INDEX_UNSET - && currentCueClusterPosition != C.INDEX_UNSET.toLong() - ) { - var trackCues = perTrackCues[currentCueTrackNumber] - if (trackCues == null) { - trackCues = ArrayList() - perTrackCues.put(currentCueTrackNumber, trackCues) - } - - trackCues.add( - MatroskaSeekMap.CuePointData( - currentCueTimeUs, - /* clusterPosition= */ segmentContentPosition + currentCueClusterPosition, - /* relativePosition= */ currentCueRelativePosition - ) - ) - } + } else { + // We have already built the cues. Ignore. } + this.cueTimesUs = null + this.cueClusterPositions = null } ID_BLOCK_GROUP -> { @@ -621,15 +520,17 @@ class UpdatedMatroskaExtractor private constructor( } ID_TRACK_ENTRY -> { - val currentTrack = checkNotNull(this.currentTrack) + val currentTrack = Assertions.checkStateNotNull(this.currentTrack) if (currentTrack.codecId == null) { throw ParserException.createForMalformedContainer( "CodecId is missing in TrackEntry element", /* cause= */null ) } else { - if (isCodecSupported(currentTrack.codecId!!)) { - currentTrack.initializeFormat(currentTrack.number); - currentTrack.output = extractorOutput!!.track(currentTrack.number, currentTrack.type); + if (isCodecSupported( + currentTrack.codecId!! + ) + ) { + currentTrack.initializeOutput(extractorOutput!!, currentTrack.number) tracks.put(currentTrack.number, currentTrack) } } @@ -639,63 +540,10 @@ class UpdatedMatroskaExtractor private constructor( ID_TRACKS -> { if (tracks.size() == 0) { throw ParserException.createForMalformedContainer( - "No valid tracks were found", /* cause= */ null + "No valid tracks were found", /* cause= */null ) } - - // Determine the track to use for default seeking. - var defaultVideoTrackNumber: Int = C.INDEX_UNSET - var firstVideoTrackNumber: Int = C.INDEX_UNSET - var defaultAudioTrackNumber: Int = C.INDEX_UNSET - var firstAudioTrackNumber: Int = C.INDEX_UNSET - - // If we're not going to seek for cues, output the formats immediately. - val mayBeSendFormatsEarly = !seekForCuesEnabled || cuesContentPosition == C.INDEX_UNSET.toLong(); - - for (i in 0 until tracks.size()) { - val trackItem: Track = tracks.valueAt(i) - - val trackType: @C.TrackType Int = trackItem.type - when (trackType) { - C.TRACK_TYPE_VIDEO -> { - if (trackItem.flagDefault) { - defaultVideoTrackNumber = trackItem.number - } - if (firstVideoTrackNumber == C.INDEX_UNSET) { - firstVideoTrackNumber = trackItem.number - } - } - - C.TRACK_TYPE_AUDIO -> { - if (trackItem.flagDefault) { - defaultAudioTrackNumber = trackItem.number - } - if (firstAudioTrackNumber == C.INDEX_UNSET) { - firstAudioTrackNumber = trackItem.number - } - } - } - - if (mayBeSendFormatsEarly) { - trackItem.assertOutputInitialized() - if (!trackItem.waitingForDtsAnalysis) { - trackItem.output!!.format(checkNotNull(trackItem.format)) - } - } - } - - primarySeekTrackNumber = when { - defaultVideoTrackNumber != C.INDEX_UNSET -> defaultVideoTrackNumber - firstVideoTrackNumber != C.INDEX_UNSET -> firstVideoTrackNumber - defaultAudioTrackNumber != C.INDEX_UNSET -> defaultAudioTrackNumber - firstAudioTrackNumber != C.INDEX_UNSET -> firstAudioTrackNumber - tracks.size() > 0 -> tracks.valueAt(0).number - else -> C.INDEX_UNSET - } - - if (mayBeSendFormatsEarly) { - maybeEndTracks() - } + extractorOutput!!.endTracks() } else -> {} @@ -738,16 +586,7 @@ class UpdatedMatroskaExtractor private constructor( ID_TRACK_NUMBER -> getCurrentTrack(id).number = value.toInt() ID_FLAG_DEFAULT -> getCurrentTrack(id).flagDefault = value == 1L ID_FLAG_FORCED -> getCurrentTrack(id).flagForced = value == 1L - ID_TRACK_TYPE -> { - val matroskaTrackType = value.toInt() - getCurrentTrack(id).type = when (matroskaTrackType) { - 1 -> C.TRACK_TYPE_VIDEO // Matroska video - 2 -> C.TRACK_TYPE_AUDIO // Matroska audio - 17 -> C.TRACK_TYPE_TEXT // Matroska subtitle - 33 -> C.TRACK_TYPE_METADATA // Matroska metadata - else -> C.TRACK_TYPE_UNKNOWN - } - } + ID_TRACK_TYPE -> getCurrentTrack(id).type = value.toInt() ID_DEFAULT_DURATION -> getCurrentTrack(id).defaultSampleDurationNs = value.toInt() ID_MAX_BLOCK_ADDITION_ID -> getCurrentTrack(id).maxBlockAdditionId = value.toInt() ID_BLOCK_ADD_ID_TYPE -> getCurrentTrack(id).blockAddIdType = value.toInt() @@ -793,35 +632,17 @@ class UpdatedMatroskaExtractor private constructor( } ID_CUE_TIME -> { - if (!sentSeekMap) { - assertInCues(id) - currentCueTimeUs = scaleTimecodeToUs(value) - } + assertInCues(id) + cueTimesUs!!.add(scaleTimecodeToUs(value)) } - ID_CUE_TRACK -> { - if (!sentSeekMap) { - assertInCues(id) - currentCueTrackNumber = value.toInt() - } - } - - ID_CUE_CLUSTER_POSITION -> { - if (!sentSeekMap) { - assertInCues(id) - if (currentCueClusterPosition == C.INDEX_UNSET.toLong()) { - currentCueClusterPosition = value - } - } - } - - ID_CUE_RELATIVE_POSITION -> { - if (!sentSeekMap) { - assertInCues(id) - if (currentCueRelativePosition == C.INDEX_UNSET.toLong()) { - currentCueRelativePosition = value - } - } + ID_CUE_CLUSTER_POSITION -> if (!seenClusterPositionForCurrentCuePoint) { + assertInCues(id) + // If there's more than one video/audio track, then there could be more than one + // CueTrackPositions within a single CuePoint. In such a case, ignore all but the first + // one (since the cluster position will be quite close for all the tracks). + cueClusterPositions!!.add(value) + seenClusterPositionForCurrentCuePoint = true } ID_TIME_CODE -> clusterTimecodeUs = scaleTimecodeToUs(value) @@ -1133,7 +954,7 @@ class UpdatedMatroskaExtractor private constructor( (scratch.data[0].toInt() shl 8) or (scratch.data[1].toInt() and 0xFF) blockTimeUs = clusterTimecodeUs + scaleTimecodeToUs(timecode.toLong()) val isKeyframe = - track.type == C.TRACK_TYPE_AUDIO + track.type == TRACK_TYPE_AUDIO || (id == ID_SIMPLE_BLOCK && (scratch.data[2].toInt() and 0x80) == 0x80) blockFlags = if (isKeyframe) C.BUFFER_FLAG_KEY_FRAME else 0 blockState = BLOCK_STATE_DATA @@ -1225,7 +1046,9 @@ class UpdatedMatroskaExtractor private constructor( } } - @Throws(ParserException::class) + @Throws( + ParserException::class + ) private fun assertInTrackEntry(id: Int) { if (currentTrack == null) { throw ParserException.createForMalformedContainer( @@ -1234,9 +1057,11 @@ class UpdatedMatroskaExtractor private constructor( } } - @Throws(ParserException::class) + @Throws( + ParserException::class + ) private fun assertInCues(id: Int) { - if (!inCuesElement) { + if (cueTimesUs == null || cueClusterPositions == null) { throw ParserException.createForMalformedContainer( "Element $id must be in a Cues", /* cause= */null ) @@ -1265,7 +1090,6 @@ class UpdatedMatroskaExtractor private constructor( } else { if (CODEC_ID_SUBRIP == track.codecId || CODEC_ID_ASS == track.codecId - || CODEC_ID_SSA == track.codecId || CODEC_ID_VTT == track.codecId ) { if (blockSampleCount > 1) { @@ -1355,7 +1179,7 @@ class UpdatedMatroskaExtractor private constructor( if (CODEC_ID_SUBRIP == track.codecId) { writeSubtitleSampleData(input, SUBRIP_PREFIX, size) return finishWriteSampleData() - } else if (CODEC_ID_ASS == track.codecId || CODEC_ID_SSA == track.codecId) { + } else if (CODEC_ID_ASS == track.codecId) { writeSubtitleSampleData(input, SSA_PREFIX, size) return finishWriteSampleData() } else if (CODEC_ID_VTT == track.codecId) { @@ -1363,20 +1187,6 @@ class UpdatedMatroskaExtractor private constructor( return finishWriteSampleData() } - if (track.waitingForDtsAnalysis) { - checkNotNull(track.format) - if (DtsUtil.isSampleDtsHd(input, size)) { - track.format = track.format!! - .buildUpon() - .setSampleMimeType(MimeTypes.AUDIO_DTS_HD) - .build() - } - - track.output!!.format(track.format!!) - track.waitingForDtsAnalysis = false - maybeEndTracks() - } - val output = track.output if (!sampleEncodingHandled) { if (track.hasContentEncryption) { @@ -1543,7 +1353,7 @@ class UpdatedMatroskaExtractor private constructor( } } else { if (track.trueHdSampleRechunker != null) { - checkState(sampleStrippedBytes.limit() == 0) + Assertions.checkState(sampleStrippedBytes.limit() == 0) track.trueHdSampleRechunker!!.startSample(input) } while (sampleBytesRead < size) { @@ -1642,6 +1452,57 @@ class UpdatedMatroskaExtractor private constructor( return bytesWritten } + /** + * Builds a [SeekMap] from the recently gathered Cues information. + * + * @return The built [SeekMap]. The returned [SeekMap] may be unseekable if cues + * information was missing or incomplete. + */ + private fun buildSeekMap( + cueTimesUs: androidx.media3.common.util.LongArray?, + cueClusterPositions: androidx.media3.common.util.LongArray? + ): SeekMap { + if (segmentContentPosition == C.INDEX_UNSET.toLong() || durationUs == C.TIME_UNSET || cueTimesUs == null || cueTimesUs.size() == 0 || cueClusterPositions == null || cueClusterPositions.size() != cueTimesUs.size()) { + // Cues information is missing or incomplete. + return Unseekable(durationUs) + } + val cuePointsSize = cueTimesUs.size() + var sizes = IntArray(cuePointsSize) + var offsets = LongArray(cuePointsSize) + var durationsUs = LongArray(cuePointsSize) + var timesUs = LongArray(cuePointsSize) + for (i in 0.. 0 && timesUs[lastValidIndex] > durationUs) { + lastValidIndex-- + } + + // Calculate sizes and durations for the last valid index + sizes[lastValidIndex] = + (segmentContentPosition + segmentContentSize - offsets[lastValidIndex]).toInt() + durationsUs[lastValidIndex] = durationUs - timesUs[lastValidIndex] + + // If the last valid index is not the last cue point, truncate the arrays + if (lastValidIndex < cuePointsSize - 1) { + Log.w(TAG, "Discarding trailing cue points with timestamps greater than total duration") + sizes = sizes.copyOf(lastValidIndex + 1) + offsets = offsets.copyOf(lastValidIndex + 1) + durationsUs = durationsUs.copyOf(lastValidIndex + 1) + timesUs = timesUs.copyOf(lastValidIndex + 1) + } + + return ChunkIndex(sizes, offsets, durationsUs, timesUs) + } + /** * Updates the position of the holder to Cues element's position if the extractor configuration * permits use of master seek entry. After building Cues sets the holder's position back to where @@ -1661,7 +1522,7 @@ class UpdatedMatroskaExtractor private constructor( // (until cues or end of segment). However this also means that we only need to seek // back to the top once, instead seeking back in a stack like manner. if (seekForSeekContent) { - checkArgument(pendingSeekHeads.isNotEmpty(), "Illegal value of seekForSeekContent") + Assertions.checkArgument(pendingSeekHeads.isNotEmpty(), "Illegal value of seekForSeekContent") // The exact order does not really matter, but it is easiest to just do stack (FILO) val next = pendingSeekHeads.removeAt(pendingSeekHeads.size - 1) seekPosition.position = next @@ -1708,22 +1569,11 @@ class UpdatedMatroskaExtractor private constructor( } private fun assertInitialized() { - checkNotNull( + Assertions.checkStateNotNull( extractorOutput ) } - private fun maybeEndTracks() { - if (!pendingEndTracks) return - - for (i in 0 until tracks.size()) { - if (tracks.valueAt(i).waitingForDtsAnalysis) return - } - - checkNotNull(extractorOutput).endTracks() - pendingEndTracks = false - } - /** Passes events through to the outer [UpdatedMatroskaExtractor]. */ private inner class InnerEbmlProcessor : EbmlProcessor { override fun getElementType(id: Int): @EbmlProcessor.ElementType Int { @@ -1768,11 +1618,10 @@ class UpdatedMatroskaExtractor private constructor( /** Holds data corresponding to a single track. */ protected class Track { // Common elements. - var isWebm: Boolean = false var name: String? = null var codecId: String? = null var number: Int = 0 - var type: @C.TrackType Int = 0 + var type: Int = 0 var defaultSampleDurationNs: Int = 0 var maxBlockAdditionId: Int = 0 var blockAddIdType: Int = 0 @@ -1822,24 +1671,23 @@ class UpdatedMatroskaExtractor private constructor( var sampleRate: Int = 8000 var codecDelayNs: Long = 0 var seekPreRollNs: Long = 0 - var trueHdSampleRechunker: TrueHdSampleRechunker? = null - var waitingForDtsAnalysis: Boolean = false + var trueHdSampleRechunker: TrueHdSampleRechunker? = + null // Text elements. var flagForced: Boolean = false - - // Common track elements. var flagDefault: Boolean = true var language: String = "eng" // Set when the output is initialized. nalUnitLengthFieldLength is only set for H264/H265. var output: TrackOutput? = null - var format: Format? = null var nalUnitLengthFieldLength: Int = 0 - /** Builds the [Format] for the track. */ - @Throws(ParserException::class) - fun initializeFormat(trackId: Int) { + /** Initializes the track with an output. */ + @Throws( + ParserException::class + ) + fun initializeOutput(output: ExtractorOutput, trackId: Int) { var mimeType: String var maxInputSize = Format.NO_VALUE var pcmEncoding: @PcmEncoding Int = Format.NO_VALUE @@ -1847,20 +1695,8 @@ class UpdatedMatroskaExtractor private constructor( var codecs: String? = null when (codecId) { CODEC_ID_VP8 -> mimeType = MimeTypes.VIDEO_VP8 - CODEC_ID_VP9 -> { - mimeType = MimeTypes.VIDEO_VP9 - initializationData = - if (codecPrivate == null) null else ImmutableList.of( - codecPrivate!! - ) - } - CODEC_ID_AV1 -> { - mimeType = MimeTypes.VIDEO_AV1 - initializationData = - if (codecPrivate == null) null else ImmutableList.of( - codecPrivate!! - ) - } + CODEC_ID_VP9 -> mimeType = MimeTypes.VIDEO_VP9 + CODEC_ID_AV1 -> mimeType = MimeTypes.VIDEO_AV1 CODEC_ID_MPEG2 -> mimeType = MimeTypes.VIDEO_MPEG2 CODEC_ID_MPEG4_SP, CODEC_ID_MPEG4_ASP, CODEC_ID_MPEG4_AP -> { mimeType = MimeTypes.VIDEO_MP4V @@ -1972,10 +1808,7 @@ class UpdatedMatroskaExtractor private constructor( trueHdSampleRechunker = TrueHdSampleRechunker() } - CODEC_ID_DTS, CODEC_ID_DTS_EXPRESS -> { - mimeType = MimeTypes.AUDIO_DTS // temporary - waitingForDtsAnalysis = true - } + CODEC_ID_DTS, CODEC_ID_DTS_EXPRESS -> mimeType = MimeTypes.AUDIO_DTS CODEC_ID_DTS_LOSSLESS -> mimeType = MimeTypes.AUDIO_DTS_HD CODEC_ID_FLAC -> { mimeType = MimeTypes.AUDIO_FLAC @@ -2074,7 +1907,7 @@ class UpdatedMatroskaExtractor private constructor( } CODEC_ID_SUBRIP -> mimeType = MimeTypes.APPLICATION_SUBRIP - CODEC_ID_ASS, CODEC_ID_SSA -> { + CODEC_ID_ASS -> { mimeType = MimeTypes.TEXT_SSA initializationData = ImmutableList.of( SSA_DIALOGUE_FORMAT, getCodecPrivate( @@ -2120,15 +1953,18 @@ class UpdatedMatroskaExtractor private constructor( selectionFlags = selectionFlags or if (flagDefault) C.SELECTION_FLAG_DEFAULT else 0 selectionFlags = selectionFlags or if (flagForced) C.SELECTION_FLAG_FORCED else 0 + val type: Int val formatBuilder = Format.Builder() // TODO: Consider reading the name elements of the tracks and, if present, incorporating them // into the trackId passed when creating the formats. if (MimeTypes.isAudio(mimeType)) { + type = C.TRACK_TYPE_AUDIO formatBuilder .setChannelCount(channelCount) .setSampleRate(sampleRate) .setPcmEncoding(pcmEncoding) } else if (MimeTypes.isVideo(mimeType)) { + type = C.TRACK_TYPE_VIDEO if (displayUnit == DISPLAY_UNIT_PIXELS) { displayWidth = if (displayWidth == Format.NO_VALUE) width else displayWidth displayHeight = if (displayHeight == Format.NO_VALUE) height else displayHeight @@ -2189,6 +2025,7 @@ class UpdatedMatroskaExtractor private constructor( || MimeTypes.APPLICATION_PGS == mimeType || MimeTypes.APPLICATION_DVBSUBS == mimeType ) { + type = C.TRACK_TYPE_TEXT } else { throw ParserException.createForMalformedContainer( "Unexpected MIME type.", /* cause= */null @@ -2199,10 +2036,9 @@ class UpdatedMatroskaExtractor private constructor( formatBuilder.setLabel(name) } - format = + val format = formatBuilder .setId(trackId) - .setContainerMimeType(if (isWebm) MimeTypes.VIDEO_WEBM else MimeTypes.VIDEO_MATROSKA) .setSampleMimeType(mimeType) .setMaxInputSize(maxInputSize) .setLanguage(language) @@ -2211,6 +2047,9 @@ class UpdatedMatroskaExtractor private constructor( .setCodecs(codecs) .setDrmInitData(drmInitData) .build() + + this.output = output.track(number, type) + this.output!!.format(format) } /** Forces any pending sample metadata to be flushed to the output. */ @@ -2285,90 +2124,6 @@ class UpdatedMatroskaExtractor private constructor( return hdrStaticInfoData } - /** - * Finds the best thumbnail timestamp from the cue points and adds it to the track's format as - * [ThumbnailMetadata]. - */ - fun maybeAddThumbnailMetadata( - perTrackCues: SparseArray>, - durationUs: Long, - segmentContentPosition: Long, - segmentContentSize: Long - ) { - if (type != C.TRACK_TYPE_VIDEO) return - - val cuePoints = perTrackCues[number] - if (cuePoints.isNullOrEmpty()) return - - val thumbnailTimestampUs = findBestThumbnailPresentationTimeUs( - cuePoints, durationUs, segmentContentPosition, segmentContentSize - ) - - if (thumbnailTimestampUs != C.TIME_UNSET) { - val currentFormat = requireNotNull(format) - val existingMetadata = currentFormat.metadata - val thumbnailMetadata = ThumbnailMetadata(thumbnailTimestampUs) - val newMetadata = if (existingMetadata == null) { - Metadata(thumbnailMetadata) - } else { - existingMetadata.copyWithAppendedEntries(thumbnailMetadata) - } - format = currentFormat.buildUpon().setMetadata(newMetadata).build() - } - } - - /** - * Finds the best thumbnail timestamp from the provided cue points. - * - *

The heuristic seeks to find a visually interesting frame by assuming that a larger chunk - * size corresponds to a more complex and representative frame. It calculates an approximate - * bitrate for each chunk and selects the timestamp of the chunk with the highest bitrate. - */ - private fun findBestThumbnailPresentationTimeUs( - cuePoints: MutableList, - durationUs: Long, - segmentContentPosition: Long, - segmentContentSize: Long - ): Long { - if (cuePoints.isEmpty()) return C.TIME_UNSET - - var maxBitrate = 0.0 - var bestCueIndex = -1 - val scanLimit = min(cuePoints.size, MAX_CHUNKS_TO_SCAN_FOR_THUMBNAIL) - - for (i in 0 until scanLimit) { - val cue = cuePoints[i] - - if (cue.timeUs > MAX_DURATION_US_TO_SCAN_FOR_THUMBNAIL) break - - val bytesBetweenCues: Long - val durationBetweenCuesUs: Long - - if (i < cuePoints.size - 1) { - val nextCue = cuePoints[i + 1] - bytesBetweenCues = (nextCue.clusterPosition + nextCue.relativePosition) - - (cue.clusterPosition + cue.relativePosition) - durationBetweenCuesUs = nextCue.timeUs - cue.timeUs - } else { - // Last cue point - bytesBetweenCues = (segmentContentPosition + segmentContentSize) - - (cue.clusterPosition + cue.relativePosition) - durationBetweenCuesUs = durationUs - cue.timeUs - } - - if (durationBetweenCuesUs > 0) { - // This is an approximation of the bitrate for thumbnail heuristic. - val bitrate = bytesBetweenCues.toDouble() / durationBetweenCuesUs - if (bitrate > maxBitrate) { - maxBitrate = bitrate - bestCueIndex = i - } - } - } - - return if (bestCueIndex == -1) C.TIME_UNSET else cuePoints[bestCueIndex].timeUs - } - /** * Checks that the track has an output. * @@ -2378,12 +2133,14 @@ class UpdatedMatroskaExtractor private constructor( * fact at runtime. */ fun assertOutputInitialized() { - checkNotNull( + Assertions.checkNotNull( output ) } - @Throws(ParserException::class) + @Throws( + ParserException::class + ) private fun getCodecPrivate(codecId: String): ByteArray { if (codecPrivate == null) { throw ParserException.createForMalformedContainer( @@ -2622,7 +2379,6 @@ class UpdatedMatroskaExtractor private constructor( private const val CODEC_ID_PCM_FLOAT = "A_PCM/FLOAT/IEEE" private const val CODEC_ID_SUBRIP = "S_TEXT/UTF8" private const val CODEC_ID_ASS = "S_TEXT/ASS" - private const val CODEC_ID_SSA = "S_TEXT/SSA" private const val CODEC_ID_VTT = "S_TEXT/WEBVTT" private const val CODEC_ID_VOBSUB = "S_VOBSUB" private const val CODEC_ID_PGS = "S_HDMV/PGS" @@ -2699,10 +2455,8 @@ class UpdatedMatroskaExtractor private constructor( private const val ID_CUES = 0x1C53BB6B private const val ID_CUE_POINT = 0xBB private const val ID_CUE_TIME = 0xB3 - private const val ID_CUE_TRACK = 0xF7 private const val ID_CUE_TRACK_POSITIONS = 0xB7 private const val ID_CUE_CLUSTER_POSITION = 0xF1 - private const val ID_CUE_RELATIVE_POSITION = 0xF0 private const val ID_LANGUAGE = 0x22B59C private const val ID_PROJECTION = 0x7670 private const val ID_PROJECTION_TYPE = 0x7671 @@ -2757,12 +2511,6 @@ class UpdatedMatroskaExtractor private constructor( private const val FOURCC_COMPRESSION_H263 = 0x33363248 private const val FOURCC_COMPRESSION_VC1 = 0x31435657 - /** The maximum number of chunks to scan when searching for a thumbnail. */ - private const val MAX_CHUNKS_TO_SCAN_FOR_THUMBNAIL = 20 - - /** The maximum duration to scan for a thumbnail, in microseconds. */ - private const val MAX_DURATION_US_TO_SCAN_FOR_THUMBNAIL = 10_000_000L - /** * A template for the prefix that must be added to each subrip sample. * @@ -2984,8 +2732,8 @@ class UpdatedMatroskaExtractor private constructor( * See documentation on [.SSA_DIALOGUE_FORMAT] and [.SUBRIP_PREFIX] for why we use * the duration as the end timecode. * - * @param codecId The subtitle codec; must be [.CODEC_ID_SUBRIP], [.CODEC_ID_ASS], - * [.CODEC_ID_SSA] or [.CODEC_ID_VTT]. + * @param codecId The subtitle codec; must be [.CODEC_ID_SUBRIP], [.CODEC_ID_ASS] or + * [.CODEC_ID_VTT]. * @param durationUs The duration of the sample, in microseconds. * @param subtitleData The subtitle sample in which to overwrite the end timecode (output * parameter). @@ -3004,7 +2752,7 @@ class UpdatedMatroskaExtractor private constructor( endTimecodeOffset = SUBRIP_PREFIX_END_TIMECODE_OFFSET } - CODEC_ID_ASS, CODEC_ID_SSA -> { + CODEC_ID_ASS -> { endTimecode = formatSubtitleTimecode( durationUs, SSA_TIMECODE_FORMAT, SSA_TIMECODE_LAST_VALUE_SCALING_FACTOR @@ -3032,7 +2780,7 @@ class UpdatedMatroskaExtractor private constructor( timeUs: Long, timecodeFormat: String, lastTimecodeValueScalingFactor: Long ): ByteArray { var timeUs = timeUs - checkArgument(timeUs != C.TIME_UNSET) + Assertions.checkArgument(timeUs != C.TIME_UNSET) val timeCodeData: ByteArray val hours = (timeUs / (3600 * C.MICROS_PER_SECOND)).toInt() timeUs -= (hours * 3600L * C.MICROS_PER_SECOND) @@ -3050,7 +2798,7 @@ class UpdatedMatroskaExtractor private constructor( private fun isCodecSupported(codecId: String): Boolean { return when (codecId) { - CODEC_ID_VP8, CODEC_ID_VP9, CODEC_ID_AV1, CODEC_ID_MPEG2, CODEC_ID_MPEG4_SP, CODEC_ID_MPEG4_ASP, CODEC_ID_MPEG4_AP, CODEC_ID_H264, CODEC_ID_H265, CODEC_ID_FOURCC, CODEC_ID_THEORA, CODEC_ID_OPUS, CODEC_ID_VORBIS, CODEC_ID_AAC, CODEC_ID_MP2, CODEC_ID_MP3, CODEC_ID_AC3, CODEC_ID_E_AC3, CODEC_ID_TRUEHD, CODEC_ID_DTS, CODEC_ID_DTS_EXPRESS, CODEC_ID_DTS_LOSSLESS, CODEC_ID_FLAC, CODEC_ID_ACM, CODEC_ID_PCM_INT_LIT, CODEC_ID_PCM_INT_BIG, CODEC_ID_PCM_FLOAT, CODEC_ID_SUBRIP, CODEC_ID_ASS, CODEC_ID_SSA, CODEC_ID_VTT, CODEC_ID_VOBSUB, CODEC_ID_PGS, CODEC_ID_DVBSUB -> true + CODEC_ID_VP8, CODEC_ID_VP9, CODEC_ID_AV1, CODEC_ID_MPEG2, CODEC_ID_MPEG4_SP, CODEC_ID_MPEG4_ASP, CODEC_ID_MPEG4_AP, CODEC_ID_H264, CODEC_ID_H265, CODEC_ID_FOURCC, CODEC_ID_THEORA, CODEC_ID_OPUS, CODEC_ID_VORBIS, CODEC_ID_AAC, CODEC_ID_MP2, CODEC_ID_MP3, CODEC_ID_AC3, CODEC_ID_E_AC3, CODEC_ID_TRUEHD, CODEC_ID_DTS, CODEC_ID_DTS_EXPRESS, CODEC_ID_DTS_LOSSLESS, CODEC_ID_FLAC, CODEC_ID_ACM, CODEC_ID_PCM_INT_LIT, CODEC_ID_PCM_INT_BIG, CODEC_ID_PCM_FLOAT, CODEC_ID_SUBRIP, CODEC_ID_ASS, CODEC_ID_VTT, CODEC_ID_VOBSUB, CODEC_ID_PGS, CODEC_ID_DVBSUB -> true else -> false } @@ -3074,169 +2822,4 @@ class UpdatedMatroskaExtractor private constructor( } } } - - class MatroskaSeekMap( - private val perTrackCues: SparseArray>, - private val durationUs: Long, - private val primarySeekTrackNumber: Int, - segmentContentPosition: Long, - segmentContentSize: Long - ) : TrackAwareSeekMap, ChunkIndexProvider { - - private val chunkIndex: ChunkIndex? = - buildChunkIndex( - perTrackCues, - durationUs, - primarySeekTrackNumber, - segmentContentPosition, - segmentContentSize - ) - - override fun isSeekable(): Boolean { - // The media is seekable overall only if the primary seek track has cue points. - return isSeekable(primarySeekTrackNumber) - } - - override fun isSeekable(trackId: Int): Boolean { - val cuePoints = perTrackCues[trackId] - return !cuePoints.isNullOrEmpty() - } - - override fun getDurationUs(): Long = durationUs - - override fun getSeekPoints(timeUs: Long): SeekPoints = - chunkIndex?.getSeekPoints(timeUs) - ?: SeekPoints(SeekPoint.START) - - override fun getSeekPoints(timeUs: Long, trackId: Int): SeekPoints { - var cuePoints = perTrackCues[trackId] - - if ((cuePoints.isNullOrEmpty()) && trackId != primarySeekTrackNumber) { - cuePoints = perTrackCues[primarySeekTrackNumber] - } - - if (cuePoints.isNullOrEmpty()) { - return SeekPoints(SeekPoint.START) - } - - val bestIndex = Util.binarySearchFloor( - cuePoints, - CuePointData(timeUs, C.INDEX_UNSET.toLong(), C.INDEX_UNSET.toLong()), - /* inclusive= */ true, - /* stayInBounds= */ false - ) - - return if (bestIndex != -1) { - val bestCue = cuePoints[bestIndex] - val firstPoint = SeekPoint(bestCue.timeUs, bestCue.clusterPosition) - - if (bestCue.timeUs < timeUs && bestIndex + 1 < cuePoints.size) { - val nextCue = cuePoints[bestIndex + 1] - val secondPoint = SeekPoint(nextCue.timeUs, nextCue.clusterPosition) - SeekPoints(firstPoint, secondPoint) - } else { - SeekPoints(firstPoint) - } - } else { - val firstCue = cuePoints[0] - SeekPoints(SeekPoint(firstCue.timeUs, firstCue.clusterPosition)) - } - } - - override fun getChunkIndex(): ChunkIndex? = chunkIndex - - private companion object { - - private fun buildChunkIndex( - perTrackCues: SparseArray>, - durationUs: Long, - primarySeekTrackNumber: Int, - segmentContentPosition: Long, - segmentContentSize: Long - ): ChunkIndex? { - - val primaryTrackCuePoints = - perTrackCues[primarySeekTrackNumber] ?: return null - - if (primaryTrackCuePoints.isEmpty()) { - return null - } - - val cuePointsSize = primaryTrackCuePoints.size - var sizes = IntArray(cuePointsSize) - var offsets = LongArray(cuePointsSize) - var durationsUs = LongArray(cuePointsSize) - var timesUs = LongArray(cuePointsSize) - - for (i in 0 until cuePointsSize) { - val cue = primaryTrackCuePoints[i] - timesUs[i] = cue.timeUs - offsets[i] = cue.clusterPosition - } - - for (i in 0 until cuePointsSize - 1) { - sizes[i] = (offsets[i + 1] - offsets[i]).toInt() - durationsUs[i] = timesUs[i + 1] - timesUs[i] - } - - // Start from the last cue point and move backward until a valid duration is found. - var lastValidIndex = cuePointsSize - 1 - while (lastValidIndex > 0 && timesUs[lastValidIndex] >= durationUs) { - lastValidIndex-- - } - - // Calculate sizes and durations for the last valid index - sizes[lastValidIndex] = - (segmentContentPosition + segmentContentSize - offsets[lastValidIndex]).toInt() - durationsUs[lastValidIndex] = durationUs - timesUs[lastValidIndex] - - // If trailing cue points were found, truncate the arrays to the last valid index. - if (lastValidIndex < cuePointsSize - 1) { - Log.w(TAG, "Discarding trailing cue points with timestamps greater than total duration.") - sizes = sizes.copyOf(lastValidIndex + 1) - offsets = offsets.copyOf(lastValidIndex + 1) - durationsUs = durationsUs.copyOf(lastValidIndex + 1) - timesUs = timesUs.copyOf(lastValidIndex + 1) - } - - return ChunkIndex(sizes, offsets, durationsUs, timesUs) - } - } - - class CuePointData( - /** The timestamp of the cue point, in microseconds. */ - val timeUs: Long, - - /** The absolute byte offset of the start of the cluster containing this cue point. */ - val clusterPosition: Long, - - /** - * The relative byte offset of the cue point's data block within its cluster. - * - *

Note: For seeking, use {@link #clusterPosition} to prevent A/V desync. - */ - val relativePosition: Long - ) : Comparable { - - override fun compareTo(other: CuePointData): Int { - return timeUs.compareTo(other.timeUs) - } - - override fun equals(other: Any?): Boolean { - if (this === other) { - return true - } - if (other !is CuePointData) { - return false - } - return this.timeUs == other.timeUs && - this.clusterPosition == other.clusterPosition && - this.relativePosition == other.relativePosition - } - - override fun hashCode(): Int { - return Objects.hash(timeUs, clusterPosition, relativePosition) - } - } - } -} +} \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index a836a75fb..90ae59c07 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -27,10 +27,10 @@ kotlinGradlePlugin = "2.3.0" kotlinxCoroutinesCore = "1.10.2" lifecycleKtx = "2.9.4" material = "1.14.0-alpha08" -media3 = "1.9.2" +media3 = "1.8.0" navigationKtx = "2.9.6" newpipeextractor = "v0.25.2" -nextlibMedia3 = "1.9.1-0.11.0" +nextlibMedia3 = "1.8.0-0.9.0" nicehttp = "0.4.16" overlappingpanels = "0.1.5" paletteKtx = "1.0.0" From eaf2b7bd0d0354a683010cfaf9ba23eadc56a8d0 Mon Sep 17 00:00:00 2001 From: saimuel Date: Wed, 18 Feb 2026 13:41:21 -0300 Subject: [PATCH 047/236] Add new ByseSX mainUrl (#2506) --- .../kotlin/com/lagradost/cloudstream3/extractors/ByseSX.kt | 6 ++++++ .../kotlin/com/lagradost/cloudstream3/utils/ExtractorApi.kt | 4 +++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/ByseSX.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/ByseSX.kt index 34ad4fad0..404112d3e 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/ByseSX.kt +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/ByseSX.kt @@ -33,6 +33,12 @@ class ByseVepoin : ByseSX() { override var mainUrl = "https://bysevepoin.com" } +@Prerelease +class ByseQekaho : ByseSX() { + override var name = "ByseQekaho" + override var mainUrl = "https://byseqekaho.com" +} + @Prerelease open class ByseSX : ExtractorApi() { override var name = "Byse" diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/ExtractorApi.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/ExtractorApi.kt index 6d4c94426..9ef0eec45 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/ExtractorApi.kt +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/ExtractorApi.kt @@ -23,6 +23,7 @@ import com.lagradost.cloudstream3.extractors.ByseSX import com.lagradost.cloudstream3.extractors.Bysezejataos import com.lagradost.cloudstream3.extractors.ByseBuho import com.lagradost.cloudstream3.extractors.ByseVepoin +import com.lagradost.cloudstream3.extractors.ByseQekaho import com.lagradost.cloudstream3.extractors.ByteShare import com.lagradost.cloudstream3.extractors.Cavanhabg import com.lagradost.cloudstream3.extractors.Cda @@ -1283,6 +1284,7 @@ val extractorApis: MutableList = arrayListOf( Up4FunTop(), GUpload(), HlsWish(), + ByseQekaho(), ) @@ -1406,4 +1408,4 @@ abstract class ExtractorApi { open fun getExtractorUrl(id: String): String { return id } -} \ No newline at end of file +} From 1d1a7fb6fe3fd41d4f7582e1c1f68f38a36602b2 Mon Sep 17 00:00:00 2001 From: Mioki <22417711+okibcn@users.noreply.github.com> Date: Wed, 18 Feb 2026 08:58:48 -0800 Subject: [PATCH 048/236] Improved TV Back Button UX (extension content -> extension selection Bttn -> home nav Bttn -> exit dialog) (#2468) --- .../cloudstream3/ui/home/HomeFragment.kt | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeFragment.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeFragment.kt index 49c6d0d77..a254e1aec 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeFragment.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeFragment.kt @@ -15,6 +15,7 @@ import android.widget.ImageView import android.widget.ListView import android.widget.TextView import android.widget.Toast +import androidx.activity.ComponentActivity import androidx.appcompat.app.AlertDialog import androidx.core.net.toUri import androidx.core.view.isGone @@ -64,6 +65,9 @@ import com.lagradost.cloudstream3.utils.AppContextUtils.loadSearchResult import com.lagradost.cloudstream3.utils.AppContextUtils.ownHide import com.lagradost.cloudstream3.utils.AppContextUtils.ownShow import com.lagradost.cloudstream3.utils.AppContextUtils.setDefaultFocus +import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper +import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.attachBackPressedCallback +import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.detachBackPressedCallback import com.lagradost.cloudstream3.utils.Coroutines.ioSafe import com.lagradost.cloudstream3.utils.DataStoreHelper import com.lagradost.cloudstream3.utils.EmptyEvent @@ -566,6 +570,7 @@ class HomeFragment : BaseFragment( } override fun onDestroyView() { + (activity as? ComponentActivity)?.detachBackPressedCallback("HomeFragment_BackPress") bottomSheetDialog?.ownHide() super.onDestroyView() } @@ -626,6 +631,9 @@ class HomeFragment : BaseFragment( @SuppressLint("SetTextI18n") override fun onBindingCreated(binding: FragmentHomeBinding) { context?.let { HomeChildItemAdapter.updatePosterSize(it) } + (activity as? ComponentActivity)?.attachBackPressedCallback("HomeFragment_BackPress") { + handleTvBackPress(this) + } binding.apply { //homeChangeApiLoading.setOnClickListener(apiChangeClickListener) //homeChangeApiLoading.setOnClickListener(apiChangeClickListener) @@ -884,4 +892,44 @@ class HomeFragment : BaseFragment( } }*/ } + + private fun handleTvBackPress(helper: BackPressedCallbackHelper.CallbackHelper) { + // Only apply custom behavior on TV interface + if (!isLayout(TV)) { + helper.runDefault() + return + } + val currentFocus = activity?.currentFocus ?: run { + helper.runDefault() + return + } + // isInsideRecycle is true when focus is inside home_master_recycler + var parent = currentFocus.parent + var isInsideRecycler = false + while (parent != null) { + if (parent is View && parent.id == R.id.home_master_recycler) { + isInsideRecycler = true + break + } + parent = parent.parent + } + when { + // Case 1: Focus is within plugin content -> Move to plugin selector + isInsideRecycler -> { + binding?.homeMasterRecycler?.scrollToPosition(0) + // Defer focus request until after scroll ends + binding?.homeChangeApi?.post { + binding?.homeChangeApi?.requestFocus() + } + } + // Case 2: Focus is on plugin selector or nearby buttons -> Move to home navigation + currentFocus.id == R.id.home_change_api || + currentFocus.id == R.id.home_preview_reload_provider || + currentFocus.id == R.id.home_preview_search_button -> { + activity?.findViewById(R.id.navigation_home)?.requestFocus() + } + // Case 3: Any other location -> Use default back behavior + else -> helper.runDefault() + } + } } From 46f9c95376cb6a5d398f00eef64ddff47e0017bd Mon Sep 17 00:00:00 2001 From: Hosted Weblate Date: Thu, 19 Feb 2026 10:09:56 +0100 Subject: [PATCH 049/236] Translated using Weblate (French) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Currently translated at 100.0% (725 of 725 strings) Merge remote-tracking branch 'origin/master' Merge remote-tracking branch 'origin/master' Translated using Weblate (German) Currently translated at 98.8% (717 of 725 strings) Translated using Weblate (Spanish) Currently translated at 100.0% (725 of 725 strings) Translated using Weblate (Russian) Currently translated at 100.0% (725 of 725 strings) Translated using Weblate (Indonesian) Currently translated at 100.0% (725 of 725 strings) Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 100.0% (725 of 725 strings) Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 100.0% (725 of 725 strings) Translated using Weblate (English) Currently translated at 99.8% (724 of 725 strings) Translated using Weblate (Swedish) Currently translated at 100.0% (725 of 725 strings) Translated using Weblate (Indonesian) Currently translated at 98.6% (715 of 725 strings) Translated using Weblate (Portuguese (Brazil)) Currently translated at 100.0% (725 of 725 strings) Translated using Weblate (Swedish) Currently translated at 100.0% (725 of 725 strings) Translated using Weblate (Turkish) Currently translated at 100.0% (725 of 725 strings) Translated using Weblate (Ukrainian) Currently translated at 100.0% (725 of 725 strings) Translated using Weblate (Croatian) Currently translated at 100.0% (725 of 725 strings) Translated using Weblate (Polish) Currently translated at 100.0% (725 of 725 strings) Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 99.8% (724 of 725 strings) Translated using Weblate (Japanese) Currently translated at 100.0% (725 of 725 strings) Translated using Weblate (Italian) Currently translated at 100.0% (725 of 725 strings) Translated using Weblate (Czech) Currently translated at 100.0% (725 of 725 strings) Merge remote-tracking branch 'origin/master' Merge remote-tracking branch 'origin/master' Merge remote-tracking branch 'origin/master' Merge remote-tracking branch 'origin/master' Translated using Weblate (Croatian) Currently translated at 99.3% (718 of 723 strings) Translated using Weblate (Turkish) Currently translated at 100.0% (723 of 723 strings) Translated using Weblate (Italian) Currently translated at 100.0% (723 of 723 strings) Translated using Weblate (Czech) Currently translated at 100.0% (723 of 723 strings) Translated using Weblate (Polish) Currently translated at 100.0% (723 of 723 strings) Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 99.8% (722 of 723 strings) Translated using Weblate (Ukrainian) Currently translated at 100.0% (723 of 723 strings) Translated using Weblate (Japanese) Currently translated at 100.0% (723 of 723 strings) Merge remote-tracking branch 'origin/master' Added translation using Weblate (Arabic (Egyptian)) Merge remote-tracking branch 'origin/master' Merge remote-tracking branch 'origin/master' Translated using Weblate (Russian) Currently translated at 99.5% (712 of 715 strings) Translated using Weblate (Belarusian) Currently translated at 99.5% (712 of 715 strings) Translated using Weblate (Spanish) Currently translated at 99.8% (714 of 715 strings) Translated using Weblate (Belarusian) Currently translated at 90.4% (647 of 715 strings) Translated using Weblate (Indonesian) Currently translated at 100.0% (715 of 715 strings) Translated using Weblate (Belarusian) Currently translated at 85.5% (612 of 715 strings) Translated using Weblate (Bengali) Currently translated at 48.9% (350 of 715 strings) Translated using Weblate (German) Currently translated at 100.0% (715 of 715 strings) Translated using Weblate (Belarusian) Currently translated at 65.5% (469 of 715 strings) Translated using Weblate (French) Currently translated at 100.0% (715 of 715 strings) Translated using Weblate (Croatian) Currently translated at 99.8% (714 of 715 strings) Translated using Weblate (Belarusian) Currently translated at 58.0% (415 of 715 strings) Translated using Weblate (Arabic) Currently translated at 100.0% (715 of 715 strings) Co-authored-by: Broo Mohamed Co-authored-by: Camila Sciocca Co-authored-by: Dan Co-authored-by: Daniel Wiik Co-authored-by: Deepak C Co-authored-by: Fjuro Co-authored-by: Fjuro Co-authored-by: Francisco Serrador Co-authored-by: Hosted Weblate Co-authored-by: Kehribar <103407696+dpentx@users.noreply.github.com> Co-authored-by: Kerim Demirkaynak Co-authored-by: Luiz Felipe Sudorio dos Santos Co-authored-by: MD Sakibur Rahman Co-authored-by: Massimo Pissarello Co-authored-by: Matthaiks Co-authored-by: Milo Ivir Co-authored-by: Oliver Co-authored-by: Posemartonis Co-authored-by: Sasha Glazko Co-authored-by: Sasha Glazko Co-authored-by: ShowhyT Co-authored-by: Takeru Mikenu Co-authored-by: Timo Panda Co-authored-by: XC3 Co-authored-by: kokolo Co-authored-by: lamrichiasmaa Co-authored-by: lizamimiku-wq Co-authored-by: opakholis Co-authored-by: tjy122 Co-authored-by: 大王叫我来巡山 Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/ar/ Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/be/ Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/bn/ Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/cs/ Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/de/ Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/en/ Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/es/ Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/fr/ Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/hr/ Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/id/ Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/it/ Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/ja/ Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/pl/ Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/pt_BR/ Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/ru/ Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/sv/ Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/tr/ Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/uk/ Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/zh_Hans/ Translation: Cloudstream/App --- app/src/main/res/values-arz/strings.xml | 3 + app/src/main/res/values-b+ar/strings.xml | 15 +- app/src/main/res/values-b+bn/strings.xml | 7 + app/src/main/res/values-b+cs/strings.xml | 20 + app/src/main/res/values-b+de/strings.xml | 13 +- app/src/main/res/values-b+es/strings.xml | 27 +- app/src/main/res/values-b+fr/strings.xml | 26 +- app/src/main/res/values-b+hr/strings.xml | 39 +- app/src/main/res/values-b+in/strings.xml | 47 ++- app/src/main/res/values-b+it/strings.xml | 18 + app/src/main/res/values-b+ja/strings.xml | 20 +- app/src/main/res/values-b+pl/strings.xml | 20 + app/src/main/res/values-b+pt+BR/strings.xml | 29 +- app/src/main/res/values-b+ru/strings.xml | 25 +- app/src/main/res/values-b+sv/strings.xml | 96 ++++- app/src/main/res/values-b+tr/strings.xml | 30 +- app/src/main/res/values-b+uk/strings.xml | 20 + app/src/main/res/values-b+zh/strings.xml | 17 +- app/src/main/res/values-be/strings.xml | 384 +++++++++++++++++++- app/src/main/res/values/strings.xml | 8 +- 20 files changed, 814 insertions(+), 50 deletions(-) create mode 100644 app/src/main/res/values-arz/strings.xml diff --git a/app/src/main/res/values-arz/strings.xml b/app/src/main/res/values-arz/strings.xml new file mode 100644 index 000000000..55344e519 --- /dev/null +++ b/app/src/main/res/values-arz/strings.xml @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/values-b+ar/strings.xml b/app/src/main/res/values-b+ar/strings.xml index 487b29d84..c68a5a649 100644 --- a/app/src/main/res/values-b+ar/strings.xml +++ b/app/src/main/res/values-b+ar/strings.xml @@ -247,7 +247,7 @@ تحديث جودة المشاهدة المفضلة (WiFi) أقصى عدد لحروف عنوان مُشغل الفيديو - أبعاد مُشغل الفيديو + عرض معلومات المشغل حجم ذاكرة التخزين المؤقت للفيديو طول التخزين المؤقت التخزين المؤقت للفيديو على القرص @@ -743,4 +743,17 @@ اعلى وسط اعلى يمين شاهد المسلسل كاملاً + سطوع إضافي + تفعيل فلتر السطوع عند تجاوز سطوع الشاشة 100% + تفعيل فلتر السطوع الإضافي + اقتراحات البحث + عرض اقتراحات البحث أثناء الكتابة + مسح الاقتراحات + عرض لوحة البث + تثبيت الإصدار التجريبي + تم تثبيت الإصدار التجريبي بالفعل. + فشل تثبيت الإصدار التجريبي. + نص الحلقة + معلومات الوسائط + اسم المصدر diff --git a/app/src/main/res/values-b+bn/strings.xml b/app/src/main/res/values-b+bn/strings.xml index 2e37f43f3..87aa8e7eb 100644 --- a/app/src/main/res/values-b+bn/strings.xml +++ b/app/src/main/res/values-b+bn/strings.xml @@ -350,4 +350,11 @@ অ্যাকাউন্ট প্রস্থান %1$d%2$s + সিজন %1$d পর্ব %2$d প্রকাশিত হবে + %1$dঘন্টা %2$dমিনিট %3$dসেকেন্ড + %1$dমিনিট %2$dসেকেন্ড + %1$dসেকেন্ড + শুরু থেকে চালু করুন + স্পিচ রিকগনিশন উপলব্ধ নেই + কথা বলা শুরু করুন… diff --git a/app/src/main/res/values-b+cs/strings.xml b/app/src/main/res/values-b+cs/strings.xml index aa2c8cc11..3f7675534 100644 --- a/app/src/main/res/values-b+cs/strings.xml +++ b/app/src/main/res/values-b+cs/strings.xml @@ -758,4 +758,24 @@ extra_brightness_enabled Informace o médiu Název zdroje + Fronta stahování + Momentálně nemáte žádná stahování ve frontě. + Stáhnout vše + Zrušit vše + Chcete stáhnout epizodu %s? + Chcete zrušit všechna stahování ve frontě? + + %d aktivní stahování + %d aktivní stahování + %d aktivních stahování + %d aktivních stahování + + + %d stahování uloženo do fronty + %d stahování uložena do fronty + %d stahování uloženo do fronty + %d stahování uloženo do fronty + + Priorita zdrojů + Rozhodněte, jak mají být řazeny zdroje videí v přehrávači diff --git a/app/src/main/res/values-b+de/strings.xml b/app/src/main/res/values-b+de/strings.xml index 0c3b9a363..8989ce795 100644 --- a/app/src/main/res/values-b+de/strings.xml +++ b/app/src/main/res/values-b+de/strings.xml @@ -255,7 +255,7 @@ Update Bevorzugte Videoqualität (WLAN) Videoplayertitel max. Zeichen - Videoplayer Auflösung + Playerinformationen anzeigen Videopuffergröße Videopufferlänge Video-Cache in Speicher @@ -738,4 +738,15 @@ Beim Tippen Suchempfehlungen anzeigen Empfehlungen löschen Zusätzliche Helligkeit + Aktiviere Helligkeitsfilter, wenn 100% Bildschirmhelligkeit überschritten ist + Erhöhte Helligkeit aktiviert + Cast-Panel zeigen + Medieninfo + Quellname + Alle herunterladen + Möchtest du Episode%s herunter laden? + + %d aktiver Download + %d aktive Downloads + diff --git a/app/src/main/res/values-b+es/strings.xml b/app/src/main/res/values-b+es/strings.xml index 88cb6749d..5e59477ce 100644 --- a/app/src/main/res/values-b+es/strings.xml +++ b/app/src/main/res/values-b+es/strings.xml @@ -56,7 +56,7 @@ Eliminar Closed Captions (CC) de los subtítulos Cantidad de búsquedas del reproductor (segundos) Use el brillo del sistema en el reproductor de la app en lugar de una superposición oscura - Resolución del reproductor de video + Mostrar la información del reproductor de video Reproductor Iniciar el siguiente episodio cuando el actual termine Omitir Intro @@ -113,7 +113,7 @@ Inicio Buscar Descargas - Configuración + Ajustes Buscar… Buscar en %s… Sin datos @@ -733,4 +733,27 @@ Mostrar sugerencias de búsqueda mientras escribe Borrar Sugerencias Mostrar panel de reparto + Atenuación extra + Active el filtro de atenuación cuando se supere el 100 % de brillo de la pantalla + atenuación_extra_activado + Información del archivo multimedia + Nombre de origen + Cola de descarga + Actualmente no hay descargas encoladas. + Prioridad de origen + Decida como los orígenes del vídeo estarían ordenados en el reproductor + Descargar todo + Cancelar todo + ¿Desea descargar el episodio %s? + ¿Desea cancelar todas las descargas de la cola? + + %d descarga activa + %d descargas activas + %d descargas activas + + + %d descarga encolada + %d descargas encoladas + %d descargas encoladas + diff --git a/app/src/main/res/values-b+fr/strings.xml b/app/src/main/res/values-b+fr/strings.xml index 0511d1cd1..f39de53f7 100644 --- a/app/src/main/res/values-b+fr/strings.xml +++ b/app/src/main/res/values-b+fr/strings.xml @@ -313,7 +313,7 @@ Téléchargement de la mise à jour… L\'épisode %d sera publié dans Étiquette de qualité - Résolution du lecteur vidéo + Afficher les informations du lecteur Cloner le site Supprimer le site Ajoute un clone à un site déjà existant, avec une URL différente @@ -729,4 +729,28 @@ Suggestions de recherche Afficher des suggestions de recherche pendant la saisie Effacer les suggestions + Luminosité supplémentaire + Activer le filtre de luminosité lorsque la luminosité dépasse 100 % + extra_brightness_enabled + Afficher le panneau de cast + Information du média + Nom de la source + File d\'attente de téléchargements + Il n\'y a actuellement aucun téléchargement en attente. + Priorité de la source + Déterminez comment les sources vidéo seront triées dans le lecteur + Télécharger tout + Tout annuler + Voulez-vous télécharger l\'épisode %s ? + Vous voulez annuler tous les téléchargements en file d\'attente ? + + %d téléchargement actif + %d téléchargements actifs + %d téléchargements actifs + + + %d téléchargement en attente + %d téléchargements en attente + %d téléchargements en attente + diff --git a/app/src/main/res/values-b+hr/strings.xml b/app/src/main/res/values-b+hr/strings.xml index 47dfa53b5..8b3a6fbf3 100644 --- a/app/src/main/res/values-b+hr/strings.xml +++ b/app/src/main/res/values-b+hr/strings.xml @@ -134,7 +134,7 @@ Količina pomicanja u playeru (sekunde) Dodirni dvaput desnu ili lijevu stranu za pomicanje prema naprijed ili natrag Dodirni dvaput u sredinu za pauziranje - Koristi svijetlinu sustava + Koristi svjetlinu sustava Koristi svjetlinu sustava u playeru aplikacija umjesto tamnog preklopa Ažuriraj napredak gledanja Automatski sinkronizira vaš trenutni napredak u filmu ili epizodi @@ -257,7 +257,7 @@ Ažuriraj Preferirana kvaliteta gledanja (WiFi) Maksimalni broj znakova u naslovu video playera - Rezolucija video playera + Prikaži podatke playera Veličina međuspremnika videa Duljina međuspremnika videa Predmemorija videa na disku @@ -740,12 +740,39 @@ Rezolucija i ime Poravnanje titlova Dolje lijevo - Dolje centrirano + Dolje u sredini Dolje desno Sredina lijevo - Sredina centrirano - Sredina desno + U sredini + Desno u sredini Gore lijevo - Gore centrirano + Gore u sredini Gore desno + Dodatna svjetlina + Uključi filtar svjetline kada se prekorači 100 % svjetline ekrana + dodatna_svjetlina_uključena + Prijedlozi za pretraživanje + Prikaži prijedloge za pretraživanje tijekom tipkanja + Izbriši prijedloge + Podaci medija + Ime izvora + Preuzmi sve + Odustani od svega + Red preuzimanja + Sada nema preuzimanja u redu. + + %d aktivno preuzimanje + %d aktivna preuzimanja + %d aktivnih preuzimanja + + + %d preuzimanje u redu čekanja + %d preuzimanja u redu čekanja + %d preuzimanja u redu čekanja + + Prioritet izvora + Odluči kako razvrstati izvor videa u playeru + Želiš li preuzeti epizodu %s? + Želiš li otkazati sva preuzimanja u redu čekanja? + Prikaži ploču glumačke postave diff --git a/app/src/main/res/values-b+in/strings.xml b/app/src/main/res/values-b+in/strings.xml index 8a34ba410..7fa837b23 100644 --- a/app/src/main/res/values-b+in/strings.xml +++ b/app/src/main/res/values-b+in/strings.xml @@ -30,15 +30,15 @@ Episode selanjutnya Genre Bagikan - Buka Di Peramban + Buka di Peramban Lewati Pemuatan Memuat… - Sedang Menonton + Sedang Ditonton Ditunda Selesai Dihentikan Ingin Ditonton - Menonton Ulang + Ditonton Ulang Putar Film Stream Torrent Sumber @@ -50,19 +50,19 @@ Unduh Terunduh Mengunduh - Unduh Dijeda - Unduh Dimulai - Unduh Gagal - Unduh Dibatalkan - Unduh Selesai + Unduhan dijeda + Unduhan dimulai + Unduhan gagal + Unduhan diibatalkan + Unduhan selesai Galat Memuat Tautan Penyimpanan Internal Dub Sub Hapus Berkas Putar Berkas - Lanjutkan Unduh - Jeda Unduh + Lanjutkan unduhan + Jeda unduhan Lebih banyak info Sembunyikan Putar @@ -234,7 +234,7 @@ Update Kualitas nonton yang diinginkan (WiFi) Karakter maksimal judul pemutar video - Resolusi pemutar video + Tampilkan informasi pemutar Ukuran buffer video Panjang buffer video Cache video di disk @@ -628,7 +628,7 @@ Aktifkan pratinjau gambar mini di bilah pencarian Sembunyikan Nama Kontrol Pemain Buka repositori - Mainkan dari Awal + Putar dari awal Tidak ada unduhan. Buka video lokal Tanggal Rilis (Lama ke Baru) @@ -741,4 +741,27 @@ Versi pra-rilis sudah terpasang. Gagal memasang versi pra-rilis. Teks Episode + Kecerahan ekstra + Aktifkan filter kecerahan saat kecerahan layar melebihi 100% + extra_brightness_enabled + Hapus saran + Tampilkan panel cast + Info media + Nama sumber + Saran pencarian + Tampilkan saran pencarian saat mengetik + + %d unduhan aktif + + + %d unduhan dalam antrean + + Antrean unduhan + Tidak ada unduhan dalam antrean. + Prioritas sumber + Tentukan bagaimana sumber video harus diurutkan dalam pemutar + Unduh semua + Batalkan semua + Apakah kamu ingin mengunduh episode %s? + Apakah kamu ingin membatalkan semua unduhan dalam antrean? diff --git a/app/src/main/res/values-b+it/strings.xml b/app/src/main/res/values-b+it/strings.xml index 49ba2ec2c..e75b4eb8c 100644 --- a/app/src/main/res/values-b+it/strings.xml +++ b/app/src/main/res/values-b+it/strings.xml @@ -765,4 +765,22 @@ Luminosità extra Attiva il filtro di luminosità quando viene superato il 100% della luminosità dello schermo extra_brightness_enabled + Coda di download + Al momento non ci sono download in coda. + Scarica tutto + Annulla tutto + Vuoi scaricare l\'episodio %s? + Vuoi annullare tutti i download in coda? + + %d download attivo + %d download attivi + %d download attivi + + + %d download in coda + %d download in coda + %d download in coda + + Priorità sorgente + Decidi come le sorgenti video devono essere ordinate nel lettore diff --git a/app/src/main/res/values-b+ja/strings.xml b/app/src/main/res/values-b+ja/strings.xml index a075b8bff..0b66ca8b2 100644 --- a/app/src/main/res/values-b+ja/strings.xml +++ b/app/src/main/res/values-b+ja/strings.xml @@ -46,7 +46,7 @@ 全般 動画 プレーヤー - 懐う + 視聴予定 予告編を再生 エピソード 視聴 @@ -81,13 +81,13 @@ 主要ポスター 次のランダム 戻り - 視聴率 %.1f + 評価: %.1f 新しいアップデートを発見! \n%1$s -> %2$s %d分 %sを検索… ソース - ろくごうきじ + 番外編 接続を再試行… 戻り 削除 @@ -717,4 +717,18 @@ 追加の輝度設定 画面の輝度が100%を超えた場合に輝度フィルターを有効にします 追加の輝度を有効化 + ダウンロードキュー + 現在、ダウンロードキューは空です。 + すべてダウンロード + すべてキャンセル + エピソード %s をダウンロードしますか? + ダウンロードキューをすべてキャンセルしますか? + + %d 件をダウンロード中 + + + %d 件が待機中 + + ソースの優先順位 + プレイヤーでのビデオソースの並び順を設定します diff --git a/app/src/main/res/values-b+pl/strings.xml b/app/src/main/res/values-b+pl/strings.xml index 7ae964085..fc167da5a 100644 --- a/app/src/main/res/values-b+pl/strings.xml +++ b/app/src/main/res/values-b+pl/strings.xml @@ -746,4 +746,24 @@ Dodatkowa jasność Włącz filtr jasności, gdy jasność wyświetlacza przekroczy 100% Włączono dodatkową jasność + Kolejka pobierania + Obecnie nie ma żadnych plików do pobrania w kolejce. + Pobierz wszystkie + Anuluj wszystkie + Czy chcesz pobrać odcinek %s? + Czy chcesz anulować wszystkie pliki do pobrania z kolejki? + + %d aktywne pobieranie + %d aktywne pobierania + %d aktywnych pobierań + %d aktywnych pobierań + + + %d pobieranie w kolejce + %d pobierania w kolejce + %d pobierań w kolejce + %d pobierań w kolejce + + Priorytet źródła + Zdecyduj, jak mają być sortowane źródła wideo w odtwarzaczu diff --git a/app/src/main/res/values-b+pt+BR/strings.xml b/app/src/main/res/values-b+pt+BR/strings.xml index 72e693089..58d598708 100644 --- a/app/src/main/res/values-b+pt+BR/strings.xml +++ b/app/src/main/res/values-b+pt+BR/strings.xml @@ -247,7 +247,7 @@ Atualizar Qualidade preferida de reprodução (Wi-fi) Máximo de caracteres do título de vídeos - Resolução do player de vídeo + Exibir informação do reprodutor Tamanho do buffer do vídeo Duração do buffer do vídeo Cache do vídeo em disco @@ -739,4 +739,31 @@ Versão antecipada instalada. Instalar versão antecipada Episódio Text + Fila de download + Não há itens na fila para download. + Brilho extra + Ativar filtro de claridade ao exceder 100% do brilho do display + brilho_extra_ativado + Sugestões de Busca + Exibir sugestões de pesquisa durante a digitação + Limpar Sugestões + Exibir painel de elenco + Informação de mídia + Prioridade de fonte + Decidir como fontes de vídeo devem estar dispostas no reprodutor + Nome da fonte + Baixar tudo + Cancelar tudo + Você deseja baixar o episódio%s + Você gostaria de cancelar todos os downloads da fila? + + %ddownload ativo + %ddownloads ativos + %ddownloads ativos + + + %d download na sequência + %d downloads na sequência + %d downloads na sequência + diff --git a/app/src/main/res/values-b+ru/strings.xml b/app/src/main/res/values-b+ru/strings.xml index 280787438..9f6b53aa7 100644 --- a/app/src/main/res/values-b+ru/strings.xml +++ b/app/src/main/res/values-b+ru/strings.xml @@ -305,7 +305,7 @@ Вступление Титры Отметить как просмотренное - Показывать информацию об игроке + Показывать информацию про видеоплеер Предпочтительное качество видео (WiFi) Максимум символов Длинна буфера @@ -726,4 +726,27 @@ Показать панель приведения Информация о средствах массовой информации Имя источника + + %d активная загрузка + %d активные загрузки + %d активных загрузок + %d активных загрузок + + + Запланировано %d загрузка + Запланировано %d загрузки + Запланировано %d загрузок + Запланировано %d загрузок + + Очередь загрузок + В настоящее время нет загрузок в очереди. + Дополнительная яркость + Включать фильтр яркости при превышении 100% яркости + extra_brightness_enabled + Приоритетный источник + Выберите способ сортировки видеоисточников в плеере + Скачать всё + Отменить всё + Вы хотите загрузить эпизод %s? + Вы хотите отменить всё запланированные загрузки? diff --git a/app/src/main/res/values-b+sv/strings.xml b/app/src/main/res/values-b+sv/strings.xml index 75c7efda4..ddc7636b1 100644 --- a/app/src/main/res/values-b+sv/strings.xml +++ b/app/src/main/res/values-b+sv/strings.xml @@ -45,7 +45,7 @@ @string/result_poster_img_des Spela upp Info - Nästa + Nästa slumpvis Byt leverantör Filtrera bokmärken Bokmärken @@ -248,7 +248,7 @@ Lagringsbehörigheter saknas. Var vänlig försök igen. Inga episoder hittade Visa bilder från Kitsu - Vissa telefoner stöder inte den nya paketinstallatören. Prova alternativet för äldre versioner om uppdateringarna inte installeras. + Vissa enheter stöjder inte den nya paketinstallatören. Prova alternativet för äldre versioner om uppdateringarna inte installeras. APK-installatör Importera typsnitt genom att placera filerna i %s Automatiskt uppdatera antalet episoder sedda @@ -270,7 +270,7 @@ Kvalitetsetikett Titel Växla UI-element på affisch - Videospelarens upplösning + Visa videospelarens information Historik Markera som sedd Inställningar för Chromecast-undertexter @@ -421,7 +421,7 @@ Nätverksflöde Databasens namn (valfritt) All %s har redan laddats ner - Varning: CloudStream 3 tar inget ansvar för att använda tredjepartstillägg och ger inget stöd för dem! + Varning: CloudStream tar inget ansvar för att använda tredjepartstillägg och ger inget stöd för dem! Felsäkert läge på Starta om appen för att se ändringar. Intern spelare @@ -591,7 +591,7 @@ \nkvarstår kopierad! Lagringsnamn och URL - För att säkerställa oavbrutna nedladdningar och aviseringar för prenumererade tv-program behöver CloudStream tillstånd att köras i bakgrunden. Genom att trycka på OK kommer du till App info. Där bläddrar du till appens batterianvändning och ställer in batterianvändningen på obegränsad. Observera att denna behörighet inte betyder att CS3 kommer att tömma ditt batteri. Den fungerar bara i bakgrunden när det behövs, till exempel när du tar emot aviseringar eller laddar ner videor från officiella tillägg. Om du väljer att avbryta kan du ändra denna inställning senare i allmänna inställningar. + För att säkerställa oavbrutna nedladdningar och aviseringar för prenumererade tv-program behöver CloudStream tillstånd att köras i bakgrunden. Genom att trycka på OK kommer du få en förfrågningsdialogruta. Tryck då på \'Tillåt\'.\n\nObservera att denna behörighet inte betyder att CS3 kommer att tömma ditt batteri. Den fungerar bara i bakgrunden när det behövs, till exempel när du tar emot aviseringar eller laddar ner videor från officiella tillägg. Din CloudStream-data har säkerhetskopierats nu. Även om möjligheten till detta är mycket liten, kan alla enheter bete sig olika. I det sällsynta fallet att du blir utelåst från att komma åt appen, rensa appdata helt och återställ från en säkerhetskopia. Vi ber om ursäkt för eventuella besvär som detta uppstår. Ljudbok Det gick inte att komma åt urklipp. Försök igen. @@ -652,11 +652,11 @@ Visa Säkerhetskopieringsmapp Visa dialogruta innan stängning av appen - Mjukvaruavkodning tillåter spelaren att spela upp videofiler som inte stöds av din enhet, men kan orsaka ostadig uppspelning vid hög upplösning + Mjukvaruavkodning tillåter spelaren att spela upp videofiler som inte stöds av din enhet, men kan orsaka ostadig uppspelning vid hög upplösning. Ljud Podcast Mjukvaruavkodning - Ogiltig torrent + Starta om appen och acceptera popup-fönstret för Stream Torrent för att fortsätta. Aktivera torrent i Inställningar/Leverantörer/Föredragen media Avkodningsfel Ladda in första möjliga @@ -677,4 +677,86 @@ Betyg%s Uppdatera Plugins Gå till Hämtade filer + Mängden av olika föremål som kan bli nedladdat i parallell + Parallel nedladdningar + Samtidiga anslutningar + Mängden av samtidiga anslutningar varje nedladdning kan använda + Ingen anslutning. \n\nAnslut till ett nätverk och försök igen. Du kan titta på dina nedladdningar medan du inte är ansluten. + Ändrar skärmens gränser + Överskanning + Ändrar storleken på affischer + Affischstorlek + LångPress Hastighet Växling + Tryck ner för 2x hastighet + Redigera Profil Bild + Ange URL för Profil Bilden + Inget URL hittades + Ogiltig URL eller Bild + Bilden ändrades + Markera som \"sett fram\" till detta avsnitt + Ta bort \"sett fram\" till detta avsnitt + Omladdad + Ladda om leverantören + Namn + Källnamn + Upplösning och Namn + Ladda ned alla + Avbryt alla + Vill du ladda ned avsnitt %s? + Vill du avbryta alla köade hämtningar? + Justera undertexter + Nederst till vänster + Nederst i mitten + Nederst till höger + Mitten vänster + I mitten + Mitten till höger + Övre vänster + Övre mitten + Övre höger + + %d aktiv nedladdning + %d aktiva nedladdningar + + + %d nedladdning i kö + %d nedladdningar i kö + + %1$dh %2$dm %3$ds + %1$dm %2$ds + %1$ds + Nedladdningskö + Spela hela serierna + Det finns inga nedladdningar i kö just nu. + Extra ljusstyrka + Aktivera ljusstyrkefiltret när ljusstyrkan överstiger 100% + extra_brightness_enabled + Sökförslag + Visa sökförslag medan du skriver + Rensa förslag + Visa skådespelarpanelen + Installera förhandsversionen + Förhandsversionen är redan installerad. + Misslyckades med att installera förhandsversionen. + Spela mirror" + Graderingstitel + Avsnitt Text + Media Info + Fråga alltid + Sändningsdatum (nyast) + Sändningsdatum (äldst) + Källprioritet + Välj hur videokällor ska sorteras i spelaren + Inget konto + Dra uppåt igen för att gå över 100% + Startar plugin uppdateringsprocessen! + %d plugin(s) har uppdaterats! + Inga plugins blev uppdaterade. + Spelarnotiser + Spelarnotisen för att styra uppspelningen i bakgrunden + Inbäddad + Ansluten + Gör alla undertexter fetstilta + Gör alla undertexter kursivstila + Bakgrundsradie diff --git a/app/src/main/res/values-b+tr/strings.xml b/app/src/main/res/values-b+tr/strings.xml index ec5d0fb2c..273a3140c 100644 --- a/app/src/main/res/values-b+tr/strings.xml +++ b/app/src/main/res/values-b+tr/strings.xml @@ -2,18 +2,18 @@ %1$s B. %2$d - Cast: %s - Bölüm %d şu tarihte yayınlanacak - %1$dg %2$ds %3$dd - %1$ds %2$dd - %dd + Oyuncular: %s + %d. Bölüm şu tarihte yayınlanacak + %1$dg %2$dsa %3$ddk + %1$dsa %2$ddk + %ddk Afiş Afiş Bölüm Afişi Ana Afiş Sonraki Rastgele - Geri git + Geri dön Sağlayıcıyı Değiştir Arkaplanı Önizle @@ -616,7 +616,7 @@ Uygulama pil kullanımı zaten sınırsız olarak ayarlanmış Sıfırla %s içinde yaklaşıyor - Sezon %1$d Bölüm %2$d tarihinde yayınlanacak + %1$d. Sezon %2$d. Bölüm şu tarihte yayınlanacak Yansıtılacak cihaz seç Ekran yansıtma CloudStream bilgi @@ -755,4 +755,20 @@ Ekstra parlaklık aktifleştirildi Medya bilgisi Kaynağın adı + İndirme kuyruğu + Şu anda bekleyen indirme yok. + Tümünü indir + Tümünü iptal et + %s numaralı bölümü indirmek istiyor musunuz? + Kuyruktaki tüm indirmeleri iptal etmek istiyor musunuz? + + %d aktif indirme + %d aktif indirmeler + + + %d indirme kuyrukta + %d indirilecekler kuyruğa alındı + + Öncelikli kaynak + Oynatıcıda video kaynaklarının nasıl sıralanacağını belirleyin diff --git a/app/src/main/res/values-b+uk/strings.xml b/app/src/main/res/values-b+uk/strings.xml index c26077014..b97c16a7e 100644 --- a/app/src/main/res/values-b+uk/strings.xml +++ b/app/src/main/res/values-b+uk/strings.xml @@ -715,4 +715,24 @@ Показати панель трансляції Інформація про медіа Назва джерела + Черга завантаження + Наразі немає завантажень у черзі. + Завантажити все + Скасувати все + Бажаєте завантажити %s епізод? + Бажаєте скасувати всі завантаження в черзі? + + %d активне завантаження + %d активні завантаження + %d активних завантажень + %d активних завантажень + + + %d завантаження в черзі + %d завантаження в черзі + %d завантажень у черзі + %d завантажень у черзі + + Пріоритетне джерело + Виберіть спосіб сортування джерел відео у програвачі diff --git a/app/src/main/res/values-b+zh/strings.xml b/app/src/main/res/values-b+zh/strings.xml index 9db07479d..e8e02d51f 100644 --- a/app/src/main/res/values-b+zh/strings.xml +++ b/app/src/main/res/values-b+zh/strings.xml @@ -1,7 +1,7 @@ - %1$s 共 %2$d 集 + %1$s Ep %2$d 演员:%s 第 %d 集将发布于 %1$dd %2$dh %3$dm @@ -756,4 +756,19 @@ 源名称 额外亮度 超过 100% 亮度时启用亮度过滤器 + 下载队列 + 队列中当前无下载。 + 全部下载 + 全部取消 + 要下载第 %s 集吗? + 要取消队列中的所有下载吗? + + %d 个活跃下载 + + + 队列中有 %d 个下载 + + 源优先级 + 确定在播放器中如何排列视频源的顺序 + 已启用额外亮度 diff --git a/app/src/main/res/values-be/strings.xml b/app/src/main/res/values-be/strings.xml index e409f8190..4c9a88313 100644 --- a/app/src/main/res/values-be/strings.xml +++ b/app/src/main/res/values-be/strings.xml @@ -255,7 +255,7 @@ Тэлесерыялы Мультфільмы Анімэ - Торренты + Torrents Дакументальныя фільмы OVA Азіяцкія драмы @@ -267,7 +267,7 @@ Мультфільм Анімэ OVA - Торрэнт + Torrent Дакументальны фільм Азіяцкая драма Прамая трансляцыя @@ -335,4 +335,384 @@ Абыходзьце блакіроўкі спасылак GitHub з дапамогай jsDelivr. Можа затрымаць абнаўленні на некалькі дзён. Кланіраваць вэб-сайт Выдаліць вэб-сайт + Дадайце копію існуючага вэб-сайта з іншым URL-адрасам + Шлях спампоўкі + URL-адрас сервера NGINX + Паказваць метку «Дубляж»/«Субцітры» для анімэ + Умясціць у экран + Запоўніць + Маштабаваць + Адмова ад адказнасці + Абходы ISP + Спасылкі + Абнаўленні праграмы + Рэзервовае капіраванне + Пашырэнні + Дзеянні + Кэш + Android TV + Жэсты + Бяспека + Уліковыя запісы + Функцыі прайгравальніка + Субцітры + Макет + Прадвызначэнні + Выгляд + Функцыі + Агульныя + Кнопка «Выпадковае» + Паказваць кнопку «Выпадковае» на дамашняй старонцы і ў бібліятэцы + Мовы пашырэнняў + Макет праграмы + Прыярытэтнае медыя + Уключыць NSFW на пашырэннях, дзе гэта падтрымліваецца + Кадзіраванне субцітраў + Пастаўшчыкі + Праверка пастаўшчыкоў + Праверыць усе пашырэнні + Гэта праверка прызначана толькі для распрацоўшчыкаў і не вызначае работу абы-якога пашырэння. + Макет + Аўтаматычны + Макет тэлевізара + Макет тэлефона + Макет эмулятара + Асноўны колер + Тэма праграмы + Размяшчэнне загалоўка плаката + Размяшчаць назву пад плакатам + parol123 + Імя карыстальніка + vitaju@sviet.com + 127.0.0.1 + НоваяНазваСайта + https://pryklad.by + Код мовы (be) + %2$s%1$s + уліковы запіс + Выйсці + Увайсці + Лакальная праверка сапраўднасці + Змяніць ўліковы запіс + Дадаць уліковы запіс + Стварыць уліковы запіс + Дадаць адсочванне + Дададзена %s + Сінхранізаваць + Ацэнка + %d / 10 + /?? + /%d + %s праверана на сапраўднасць + Не ўдалося ўвайсці ў %s + Выключыць + Нічога + Звычайны + Усё + Макс. + Мін. + Контур + Заніжаныя + Цень + Паднятыя + Сінхранізаваць субцітры + 1000 мс + Затрымка субцітраў + Выкарыстоўвайце, калі субцітры паяўляюцца на %d мс раней + Выкарыстоўвайце, калі субцітры паяўляюцца на %d мс пазней + Без затрымкі субцітраў + У цэху з’яўляецца чорт, кроўю і пугай фарбуе свежы шампіньён + Рэкамендавана + Загружана %s + Загрузіць з файла + Спампаваць з інтэрнэту + Спампаваць першыя даступныя + Спампаваны файл + Галоўны + Другасны + Фонавы + Крыніца + Выпадковае + Скора… + Cam + Cam + Cam + HQ + HD + TS + TC + Blu-ray + WP + DVD + 4K + SD + UHD + HDR + SDR + Web + Відарыс плаката + Відарыс QR-кода + Прайгравальнік + Раздзяляльнасць і загаловак + Загаловак + Раздзяляльнасць + Звесткі пра медыя + Памылковы ID + Памылковыя даныя + Памылковы URL-адрэс + Памылка + Прыбраць схаваныя цітры з субцітраў + Прыбраць раздуванне з субцітраў + Фільтраваць па прыярытэтнай мове медыя + Дадатковае + Трэйлер + https://pryklad.by/pryklad.mp4 + Referer (неабавязкова) + Далей + Праглядвайце відэа на гэтых мовах + Назад + Прапусціць наладжванне + Змяніце выгляд праграмы пад вашу прыладу + Што хочаце ўбачыць + Гатова + Пашырэнні + Дадаць рэпазіторый + Назва рэпазіторыя (неабавязкова) + URL-адрас рэпазіторыя або кароткі код + Убудова загружана + Убудава спампавана + Убудова выдалена + Не ўдалося загрузіць %s + 18+ + Пачатак спампоўвання %1$d%2$s… + %1$d%2$s спампавана + Усе %s ужо спампаваныя + У рэпазіторыі не знойдзена ўбудоў + Рэпазіторый не знойдзены, праверце URL-адрас і паспрабуйце VPN + Пакетная спампоўка + убудова + убудовы + Гэта таксама прывядзе да выдалення ўсіх убудоў рэпазіторыя + Выдаліць рэпазіторый + Выдаліць убудову + Спампуйце спіс вэб-сайтаў, якімі вы хочаце карыстацца + Спампавана: %d + Выключана: %d + Не спампавана: %d + Абноўлена %d плагіна(ў) + Прадвызначана на CloudStream няма ўсталяваных вэб-сайтаў. Вам трэба ўсталяваць вэб-сайты з рэпазіторыяў. \n \nДалучыцеся да нашага сервера Discord або пашукайце ў сетцы. + Праглядзець рэпазіторыі ад супольнасці + Публічны спіс + Усе субцітры верхнім рэгістрам + Увага: CloudStream не нясе адказнасці за выкарыстанне старонніх пашырэнняў і не пастаўляе для іх ніякай падтрымцы! + %s (выключана) + Трэкі + Аўдыятрэкі + Відэатрэкі + Перазапусціце праграму, каб убачыць змены. + Перазапусціць + Спыніць + Бяспечны рэжым уключаны + Усе пашырэнні былі выключаны праз збой, каб вы змаглі знайсці, якое з іх выклікае праблемы. + Праглядзець звесткі пра збой + Рэйтынг: %s + Апісанне + Версія + Стан + Памер + Аўтары + Падтрымлівае + Мова + Спачатку ўсталюйце пашырэнне + Плэй-ліст HLS + Прыярытэтны прайгравальнік + Убудаваны прайгравальнік + Заўсёды пытацца + Выбраць прыладу для трансляцыі + Праграмы не знойдзена + Усе мовы + Прапусціць %s + Опенінг + Заканчэнне + Зводка + Змешанае заканчэнне + Змешаны опенінг + Удзельнікі + Застаўка + Ачысціць гісторыю + Гісторыя + Паказваць усплывальнае акно для пропуску опенінга/заканчэння + Надта шмат тэксту. Не ўдалося захаваць да буфера абмену. + Памылка доступу да буфера абмену, паспрабуйце яшчэ раз. + Памылка пры капіраванні, скапіруйце logcat і звярніцеся ў падтрымку. + Пазначыць як прагледжанае + Прыбраць з прагледжанага + Вы ўпэўнены, што хочаце выйсці? + Так + Не + ОК + Адхіліць + Адкрыць рэпазіторый + Выключыць аптымізацыю батарэі + Каб забяспечыць бесперапынныя спампоўкі і апавяшчэнні аб тэлеперадачах, на якія вы падпісаны, CloudStream патрэбны дазвол на выкананне ў фонавым рэжыме. Па націсканні «ОК», вам пакажацца дыялогавае акно. Націсніце «Дазволіць».\n\nЗвярніце ўвагу — гэта не значыць, што CS3 будзе садзіць вашу батарэю. Праграма будзе працаваць у фоне толькі калі патрэбна, напрыклад пры атрыманні апавяшчэнняў або спампоўванні відэа з афіцыйных пашырэнняў. + Выкарыстанне батарэі ўжо выстаўлена як неабмежаванае + Не ўдалося адкрыць звесткі пра праграму CloudStream. + Спампоўванне абнаўлення праграмы… + Усталяванне абнаўлення праграмы… + Не ўдалося ўсталяваць новую версію праграмы + Састарэлая версія + Усталёўшчык пакетаў + Праграма будзе абноўлена пасля выхаду + Сартаваць па + Сартаваць + Рэйтынгу (ад высокага да нізкага) + Рэйтынгу (ад нізкага да высокага) + Абнаўленню (ад новага да старога) + Абнаўленню (ад старога да новага) + Алфавіту (ад А да Я) + Алфавіту (ад Я да А) + Серыі (па ўзрастанні) + Серыі (па ўбыванні) + Рэйтынгу (найвышэйшы) + Рэйтынгу (найніжэйшы) + Даце паказу (найноўшыя) + Даце паказу (найстарэйшыя) + Сер. %s + Рэйтынг %s + Дата %s + Выбраць бібліятэку + Адкрыць праз + Ваша бібліятэка пустая :( \nУвайдзіце ва ўліковы запіс з бібліятэкай або дадайце праграмы да вашай лакальнай бібліятэкі. + Гэты спіс пусты. Паспрабуйце пераключыцца на іншы. + Знойдзены файл бяспечнага рэжыму! \nПашырэнні не будуць загружацца на запуску, пакуль файл не будзе выдалены. + Вярнуць + Ідзе абнаўленне падпісак + Вы падпісаны + Вы падпісаліся на %s + Вы адпісаліся ад %s + Выпушчана серыя %d! + Падпісацца + Адпісацца + Профіль %d + Wi-Fi + Мабільная перадача даных + Выбраць як прадвызначаны + Выкарыстоўваць + Рэдагаваць + Профілі + Даведка + Тут можна змяніць парадак крыніц. Калі ў відэа большы прыярытэт, яно будзе паказвацца вышэй пры выбары крыніцы. Прыярытэтам відэа з\'яўляецца сума прыярытэту крыніцы і прыярытэту якасці.\n\nКрыніца А: 3\nЯкасць Б: 7\nПрыярытэт відэа будзе 10.\n\nЗАЎВАГА: Калі сума раўняецца 10 ці болей прайгравальнік аўтаматычна прапусціць загрузку для гэтай спасылцы! + Якасці + Фон профілю + Не ўдалося карэктна стварыць інтэрфейс, гэта СУР\'ЁЗНАЯ ХІБА, пра якую варта неадкладна паведаміць %s + Вы ўжо прагаласавалі + Абраныя + %s дададзена ў абранае + %s прыбрана з абранага + Дадаць у абранае + Прыбраць з абранага + Знойдзены магчымы дублікат + Дадаць + Замяніць + Замяніць усё + Падобна на тое, што ў вашай бібліятэцы ёсць дублікат гэтага элемента: ‹%s.›\n\nЦі хочаце вы ўсё роўна яго дадаць, замяніць ужо існуючы, або скасаваць дзеянне? + У вашай бібліятэцы знойдзены магчымыя дублікаты: \n\n%s \n\nЦі хочаце вы ўсё роўна яго дадаць, замяніць ужо існуючыя, або скасаваць дзеянне? + Увядзіце PIN-код + Увядзіце PIN-код для %s + Увядзіце бягучы PIN-код + Заблакіраваць профіль + PIN-код + Няправільны PIN-код. Паспрабуйце яшчэ раз. + PIN-код павінны быць з 4 сімвалаў + Выберыце ўліковы запіс + Без ўліковага запісу + Кіраванне ўліковымі запісамі + Рэдагаваць уліковы запіс + Вы ўвайшлі як %s + Прапускаць выбар уліковага запісу пры запуске + Выкарыстоўваць прадвызначаны ўліковы запіс + Павярнуць + Паказваць кнопку пераключэння арыентацыі экрана + Уключыць аўтаматычнае пераключэнне арыентацыі экрана ў залежнасці ад арыентацыі відэа + Аўтапаварот + Дадаць у абраныя + Выдаліць з абранага + Разблакіруйце CloudStream + Блакіроўка біяметрыяй + Праверка сапраўднасці паролем/PIN-кодам + Праверка сапраўднасці біяметрыяй не падтрымліваецца на гэтай прыладзе + Разблакіруйце праграму адбіткам пальца, Face ID, PIN-кодам, узорам разблакіроўкі або паролем. + Праз некалькі няўдалых спроб акно з запытам закрыецца. Проста перазапусціце праграму, каб паўтарыць спробу. + Вашы даныя CloudStream былі зарэзерваваныя. Нягледзячы на тое, што магчымасць вельмі маленькая, усе прылады могуць паводзіць сябе па-рознаму. У рэдкасным выпадку, калі вы страціце доступ да праграмы, поўнасцю ачысціце даныя і аднавіце іх праз рэзервовую копію. Выбачайце за любую нязручнасць, якая можа з гэтага атрымацца. + Скінуць + CloudStream-Вікі + Наведайце %s на вашым смартфоне або камп\'ютары і ўвядзіце код вышэй + Не ўдалося атрымаць PIN-код прылады, паспрабуйце лакальную праверку сапраўднасці + PIN-код састарэў! + Код мінуе праз %1$dхв %2$dс + Даце выпуску (ад новага да старога) + Даце выпуску (ад старога да новага) + Схаваць назвы элементаў кіравання прайгравальніка + Перадпрагляд на шкале часу + Уключыць мініяцюру папярэдняга прагляду на шкале часу + Субцітраў яшчэ не загружана + Размяшчэнне папкі з рэзервовымі копіямі + Уласнае + Пацвярджэнне перад выхадам + Паказваць дыялог перад выхадам з праграмы + Паказваць + Не паказваць + Памер краёў + Уключыце Torrent у Наладах/Пастаўшчыкі/Прыярытэтнае медыя + Перазапусціце праграму і прыміце ўсплывальнае акно «Трансліраваць Torrent», каб працягнуць. + Праграмная дэкадзіроўка + Праграмная дэкадзіроўка дазваляе прайгравальніку паказваць відэафайлы, якія не падтрымліваюцца на вашай прыладзе, але можа выклікаць затрымкі або рабіць прайграванне нестабільным на высокіх раздзяляльнасцях. + Гучнасць перавысіла 100% + Правядзіце пальцам уверх яшчэ раз, каб зрабіць гучнасць больш за 100% + Абнавіць убудовы + Абнавіць убудовы ўручную + Пачынаецца абнаўленне ўбудоў! + Абноўлена %d убудова(ы/ў)! + Не абнавілася ніводная ўбудова. + Апавяшчэнні прайгравальніка + Апавяшчэнне прайгравальніка для кіравання прайграваннем у фонавым рэжыме + Убудаваны + Сеткавы + Зрабіць усе субцітры тоўстымі + Зрабіць усе субцітры курсіўнымі + Радыус фону + Колькі разных элементаў можна спампоўваць паралельна + Паралельныя спампоўкі + Адначасовыя злучэнні + Колькі адначасовых злучэнняў можа выкарыстоўваць кожнае спампоўванне + Перайсці ў спампоўкі + Інтэрнет-злучэнне адсутнічае.\n\nЗлучыцеся з інтэрнэтам і паспрабуйце яшчэ раз, або паглядзіце свае спампоўкі, пакуль вы па-за сеткай. + Змяняе краі экрана + Абрэзка відарыса + Змяняе памер плакатаў + Памер плаката + Пераключэнне хуткасці пры доўгім націсканні + Утрымлівайце, каб пераключыцца на 2-кратную хуткасць + Рэдагаваць відарыс профілю + Увядзіце URL-адрас відарыса профілю + URL-адрас не знойдзены + Памылковы URL-адрас або відарыс + Відарыс абноўлены + Пазначыць прагледжаным усё да гэтай серыі + Прыбраць пазнаку «Прагледжана» з усіх серый да гэтай + Перазагружана + Перазагрузіць пастаўшчыка + Назва + Назва крыніцы + Раздзяляльнасць і назва + Выраўноўванне + Знізу злева + Знізу па цэнтру + Знізу справа + У сярэдзіне злева + У сярэдзіне па цэнтру + У сярэдзіне справа + Зверху злева + Зверху па цэнтру + Зверху справа diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a80b9120c..a1a4fdc3f 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -201,9 +201,9 @@ Show app updates Automatically search for new updates after starting the app. Redo setup process - Install pre-release version - Pre-release is already installed. - Failed to install pre-release. + Install pre-release version + Pre-release is already installed. + Failed to install pre-release. APK Installer Some devices do not support the new package installer. Try the legacy option if updates do not install. Github @@ -771,12 +771,10 @@ Top left Top center Top right - %d active download %d active downloads - %d download queued %d downloads queued From 6e423ba24e1393cc6d09d0aacea3920e22af840b Mon Sep 17 00:00:00 2001 From: Saurabh Kaperwan <91174352+SaurabhKaperwan@users.noreply.github.com> Date: Sat, 21 Feb 2026 04:32:55 +0530 Subject: [PATCH 050/236] Add multiple extractors new domains (#2516) --- .../cloudstream3/extractors/DoodExtractor.kt | 8 +++++++ .../cloudstream3/extractors/Filesim.kt | 4 +++- .../cloudstream3/extractors/LuluStream.kt | 7 +++++- .../cloudstream3/extractors/MixDrop.kt | 18 ++++++++++++++- .../cloudstream3/extractors/StreamTape.kt | 4 ++++ .../cloudstream3/extractors/VidHidePro.kt | 8 +++++++ .../cloudstream3/utils/ExtractorApi.kt | 23 ++++++++++++++++++- 7 files changed, 68 insertions(+), 4 deletions(-) diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/DoodExtractor.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/DoodExtractor.kt index a686cdd83..0cef9eb4c 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/DoodExtractor.kt +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/DoodExtractor.kt @@ -10,6 +10,14 @@ import com.lagradost.cloudstream3.utils.newExtractorLink import java.net.URI import kotlin.random.Random +class Doodspro : DoodLaExtractor() { + override var mainUrl = "https://doods.pro" +} + +class Dsvplay : DoodLaExtractor() { + override var mainUrl = "https://dsvplay.com" +} + class D0000d : DoodLaExtractor() { override var mainUrl = "https://d0000d.com" } diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Filesim.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Filesim.kt index 4c5352dd9..8c0cbec32 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Filesim.kt +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Filesim.kt @@ -6,7 +6,9 @@ import com.lagradost.cloudstream3.utils.* import com.lagradost.api.Log import com.lagradost.cloudstream3.network.WebViewResolver - +class Multimoviesshg : Filesim() { + override var mainUrl = "https://multimoviesshg.com" +} class Guccihide : Filesim() { override val name = "Guccihide" diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/LuluStream.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/LuluStream.kt index c130ca2de..c7b658606 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/LuluStream.kt +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/LuluStream.kt @@ -8,6 +8,11 @@ import com.lagradost.cloudstream3.utils.INFER_TYPE import com.lagradost.cloudstream3.utils.Qualities import com.lagradost.cloudstream3.utils.newExtractorLink + +class Luluvdoo : LuluStream() { + override var mainUrl = "https://luluvdoo.com" +} + class Lulustream1 : LuluStream() { override val name = "Lulustream" override val mainUrl = "https://lulustream.com" @@ -56,4 +61,4 @@ open class LuluStream : ExtractorApi() { } } } -} \ No newline at end of file +} diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/MixDrop.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/MixDrop.kt index 746dc7eb3..75ac299d8 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/MixDrop.kt +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/MixDrop.kt @@ -3,6 +3,22 @@ package com.lagradost.cloudstream3.extractors import com.lagradost.cloudstream3.app import com.lagradost.cloudstream3.utils.* +class MixDropPs : MixDrop() { + override var mainUrl = "https://mixdrop.ps" +} + +class Mdy : MixDrop() { + override var mainUrl = "https://mdy48tn97.com" +} + +class MxDropTo : MixDrop() { + override var mainUrl = "https://mxdrop.to" +} + +class MixDropSi : MixDrop() { + override var mainUrl = "https://mixdrop.si" +} + class MixDropBz : MixDrop(){ override var mainUrl = "https://mixdrop.bz" } @@ -47,4 +63,4 @@ open class MixDrop : ExtractorApi() { } return null } -} \ No newline at end of file +} diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/StreamTape.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/StreamTape.kt index 47fb96a89..211b5ecf9 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/StreamTape.kt +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/StreamTape.kt @@ -7,6 +7,10 @@ import com.lagradost.cloudstream3.utils.Qualities import com.lagradost.cloudstream3.utils.newExtractorLink import org.mozilla.javascript.Context +class Watchadsontape : StreamTape() { + override var mainUrl = "https://watchadsontape.com" +} + class StreamTapeNet : StreamTape() { override var mainUrl = "https://streamtape.net" } diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/VidHidePro.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/VidHidePro.kt index cce5b01b3..469efc5ec 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/VidHidePro.kt +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/VidHidePro.kt @@ -6,6 +6,14 @@ import com.lagradost.cloudstream3.app import com.lagradost.cloudstream3.utils.* import com.lagradost.cloudstream3.utils.M3u8Helper.Companion.generateM3u8 +class Ryderjet: VidHidePro() { + override var mainUrl = "https://ryderjet.com" +} + +class VidHideHub : VidHidePro() { + override var mainUrl = "https://vidhidehub.com" +} + class VidHidePro1 : VidHidePro() { override var mainUrl = "https://filelions.live" } diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/ExtractorApi.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/ExtractorApi.kt index 9ef0eec45..d1749cf85 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/ExtractorApi.kt +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/ExtractorApi.kt @@ -57,6 +57,8 @@ import com.lagradost.cloudstream3.extractors.DoodWatchExtractor import com.lagradost.cloudstream3.extractors.DoodWfExtractor import com.lagradost.cloudstream3.extractors.DoodWsExtractor import com.lagradost.cloudstream3.extractors.DoodYtExtractor +import com.lagradost.cloudstream3.extractors.Doodspro +import com.lagradost.cloudstream3.extractors.Dsvplay import com.lagradost.cloudstream3.extractors.Doodporn import com.lagradost.cloudstream3.extractors.DoodstreamCom import com.lagradost.cloudstream3.extractors.Dooood @@ -82,6 +84,7 @@ import com.lagradost.cloudstream3.extractors.FileMoonSx import com.lagradost.cloudstream3.extractors.Filegram import com.lagradost.cloudstream3.extractors.FilemoonV2 import com.lagradost.cloudstream3.extractors.Filesim +import com.lagradost.cloudstream3.extractors.Multimoviesshg import com.lagradost.cloudstream3.extractors.FlaswishCom import com.lagradost.cloudstream3.extractors.FourCX import com.lagradost.cloudstream3.extractors.FourPichive @@ -134,6 +137,7 @@ import com.lagradost.cloudstream3.extractors.Linkbox import com.lagradost.cloudstream3.extractors.LuluStream import com.lagradost.cloudstream3.extractors.Lulustream1 import com.lagradost.cloudstream3.extractors.Lulustream2 +import com.lagradost.cloudstream3.extractors.Luluvdoo import com.lagradost.cloudstream3.extractors.Luxubu import com.lagradost.cloudstream3.extractors.Lvturbo import com.lagradost.cloudstream3.extractors.MailRu @@ -149,6 +153,10 @@ import com.lagradost.cloudstream3.extractors.MixDropAg import com.lagradost.cloudstream3.extractors.MixDropBz import com.lagradost.cloudstream3.extractors.MixDropCh import com.lagradost.cloudstream3.extractors.MixDropTo +import com.lagradost.cloudstream3.extractors.MixDropPs +import com.lagradost.cloudstream3.extractors.Mdy +import com.lagradost.cloudstream3.extractors.MixDropSi +import com.lagradost.cloudstream3.extractors.MxDropTo import com.lagradost.cloudstream3.extractors.Movhide import com.lagradost.cloudstream3.extractors.Moviehab import com.lagradost.cloudstream3.extractors.MoviehabNet @@ -224,6 +232,7 @@ import com.lagradost.cloudstream3.extractors.StreamSilk import com.lagradost.cloudstream3.extractors.StreamTape import com.lagradost.cloudstream3.extractors.StreamTapeNet import com.lagradost.cloudstream3.extractors.StreamTapeXyz +import com.lagradost.cloudstream3.extractors.Watchadsontape import com.lagradost.cloudstream3.extractors.StreamWishExtractor import com.lagradost.cloudstream3.extractors.StreamhideCom import com.lagradost.cloudstream3.extractors.StreamhideTo @@ -272,6 +281,8 @@ import com.lagradost.cloudstream3.extractors.VidHidePro3 import com.lagradost.cloudstream3.extractors.VidHidePro4 import com.lagradost.cloudstream3.extractors.VidHidePro5 import com.lagradost.cloudstream3.extractors.VidHidePro6 +import com.lagradost.cloudstream3.extractors.VidHideHub +import com.lagradost.cloudstream3.extractors.Ryderjet import com.lagradost.cloudstream3.extractors.VidMoxy import com.lagradost.cloudstream3.extractors.VidStack import com.lagradost.cloudstream3.extractors.VideoSeyred @@ -945,14 +956,18 @@ val extractorApis: MutableList = arrayListOf( StreamTapeNet(), ShaveTape(), StreamTapeXyz(), + Watchadsontape(), //mixdrop extractors MixDropBz(), MixDropCh(), MixDropTo(), MixDropAg(), - MixDrop(), + MixDropPs(), + Mdy(), + MxDropTo(), + MixDropSi(), Mcloud(), XStreamCdn(), @@ -1056,6 +1071,8 @@ val extractorApis: MutableList = arrayListOf( DoodWatchExtractor(), DoodWfExtractor(), DoodYtExtractor(), + Doodspro(), + Dsvplay(), AsianLoad(), @@ -1121,6 +1138,7 @@ val extractorApis: MutableList = arrayListOf( FileMoonIn(), Moviesm4u(), Filesim(), + Multimoviesshg(), Ahvsh(), Guccihide(), FileMoon(), @@ -1188,6 +1206,8 @@ val extractorApis: MutableList = arrayListOf( VidHidePro4(), VidHidePro5(), VidHidePro6(), + VidHideHub(), + Ryderjet(), Dhtpre(), // CineMM Redirects @@ -1211,6 +1231,7 @@ val extractorApis: MutableList = arrayListOf( LuluStream(), Lulustream1(), Lulustream2(), + Luluvdoo(), StreamWishExtractor(), StreamHLS(), BigwarpIO(), From 8e71baeb847530e2e3c226cb004a5217a5f2ed8d Mon Sep 17 00:00:00 2001 From: Phisher98 <153359846+phisher98@users.noreply.github.com> Date: Sat, 21 Feb 2026 04:40:41 +0530 Subject: [PATCH 051/236] Make video info slightly dimmer (#2508) --- app/src/main/res/layout/player_custom_layout_tv.xml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/src/main/res/layout/player_custom_layout_tv.xml b/app/src/main/res/layout/player_custom_layout_tv.xml index 450b77c70..c096ec4d9 100644 --- a/app/src/main/res/layout/player_custom_layout_tv.xml +++ b/app/src/main/res/layout/player_custom_layout_tv.xml @@ -325,11 +325,11 @@ android:layout_height="wrap_content" android:layout_marginStart="6dp" android:layout_marginBottom="2.5dp" - android:textColor="@color/white" + android:layout_gravity="end" + android:textColor="#B3FFFFFF" android:textSize="16sp" android:visibility="gone" - android:layout_gravity="end" - tools:text="HDR10 • HEVC" /> + tools:text="HEVC • English • 5.1 • E-AC3" /> Date: Sun, 1 Mar 2026 02:55:44 +0530 Subject: [PATCH 052/236] Removing default headers that caused some streams to return 2004. (#2533) --- .../com/lagradost/cloudstream3/ui/player/CS3IPlayer.kt | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/CS3IPlayer.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/CS3IPlayer.kt index fdcbb044c..409ac9374 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/CS3IPlayer.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/CS3IPlayer.kt @@ -824,14 +824,7 @@ class CS3IPlayer : IPlayer { // These are extra headers the browser like to insert, not sure if we want to include them // for WIDEVINE/drm as well? Do that if someone gets 404 and creates an issue. - val headers = mapOf( - "accept" to "*/*", - "sec-ch-ua" to "\"Chromium\";v=\"91\", \" Not;A Brand\";v=\"99\"", - "sec-ch-ua-mobile" to "?0", - "sec-fetch-user" to "?1", - "sec-fetch-mode" to "navigate", - "sec-fetch-dest" to "video" - ) + refererMap + link.headers // Adds the headers from the provider, e.g Authorization + val headers = refererMap + link.headers // Adds the headers from the provider, e.g Authorization return source.apply { setDefaultRequestProperties(headers) From 9f2067bbffc6a391b9549e990df2c6c2004d5a5a Mon Sep 17 00:00:00 2001 From: Saurabh Kaperwan <91174352+SaurabhKaperwan@users.noreply.github.com> Date: Sun, 1 Mar 2026 03:24:34 +0530 Subject: [PATCH 053/236] fix gofile extractor (#2525) * fix gofile extractor * minor fix --- .../cloudstream3/extractors/Gofile.kt | 88 +++++++++++++------ 1 file changed, 62 insertions(+), 26 deletions(-) diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Gofile.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Gofile.kt index defd6f698..ced827eec 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Gofile.kt +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Gofile.kt @@ -5,6 +5,7 @@ import com.lagradost.cloudstream3.SubtitleFile import com.lagradost.cloudstream3.app import com.lagradost.cloudstream3.utils.ExtractorApi import com.lagradost.cloudstream3.utils.ExtractorLink +import com.lagradost.cloudstream3.utils.ExtractorLinkType import com.lagradost.cloudstream3.utils.Qualities import com.lagradost.cloudstream3.utils.newExtractorLink @@ -20,27 +21,45 @@ open class Gofile : ExtractorApi() { subtitleCallback: (SubtitleFile) -> Unit, callback: (ExtractorLink) -> Unit ) { - val id = Regex("/(?:\\?c=|d/)([\\da-zA-Z-]+)").find(url)?.groupValues?.get(1) - val token = app.get("$mainApi/createAccount").parsedSafe()?.data?.get("token") - val websiteToken = app.get("$mainUrl/dist/js/alljs.js").text.let { - Regex("fetchData.wt\\s*=\\s*\"([^\"]+)").find(it)?.groupValues?.get(1) - } - app.get("$mainApi/getContent?contentId=$id&token=$token&wt=$websiteToken") - .parsedSafe()?.data?.contents?.forEach { - callback.invoke( - newExtractorLink( - this.name, - this.name, - it.value["link"] ?: return, - ) { - this.quality = getQuality(it.value["name"]) - this.headers = mapOf( - "Cookie" to "accountToken=$token" - ) - } - ) - } + val id = Regex("/(?:\\?c=|d/)([\\da-zA-Z-]+)").find(url)?.groupValues?.get(1) ?: return + val token = app.post( + "$mainApi/accounts", + ).parsedSafe()?.data?.token ?: return + + val globalRes = app.get("$mainUrl/dist/js/config.js").text + val wt = Regex("""appdata\.wt\s*=\s*[\"']([^\"']+)[\"']""").find(globalRes)?.groupValues?.get(1) ?: return + + val headers = mapOf( + "Authorization" to "Bearer $token", + "X-Website-Token" to wt + ) + + val parsedResponse = app.get( + "$mainApi/contents/$id?contentFilter=&page=1&pageSize=1000&sortField=name&sortDirection=1", + headers = headers + ).parsedSafe() + + val childrenMap = parsedResponse?.data?.children ?: return + + for ((_, file) in childrenMap) { + if (file.link.isNullOrEmpty() || file.type != "file") continue + val fileName = file.name ?: "" + val size = file.size ?: 0L + val formattedSize = formatBytes(size) + + callback.invoke( + newExtractorLink( + "Gofile", + "[Gofile] $fileName [$formattedSize]", + file.link, + ExtractorLinkType.VIDEO + ) { + this.quality = getQuality(fileName) + this.headers = mapOf("Cookie" to "accountToken=$token") + } + ) + } } private fun getQuality(str: String?): Int { @@ -48,16 +67,33 @@ open class Gofile : ExtractorApi() { ?: Qualities.Unknown.value } - data class Account( - @JsonProperty("data") val data: HashMap? = null, + private fun formatBytes(bytes: Long): String { + return when { + bytes < 1024L * 1024 * 1024 -> "%.2f MB".format(bytes.toDouble() / (1024 * 1024)) + else -> "%.2f GB".format(bytes.toDouble() / (1024 * 1024 * 1024)) + } + } + + data class AccountResponse( + @JsonProperty("data") val data: AccountData? = null ) - data class Data( - @JsonProperty("contents") val contents: HashMap>? = null, + data class AccountData( + @JsonProperty("token") val token: String? = null ) - data class Source( - @JsonProperty("data") val data: Data? = null, + data class GofileResponse( + @JsonProperty("data") val data: GofileData? = null ) + data class GofileData( + @JsonProperty("children") val children: Map? = null + ) + + data class GofileFile( + @JsonProperty("type") val type: String? = null, + @JsonProperty("name") val name: String? = null, + @JsonProperty("link") val link: String? = null, + @JsonProperty("size") val size: Long? = 0L + ) } From a65828e2b093f4deca3da7b211254b06c9c382fb Mon Sep 17 00:00:00 2001 From: DieGon7771 Date: Sat, 28 Feb 2026 22:57:01 +0100 Subject: [PATCH 054/236] Fix trailer zoom not resetting after fullscreen exit (#2512) --- .../lagradost/cloudstream3/ui/player/FullScreenPlayer.kt | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/FullScreenPlayer.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/FullScreenPlayer.kt index 2ba4c691c..2dfbb5598 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/FullScreenPlayer.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/FullScreenPlayer.kt @@ -615,6 +615,7 @@ open class FullScreenPlayer : AbstractPlayerFragment() { } protected fun exitFullscreen() { + resetZoomToDefault() // if (lockRotation) activity?.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_USER @@ -628,6 +629,9 @@ open class FullScreenPlayer : AbstractPlayerFragment() { activity?.window?.attributes = lp activity?.showSystemUI() } + private fun resetZoomToDefault() { + if (zoomMatrix != null) resize(PlayerResize.Fit, false) + } override fun onResume() { enterFullscreen() @@ -2649,4 +2653,4 @@ open class FullScreenPlayer : AbstractPlayerFragment() { .start() } } -} \ No newline at end of file +} From ef9e49d955333a3a4fa410f88f20e3ba6fea7144 Mon Sep 17 00:00:00 2001 From: saimuel Date: Sat, 28 Feb 2026 18:58:33 -0300 Subject: [PATCH 055/236] Remove dead Extractors (#2511) --- .../cloudstream3/extractors/AStreamHub.kt | 41 --- .../cloudstream3/extractors/BullStream.kt | 29 -- .../cloudstream3/extractors/ByteShare.kt | 21 -- .../cloudstream3/extractors/EPlay.kt | 27 -- .../cloudstream3/extractors/Filegram.kt | 60 ---- .../cloudstream3/extractors/GMPlayer.kt | 47 --- .../cloudstream3/extractors/GuardareStream.kt | 89 ------ .../cloudstream3/extractors/Jawcloud.kt | 27 -- .../cloudstream3/extractors/Okrulink.kt | 37 --- .../cloudstream3/extractors/Solidfiles.kt | 47 --- .../cloudstream3/extractors/Tomatomatela.kt | 61 ---- .../extractors/{AsianLoad.kt => VidNest.kt} | 11 +- .../cloudstream3/extractors/VideoVard.kt | 271 ------------------ .../cloudstream3/extractors/Vidguard.kt | 139 --------- .../cloudstream3/extractors/WcoStream.kt | 142 --------- .../cloudstream3/extractors/Zorofile.kt | 76 ----- .../cloudstream3/utils/ExtractorApi.kt | 78 +---- 17 files changed, 5 insertions(+), 1198 deletions(-) delete mode 100644 library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/AStreamHub.kt delete mode 100644 library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/BullStream.kt delete mode 100644 library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/ByteShare.kt delete mode 100644 library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/EPlay.kt delete mode 100644 library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Filegram.kt delete mode 100644 library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/GMPlayer.kt delete mode 100644 library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/GuardareStream.kt delete mode 100644 library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Jawcloud.kt delete mode 100644 library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Okrulink.kt delete mode 100644 library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Solidfiles.kt delete mode 100644 library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Tomatomatela.kt rename library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/{AsianLoad.kt => VidNest.kt} (89%) delete mode 100644 library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/VideoVard.kt delete mode 100644 library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Vidguard.kt delete mode 100644 library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/WcoStream.kt delete mode 100644 library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Zorofile.kt diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/AStreamHub.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/AStreamHub.kt deleted file mode 100644 index 7575a6347..000000000 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/AStreamHub.kt +++ /dev/null @@ -1,41 +0,0 @@ -package com.lagradost.cloudstream3.extractors - -import com.lagradost.api.Log -import com.lagradost.cloudstream3.app -import com.lagradost.cloudstream3.utils.ExtractorApi -import com.lagradost.cloudstream3.utils.ExtractorLink -import com.lagradost.cloudstream3.utils.ExtractorLinkType -import com.lagradost.cloudstream3.utils.Qualities -import com.lagradost.cloudstream3.utils.newExtractorLink - -open class AStreamHub : ExtractorApi() { - override val name = "AStreamHub" - override val mainUrl = "https://astreamhub.com" - override val requiresReferer = true - - override suspend fun getUrl(url: String, referer: String?): List { - val sources = mutableListOf() - app.get(url).document.selectFirst("body > script")?.data()?.let { script -> - Log.i("Dev", "script => $script") - if (script.isNotBlank()) { - val m3link = "(?<=file:)(.*)(?=,)".toRegex().find(script) - ?.groupValues?.get(0)?.trim()?.trim('"') ?: "" - Log.i("Dev", "m3link => $m3link") - if (m3link.isNotBlank()) { - sources.add( - newExtractorLink( - name = name, - source = name, - url = m3link, - type = ExtractorLinkType.M3U8 - ) { - this.quality = Qualities.Unknown.value - this.referer = referer ?: url - } - ) - } - } - } - return sources - } -} \ No newline at end of file diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/BullStream.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/BullStream.kt deleted file mode 100644 index 71fa7066b..000000000 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/BullStream.kt +++ /dev/null @@ -1,29 +0,0 @@ -package com.lagradost.cloudstream3.extractors - -import com.lagradost.cloudstream3.app -import com.lagradost.cloudstream3.utils.ExtractorApi -import com.lagradost.cloudstream3.utils.ExtractorLink -import com.lagradost.cloudstream3.utils.M3u8Helper - -open class BullStream : ExtractorApi() { - override val name = "BullStream" - override val mainUrl = "https://bullstream.xyz" - override val requiresReferer = false - val regex = Regex("(?<=sniff\\()(.*)(?=\\)\\);)") - - override suspend fun getUrl(url: String, referer: String?): List? { - val data = regex.find(app.get(url).text)?.value - ?.replace("\"", "") - ?.split(",") - ?: return null - - val m3u8 = "$mainUrl/m3u8/${data[1]}/${data[2]}/master.txt?s=1&cache=${data[4]}" - //println("shiv : $m3u8") - return M3u8Helper.generateM3u8( - name, - m3u8, - url, - headers = mapOf("referer" to url, "accept" to "*/*") - ) - } -} \ No newline at end of file diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/ByteShare.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/ByteShare.kt deleted file mode 100644 index ca9504d2c..000000000 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/ByteShare.kt +++ /dev/null @@ -1,21 +0,0 @@ -package com.lagradost.cloudstream3.extractors - -import com.lagradost.cloudstream3.utils.* - -open class ByteShare : ExtractorApi() { - override val name = "ByteShare" - override val mainUrl = "https://byteshare.to" - override val requiresReferer = false - - override suspend fun getUrl(url: String, referer: String?): List { - val sources = mutableListOf() - sources.add( - newExtractorLink( - source = name, - name = name, - url = url.replace("/embed/", "/download/"), - ) - ) - return sources - } -} diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/EPlay.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/EPlay.kt deleted file mode 100644 index 146859cf4..000000000 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/EPlay.kt +++ /dev/null @@ -1,27 +0,0 @@ -package com.lagradost.cloudstream3.extractors - -import com.fasterxml.jackson.annotation.JsonProperty -import com.lagradost.cloudstream3.app -import com.lagradost.cloudstream3.utils.* -import com.lagradost.cloudstream3.utils.AppUtils.tryParseJson - -open class EPlayExtractor : ExtractorApi() { - override var name = "EPlay" - override var mainUrl = "https://eplayvid.net" - override val requiresReferer = true - - override suspend fun getUrl(url: String, referer: String?): List? { - val response = app.get(url).document - val trueUrl = response.select("source").attr("src") - return listOf( - newExtractorLink( - this.name, - this.name, - trueUrl, - ) { - this.referer = mainUrl - this.quality = getQualityFromName("") // this needs to be auto - } - ) - } -} diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Filegram.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Filegram.kt deleted file mode 100644 index 7baa62710..000000000 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Filegram.kt +++ /dev/null @@ -1,60 +0,0 @@ -package com.lagradost.cloudstream3.extractors - -import com.lagradost.cloudstream3.SubtitleFile -import com.lagradost.cloudstream3.USER_AGENT -import com.lagradost.cloudstream3.app -import com.lagradost.cloudstream3.utils.ExtractorApi -import com.lagradost.cloudstream3.utils.ExtractorLink -import com.lagradost.cloudstream3.utils.M3u8Helper -import com.lagradost.cloudstream3.utils.fixUrl -import com.lagradost.cloudstream3.utils.getAndUnpack -import org.jsoup.nodes.Element - -open class Filegram : ExtractorApi() { - override val name = "Filegram" - override val mainUrl = "https://filegram.to" - override val requiresReferer = true - - override suspend fun getUrl( - url: String, - referer: String?, - subtitleCallback: (SubtitleFile) -> Unit, - callback: (ExtractorLink) -> Unit - ) { - val header = mapOf( - "Accept" to "*/*", - "Accept-language" to "en-US,en;q=0.9", - "Origin" to mainUrl, - "Accept-Encoding" to "gzip, deflate, br, zstd", - "Connection" to "keep-alive", - "Sec-Fetch-Dest" to "empty", - "Sec-Fetch-Mode" to "cors", - "Sec-Fetch-Site" to "same-site", - "user-agent" to USER_AGENT, - ) - - val doc = app.get(getEmbedUrl(url), referer = referer).document - val unpackedJs = unpackJs(doc).toString() - val videoUrl = Regex("""file:\s*"([^"]+\.m3u8[^"]*)"""").find(unpackedJs)?.groupValues?.get(1) - if (videoUrl != null) { - M3u8Helper.generateM3u8( - this.name, - fixUrl(videoUrl), - "$mainUrl/", - headers = header - ).forEach(callback) - } - } - - private fun unpackJs(script: Element): String? { - return script.select("script").find { it.data().contains("eval(function(p,a,c,k,e,d)") } - ?.data()?.let { getAndUnpack(it) } - } - - private fun getEmbedUrl(url: String): String { - return if (!url.contains("/embed-")) { - val videoId = url.substringAfter("$mainUrl/") - "$mainUrl/embed-$videoId" - } else url - } -} diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/GMPlayer.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/GMPlayer.kt deleted file mode 100644 index 7aa4ed53f..000000000 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/GMPlayer.kt +++ /dev/null @@ -1,47 +0,0 @@ -package com.lagradost.cloudstream3.extractors - -import com.lagradost.cloudstream3.app -import com.lagradost.cloudstream3.utils.ExtractorApi -import com.lagradost.cloudstream3.utils.ExtractorLink -import com.lagradost.cloudstream3.utils.ExtractorLinkType -import com.lagradost.cloudstream3.utils.Qualities -import com.lagradost.cloudstream3.utils.newExtractorLink - -open class GMPlayer : ExtractorApi() { - override val name = "GM Player" - override val mainUrl = "https://gmplayer.xyz" - override val requiresReferer = true - - override suspend fun getUrl(url: String, referer: String?): List? { - val ref = referer ?: return null - val id = url.substringAfter("/video/").substringBefore("/") - - val m3u8 = app.post( - "$mainUrl/player/index.php?data=$id&do=getVideo", - mapOf( - "accept" to "*/*", - "referer" to ref, - "x-requested-with" to "XMLHttpRequest", - "origin" to mainUrl - ), - data = mapOf("hash" to id, "r" to ref) - ).parsed().videoSource ?: return null - - return listOf( - newExtractorLink( - source = this.name, - name = this.name, - url = m3u8, - type = ExtractorLinkType.M3U8 - ) { - this.referer = ref - this.quality = Qualities.Unknown.value - this.headers = mapOf("accept" to "*/*") - } - ) - } - - private data class GmResponse( - val videoSource: String? = null - ) -} \ No newline at end of file diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/GuardareStream.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/GuardareStream.kt deleted file mode 100644 index c8eccd988..000000000 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/GuardareStream.kt +++ /dev/null @@ -1,89 +0,0 @@ -package com.lagradost.cloudstream3.extractors - -import com.fasterxml.jackson.annotation.JsonProperty -import com.lagradost.cloudstream3.SubtitleFile -import com.lagradost.cloudstream3.newSubtitleFile -import com.lagradost.cloudstream3.utils.ExtractorLink -import com.lagradost.cloudstream3.app -import com.lagradost.cloudstream3.utils.* - -class Vanfem : GuardareStream() { - override var name = "Vanfem" - override var mainUrl = "https://vanfem.com/" -} - -class CineGrabber : GuardareStream() { - override var name = "CineGrabber" - override var mainUrl = "https://cinegrabber.com" -} - -open class GuardareStream : ExtractorApi() { - override var name = "Guardare" - override var mainUrl = "https://guardare.stream" - override val requiresReferer = false - - data class GuardareJsonData( - @JsonProperty("data") val data: List, - @JsonProperty("captions") val captions: List?, - ) - - data class GuardareData( - @JsonProperty("file") val file: String, - @JsonProperty("label") val label: String, - @JsonProperty("type") val type: String - ) - - - // https://cinegrabber.com/asset/userdata/224879/caption/gqdmzh-71ez76z8/876438.srt - data class GuardareCaptions( - @JsonProperty("id") val id: String, - @JsonProperty("hash") val hash: String, - @JsonProperty("language") val language: String?, - @JsonProperty("extension") val extension: String - ) { - fun getUrl(mainUrl: String, userId: String): String { - return "$mainUrl/asset/userdata/$userId/caption/$hash/$id.$extension" - } - } - - override suspend fun getUrl( - url: String, - referer: String?, - subtitleCallback: (SubtitleFile) -> Unit, - callback: (ExtractorLink) -> Unit - ) { - val response = - app.post(url.replace("/v/", "/api/source/"), data = mapOf("d" to mainUrl)).text - - val jsonVideoData = AppUtils.parseJson(response) - jsonVideoData.data.forEach { - callback.invoke( - newExtractorLink( - this.name, - this.name, - it.file + ".${it.type}", - ) { - this.referer = mainUrl - this.quality = it.label.filter { it.isDigit() }.toInt() - } - ) - } - - if (!jsonVideoData.captions.isNullOrEmpty()){ - val iframe = app.get(url) - // var USER_ID = '224879'; - val userIdRegex = Regex("""USER_ID.*?(\d+)""") - val userId = userIdRegex.find(iframe.text)?.groupValues?.getOrNull(1) ?: return - jsonVideoData.captions.forEach { - if (it == null) return@forEach - val subUrl = it.getUrl(mainUrl, userId) - subtitleCallback.invoke( - newSubtitleFile( - it.language ?: "", - subUrl - ) - ) - } - } - } -} \ No newline at end of file diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Jawcloud.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Jawcloud.kt deleted file mode 100644 index 203a266c1..000000000 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Jawcloud.kt +++ /dev/null @@ -1,27 +0,0 @@ -package com.lagradost.cloudstream3.extractors - -import com.lagradost.cloudstream3.app -import com.lagradost.cloudstream3.utils.ExtractorApi -import com.lagradost.cloudstream3.utils.ExtractorLink -import com.lagradost.cloudstream3.utils.M3u8Helper - - -open class Jawcloud : ExtractorApi() { - override var name = "Jawcloud" - override var mainUrl = "https://jawcloud.co" - override val requiresReferer = false - - override suspend fun getUrl(url: String, referer: String?): List? { - val doc = app.get(url).document - val urlString = doc.select("html body div source").attr("src") - val sources = mutableListOf() - if (urlString.contains("m3u8")) - M3u8Helper.generateM3u8( - name, - urlString, - url, - headers = app.get(url).headers.toMap() - ).forEach { link -> sources.add(link) } - return sources - } -} \ No newline at end of file diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Okrulink.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Okrulink.kt deleted file mode 100644 index 2c4701855..000000000 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Okrulink.kt +++ /dev/null @@ -1,37 +0,0 @@ -package com.lagradost.cloudstream3.extractors - -import com.fasterxml.jackson.annotation.JsonProperty -import com.lagradost.cloudstream3.app -import com.lagradost.cloudstream3.utils.ExtractorApi -import com.lagradost.cloudstream3.utils.ExtractorLink -import com.lagradost.cloudstream3.utils.Qualities -import com.lagradost.cloudstream3.utils.newExtractorLink - -data class Okrulinkdata ( - @JsonProperty("status" ) var status : String? = null, - @JsonProperty("url" ) var url : String? = null -) - -open class Okrulink: ExtractorApi() { - override var mainUrl = "https://okru.link" - override var name = "Okrulink" - override val requiresReferer = false - - override suspend fun getUrl(url: String, referer: String?): List { - val sources = mutableListOf() - val key = url.substringAfter("html?t=") - val request = app.post("https://apizz.okru.link/decoding", allowRedirects = false, - data = mapOf("video" to key) - ).parsedSafe() - if (request?.url != null) { - sources.add( - newExtractorLink( - name, - name, - request.url!! - ) - ) - } - return sources - } -} diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Solidfiles.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Solidfiles.kt deleted file mode 100644 index 9c6fd614e..000000000 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Solidfiles.kt +++ /dev/null @@ -1,47 +0,0 @@ -package com.lagradost.cloudstream3.extractors - -import com.fasterxml.jackson.annotation.JsonProperty -import com.lagradost.cloudstream3.app -import com.lagradost.cloudstream3.utils.AppUtils.tryParseJson -import com.lagradost.cloudstream3.utils.ExtractorApi -import com.lagradost.cloudstream3.utils.ExtractorLink -import com.lagradost.cloudstream3.utils.getQualityFromName -import com.lagradost.cloudstream3.utils.newExtractorLink - - -open class Solidfiles : ExtractorApi() { - override val name = "Solidfiles" - override val mainUrl = "https://www.solidfiles.com" - override val requiresReferer = false - - override suspend fun getUrl(url: String, referer: String?): List { - val sources = mutableListOf() - with(app.get(url).document) { - this.select("script").map { script -> - if (script.data().contains("\"streamUrl\":")) { - val data = script.data().substringAfter("constant('viewerOptions', {").substringBefore("});") - val source = tryParseJson("{$data}") - val quality = Regex("\\d{3,4}p").find(source!!.nodeName)?.groupValues?.get(0) - sources.add( - newExtractorLink( - name, - name, - source.streamUrl, - ) { - this.referer = url - this.quality = getQualityFromName(quality) - } - ) - } - } - } - return sources - } - - - private data class ResponseSource( - @JsonProperty("streamUrl") val streamUrl: String, - @JsonProperty("nodeName") val nodeName: String - ) - -} \ No newline at end of file diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Tomatomatela.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Tomatomatela.kt deleted file mode 100644 index b8d131ad9..000000000 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Tomatomatela.kt +++ /dev/null @@ -1,61 +0,0 @@ -package com.lagradost.cloudstream3.extractors - -import com.lagradost.cloudstream3.utils.* -import com.lagradost.cloudstream3.app -import com.fasterxml.jackson.annotation.JsonProperty -import com.fasterxml.jackson.module.kotlin.readValue -import com.lagradost.cloudstream3.USER_AGENT -import com.lagradost.cloudstream3.mapper -import com.lagradost.cloudstream3.utils.AppUtils.parseJson - - -class Cinestart: Tomatomatela() { - override var name: String = "Cinestart" - override val mainUrl: String = "https://cinestart.net" - override val details = "vr.php?v=" -} - -class TomatomatelalClub: Tomatomatela() { - override var name: String = "Tomatomatela" - override val mainUrl: String = "https://tomatomatela.club" -} - -open class Tomatomatela : ExtractorApi() { - override var name = "Tomatomatela" - override val mainUrl = "https://tomatomatela.com" - override val requiresReferer = false - private data class Tomato ( - @JsonProperty("status") val status: Int, - @JsonProperty("file") val file: String? - ) - open val details = "details.php?v=" - open val embeddetails = "/embed.html#" - override suspend fun getUrl(url: String, referer: String?): List? { - val link = url.replace("$mainUrl$embeddetails","$mainUrl/$details") - val sources = ArrayList() - val server = app.get(link, allowRedirects = false, - headers = mapOf( - "User-Agent" to USER_AGENT, - "Accept" to "application/json, text/javascript, */*; q=0.01", - "Accept-Language" to "en-US,en;q=0.5", - "X-Requested-With" to "XMLHttpRequest", - "DNT" to "1", - "Connection" to "keep-alive", - "Sec-Fetch-Dest" to "empty", - "Sec-Fetch-Mode" to "cors", - "Sec-Fetch-Site" to "same-origin" - - ) - ).parsedSafe() - if (server?.file != null) { - sources.add( - newExtractorLink( - name, - name, - server.file, - ) - ) - } - return sources - } -} \ No newline at end of file diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/AsianLoad.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/VidNest.kt similarity index 89% rename from library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/AsianLoad.kt rename to library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/VidNest.kt index 70e869f55..f9d45ebb8 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/AsianLoad.kt +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/VidNest.kt @@ -6,14 +6,9 @@ import com.lagradost.cloudstream3.utils.ExtractorLink import com.lagradost.cloudstream3.utils.M3u8Helper import com.lagradost.cloudstream3.utils.newExtractorLink -class Vidnest : AsianLoad() { - override var name = "Vidnest" +open class VidNest : ExtractorApi() { + override var name = "VidNest" override var mainUrl = "https://vidnest.io" -} - -open class AsianLoad : ExtractorApi() { - override var name = "AsianLoad" - override var mainUrl = "https://asianhdplay.pro" override val requiresReferer = true private val sourceRegex = Regex("""sources:[\W\w]*?file:\s*?["'](.*?)["']""") @@ -47,4 +42,4 @@ open class AsianLoad : ExtractorApi() { return extractedLinksList } } -} +} \ No newline at end of file diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/VideoVard.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/VideoVard.kt deleted file mode 100644 index 30a1d8fe6..000000000 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/VideoVard.kt +++ /dev/null @@ -1,271 +0,0 @@ -package com.lagradost.cloudstream3.extractors - -import com.lagradost.cloudstream3.app -import com.lagradost.cloudstream3.utils.ExtractorApi -import com.lagradost.cloudstream3.utils.ExtractorLink -import com.lagradost.cloudstream3.utils.M3u8Helper.Companion.generateM3u8 -import kotlinx.coroutines.delay -import java.math.BigInteger - -class VideovardSX : WcoStream() { - override var mainUrl = "https://videovard.sx" -} - -open class VideoVard : ExtractorApi() { - override var name = "Videovard" // Cause works for animekisa and wco - override var mainUrl = "https://videovard.to" - override val requiresReferer = false - - //The following code was extracted from https://github.com/saikou-app/saikou/blob/main/app/src/main/java/ani/saikou/parsers/anime/extractors/VideoVard.kt - override suspend fun getUrl(url: String, referer: String?): List { - val id = url.substringAfter("e/").substringBefore("/") - val sources = mutableListOf() - val hash = app.get("$mainUrl/api/make/download/$id").parsed() - delay(11_000) - val resm3u8 = app.post( - "$mainUrl/api/player/setup", - mapOf("Referer" to "$mainUrl/"), - data = mapOf( - "cmd" to "get_stream", - "file_code" to id, - "hash" to hash.hash!! - ) - ).parsed() - val m3u8 = decode(resm3u8.src!!, resm3u8.seed) - sources.addAll( - generateM3u8( - name, - m3u8, - mainUrl, - headers = mapOf("Referer" to mainUrl) - ) - ) - return sources - } - - companion object { - private val big0 = 0.toBigInteger() - private val big3 = 3.toBigInteger() - private val big4 = 4.toBigInteger() - private val big15 = 15.toBigInteger() - private val big16 = 16.toBigInteger() - private val big255 = 255.toBigInteger() - - private fun decode(dataFile: String, seed: String): String { - val dataSeed = replace(seed) - val newDataSeed = binaryDigest(dataSeed) - val newDataFile = bytes2blocks(ascii2bytes(dataFile)) - var list = listOf(1633837924, 1650680933).map { it.toBigInteger() } - val xorList = mutableListOf() - for (i in newDataFile.indices step 2) { - val temp = newDataFile.slice(i..i + 1) - xorList += xorBlocks(list, tearDecode(temp, newDataSeed)) - list = temp - } - - val result = replace(unPad(blocks2bytes(xorList)).map { it.toInt().toChar() }.joinToString("")) - return padLastChars(result) - } - - private fun binaryDigest(input: String): List { - val keys = listOf(1633837924, 1650680933, 1667523942, 1684366951).map { it.toBigInteger() } - var list1 = keys.slice(0..1) - var list2 = list1 - val blocks = bytes2blocks(digestPad(input)) - - for (i in blocks.indices step 4) { - list1 = tearCode(xorBlocks(blocks.slice(i..i + 1), list1), keys).toMutableList() - list2 = tearCode(xorBlocks(blocks.slice(i + 2..i + 3), list2), keys).toMutableList() - - val temp = list1[0] - list1[0] = list1[1] - list1[1] = list2[0] - list2[0] = list2[1] - list2[1] = temp - } - - return listOf(list1[0], list1[1], list2[0], list2[1]) - } - - private fun tearDecode(a90: List, a91: List): MutableList { - var (a95, a96) = a90 - - var a97 = (-957401312).toBigInteger() - for (_i in 0 until 32) { - a96 -= ((((a95 shl 4) xor rShift(a95, 5)) + a95) xor (a97 + a91[rShift(a97, 11).and(3.toBigInteger()).toInt()])) - a97 += 1640531527.toBigInteger() - a95 -= ((((a96 shl 4) xor rShift(a96, 5)) + a96) xor (a97 + a91[a97.and(3.toBigInteger()).toInt()])) - - } - - return mutableListOf(a95, a96) - } - - private fun digestPad(string: String): List { - val empList = mutableListOf() - val length = string.length - val extra = big15 - (length.toBigInteger() % big16) - empList.add(extra) - for (i in 0 until length) { - empList.add(string[i].code.toBigInteger()) - } - for (i in 0 until extra.toInt()) { - empList.add(big0) - } - - return empList - } - - private fun bytes2blocks(a22: List): List { - val empList = mutableListOf() - val length = a22.size - var listIndex = 0 - - for (i in 0 until length) { - val subIndex = i % 4 - val shiftedByte = a22[i] shl (3 - subIndex) * 8 - - if (subIndex == 0) { - empList.add(shiftedByte) - } else { - empList[listIndex] = empList[listIndex] or shiftedByte - } - - if (subIndex == 3) listIndex += 1 - } - - return empList - } - - private fun blocks2bytes(inp: List): List { - val tempList = mutableListOf() - inp.indices.forEach { i -> - tempList += (big255 and rShift(inp[i], 24)) - tempList += (big255 and rShift(inp[i], 16)) - tempList += (big255 and rShift(inp[i], 8)) - tempList += (big255 and inp[i]) - } - return tempList - } - - private fun unPad(a46: List): List { - val evenOdd = a46[0].toInt().mod(2) - return (1 until (a46.size - evenOdd)).map { - a46[it] - } - } - - private fun xorBlocks(a76: List, a77: List): List { - return listOf(a76[0] xor a77[0], a76[1] xor a77[1]) - } - - private fun rShift(input: BigInteger, by: Int): BigInteger { - return (input.mod(4294967296.toBigInteger()) shr by) - } - - private fun tearCode(list1: List, list2: List): MutableList { - var a1 = list1[0] - var a2 = list1[1] - var temp = big0 - - for (_i in 0 until 32) { - a1 += (a2 shl 4 xor rShift(a2, 5)) + a2 xor temp + list2[(temp and big3).toInt()] - temp -= 1640531527.toBigInteger() - a2 += (a1 shl 4 xor rShift(a1, 5)) + a1 xor temp + list2[(rShift(temp, 11) and big3).toInt()] - } - return mutableListOf(a1, a2) - } - - private fun ascii2bytes(input: String): List { - val abc = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_" - val abcMap = abc.mapIndexed { i, c -> c to i.toBigInteger() }.toMap() - var index = -1 - val length = input.length - var listIndex = 0 - val bytes = mutableListOf() - - while (true) { - for (i in input) { - if (abc.contains(i)) { - index++ - break - } - } - - bytes.add((abcMap[input.getOrNull(index)?:return bytes]!! * big4)) - - while (true) { - index++ - if (abc.contains(input[index])) { - break - } - } - - var temp = abcMap[input[index]]!! - - bytes[listIndex] = bytes[listIndex] or rShift(temp, 4) - listIndex++ - temp = (big15.and(temp)) - - if ((temp == big0) && (index == (length - 1))) return bytes - - bytes.add((temp * big4 * big4)) - - while (true) { - index++ - if (index >= length) return bytes - if (abc.contains(input[index])) break - } - - temp = abcMap[input[index]]!! - bytes[listIndex] = bytes[listIndex] or rShift(temp, 2) - listIndex++ - temp = (big3 and temp) - if ((temp == big0) && (index == (length - 1))) { - return bytes - } - bytes.add((temp shl 6)) - for (i in input) { - index++ - if (abc.contains(input[index])) { - break - } - } - bytes[listIndex] = bytes[listIndex] or abcMap[input[index]]!! - listIndex++ - } - } - - private fun replace(a: String): String { - val map = mapOf( - '0' to '5', - '1' to '6', - '2' to '7', - '5' to '0', - '6' to '1', - '7' to '2' - ) - var b = "" - a.forEach { - b += if (map.containsKey(it)) map[it] else it - } - return b - } - - private fun padLastChars(input:String):String{ - return if(input.reversed()[3].isDigit()) input - else input.dropLast(4) - } - - private data class HashResponse( - val hash: String? = null, - val version:String? = null - ) - - private data class SetupResponse( - val seed: String, - val src: String?=null, - val link:String?=null - ) - } -} \ No newline at end of file diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Vidguard.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Vidguard.kt deleted file mode 100644 index 03a16c0a7..000000000 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Vidguard.kt +++ /dev/null @@ -1,139 +0,0 @@ -package com.lagradost.cloudstream3.extractors - -import com.lagradost.api.Log -import com.lagradost.cloudstream3.SubtitleFile -import com.lagradost.cloudstream3.app -import com.lagradost.cloudstream3.utils.AppUtils -import com.lagradost.cloudstream3.utils.ExtractorApi -import com.lagradost.cloudstream3.utils.ExtractorLink -import com.lagradost.cloudstream3.utils.INFER_TYPE -import com.lagradost.cloudstream3.utils.Qualities -import com.lagradost.cloudstream3.utils.newExtractorLink -import org.mozilla.javascript.Context -import org.mozilla.javascript.NativeJSON -import org.mozilla.javascript.NativeObject -import org.mozilla.javascript.Scriptable -import kotlin.io.encoding.Base64 -import kotlin.io.encoding.ExperimentalEncodingApi - -class Vidguardto1 : Vidguardto() { - override val mainUrl = "https://bembed.net" -} - -class Vidguardto2 : Vidguardto() { - override val mainUrl = "https://listeamed.net" -} - -class Vidguardto3 : Vidguardto() { - override val mainUrl = "https://vgfplay.com" -} - -open class Vidguardto : ExtractorApi() { - override val name = "Vidguard" - override val mainUrl = "https://vidguard.to" - override val requiresReferer = false - - override suspend fun getUrl( - url: String, - referer: String?, - subtitleCallback: (SubtitleFile) -> Unit, - callback: (ExtractorLink) -> Unit - ) { - val res = app.get(getEmbedUrl(url)) - val resc = res.document.select("script:containsData(eval)").firstOrNull()?.data() - resc?.let { - val jsonStr2 = AppUtils.parseJson(runJS2(it)) - val watchlink = sigDecode(jsonStr2.stream) - - callback.invoke( - newExtractorLink( - this.name, - name, - watchlink, - ) { - this.referer = mainUrl - } - ) - } - } - - @OptIn(ExperimentalEncodingApi::class) - private fun sigDecode(url: String): String { - val sig = url.split("sig=")[1].split("&")[0] - val t = sig.chunked(2) - .joinToString("") { (Integer.parseInt(it, 16) xor 2).toChar().toString() } - .let { - val padding = when (it.length % 4) { - 2 -> "==" - 3 -> "=" - else -> "" - } - String(Base64.decode((it + padding).toByteArray(Charsets.UTF_8))) - } - .dropLast(5) - .reversed() - .toCharArray() - .apply { - for (i in indices step 2) { - if (i + 1 < size) { - this[i] = this[i + 1].also { this[i + 1] = this[i] } - } - } - } - .concatToString() - .dropLast(5) - return url.replace(sig, t) - } - - private fun runJS2(hideMyHtmlContent: String): String { - var result = "" - val r = Runnable { - val rhino = Context.enter() - rhino.initSafeStandardObjects() - rhino.setInterpretedMode(true) - val scope: Scriptable = rhino.initSafeStandardObjects() - scope.put("window", scope, scope) - try { - rhino.evaluateString( - scope, - hideMyHtmlContent, - "JavaScript", - 1, - null - ) - val svgObject = scope.get("svg", scope) - result = if (svgObject is NativeObject) { - NativeJSON.stringify( - Context.getCurrentContext(), - scope, - svgObject, - null, - null - ).toString() - } else { - Context.toString(svgObject) - } - } catch (e: Exception) { - Log.e("runJS", "Error executing JavaScript: ${e.message}") - } finally { - Context.exit() - } - } - val t = Thread(ThreadGroup("A"), r, "thread_rhino", 8 * 1024 * 1024) // Increase stack size to 8MB - t.start() - t.join() - t.interrupt() - return result - } - - private fun getEmbedUrl(url: String): String { - return url.takeIf { it.contains("/d/") || it.contains("/v/") } - ?.replace("/d/", "/e/")?.replace("/v/", "/e/") ?: url - } - - data class SvgObject( - val stream: String, - val hash: String - ) - -} diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/WcoStream.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/WcoStream.kt deleted file mode 100644 index 0488c0d38..000000000 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/WcoStream.kt +++ /dev/null @@ -1,142 +0,0 @@ -package com.lagradost.cloudstream3.extractors - -import com.fasterxml.jackson.annotation.JsonProperty -import com.lagradost.cloudstream3.ErrorLoadingException -import com.lagradost.cloudstream3.extractors.helper.NineAnimeHelper.cipher -import com.lagradost.cloudstream3.extractors.helper.NineAnimeHelper.encrypt -import com.lagradost.cloudstream3.app -import com.lagradost.cloudstream3.utils.ExtractorApi -import com.lagradost.cloudstream3.utils.ExtractorLink -import com.lagradost.cloudstream3.utils.newExtractorLink -import com.lagradost.cloudstream3.utils.INFER_TYPE -import com.lagradost.cloudstream3.utils.Qualities - -class Vidstreamz : WcoStream() { - override var mainUrl = "https://vidstreamz.online" -} - -class Vizcloud : WcoStream() { - override var mainUrl = "https://vizcloud2.ru" -} - -class Vizcloud2 : WcoStream() { - override var mainUrl = "https://vizcloud2.online" -} - -class VizcloudOnline : WcoStream() { - override var mainUrl = "https://vizcloud.online" -} - -class VizcloudXyz : WcoStream() { - override var mainUrl = "https://vizcloud.xyz" -} - -class VizcloudLive : WcoStream() { - override var mainUrl = "https://vizcloud.live" -} - -class VizcloudInfo : WcoStream() { - override var mainUrl = "https://vizcloud.info" -} - -class MwvnVizcloudInfo : WcoStream() { - override var mainUrl = "https://mwvn.vizcloud.info" -} - -class VizcloudDigital : WcoStream() { - override var mainUrl = "https://vizcloud.digital" -} - -class VizcloudCloud : WcoStream() { - override var mainUrl = "https://vizcloud.cloud" -} - -class VizcloudSite : WcoStream() { - override var mainUrl = "https://vizcloud.site" -} - -class Mcloud : WcoStream() { - override var name = "Mcloud" - override var mainUrl = "https://mcloud.to" - override val requiresReferer = true -} - -open class WcoStream : ExtractorApi() { - override var name = "VidStream" // Cause works for animekisa and wco - override var mainUrl = "https://vidstream.pro" - override val requiresReferer = false - private val regex = Regex("(.+?/)e(?:mbed)?/([a-zA-Z0-9]+)") - - companion object { - // taken from https://github.com/saikou-app/saikou/blob/b35364c8c2a00364178a472fccf1ab72f09815b4/app/src/main/java/ani/saikou/parsers/anime/extractors/VizCloud.kt - // GNU General Public License v3.0 https://github.com/saikou-app/saikou/blob/main/LICENSE.md - private var lastChecked = 0L - private const val jsonLink = - "https://raw.githubusercontent.com/chenkaslowankiya/BruhFlow/main/keys.json" - private var cipherKey: VizCloudKey? = null - suspend fun getKey(): VizCloudKey { - cipherKey = - if (cipherKey != null && (lastChecked - System.currentTimeMillis()) < 1000 * 60 * 30) cipherKey!! - else { - lastChecked = System.currentTimeMillis() - app.get(jsonLink).parsed() - } - return cipherKey!! - } - - data class VizCloudKey( - @JsonProperty("cipherKey") val cipherKey: String, - @JsonProperty("mainKey") val mainKey: String, - @JsonProperty("encryptKey") val encryptKey: String, - @JsonProperty("dashTable") val dashTable: String - ) - - private const val baseTable = - "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+=/_" - - private fun dashify(id: String, dashTable: String): String { - val table = dashTable.split(" ") - return id.mapIndexedNotNull { i, c -> - table.getOrNull((baseTable.indexOf(c) * 16) + (i % 16)) - }.joinToString("-") - } - } - - //private val key = "LCbu3iYC7ln24K7P" // key credits @Modder4869 - override suspend fun getUrl(url: String, referer: String?): List { - val group = regex.find(url)?.groupValues!! - - val host = group[1] - val viz = getKey() - val id = encrypt( - cipher( - viz.cipherKey, - encrypt(group[2], viz.encryptKey).also { println(it) } - ).also { println(it) }, - viz.encryptKey - ).also { println(it) } - - val link = - "${host}mediainfo/${dashify(id, viz.dashTable)}?key=${viz.mainKey}" // - val response = app.get(link, referer = referer) - - data class Sources(@JsonProperty("file") val file: String) - data class Media(@JsonProperty("sources") val sources: List) - data class Data(@JsonProperty("media") val media: Media) - data class Response(@JsonProperty("data") val data: Data) - - - if (!response.text.startsWith("{")) throw ErrorLoadingException("Seems like 9Anime kiddies changed stuff again, Go touch some grass for bout an hour Or use a different Server") - return response.parsed().data.media.sources.map { - newExtractorLink( - name, - it.file, - it.file, - type = INFER_TYPE - ) { - this.referer = host - this.quality = Qualities.Unknown.value - } - } - } -} diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Zorofile.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Zorofile.kt deleted file mode 100644 index 43c4eefb2..000000000 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Zorofile.kt +++ /dev/null @@ -1,76 +0,0 @@ -package com.lagradost.cloudstream3.extractors - -import com.fasterxml.jackson.annotation.JsonProperty -import com.lagradost.cloudstream3.APIHolder.getCaptchaToken -import com.lagradost.cloudstream3.ErrorLoadingException -import com.lagradost.cloudstream3.SubtitleFile -import com.lagradost.cloudstream3.app -import com.lagradost.cloudstream3.utils.AppUtils.tryParseJson -import com.lagradost.cloudstream3.utils.ExtractorApi -import com.lagradost.cloudstream3.utils.ExtractorLink -import com.lagradost.cloudstream3.utils.M3u8Helper - -open class Zorofile : ExtractorApi() { - override val name = "Zorofile" - override val mainUrl = "https://zorofile.com" - override val requiresReferer = true - - override suspend fun getUrl( - url: String, - referer: String?, - subtitleCallback: (SubtitleFile) -> Unit, - callback: (ExtractorLink) -> Unit - ) { - val id = url.split("?").first().split("/").last() - val token = app.get( - url, - referer = referer - ).document.select("button.g-recaptcha").attr("data-sitekey").let { captchaKey -> - getCaptchaToken( - url, - captchaKey, - referer = referer - ) - } ?: throw ErrorLoadingException("can't bypass captcha") - - val data = app.post( - "$mainUrl/dl", - data = mapOf( - "op" to "embed", - "file_code" to id, - "auto" to "1", - "referer" to "$referer/", - "g-recaptcha-response" to token - ), - referer = url, - headers = mapOf( - "Accept" to "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8", - "Content-Type" to "application/x-www-form-urlencoded", - "Origin" to mainUrl, - "Sec-Fetch-Dest" to "iframe", - "Sec-Fetch-Mode" to "navigate", - "Sec-Fetch-Site" to "same-origin", - "Sec-Fetch-User" to "?1", - "Upgrade-Insecure-Requests" to "1", - ) - ).document.select("script").find { it.data().contains("var holaplayer;") }?.data() - ?.substringAfter("sources: [")?.substringBefore("],")?.replace("src", "\"src\"") - ?.replace("type", "\"type\"") - - tryParseJson("$data")?.let { res -> - return M3u8Helper.generateM3u8( - name, - res.src ?: return@let, - "$mainUrl/", - headers = mapOf( - "Origin" to mainUrl, - ) - ).forEach(callback) - } - } - - private data class Sources( - @JsonProperty("src") val src: String? = null, - @JsonProperty("type") val type: String? = null, - ) -} \ No newline at end of file diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/ExtractorApi.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/ExtractorApi.kt index d1749cf85..f796f3fdd 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/ExtractorApi.kt +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/ExtractorApi.kt @@ -6,11 +6,9 @@ import com.lagradost.cloudstream3.IDownloadableMinimum import com.lagradost.cloudstream3.SubtitleFile import com.lagradost.cloudstream3.USER_AGENT import com.lagradost.cloudstream3.app -import com.lagradost.cloudstream3.extractors.AStreamHub import com.lagradost.cloudstream3.extractors.Acefile import com.lagradost.cloudstream3.extractors.Ahvsh import com.lagradost.cloudstream3.extractors.Aico -import com.lagradost.cloudstream3.extractors.AsianLoad import com.lagradost.cloudstream3.extractors.Asnwish import com.lagradost.cloudstream3.extractors.Auvexiug import com.lagradost.cloudstream3.extractors.Awish @@ -18,19 +16,15 @@ import com.lagradost.cloudstream3.extractors.BgwpCC import com.lagradost.cloudstream3.extractors.BigwarpArt import com.lagradost.cloudstream3.extractors.BigwarpIO import com.lagradost.cloudstream3.extractors.Blogger -import com.lagradost.cloudstream3.extractors.BullStream import com.lagradost.cloudstream3.extractors.ByseSX import com.lagradost.cloudstream3.extractors.Bysezejataos import com.lagradost.cloudstream3.extractors.ByseBuho import com.lagradost.cloudstream3.extractors.ByseVepoin import com.lagradost.cloudstream3.extractors.ByseQekaho -import com.lagradost.cloudstream3.extractors.ByteShare import com.lagradost.cloudstream3.extractors.Cavanhabg import com.lagradost.cloudstream3.extractors.Cda import com.lagradost.cloudstream3.extractors.Cdnplayer import com.lagradost.cloudstream3.extractors.CdnwishCom -import com.lagradost.cloudstream3.extractors.CineGrabber -import com.lagradost.cloudstream3.extractors.Cinestart import com.lagradost.cloudstream3.extractors.CloudMailRu import com.lagradost.cloudstream3.extractors.ContentX import com.lagradost.cloudstream3.extractors.CsstOnline @@ -67,7 +61,6 @@ import com.lagradost.cloudstream3.extractors.Ds2video import com.lagradost.cloudstream3.extractors.DsstOnline import com.lagradost.cloudstream3.extractors.Dumbalag import com.lagradost.cloudstream3.extractors.Dwish -import com.lagradost.cloudstream3.extractors.EPlayExtractor import com.lagradost.cloudstream3.extractors.Embedgram import com.lagradost.cloudstream3.extractors.EmturbovidExtractor import com.lagradost.cloudstream3.extractors.Evoload @@ -81,7 +74,6 @@ import com.lagradost.cloudstream3.extractors.Fembed9hd import com.lagradost.cloudstream3.extractors.FileMoon import com.lagradost.cloudstream3.extractors.FileMoonIn import com.lagradost.cloudstream3.extractors.FileMoonSx -import com.lagradost.cloudstream3.extractors.Filegram import com.lagradost.cloudstream3.extractors.FilemoonV2 import com.lagradost.cloudstream3.extractors.Filesim import com.lagradost.cloudstream3.extractors.Multimoviesshg @@ -92,7 +84,6 @@ import com.lagradost.cloudstream3.extractors.FourPlayRu import com.lagradost.cloudstream3.extractors.Fplayer import com.lagradost.cloudstream3.extractors.FsstOnline import com.lagradost.cloudstream3.extractors.GDMirrorbot -import com.lagradost.cloudstream3.extractors.GMPlayer import com.lagradost.cloudstream3.extractors.GUpload import com.lagradost.cloudstream3.extractors.GamoVideo import com.lagradost.cloudstream3.extractors.Gdriveplayer @@ -108,7 +99,6 @@ import com.lagradost.cloudstream3.extractors.Gdriveplayerus import com.lagradost.cloudstream3.extractors.Geodailymotion import com.lagradost.cloudstream3.extractors.Gofile import com.lagradost.cloudstream3.extractors.GoodstreamExtractor -import com.lagradost.cloudstream3.extractors.GuardareStream import com.lagradost.cloudstream3.extractors.Guccihide import com.lagradost.cloudstream3.extractors.Guxhag import com.lagradost.cloudstream3.extractors.HDMomPlayer @@ -124,7 +114,6 @@ import com.lagradost.cloudstream3.extractors.Hxfile import com.lagradost.cloudstream3.extractors.HlsWish import com.lagradost.cloudstream3.extractors.InternetArchive import com.lagradost.cloudstream3.extractors.JWPlayer -import com.lagradost.cloudstream3.extractors.Jawcloud import com.lagradost.cloudstream3.extractors.Jeniusplay import com.lagradost.cloudstream3.extractors.Jodwish import com.lagradost.cloudstream3.extractors.Keephealth @@ -142,7 +131,6 @@ import com.lagradost.cloudstream3.extractors.Luxubu import com.lagradost.cloudstream3.extractors.Lvturbo import com.lagradost.cloudstream3.extractors.MailRu import com.lagradost.cloudstream3.extractors.Maxstream -import com.lagradost.cloudstream3.extractors.Mcloud import com.lagradost.cloudstream3.extractors.Mediafire import com.lagradost.cloudstream3.extractors.Megacloud import com.lagradost.cloudstream3.extractors.Meownime @@ -166,7 +154,6 @@ import com.lagradost.cloudstream3.extractors.Multimovies import com.lagradost.cloudstream3.extractors.Mvidoo import com.lagradost.cloudstream3.extractors.MyVidPlay import com.lagradost.cloudstream3.extractors.Mwish -import com.lagradost.cloudstream3.extractors.MwvnVizcloudInfo import com.lagradost.cloudstream3.extractors.NathanFromSubject import com.lagradost.cloudstream3.extractors.Nekostream import com.lagradost.cloudstream3.extractors.Nekowish @@ -178,7 +165,6 @@ import com.lagradost.cloudstream3.extractors.OkRuHTTP import com.lagradost.cloudstream3.extractors.OkRuHTTPMobile import com.lagradost.cloudstream3.extractors.OkRuSSL import com.lagradost.cloudstream3.extractors.OkRuSSLMobile -import com.lagradost.cloudstream3.extractors.Okrulink import com.lagradost.cloudstream3.extractors.PeaceMakerst import com.lagradost.cloudstream3.extractors.Peytonepre import com.lagradost.cloudstream3.extractors.Pichive @@ -211,7 +197,6 @@ import com.lagradost.cloudstream3.extractors.SibNet import com.lagradost.cloudstream3.extractors.Simpulumlamerop import com.lagradost.cloudstream3.extractors.Smoothpre import com.lagradost.cloudstream3.extractors.Sobreatsesuyp -import com.lagradost.cloudstream3.extractors.Solidfiles import com.lagradost.cloudstream3.extractors.Ssbstream import com.lagradost.cloudstream3.extractors.StreamEmbed import com.lagradost.cloudstream3.extractors.StreamHLS @@ -253,8 +238,6 @@ import com.lagradost.cloudstream3.extractors.TRsTX import com.lagradost.cloudstream3.extractors.Tantifilm import com.lagradost.cloudstream3.extractors.TauVideo import com.lagradost.cloudstream3.extractors.Techinmind -import com.lagradost.cloudstream3.extractors.Tomatomatela -import com.lagradost.cloudstream3.extractors.TomatomatelalClub import com.lagradost.cloudstream3.extractors.Tubeless import com.lagradost.cloudstream3.extractors.Uasopt import com.lagradost.cloudstream3.extractors.Up4FunTop @@ -272,7 +255,6 @@ import com.lagradost.cloudstream3.extractors.Userload import com.lagradost.cloudstream3.extractors.Userscloud import com.lagradost.cloudstream3.extractors.Uservideo import com.lagradost.cloudstream3.extractors.Videa -import com.lagradost.cloudstream3.extractors.Vanfem import com.lagradost.cloudstream3.extractors.Vicloud import com.lagradost.cloudstream3.extractors.VidHidePro import com.lagradost.cloudstream3.extractors.VidHidePro1 @@ -286,40 +268,23 @@ import com.lagradost.cloudstream3.extractors.Ryderjet import com.lagradost.cloudstream3.extractors.VidMoxy import com.lagradost.cloudstream3.extractors.VidStack import com.lagradost.cloudstream3.extractors.VideoSeyred -import com.lagradost.cloudstream3.extractors.VideoVard -import com.lagradost.cloudstream3.extractors.VideovardSX import com.lagradost.cloudstream3.extractors.Videzz import com.lagradost.cloudstream3.extractors.Vidgomunime import com.lagradost.cloudstream3.extractors.Vidgomunimesb -import com.lagradost.cloudstream3.extractors.Vidguardto -import com.lagradost.cloudstream3.extractors.Vidguardto1 -import com.lagradost.cloudstream3.extractors.Vidguardto2 -import com.lagradost.cloudstream3.extractors.Vidguardto3 import com.lagradost.cloudstream3.extractors.VidhideExtractor import com.lagradost.cloudstream3.extractors.Vidmoly import com.lagradost.cloudstream3.extractors.Vidmolyme import com.lagradost.cloudstream3.extractors.Vidmolyto import com.lagradost.cloudstream3.extractors.Vidmolybiz -import com.lagradost.cloudstream3.extractors.Vidnest import com.lagradost.cloudstream3.extractors.Vido import com.lagradost.cloudstream3.extractors.Vidoza -import com.lagradost.cloudstream3.extractors.Vidstreamz import com.lagradost.cloudstream3.extractors.VinovoSi import com.lagradost.cloudstream3.extractors.VinovoTo -import com.lagradost.cloudstream3.extractors.Vizcloud -import com.lagradost.cloudstream3.extractors.Vizcloud2 -import com.lagradost.cloudstream3.extractors.VizcloudCloud -import com.lagradost.cloudstream3.extractors.VizcloudDigital -import com.lagradost.cloudstream3.extractors.VizcloudInfo -import com.lagradost.cloudstream3.extractors.VizcloudLive -import com.lagradost.cloudstream3.extractors.VizcloudOnline -import com.lagradost.cloudstream3.extractors.VizcloudSite -import com.lagradost.cloudstream3.extractors.VizcloudXyz +import com.lagradost.cloudstream3.extractors.VidNest import com.lagradost.cloudstream3.extractors.VkExtractor import com.lagradost.cloudstream3.extractors.Voe import com.lagradost.cloudstream3.extractors.Voe1 import com.lagradost.cloudstream3.extractors.Vtbe -import com.lagradost.cloudstream3.extractors.WcoStream import com.lagradost.cloudstream3.extractors.Wibufile import com.lagradost.cloudstream3.extractors.WishembedPro import com.lagradost.cloudstream3.extractors.Wishfast @@ -334,7 +299,6 @@ import com.lagradost.cloudstream3.extractors.YoutubeNoCookieExtractor import com.lagradost.cloudstream3.extractors.YoutubeShortLinkExtractor import com.lagradost.cloudstream3.extractors.Yufiles import com.lagradost.cloudstream3.extractors.Yuguaab -import com.lagradost.cloudstream3.extractors.Zorofile import com.lagradost.cloudstream3.extractors.Zplayer import com.lagradost.cloudstream3.extractors.ZplayerV2 import com.lagradost.cloudstream3.extractors.Ztreamhub @@ -937,20 +901,6 @@ suspend fun loadExtractor( val extractorApis: MutableList = arrayListOf( //AllProvider(), - WcoStream(), - Vidstreamz(), - Vizcloud(), - Vizcloud2(), - VizcloudOnline(), - VizcloudXyz(), - VizcloudLive(), - VizcloudInfo(), - MwvnVizcloudInfo(), - VizcloudDigital(), - VizcloudCloud(), - VizcloudSite(), - VideoVard(), - VideovardSX(), Mp4Upload(), StreamTape(), StreamTapeNet(), @@ -969,7 +919,6 @@ val extractorApis: MutableList = arrayListOf( MxDropTo(), MixDropSi(), - Mcloud(), XStreamCdn(), StreamSB(), @@ -1046,14 +995,10 @@ val extractorApis: MutableList = arrayListOf( PixelDrainDev(), MailRu(), - Tomatomatela(), - TomatomatelalClub(), - Cinestart(), OkRuSSL(), OkRuSSLMobile(), OkRuHTTP(), OkRuHTTPMobile(), - Okrulink(), Sendvid(), // dood extractors @@ -1074,10 +1019,7 @@ val extractorApis: MutableList = arrayListOf( Doodspro(), Dsvplay(), - AsianLoad(), - // GenericM3U8(), - Jawcloud(), Zplayer(), ZplayerV2(), Upstream(), @@ -1086,9 +1028,6 @@ val extractorApis: MutableList = arrayListOf( Tantifilm(), Userload(), Supervideo(), - GuardareStream(), - CineGrabber(), - Vanfem(), // StreamSB.kt works // SBPlay(), @@ -1097,11 +1036,7 @@ val extractorApis: MutableList = arrayListOf( PlayerVoxzer(), - BullStream(), - GMPlayer(), - Blogger(), - Solidfiles(), YourUpload(), Hxfile(), @@ -1149,7 +1084,6 @@ val extractorApis: MutableList = arrayListOf( Linkbox(), Acefile(), Minoplres(), // formerly SpeedoStream - Zorofile(), Embedgram(), Mvidoo(), Streamplay(), @@ -1189,11 +1123,9 @@ val extractorApis: MutableList = arrayListOf( YoutubeNoCookieExtractor(), Streamlare(), PlayLtXyz(), - AStreamHub(), Cda(), Dailymotion(), - ByteShare(), Ztreamhub(), Rabbitstream(), Dokicloud(), @@ -1208,6 +1140,7 @@ val extractorApis: MutableList = arrayListOf( VidHidePro6(), VidHideHub(), Ryderjet(), + VidNest(), Dhtpre(), // CineMM Redirects @@ -1242,15 +1175,9 @@ val extractorApis: MutableList = arrayListOf( FlaswishCom(), SfastwishCom(), Playerwish(), - Vidnest(), StreamEmbed(), EmturbovidExtractor(), Vtbe(), - EPlayExtractor(), - Vidguardto(), - Vidguardto1(), - Vidguardto2(), - Vidguardto3(), SecvideoOnline(), FsstOnline(), CsstOnline(), @@ -1283,7 +1210,6 @@ val extractorApis: MutableList = arrayListOf( Wishonly(), Ds2play(), Ds2video(), - Filegram(), InternetArchive(), VidStack(), GDMirrorbot(), From ecc3e506f91e6cb3347356a56eceeac595b4d675 Mon Sep 17 00:00:00 2001 From: CranberrySoup <142951702+CranberrySoup@users.noreply.github.com> Date: Sat, 28 Feb 2026 21:59:10 +0000 Subject: [PATCH 056/236] Backup fix (#2542) * Fix subtitle selection * Move logic to getLanguageDataFromName * Update BackupUtils.kt --- .../java/com/lagradost/cloudstream3/utils/BackupUtils.kt | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/BackupUtils.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/BackupUtils.kt index a444ef3d0..29410ab4d 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/BackupUtils.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/BackupUtils.kt @@ -88,10 +88,14 @@ object BackupUtils { // The download path URI can not be transferred. // In the future we may potentially write metadata to files in the download directory // and make it possible to restore download folders using that metadata. - DOWNLOAD_HEADER_CACHE_BACKUP, - DOWNLOAD_HEADER_CACHE, DOWNLOAD_EPISODE_CACHE_BACKUP, DOWNLOAD_EPISODE_CACHE, + + // Download headers are unintuitively used in the resume watching system. + // We can therefore not prune download headers in backups. + //DOWNLOAD_HEADER_CACHE_BACKUP, + //DOWNLOAD_HEADER_CACHE, + // This may overwrite valid local data with invalid data KEY_DOWNLOAD_INFO, From 809b66af81e4f1874f11d8db23b8d3759d9feb9c Mon Sep 17 00:00:00 2001 From: Luna712 <142361265+Luna712@users.noreply.github.com> Date: Sat, 28 Feb 2026 15:02:49 -0700 Subject: [PATCH 057/236] Revert "Revert "Update media3 to 1.9.2"" (#2510) --- .../cloudstream3/ui/player/CS3IPlayer.kt | 4 +- .../player/UpdatedDefaultExtractorsFactory.kt | 60 +- .../ui/player/UpdatedMatroskaExtractor.kt | 675 ++++++++++++++---- gradle/libs.versions.toml | 4 +- 4 files changed, 593 insertions(+), 150 deletions(-) diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/CS3IPlayer.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/CS3IPlayer.kt index 409ac9374..3ceaa99c3 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/CS3IPlayer.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/CS3IPlayer.kt @@ -544,10 +544,10 @@ class CS3IPlayer : IPlayer { currentSubtitleOffset = offset CustomDecoder.subtitleOffset = offset if (currentTextRenderer?.state == STATE_ENABLED || currentTextRenderer?.state == STATE_STARTED) { - exoPlayer?.currentPosition?.let { pos -> + exoPlayer?.currentPosition?.also { pos -> // This seems to properly refresh all subtitles // It needs to be done as all subtitle cues with timings are pre-processed - currentTextRenderer?.resetPosition(pos) + currentTextRenderer?.resetPosition(pos, false) } } } diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/UpdatedDefaultExtractorsFactory.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/UpdatedDefaultExtractorsFactory.kt index 8ea0f4e61..b3873bd32 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/UpdatedDefaultExtractorsFactory.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/UpdatedDefaultExtractorsFactory.kt @@ -13,6 +13,7 @@ package com.lagradost.cloudstream3.ui.player import android.net.Uri import androidx.annotation.GuardedBy +import androidx.media3.common.C import androidx.media3.common.FileTypes import androidx.media3.common.Format import androidx.media3.common.util.TimestampAdjuster @@ -48,7 +49,6 @@ import java.lang.reflect.Constructor import java.lang.reflect.InvocationTargetException import java.util.concurrent.atomic.AtomicBoolean - /** * An [ExtractorsFactory] that provides an array of extractors for the following formats: * @@ -103,13 +103,16 @@ class UpdatedDefaultExtractorsFactory : ExtractorsFactory { private var tsTimestampSearchBytes: Int private var textTrackTranscodingEnabled: Boolean private var subtitleParserFactory: SubtitleParser.Factory + private var codecsToParseWithinGopSampleDependencies: @C.VideoCodecFlags Int private var jpegFlags: @JpegExtractor.Flags Int = 0 + private var heifFlags: @HeifExtractor.Flags Int = 0 init { tsMode = TsExtractor.MODE_SINGLE_PMT tsTimestampSearchBytes = TsExtractor.DEFAULT_TIMESTAMP_SEARCH_BYTES subtitleParserFactory = DefaultSubtitleParserFactory() textTrackTranscodingEnabled = true + codecsToParseWithinGopSampleDependencies = C.VIDEO_CODEC_FLAG_H264 or C.VIDEO_CODEC_FLAG_H265 } /** @@ -346,6 +349,14 @@ class UpdatedDefaultExtractorsFactory : ExtractorsFactory { return this } + @Synchronized + override fun experimentalSetCodecsToParseWithinGopSampleDependencies( + codecsToParseWithinGopSampleDependencies: @C.VideoCodecFlags Int + ): UpdatedDefaultExtractorsFactory { + this.codecsToParseWithinGopSampleDependencies = codecsToParseWithinGopSampleDependencies + return this + } + /** * Sets flags for [JpegExtractor] instances created by the factory. * @@ -361,6 +372,21 @@ class UpdatedDefaultExtractorsFactory : ExtractorsFactory { return this } + /** + * Sets flags for [HeifExtractor] instances created by the factory. + * + * @see HeifExtractor.HeifExtractor + * @param flags The flags to use. + * @return The factory, for convenience. + */ + @Synchronized + fun setHeifExtractorFlags( + flags: @HeifExtractor.Flags Int + ): UpdatedDefaultExtractorsFactory { + this.heifFlags = flags + return this + } + @Synchronized override fun createExtractors(): Array { return createExtractors(Uri.EMPTY, HashMap()) @@ -468,21 +494,26 @@ class UpdatedDefaultExtractorsFactory : ExtractorsFactory { extractors.add( FragmentedMp4Extractor( subtitleParserFactory, - fragmentedMp4Flags - or (if (textTrackTranscodingEnabled) - 0 - else - FragmentedMp4Extractor.FLAG_EMIT_RAW_SUBTITLE_DATA) + fragmentedMp4Flags or + FragmentedMp4Extractor + .codecsToParseWithinGopSampleDependenciesAsFlags( + codecsToParseWithinGopSampleDependencies + ) or + if (textTrackTranscodingEnabled) 0 + else FragmentedMp4Extractor.FLAG_EMIT_RAW_SUBTITLE_DATA ) ) + extractors.add( Mp4Extractor( subtitleParserFactory, - mp4Flags - or (if (textTrackTranscodingEnabled) - 0 - else - Mp4Extractor.FLAG_EMIT_RAW_SUBTITLE_DATA) + mp4Flags or + Mp4Extractor + .codecsToParseWithinGopSampleDependenciesAsFlags( + codecsToParseWithinGopSampleDependencies + ) or + if (textTrackTranscodingEnabled) 0 + else Mp4Extractor.FLAG_EMIT_RAW_SUBTITLE_DATA ) ) } @@ -524,12 +555,7 @@ class UpdatedDefaultExtractorsFactory : ExtractorsFactory { FileTypes.PNG -> extractors.add(PngExtractor()) FileTypes.WEBP -> extractors.add(WebpExtractor()) FileTypes.BMP -> extractors.add(BmpExtractor()) - FileTypes.HEIF -> if ((mp4Flags and Mp4Extractor.FLAG_READ_MOTION_PHOTO_METADATA) == 0 - && (mp4Flags and Mp4Extractor.FLAG_READ_SEF_DATA) == 0 - ) { - extractors.add(HeifExtractor()) - } - + FileTypes.HEIF -> extractors.add(HeifExtractor(heifFlags)) FileTypes.AVIF -> extractors.add(AvifExtractor()) FileTypes.WEBVTT, FileTypes.UNKNOWN -> {} else -> {} diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/UpdatedMatroskaExtractor.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/UpdatedMatroskaExtractor.kt index 6868af771..5937b1973 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/UpdatedMatroskaExtractor.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/UpdatedMatroskaExtractor.kt @@ -41,18 +41,20 @@ import androidx.media3.common.ColorInfo import androidx.media3.common.DrmInitData import androidx.media3.common.DrmInitData.SchemeData import androidx.media3.common.Format +import androidx.media3.common.Metadata import androidx.media3.common.MimeTypes import androidx.media3.common.ParserException -import androidx.media3.common.util.Assertions import androidx.media3.common.util.Log import androidx.media3.common.util.ParsableByteArray import androidx.media3.common.util.UnstableApi import androidx.media3.common.util.Util +import androidx.media3.container.DolbyVisionConfig import androidx.media3.container.NalUnitUtil import androidx.media3.extractor.AacUtil import androidx.media3.extractor.AvcConfig import androidx.media3.extractor.ChunkIndex -import androidx.media3.container.DolbyVisionConfig +import androidx.media3.extractor.ChunkIndexProvider +import androidx.media3.extractor.DtsUtil import androidx.media3.extractor.Extractor import androidx.media3.extractor.ExtractorInput import androidx.media3.extractor.ExtractorOutput @@ -61,12 +63,18 @@ import androidx.media3.extractor.HevcConfig import androidx.media3.extractor.MpegAudioUtil import androidx.media3.extractor.PositionHolder import androidx.media3.extractor.SeekMap -import androidx.media3.extractor.SeekMap.Unseekable +import androidx.media3.extractor.SeekMap.SeekPoints +import androidx.media3.extractor.SeekPoint +import androidx.media3.extractor.TrackAwareSeekMap import androidx.media3.extractor.TrackOutput import androidx.media3.extractor.TrackOutput.CryptoData import androidx.media3.extractor.TrueHdSampleRechunker +import androidx.media3.extractor.metadata.ThumbnailMetadata import androidx.media3.extractor.text.SubtitleParser import androidx.media3.extractor.text.SubtitleTranscodingExtractorOutput +import com.google.common.base.Preconditions.checkArgument +import com.google.common.base.Preconditions.checkNotNull +import com.google.common.base.Preconditions.checkState import com.google.common.collect.ImmutableList import java.io.IOException import java.nio.ByteBuffer @@ -74,6 +82,7 @@ import java.nio.ByteOrder import java.util.Arrays import java.util.Collections import java.util.Locale +import java.util.Objects import java.util.UUID import kotlin.math.max import kotlin.math.min @@ -119,6 +128,8 @@ class UpdatedMatroskaExtractor private constructor( private var timecodeScale = C.TIME_UNSET private var durationTimecode = C.TIME_UNSET private var durationUs = C.TIME_UNSET + private var isWebm: Boolean = false + private var pendingEndTracks: Boolean // The track corresponding to the current TrackEntry element, or null. private var currentTrack: Track? = null @@ -131,6 +142,13 @@ class UpdatedMatroskaExtractor private constructor( private var seekEntryPosition: Long = 0 // Cue related elements. + private val perTrackCues: SparseArray> + private var inCuesElement = false + private var currentCueTimeUs: Long = C.TIME_UNSET + private var currentCueTrackNumber: Int = C.INDEX_UNSET + private var currentCueClusterPosition: Long = C.INDEX_UNSET.toLong() + private var currentCueRelativePosition: Long = C.INDEX_UNSET.toLong() + private var primarySeekTrackNumber: Int = C.INDEX_UNSET private var seekForCues = false private var seekForSeekContent = false private var visitedSeekHeads: HashSet = HashSet() @@ -139,9 +157,6 @@ class UpdatedMatroskaExtractor private constructor( private var cuesContentPosition = C.INDEX_UNSET.toLong() private var seekPositionAfterBuildingCues = C.INDEX_UNSET.toLong() private var clusterTimecodeUs = C.TIME_UNSET - private var cueTimesUs: androidx.media3.common.util.LongArray? = null - private var cueClusterPositions: androidx.media3.common.util.LongArray? = null - private var seenClusterPositionForCurrentCuePoint = false // Reading state. private var haveOutputSample = false @@ -218,6 +233,7 @@ class UpdatedMatroskaExtractor private constructor( init { reader.init(InnerEbmlProcessor()) this.subtitleParserFactory = subtitleParserFactory + this.perTrackCues = SparseArray() seekForCuesEnabled = (flags and FLAG_DISABLE_SEEK_FOR_CUES) == 0 parseSubtitlesDuringExtraction = (flags and FLAG_EMIT_RAW_SUBTITLE_DATA) == 0 varintReader = VarintReader() @@ -233,6 +249,7 @@ class UpdatedMatroskaExtractor private constructor( encryptionSubsampleData = ParsableByteArray() supplementalData = ParsableByteArray() blockSampleSizes = IntArray(1) + pendingEndTracks = true } @Throws(IOException::class) @@ -255,6 +272,17 @@ class UpdatedMatroskaExtractor private constructor( reader.reset() varintReader.reset() resetWriteSampleData() + inCuesElement = false + currentCueTimeUs = C.TIME_UNSET + currentCueTrackNumber = C.INDEX_UNSET + currentCueClusterPosition = C.INDEX_UNSET.toLong() + currentCueRelativePosition = C.INDEX_UNSET.toLong() + // To prevent creating duplicate cue points on a re-parse, clear any existing cue data if the + // seek map has not yet been sent. Once sent, the cue data is considered final, and subsequent + // Cues elements will be ignored by the parsing logic. + if (!sentSeekMap) { + perTrackCues.clear() + } for (i in 0.. EbmlProcessor.ELEMENT_TYPE_MASTER - ID_EBML_READ_VERSION, ID_DOC_TYPE_READ_VERSION, ID_SEEK_POSITION, ID_TIMECODE_SCALE, ID_TIME_CODE, ID_BLOCK_DURATION, ID_PIXEL_WIDTH, ID_PIXEL_HEIGHT, ID_DISPLAY_WIDTH, ID_DISPLAY_HEIGHT, ID_DISPLAY_UNIT, ID_TRACK_NUMBER, ID_TRACK_TYPE, ID_FLAG_DEFAULT, ID_FLAG_FORCED, ID_DEFAULT_DURATION, ID_MAX_BLOCK_ADDITION_ID, ID_BLOCK_ADD_ID_TYPE, ID_CODEC_DELAY, ID_SEEK_PRE_ROLL, ID_DISCARD_PADDING, ID_CHANNELS, ID_AUDIO_BIT_DEPTH, ID_CONTENT_ENCODING_ORDER, ID_CONTENT_ENCODING_SCOPE, ID_CONTENT_COMPRESSION_ALGORITHM, ID_CONTENT_ENCRYPTION_ALGORITHM, ID_CONTENT_ENCRYPTION_AES_SETTINGS_CIPHER_MODE, ID_CUE_TIME, ID_CUE_CLUSTER_POSITION, ID_REFERENCE_BLOCK, ID_STEREO_MODE, ID_COLOUR_BITS_PER_CHANNEL, ID_COLOUR_RANGE, ID_COLOUR_TRANSFER, ID_COLOUR_PRIMARIES, ID_MAX_CLL, ID_MAX_FALL, ID_PROJECTION_TYPE, ID_BLOCK_ADD_ID -> EbmlProcessor.ELEMENT_TYPE_UNSIGNED_INT + ID_EBML_READ_VERSION, ID_DOC_TYPE_READ_VERSION, ID_SEEK_POSITION, ID_TIMECODE_SCALE, ID_TIME_CODE, ID_BLOCK_DURATION, ID_PIXEL_WIDTH, ID_PIXEL_HEIGHT, ID_DISPLAY_WIDTH, ID_DISPLAY_HEIGHT, ID_DISPLAY_UNIT, ID_TRACK_NUMBER, ID_TRACK_TYPE, ID_FLAG_DEFAULT, ID_FLAG_FORCED, ID_DEFAULT_DURATION, ID_MAX_BLOCK_ADDITION_ID, ID_BLOCK_ADD_ID_TYPE, ID_CODEC_DELAY, ID_SEEK_PRE_ROLL, ID_DISCARD_PADDING, ID_CHANNELS, ID_AUDIO_BIT_DEPTH, ID_CONTENT_ENCODING_ORDER, ID_CONTENT_ENCODING_SCOPE, ID_CONTENT_COMPRESSION_ALGORITHM, ID_CONTENT_ENCRYPTION_ALGORITHM, ID_CONTENT_ENCRYPTION_AES_SETTINGS_CIPHER_MODE, ID_CUE_TIME, ID_CUE_CLUSTER_POSITION, ID_CUE_RELATIVE_POSITION, ID_CUE_TRACK, ID_REFERENCE_BLOCK, ID_STEREO_MODE, ID_COLOUR_BITS_PER_CHANNEL, ID_COLOUR_RANGE, ID_COLOUR_TRANSFER, ID_COLOUR_PRIMARIES, ID_MAX_CLL, ID_MAX_FALL, ID_PROJECTION_TYPE, ID_BLOCK_ADD_ID -> EbmlProcessor.ELEMENT_TYPE_UNSIGNED_INT ID_DOC_TYPE, ID_NAME, ID_CODEC_ID, ID_LANGUAGE -> EbmlProcessor.ELEMENT_TYPE_STRING ID_SEEK_ID, ID_BLOCK_ADD_ID_EXTRA_DATA, ID_CONTENT_COMPRESSION_SETTINGS, ID_CONTENT_ENCRYPTION_KEY_ID, ID_SIMPLE_BLOCK, ID_BLOCK, ID_CODEC_PRIVATE, ID_PROJECTION_PRIVATE, ID_BLOCK_ADDITIONAL -> EbmlProcessor.ELEMENT_TYPE_BINARY @@ -341,11 +369,27 @@ class UpdatedMatroskaExtractor private constructor( } ID_CUES -> { - cueTimesUs = androidx.media3.common.util.LongArray() - cueClusterPositions = androidx.media3.common.util.LongArray() + if (!sentSeekMap) { + inCuesElement = true + } + } + + ID_CUE_POINT -> { + if (!sentSeekMap) { + assertInCues(id) + currentCueTimeUs = C.TIME_UNSET + } + } + + ID_CUE_TRACK_POSITIONS -> { + if (!sentSeekMap) { + assertInCues(id) + currentCueTrackNumber = C.INDEX_UNSET + currentCueClusterPosition = C.INDEX_UNSET.toLong() + currentCueRelativePosition = C.INDEX_UNSET.toLong() + } } - ID_CUE_POINT -> seenClusterPositionForCurrentCuePoint = false ID_CLUSTER -> if (!sentSeekMap) { // We need to build cues before parsing the cluster. if (seekForCuesEnabled && cuesContentPosition != C.INDEX_UNSET.toLong()) { @@ -358,7 +402,7 @@ class UpdatedMatroskaExtractor private constructor( } else { // We don't know where the Cues element is located. It's most likely omitted. Allow // playback, but disable seeking. - extractorOutput!!.seekMap(Unseekable(durationUs)) + extractorOutput!!.seekMap(SeekMap.Unseekable(durationUs)) sentSeekMap = true } } @@ -370,7 +414,10 @@ class UpdatedMatroskaExtractor private constructor( ID_CONTENT_ENCODING -> {} ID_CONTENT_ENCRYPTION -> getCurrentTrack(id).hasContentEncryption = true - ID_TRACK_ENTRY -> currentTrack = Track() + ID_TRACK_ENTRY -> { + currentTrack = Track() + currentTrack!!.isWebm = isWebm + } ID_MASTERING_METADATA -> getCurrentTrack(id).hasColorInfo = true else -> {} } @@ -409,7 +456,7 @@ class UpdatedMatroskaExtractor private constructor( } else { // Otherwise, if we not found any cues nor any more seek heads then we mark // this as unseekable. - extractorOutput!!.seekMap(Unseekable(durationUs)) + extractorOutput!!.seekMap(SeekMap.Unseekable(durationUs)) sentSeekMap = true } } @@ -438,13 +485,67 @@ class UpdatedMatroskaExtractor private constructor( ID_CUES -> { if (!sentSeekMap) { - extractorOutput!!.seekMap(buildSeekMap(cueTimesUs, cueClusterPositions)) + var hasAnyCues = false + for (i in 0 until perTrackCues.size()) { + if (perTrackCues.valueAt(i).isNotEmpty()) { + hasAnyCues = true + break + } + } + + if (!hasAnyCues || durationUs == C.TIME_UNSET) { + // Cues are missing, empty, or duration is unknown. + extractorOutput!!.seekMap(SeekMap.Unseekable(durationUs)) + } else { + for (i in 0 until perTrackCues.size()) { + perTrackCues.valueAt(i).sort() + } + + val seekMap = MatroskaSeekMap( + perTrackCues, + durationUs, + primarySeekTrackNumber, + segmentContentPosition, + segmentContentSize + ) + extractorOutput!!.seekMap(seekMap) + } sentSeekMap = true - } else { - // We have already built the cues. Ignore. + inCuesElement = false + for (i in 0 until tracks.size()) { + val track: Track = tracks.valueAt(i) + track.maybeAddThumbnailMetadata(perTrackCues, durationUs, segmentContentPosition, segmentContentSize) + if (!track.waitingForDtsAnalysis) { + track.assertOutputInitialized() + track.output!!.format(requireNotNull(track.format)) + } + } + maybeEndTracks() + } + } + + ID_CUE_TRACK_POSITIONS -> { + if (!sentSeekMap) { + assertInCues(id) + if (currentCueTimeUs != C.TIME_UNSET + && currentCueTrackNumber != C.INDEX_UNSET + && currentCueClusterPosition != C.INDEX_UNSET.toLong() + ) { + var trackCues = perTrackCues[currentCueTrackNumber] + if (trackCues == null) { + trackCues = ArrayList() + perTrackCues.put(currentCueTrackNumber, trackCues) + } + + trackCues.add( + MatroskaSeekMap.CuePointData( + currentCueTimeUs, + /* clusterPosition= */ segmentContentPosition + currentCueClusterPosition, + /* relativePosition= */ currentCueRelativePosition + ) + ) + } } - this.cueTimesUs = null - this.cueClusterPositions = null } ID_BLOCK_GROUP -> { @@ -520,17 +621,15 @@ class UpdatedMatroskaExtractor private constructor( } ID_TRACK_ENTRY -> { - val currentTrack = Assertions.checkStateNotNull(this.currentTrack) + val currentTrack = checkNotNull(this.currentTrack) if (currentTrack.codecId == null) { throw ParserException.createForMalformedContainer( "CodecId is missing in TrackEntry element", /* cause= */null ) } else { - if (isCodecSupported( - currentTrack.codecId!! - ) - ) { - currentTrack.initializeOutput(extractorOutput!!, currentTrack.number) + if (isCodecSupported(currentTrack.codecId!!)) { + currentTrack.initializeFormat(currentTrack.number); + currentTrack.output = extractorOutput!!.track(currentTrack.number, currentTrack.type); tracks.put(currentTrack.number, currentTrack) } } @@ -540,10 +639,63 @@ class UpdatedMatroskaExtractor private constructor( ID_TRACKS -> { if (tracks.size() == 0) { throw ParserException.createForMalformedContainer( - "No valid tracks were found", /* cause= */null + "No valid tracks were found", /* cause= */ null ) } - extractorOutput!!.endTracks() + + // Determine the track to use for default seeking. + var defaultVideoTrackNumber: Int = C.INDEX_UNSET + var firstVideoTrackNumber: Int = C.INDEX_UNSET + var defaultAudioTrackNumber: Int = C.INDEX_UNSET + var firstAudioTrackNumber: Int = C.INDEX_UNSET + + // If we're not going to seek for cues, output the formats immediately. + val mayBeSendFormatsEarly = !seekForCuesEnabled || cuesContentPosition == C.INDEX_UNSET.toLong(); + + for (i in 0 until tracks.size()) { + val trackItem: Track = tracks.valueAt(i) + + val trackType: @C.TrackType Int = trackItem.type + when (trackType) { + C.TRACK_TYPE_VIDEO -> { + if (trackItem.flagDefault) { + defaultVideoTrackNumber = trackItem.number + } + if (firstVideoTrackNumber == C.INDEX_UNSET) { + firstVideoTrackNumber = trackItem.number + } + } + + C.TRACK_TYPE_AUDIO -> { + if (trackItem.flagDefault) { + defaultAudioTrackNumber = trackItem.number + } + if (firstAudioTrackNumber == C.INDEX_UNSET) { + firstAudioTrackNumber = trackItem.number + } + } + } + + if (mayBeSendFormatsEarly) { + trackItem.assertOutputInitialized() + if (!trackItem.waitingForDtsAnalysis) { + trackItem.output!!.format(checkNotNull(trackItem.format)) + } + } + } + + primarySeekTrackNumber = when { + defaultVideoTrackNumber != C.INDEX_UNSET -> defaultVideoTrackNumber + firstVideoTrackNumber != C.INDEX_UNSET -> firstVideoTrackNumber + defaultAudioTrackNumber != C.INDEX_UNSET -> defaultAudioTrackNumber + firstAudioTrackNumber != C.INDEX_UNSET -> firstAudioTrackNumber + tracks.size() > 0 -> tracks.valueAt(0).number + else -> C.INDEX_UNSET + } + + if (mayBeSendFormatsEarly) { + maybeEndTracks() + } } else -> {} @@ -586,7 +738,16 @@ class UpdatedMatroskaExtractor private constructor( ID_TRACK_NUMBER -> getCurrentTrack(id).number = value.toInt() ID_FLAG_DEFAULT -> getCurrentTrack(id).flagDefault = value == 1L ID_FLAG_FORCED -> getCurrentTrack(id).flagForced = value == 1L - ID_TRACK_TYPE -> getCurrentTrack(id).type = value.toInt() + ID_TRACK_TYPE -> { + val matroskaTrackType = value.toInt() + getCurrentTrack(id).type = when (matroskaTrackType) { + 1 -> C.TRACK_TYPE_VIDEO // Matroska video + 2 -> C.TRACK_TYPE_AUDIO // Matroska audio + 17 -> C.TRACK_TYPE_TEXT // Matroska subtitle + 33 -> C.TRACK_TYPE_METADATA // Matroska metadata + else -> C.TRACK_TYPE_UNKNOWN + } + } ID_DEFAULT_DURATION -> getCurrentTrack(id).defaultSampleDurationNs = value.toInt() ID_MAX_BLOCK_ADDITION_ID -> getCurrentTrack(id).maxBlockAdditionId = value.toInt() ID_BLOCK_ADD_ID_TYPE -> getCurrentTrack(id).blockAddIdType = value.toInt() @@ -632,17 +793,35 @@ class UpdatedMatroskaExtractor private constructor( } ID_CUE_TIME -> { - assertInCues(id) - cueTimesUs!!.add(scaleTimecodeToUs(value)) + if (!sentSeekMap) { + assertInCues(id) + currentCueTimeUs = scaleTimecodeToUs(value) + } } - ID_CUE_CLUSTER_POSITION -> if (!seenClusterPositionForCurrentCuePoint) { - assertInCues(id) - // If there's more than one video/audio track, then there could be more than one - // CueTrackPositions within a single CuePoint. In such a case, ignore all but the first - // one (since the cluster position will be quite close for all the tracks). - cueClusterPositions!!.add(value) - seenClusterPositionForCurrentCuePoint = true + ID_CUE_TRACK -> { + if (!sentSeekMap) { + assertInCues(id) + currentCueTrackNumber = value.toInt() + } + } + + ID_CUE_CLUSTER_POSITION -> { + if (!sentSeekMap) { + assertInCues(id) + if (currentCueClusterPosition == C.INDEX_UNSET.toLong()) { + currentCueClusterPosition = value + } + } + } + + ID_CUE_RELATIVE_POSITION -> { + if (!sentSeekMap) { + assertInCues(id) + if (currentCueRelativePosition == C.INDEX_UNSET.toLong()) { + currentCueRelativePosition = value + } + } } ID_TIME_CODE -> clusterTimecodeUs = scaleTimecodeToUs(value) @@ -954,7 +1133,7 @@ class UpdatedMatroskaExtractor private constructor( (scratch.data[0].toInt() shl 8) or (scratch.data[1].toInt() and 0xFF) blockTimeUs = clusterTimecodeUs + scaleTimecodeToUs(timecode.toLong()) val isKeyframe = - track.type == TRACK_TYPE_AUDIO + track.type == C.TRACK_TYPE_AUDIO || (id == ID_SIMPLE_BLOCK && (scratch.data[2].toInt() and 0x80) == 0x80) blockFlags = if (isKeyframe) C.BUFFER_FLAG_KEY_FRAME else 0 blockState = BLOCK_STATE_DATA @@ -1046,9 +1225,7 @@ class UpdatedMatroskaExtractor private constructor( } } - @Throws( - ParserException::class - ) + @Throws(ParserException::class) private fun assertInTrackEntry(id: Int) { if (currentTrack == null) { throw ParserException.createForMalformedContainer( @@ -1057,11 +1234,9 @@ class UpdatedMatroskaExtractor private constructor( } } - @Throws( - ParserException::class - ) + @Throws(ParserException::class) private fun assertInCues(id: Int) { - if (cueTimesUs == null || cueClusterPositions == null) { + if (!inCuesElement) { throw ParserException.createForMalformedContainer( "Element $id must be in a Cues", /* cause= */null ) @@ -1090,6 +1265,7 @@ class UpdatedMatroskaExtractor private constructor( } else { if (CODEC_ID_SUBRIP == track.codecId || CODEC_ID_ASS == track.codecId + || CODEC_ID_SSA == track.codecId || CODEC_ID_VTT == track.codecId ) { if (blockSampleCount > 1) { @@ -1179,7 +1355,7 @@ class UpdatedMatroskaExtractor private constructor( if (CODEC_ID_SUBRIP == track.codecId) { writeSubtitleSampleData(input, SUBRIP_PREFIX, size) return finishWriteSampleData() - } else if (CODEC_ID_ASS == track.codecId) { + } else if (CODEC_ID_ASS == track.codecId || CODEC_ID_SSA == track.codecId) { writeSubtitleSampleData(input, SSA_PREFIX, size) return finishWriteSampleData() } else if (CODEC_ID_VTT == track.codecId) { @@ -1187,6 +1363,20 @@ class UpdatedMatroskaExtractor private constructor( return finishWriteSampleData() } + if (track.waitingForDtsAnalysis) { + checkNotNull(track.format) + if (DtsUtil.isSampleDtsHd(input, size)) { + track.format = track.format!! + .buildUpon() + .setSampleMimeType(MimeTypes.AUDIO_DTS_HD) + .build() + } + + track.output!!.format(track.format!!) + track.waitingForDtsAnalysis = false + maybeEndTracks() + } + val output = track.output if (!sampleEncodingHandled) { if (track.hasContentEncryption) { @@ -1353,7 +1543,7 @@ class UpdatedMatroskaExtractor private constructor( } } else { if (track.trueHdSampleRechunker != null) { - Assertions.checkState(sampleStrippedBytes.limit() == 0) + checkState(sampleStrippedBytes.limit() == 0) track.trueHdSampleRechunker!!.startSample(input) } while (sampleBytesRead < size) { @@ -1452,57 +1642,6 @@ class UpdatedMatroskaExtractor private constructor( return bytesWritten } - /** - * Builds a [SeekMap] from the recently gathered Cues information. - * - * @return The built [SeekMap]. The returned [SeekMap] may be unseekable if cues - * information was missing or incomplete. - */ - private fun buildSeekMap( - cueTimesUs: androidx.media3.common.util.LongArray?, - cueClusterPositions: androidx.media3.common.util.LongArray? - ): SeekMap { - if (segmentContentPosition == C.INDEX_UNSET.toLong() || durationUs == C.TIME_UNSET || cueTimesUs == null || cueTimesUs.size() == 0 || cueClusterPositions == null || cueClusterPositions.size() != cueTimesUs.size()) { - // Cues information is missing or incomplete. - return Unseekable(durationUs) - } - val cuePointsSize = cueTimesUs.size() - var sizes = IntArray(cuePointsSize) - var offsets = LongArray(cuePointsSize) - var durationsUs = LongArray(cuePointsSize) - var timesUs = LongArray(cuePointsSize) - for (i in 0.. 0 && timesUs[lastValidIndex] > durationUs) { - lastValidIndex-- - } - - // Calculate sizes and durations for the last valid index - sizes[lastValidIndex] = - (segmentContentPosition + segmentContentSize - offsets[lastValidIndex]).toInt() - durationsUs[lastValidIndex] = durationUs - timesUs[lastValidIndex] - - // If the last valid index is not the last cue point, truncate the arrays - if (lastValidIndex < cuePointsSize - 1) { - Log.w(TAG, "Discarding trailing cue points with timestamps greater than total duration") - sizes = sizes.copyOf(lastValidIndex + 1) - offsets = offsets.copyOf(lastValidIndex + 1) - durationsUs = durationsUs.copyOf(lastValidIndex + 1) - timesUs = timesUs.copyOf(lastValidIndex + 1) - } - - return ChunkIndex(sizes, offsets, durationsUs, timesUs) - } - /** * Updates the position of the holder to Cues element's position if the extractor configuration * permits use of master seek entry. After building Cues sets the holder's position back to where @@ -1522,7 +1661,7 @@ class UpdatedMatroskaExtractor private constructor( // (until cues or end of segment). However this also means that we only need to seek // back to the top once, instead seeking back in a stack like manner. if (seekForSeekContent) { - Assertions.checkArgument(pendingSeekHeads.isNotEmpty(), "Illegal value of seekForSeekContent") + checkArgument(pendingSeekHeads.isNotEmpty(), "Illegal value of seekForSeekContent") // The exact order does not really matter, but it is easiest to just do stack (FILO) val next = pendingSeekHeads.removeAt(pendingSeekHeads.size - 1) seekPosition.position = next @@ -1569,11 +1708,22 @@ class UpdatedMatroskaExtractor private constructor( } private fun assertInitialized() { - Assertions.checkStateNotNull( + checkNotNull( extractorOutput ) } + private fun maybeEndTracks() { + if (!pendingEndTracks) return + + for (i in 0 until tracks.size()) { + if (tracks.valueAt(i).waitingForDtsAnalysis) return + } + + checkNotNull(extractorOutput).endTracks() + pendingEndTracks = false + } + /** Passes events through to the outer [UpdatedMatroskaExtractor]. */ private inner class InnerEbmlProcessor : EbmlProcessor { override fun getElementType(id: Int): @EbmlProcessor.ElementType Int { @@ -1618,10 +1768,11 @@ class UpdatedMatroskaExtractor private constructor( /** Holds data corresponding to a single track. */ protected class Track { // Common elements. + var isWebm: Boolean = false var name: String? = null var codecId: String? = null var number: Int = 0 - var type: Int = 0 + var type: @C.TrackType Int = 0 var defaultSampleDurationNs: Int = 0 var maxBlockAdditionId: Int = 0 var blockAddIdType: Int = 0 @@ -1671,23 +1822,24 @@ class UpdatedMatroskaExtractor private constructor( var sampleRate: Int = 8000 var codecDelayNs: Long = 0 var seekPreRollNs: Long = 0 - var trueHdSampleRechunker: TrueHdSampleRechunker? = - null + var trueHdSampleRechunker: TrueHdSampleRechunker? = null + var waitingForDtsAnalysis: Boolean = false // Text elements. var flagForced: Boolean = false + + // Common track elements. var flagDefault: Boolean = true var language: String = "eng" // Set when the output is initialized. nalUnitLengthFieldLength is only set for H264/H265. var output: TrackOutput? = null + var format: Format? = null var nalUnitLengthFieldLength: Int = 0 - /** Initializes the track with an output. */ - @Throws( - ParserException::class - ) - fun initializeOutput(output: ExtractorOutput, trackId: Int) { + /** Builds the [Format] for the track. */ + @Throws(ParserException::class) + fun initializeFormat(trackId: Int) { var mimeType: String var maxInputSize = Format.NO_VALUE var pcmEncoding: @PcmEncoding Int = Format.NO_VALUE @@ -1695,8 +1847,20 @@ class UpdatedMatroskaExtractor private constructor( var codecs: String? = null when (codecId) { CODEC_ID_VP8 -> mimeType = MimeTypes.VIDEO_VP8 - CODEC_ID_VP9 -> mimeType = MimeTypes.VIDEO_VP9 - CODEC_ID_AV1 -> mimeType = MimeTypes.VIDEO_AV1 + CODEC_ID_VP9 -> { + mimeType = MimeTypes.VIDEO_VP9 + initializationData = + if (codecPrivate == null) null else ImmutableList.of( + codecPrivate!! + ) + } + CODEC_ID_AV1 -> { + mimeType = MimeTypes.VIDEO_AV1 + initializationData = + if (codecPrivate == null) null else ImmutableList.of( + codecPrivate!! + ) + } CODEC_ID_MPEG2 -> mimeType = MimeTypes.VIDEO_MPEG2 CODEC_ID_MPEG4_SP, CODEC_ID_MPEG4_ASP, CODEC_ID_MPEG4_AP -> { mimeType = MimeTypes.VIDEO_MP4V @@ -1808,7 +1972,10 @@ class UpdatedMatroskaExtractor private constructor( trueHdSampleRechunker = TrueHdSampleRechunker() } - CODEC_ID_DTS, CODEC_ID_DTS_EXPRESS -> mimeType = MimeTypes.AUDIO_DTS + CODEC_ID_DTS, CODEC_ID_DTS_EXPRESS -> { + mimeType = MimeTypes.AUDIO_DTS // temporary + waitingForDtsAnalysis = true + } CODEC_ID_DTS_LOSSLESS -> mimeType = MimeTypes.AUDIO_DTS_HD CODEC_ID_FLAC -> { mimeType = MimeTypes.AUDIO_FLAC @@ -1907,7 +2074,7 @@ class UpdatedMatroskaExtractor private constructor( } CODEC_ID_SUBRIP -> mimeType = MimeTypes.APPLICATION_SUBRIP - CODEC_ID_ASS -> { + CODEC_ID_ASS, CODEC_ID_SSA -> { mimeType = MimeTypes.TEXT_SSA initializationData = ImmutableList.of( SSA_DIALOGUE_FORMAT, getCodecPrivate( @@ -1953,18 +2120,15 @@ class UpdatedMatroskaExtractor private constructor( selectionFlags = selectionFlags or if (flagDefault) C.SELECTION_FLAG_DEFAULT else 0 selectionFlags = selectionFlags or if (flagForced) C.SELECTION_FLAG_FORCED else 0 - val type: Int val formatBuilder = Format.Builder() // TODO: Consider reading the name elements of the tracks and, if present, incorporating them // into the trackId passed when creating the formats. if (MimeTypes.isAudio(mimeType)) { - type = C.TRACK_TYPE_AUDIO formatBuilder .setChannelCount(channelCount) .setSampleRate(sampleRate) .setPcmEncoding(pcmEncoding) } else if (MimeTypes.isVideo(mimeType)) { - type = C.TRACK_TYPE_VIDEO if (displayUnit == DISPLAY_UNIT_PIXELS) { displayWidth = if (displayWidth == Format.NO_VALUE) width else displayWidth displayHeight = if (displayHeight == Format.NO_VALUE) height else displayHeight @@ -2025,7 +2189,6 @@ class UpdatedMatroskaExtractor private constructor( || MimeTypes.APPLICATION_PGS == mimeType || MimeTypes.APPLICATION_DVBSUBS == mimeType ) { - type = C.TRACK_TYPE_TEXT } else { throw ParserException.createForMalformedContainer( "Unexpected MIME type.", /* cause= */null @@ -2036,9 +2199,10 @@ class UpdatedMatroskaExtractor private constructor( formatBuilder.setLabel(name) } - val format = + format = formatBuilder .setId(trackId) + .setContainerMimeType(if (isWebm) MimeTypes.VIDEO_WEBM else MimeTypes.VIDEO_MATROSKA) .setSampleMimeType(mimeType) .setMaxInputSize(maxInputSize) .setLanguage(language) @@ -2047,9 +2211,6 @@ class UpdatedMatroskaExtractor private constructor( .setCodecs(codecs) .setDrmInitData(drmInitData) .build() - - this.output = output.track(number, type) - this.output!!.format(format) } /** Forces any pending sample metadata to be flushed to the output. */ @@ -2124,6 +2285,90 @@ class UpdatedMatroskaExtractor private constructor( return hdrStaticInfoData } + /** + * Finds the best thumbnail timestamp from the cue points and adds it to the track's format as + * [ThumbnailMetadata]. + */ + fun maybeAddThumbnailMetadata( + perTrackCues: SparseArray>, + durationUs: Long, + segmentContentPosition: Long, + segmentContentSize: Long + ) { + if (type != C.TRACK_TYPE_VIDEO) return + + val cuePoints = perTrackCues[number] + if (cuePoints.isNullOrEmpty()) return + + val thumbnailTimestampUs = findBestThumbnailPresentationTimeUs( + cuePoints, durationUs, segmentContentPosition, segmentContentSize + ) + + if (thumbnailTimestampUs != C.TIME_UNSET) { + val currentFormat = requireNotNull(format) + val existingMetadata = currentFormat.metadata + val thumbnailMetadata = ThumbnailMetadata(thumbnailTimestampUs) + val newMetadata = if (existingMetadata == null) { + Metadata(thumbnailMetadata) + } else { + existingMetadata.copyWithAppendedEntries(thumbnailMetadata) + } + format = currentFormat.buildUpon().setMetadata(newMetadata).build() + } + } + + /** + * Finds the best thumbnail timestamp from the provided cue points. + * + *

The heuristic seeks to find a visually interesting frame by assuming that a larger chunk + * size corresponds to a more complex and representative frame. It calculates an approximate + * bitrate for each chunk and selects the timestamp of the chunk with the highest bitrate. + */ + private fun findBestThumbnailPresentationTimeUs( + cuePoints: MutableList, + durationUs: Long, + segmentContentPosition: Long, + segmentContentSize: Long + ): Long { + if (cuePoints.isEmpty()) return C.TIME_UNSET + + var maxBitrate = 0.0 + var bestCueIndex = -1 + val scanLimit = min(cuePoints.size, MAX_CHUNKS_TO_SCAN_FOR_THUMBNAIL) + + for (i in 0 until scanLimit) { + val cue = cuePoints[i] + + if (cue.timeUs > MAX_DURATION_US_TO_SCAN_FOR_THUMBNAIL) break + + val bytesBetweenCues: Long + val durationBetweenCuesUs: Long + + if (i < cuePoints.size - 1) { + val nextCue = cuePoints[i + 1] + bytesBetweenCues = (nextCue.clusterPosition + nextCue.relativePosition) - + (cue.clusterPosition + cue.relativePosition) + durationBetweenCuesUs = nextCue.timeUs - cue.timeUs + } else { + // Last cue point + bytesBetweenCues = (segmentContentPosition + segmentContentSize) - + (cue.clusterPosition + cue.relativePosition) + durationBetweenCuesUs = durationUs - cue.timeUs + } + + if (durationBetweenCuesUs > 0) { + // This is an approximation of the bitrate for thumbnail heuristic. + val bitrate = bytesBetweenCues.toDouble() / durationBetweenCuesUs + if (bitrate > maxBitrate) { + maxBitrate = bitrate + bestCueIndex = i + } + } + } + + return if (bestCueIndex == -1) C.TIME_UNSET else cuePoints[bestCueIndex].timeUs + } + /** * Checks that the track has an output. * @@ -2133,14 +2378,12 @@ class UpdatedMatroskaExtractor private constructor( * fact at runtime. */ fun assertOutputInitialized() { - Assertions.checkNotNull( + checkNotNull( output ) } - @Throws( - ParserException::class - ) + @Throws(ParserException::class) private fun getCodecPrivate(codecId: String): ByteArray { if (codecPrivate == null) { throw ParserException.createForMalformedContainer( @@ -2379,6 +2622,7 @@ class UpdatedMatroskaExtractor private constructor( private const val CODEC_ID_PCM_FLOAT = "A_PCM/FLOAT/IEEE" private const val CODEC_ID_SUBRIP = "S_TEXT/UTF8" private const val CODEC_ID_ASS = "S_TEXT/ASS" + private const val CODEC_ID_SSA = "S_TEXT/SSA" private const val CODEC_ID_VTT = "S_TEXT/WEBVTT" private const val CODEC_ID_VOBSUB = "S_VOBSUB" private const val CODEC_ID_PGS = "S_HDMV/PGS" @@ -2455,8 +2699,10 @@ class UpdatedMatroskaExtractor private constructor( private const val ID_CUES = 0x1C53BB6B private const val ID_CUE_POINT = 0xBB private const val ID_CUE_TIME = 0xB3 + private const val ID_CUE_TRACK = 0xF7 private const val ID_CUE_TRACK_POSITIONS = 0xB7 private const val ID_CUE_CLUSTER_POSITION = 0xF1 + private const val ID_CUE_RELATIVE_POSITION = 0xF0 private const val ID_LANGUAGE = 0x22B59C private const val ID_PROJECTION = 0x7670 private const val ID_PROJECTION_TYPE = 0x7671 @@ -2511,6 +2757,12 @@ class UpdatedMatroskaExtractor private constructor( private const val FOURCC_COMPRESSION_H263 = 0x33363248 private const val FOURCC_COMPRESSION_VC1 = 0x31435657 + /** The maximum number of chunks to scan when searching for a thumbnail. */ + private const val MAX_CHUNKS_TO_SCAN_FOR_THUMBNAIL = 20 + + /** The maximum duration to scan for a thumbnail, in microseconds. */ + private const val MAX_DURATION_US_TO_SCAN_FOR_THUMBNAIL = 10_000_000L + /** * A template for the prefix that must be added to each subrip sample. * @@ -2732,8 +2984,8 @@ class UpdatedMatroskaExtractor private constructor( * See documentation on [.SSA_DIALOGUE_FORMAT] and [.SUBRIP_PREFIX] for why we use * the duration as the end timecode. * - * @param codecId The subtitle codec; must be [.CODEC_ID_SUBRIP], [.CODEC_ID_ASS] or - * [.CODEC_ID_VTT]. + * @param codecId The subtitle codec; must be [.CODEC_ID_SUBRIP], [.CODEC_ID_ASS], + * [.CODEC_ID_SSA] or [.CODEC_ID_VTT]. * @param durationUs The duration of the sample, in microseconds. * @param subtitleData The subtitle sample in which to overwrite the end timecode (output * parameter). @@ -2752,7 +3004,7 @@ class UpdatedMatroskaExtractor private constructor( endTimecodeOffset = SUBRIP_PREFIX_END_TIMECODE_OFFSET } - CODEC_ID_ASS -> { + CODEC_ID_ASS, CODEC_ID_SSA -> { endTimecode = formatSubtitleTimecode( durationUs, SSA_TIMECODE_FORMAT, SSA_TIMECODE_LAST_VALUE_SCALING_FACTOR @@ -2780,7 +3032,7 @@ class UpdatedMatroskaExtractor private constructor( timeUs: Long, timecodeFormat: String, lastTimecodeValueScalingFactor: Long ): ByteArray { var timeUs = timeUs - Assertions.checkArgument(timeUs != C.TIME_UNSET) + checkArgument(timeUs != C.TIME_UNSET) val timeCodeData: ByteArray val hours = (timeUs / (3600 * C.MICROS_PER_SECOND)).toInt() timeUs -= (hours * 3600L * C.MICROS_PER_SECOND) @@ -2798,7 +3050,7 @@ class UpdatedMatroskaExtractor private constructor( private fun isCodecSupported(codecId: String): Boolean { return when (codecId) { - CODEC_ID_VP8, CODEC_ID_VP9, CODEC_ID_AV1, CODEC_ID_MPEG2, CODEC_ID_MPEG4_SP, CODEC_ID_MPEG4_ASP, CODEC_ID_MPEG4_AP, CODEC_ID_H264, CODEC_ID_H265, CODEC_ID_FOURCC, CODEC_ID_THEORA, CODEC_ID_OPUS, CODEC_ID_VORBIS, CODEC_ID_AAC, CODEC_ID_MP2, CODEC_ID_MP3, CODEC_ID_AC3, CODEC_ID_E_AC3, CODEC_ID_TRUEHD, CODEC_ID_DTS, CODEC_ID_DTS_EXPRESS, CODEC_ID_DTS_LOSSLESS, CODEC_ID_FLAC, CODEC_ID_ACM, CODEC_ID_PCM_INT_LIT, CODEC_ID_PCM_INT_BIG, CODEC_ID_PCM_FLOAT, CODEC_ID_SUBRIP, CODEC_ID_ASS, CODEC_ID_VTT, CODEC_ID_VOBSUB, CODEC_ID_PGS, CODEC_ID_DVBSUB -> true + CODEC_ID_VP8, CODEC_ID_VP9, CODEC_ID_AV1, CODEC_ID_MPEG2, CODEC_ID_MPEG4_SP, CODEC_ID_MPEG4_ASP, CODEC_ID_MPEG4_AP, CODEC_ID_H264, CODEC_ID_H265, CODEC_ID_FOURCC, CODEC_ID_THEORA, CODEC_ID_OPUS, CODEC_ID_VORBIS, CODEC_ID_AAC, CODEC_ID_MP2, CODEC_ID_MP3, CODEC_ID_AC3, CODEC_ID_E_AC3, CODEC_ID_TRUEHD, CODEC_ID_DTS, CODEC_ID_DTS_EXPRESS, CODEC_ID_DTS_LOSSLESS, CODEC_ID_FLAC, CODEC_ID_ACM, CODEC_ID_PCM_INT_LIT, CODEC_ID_PCM_INT_BIG, CODEC_ID_PCM_FLOAT, CODEC_ID_SUBRIP, CODEC_ID_ASS, CODEC_ID_SSA, CODEC_ID_VTT, CODEC_ID_VOBSUB, CODEC_ID_PGS, CODEC_ID_DVBSUB -> true else -> false } @@ -2822,4 +3074,169 @@ class UpdatedMatroskaExtractor private constructor( } } } -} \ No newline at end of file + + class MatroskaSeekMap( + private val perTrackCues: SparseArray>, + private val durationUs: Long, + private val primarySeekTrackNumber: Int, + segmentContentPosition: Long, + segmentContentSize: Long + ) : TrackAwareSeekMap, ChunkIndexProvider { + + private val chunkIndex: ChunkIndex? = + buildChunkIndex( + perTrackCues, + durationUs, + primarySeekTrackNumber, + segmentContentPosition, + segmentContentSize + ) + + override fun isSeekable(): Boolean { + // The media is seekable overall only if the primary seek track has cue points. + return isSeekable(primarySeekTrackNumber) + } + + override fun isSeekable(trackId: Int): Boolean { + val cuePoints = perTrackCues[trackId] + return !cuePoints.isNullOrEmpty() + } + + override fun getDurationUs(): Long = durationUs + + override fun getSeekPoints(timeUs: Long): SeekPoints = + chunkIndex?.getSeekPoints(timeUs) + ?: SeekPoints(SeekPoint.START) + + override fun getSeekPoints(timeUs: Long, trackId: Int): SeekPoints { + var cuePoints = perTrackCues[trackId] + + if ((cuePoints.isNullOrEmpty()) && trackId != primarySeekTrackNumber) { + cuePoints = perTrackCues[primarySeekTrackNumber] + } + + if (cuePoints.isNullOrEmpty()) { + return SeekPoints(SeekPoint.START) + } + + val bestIndex = Util.binarySearchFloor( + cuePoints, + CuePointData(timeUs, C.INDEX_UNSET.toLong(), C.INDEX_UNSET.toLong()), + /* inclusive= */ true, + /* stayInBounds= */ false + ) + + return if (bestIndex != -1) { + val bestCue = cuePoints[bestIndex] + val firstPoint = SeekPoint(bestCue.timeUs, bestCue.clusterPosition) + + if (bestCue.timeUs < timeUs && bestIndex + 1 < cuePoints.size) { + val nextCue = cuePoints[bestIndex + 1] + val secondPoint = SeekPoint(nextCue.timeUs, nextCue.clusterPosition) + SeekPoints(firstPoint, secondPoint) + } else { + SeekPoints(firstPoint) + } + } else { + val firstCue = cuePoints[0] + SeekPoints(SeekPoint(firstCue.timeUs, firstCue.clusterPosition)) + } + } + + override fun getChunkIndex(): ChunkIndex? = chunkIndex + + private companion object { + + private fun buildChunkIndex( + perTrackCues: SparseArray>, + durationUs: Long, + primarySeekTrackNumber: Int, + segmentContentPosition: Long, + segmentContentSize: Long + ): ChunkIndex? { + + val primaryTrackCuePoints = + perTrackCues[primarySeekTrackNumber] ?: return null + + if (primaryTrackCuePoints.isEmpty()) { + return null + } + + val cuePointsSize = primaryTrackCuePoints.size + var sizes = IntArray(cuePointsSize) + var offsets = LongArray(cuePointsSize) + var durationsUs = LongArray(cuePointsSize) + var timesUs = LongArray(cuePointsSize) + + for (i in 0 until cuePointsSize) { + val cue = primaryTrackCuePoints[i] + timesUs[i] = cue.timeUs + offsets[i] = cue.clusterPosition + } + + for (i in 0 until cuePointsSize - 1) { + sizes[i] = (offsets[i + 1] - offsets[i]).toInt() + durationsUs[i] = timesUs[i + 1] - timesUs[i] + } + + // Start from the last cue point and move backward until a valid duration is found. + var lastValidIndex = cuePointsSize - 1 + while (lastValidIndex > 0 && timesUs[lastValidIndex] >= durationUs) { + lastValidIndex-- + } + + // Calculate sizes and durations for the last valid index + sizes[lastValidIndex] = + (segmentContentPosition + segmentContentSize - offsets[lastValidIndex]).toInt() + durationsUs[lastValidIndex] = durationUs - timesUs[lastValidIndex] + + // If trailing cue points were found, truncate the arrays to the last valid index. + if (lastValidIndex < cuePointsSize - 1) { + Log.w(TAG, "Discarding trailing cue points with timestamps greater than total duration.") + sizes = sizes.copyOf(lastValidIndex + 1) + offsets = offsets.copyOf(lastValidIndex + 1) + durationsUs = durationsUs.copyOf(lastValidIndex + 1) + timesUs = timesUs.copyOf(lastValidIndex + 1) + } + + return ChunkIndex(sizes, offsets, durationsUs, timesUs) + } + } + + class CuePointData( + /** The timestamp of the cue point, in microseconds. */ + val timeUs: Long, + + /** The absolute byte offset of the start of the cluster containing this cue point. */ + val clusterPosition: Long, + + /** + * The relative byte offset of the cue point's data block within its cluster. + * + *

Note: For seeking, use {@link #clusterPosition} to prevent A/V desync. + */ + val relativePosition: Long + ) : Comparable { + + override fun compareTo(other: CuePointData): Int { + return timeUs.compareTo(other.timeUs) + } + + override fun equals(other: Any?): Boolean { + if (this === other) { + return true + } + if (other !is CuePointData) { + return false + } + return this.timeUs == other.timeUs && + this.clusterPosition == other.clusterPosition && + this.relativePosition == other.relativePosition + } + + override fun hashCode(): Int { + return Objects.hash(timeUs, clusterPosition, relativePosition) + } + } + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 90ae59c07..5eaa1fd83 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -27,10 +27,10 @@ kotlinGradlePlugin = "2.3.0" kotlinxCoroutinesCore = "1.10.2" lifecycleKtx = "2.9.4" material = "1.14.0-alpha08" -media3 = "1.8.0" +media3 = "1.9.2" navigationKtx = "2.9.6" newpipeextractor = "v0.25.2" -nextlibMedia3 = "1.8.0-0.9.0" +nextlibMedia3 = "1.9.1-0.10.1" nicehttp = "0.4.16" overlappingpanels = "0.1.5" paletteKtx = "1.0.0" From a8f6ef0ea559d01580fc1f2b4476632c6aeba4ff Mon Sep 17 00:00:00 2001 From: firelight <147925818+fire-light42@users.noreply.github.com> Date: Sat, 28 Feb 2026 23:23:24 +0100 Subject: [PATCH 058/236] Fix: Nextlib textrenderer from #2510 --- .../cloudstream3/ui/player/CS3IPlayer.kt | 2 +- .../ui/player/FixedNextRenderersFactory.kt | 28 +++++++++++++++++++ gradle/libs.versions.toml | 2 +- 3 files changed, 30 insertions(+), 2 deletions(-) create mode 100644 app/src/main/java/com/lagradost/cloudstream3/ui/player/FixedNextRenderersFactory.kt diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/CS3IPlayer.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/CS3IPlayer.kt index 3ceaa99c3..7f6586912 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/CS3IPlayer.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/CS3IPlayer.kt @@ -1084,7 +1084,7 @@ class CS3IPlayer : IPlayer { } val factory = if (isSoftwareDecodingEnabled) { - NextRenderersFactory(context).apply { + FixedNextRenderersFactory(context).apply { setEnableDecoderFallback(true) setExtensionRendererMode( if (isSoftwareDecodingPreferred) diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/FixedNextRenderersFactory.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/FixedNextRenderersFactory.kt new file mode 100644 index 000000000..025267cc9 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/FixedNextRenderersFactory.kt @@ -0,0 +1,28 @@ +package com.lagradost.cloudstream3.ui.player + +import android.content.Context +import android.os.Looper +import androidx.media3.common.util.UnstableApi +import androidx.media3.exoplayer.Renderer +import androidx.media3.exoplayer.text.TextOutput +import androidx.media3.exoplayer.text.TextRenderer +import io.github.anilbeesetti.nextlib.media3ext.ffdecoder.NextRenderersFactory + +@UnstableApi +class FixedNextRenderersFactory(context: Context) : NextRenderersFactory(context) { + /** Somehow the nextlib authors decided that we need a text renderer that causes + * "ERROR_CODE_FAILED_RUNTIME_CHECK". + * + * Core issue: https://github.com/anilbeesetti/nextlib/pull/158 + * Comment: https://github.com/recloudstream/cloudstream/pull/2342#issuecomment-3917751718 + * */ + override fun buildTextRenderers( + context: Context, + output: TextOutput, + outputLooper: Looper, + extensionRendererMode: Int, + out: ArrayList + ) { + out.add(TextRenderer(output, outputLooper)) + } +} \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 5eaa1fd83..a836a75fb 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -30,7 +30,7 @@ material = "1.14.0-alpha08" media3 = "1.9.2" navigationKtx = "2.9.6" newpipeextractor = "v0.25.2" -nextlibMedia3 = "1.9.1-0.10.1" +nextlibMedia3 = "1.9.1-0.11.0" nicehttp = "0.4.16" overlappingpanels = "0.1.5" paletteKtx = "1.0.0" From 2ca21051d5d371c8ee48633d1c6810469bb9645b Mon Sep 17 00:00:00 2001 From: Luna712 <142361265+Luna712@users.noreply.github.com> Date: Sat, 28 Feb 2026 15:29:54 -0700 Subject: [PATCH 059/236] Bump androidx libraries (#2518) --- gradle/libs.versions.toml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index a836a75fb..20ff0f048 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,10 +1,10 @@ # https://docs.gradle.org/current/userguide/plugins.html#sec:version_catalog_plugin_application # https://docs.gradle.org/current/userguide/dependency_versions.html#sec:strict-version [versions] -activityKtx = "1.11.0" +activityKtx = "1.12.4" androidGradlePlugin = "8.13.2" appcompat = "1.7.1" -biometric = "1.4.0-alpha04" +biometric = "1.4.0-alpha05" buildkonfigGradlePlugin = "0.17.1" coil = "3.3.0" colorpicker = "6b46b49bd5" @@ -25,10 +25,10 @@ junitVersion = "1.3.0" juniversalchardet = "2.5.0" kotlinGradlePlugin = "2.3.0" kotlinxCoroutinesCore = "1.10.2" -lifecycleKtx = "2.9.4" +lifecycleKtx = "2.10.0" material = "1.14.0-alpha08" media3 = "1.9.2" -navigationKtx = "2.9.6" +navigationKtx = "2.9.7" newpipeextractor = "v0.25.2" nextlibMedia3 = "1.9.1-0.11.0" nicehttp = "0.4.16" @@ -44,7 +44,7 @@ tmdbJava = "2.13.0" torrentserver = "7861970e038b35cd8c6918384e49caf26903e09e" tvprovider = "1.1.0" video = "1.0.0" -workRuntimeKtx = "2.10.5" +workRuntimeKtx = "2.11.1" zipline = "1.24.0" jvmTarget = "1.8" From 1fb6ce310d9453a91e28a7bd1cef16837df2041a Mon Sep 17 00:00:00 2001 From: Luna712 <142361265+Luna712@users.noreply.github.com> Date: Sat, 28 Feb 2026 15:34:24 -0700 Subject: [PATCH 060/236] Bump material (#2519) --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 20ff0f048..1e5a2ade9 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -26,7 +26,7 @@ juniversalchardet = "2.5.0" kotlinGradlePlugin = "2.3.0" kotlinxCoroutinesCore = "1.10.2" lifecycleKtx = "2.10.0" -material = "1.14.0-alpha08" +material = "1.14.0-alpha09" media3 = "1.9.2" navigationKtx = "2.9.7" newpipeextractor = "v0.25.2" From 618f9cde65d8db19bf6ebfe0cf275555995daba4 Mon Sep 17 00:00:00 2001 From: Hosted Weblate Date: Mon, 9 Mar 2026 19:09:54 +0100 Subject: [PATCH 061/236] Translated using Weblate (Vietnamese) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Currently translated at 99.8% (724 of 725 strings) Translated using Weblate (Hungarian) Currently translated at 82.0% (595 of 725 strings) Translated using Weblate (Korean) Currently translated at 100.0% (725 of 725 strings) Translated using Weblate (Belarusian) Currently translated at 99.5% (722 of 725 strings) Merge remote-tracking branch 'origin/master' Merge remote-tracking branch 'origin/master' Merge remote-tracking branch 'origin/master' Merge remote-tracking branch 'origin/master' Merge remote-tracking branch 'origin/master' Merge remote-tracking branch 'origin/master' Merge remote-tracking branch 'origin/master' Merge remote-tracking branch 'origin/master' Translated using Weblate (Belarusian) Currently translated at 99.5% (722 of 725 strings) Translated using Weblate (Filipino) Currently translated at 21.2% (154 of 725 strings) Translated using Weblate (Hungarian) Currently translated at 77.3% (561 of 725 strings) Translated using Weblate (Dutch) Currently translated at 89.1% (646 of 725 strings) Translated using Weblate (Tamil) Currently translated at 100.0% (725 of 725 strings) Translated using Weblate (German) Currently translated at 100.0% (725 of 725 strings) Translated using Weblate (Tamil) Currently translated at 100.0% (725 of 725 strings) Translated using Weblate (Hungarian) Currently translated at 75.4% (547 of 725 strings) Co-authored-by: Aron Folkerts Co-authored-by: David Hermann Co-authored-by: Hosted Weblate Co-authored-by: Nguyễn Tiến Đạt Co-authored-by: Romhányi-Kakucska Viktor Co-authored-by: Sasha Glazko Co-authored-by: Wacky Wars Co-authored-by: clearstripe Co-authored-by: தமிழ்நேரம் Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/be/ Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/de/ Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/fil/ Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/hu/ Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/ko/ Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/nl/ Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/ta/ Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/vi/ Translation: Cloudstream/App --- app/src/main/res/values-b+de/strings.xml | 12 ++- app/src/main/res/values-b+fil/strings.xml | 104 ++++++++++++++++++++++ app/src/main/res/values-b+hu/strings.xml | 62 +++++++++++++ app/src/main/res/values-b+ko/strings.xml | 61 +++++++++---- app/src/main/res/values-b+nl/strings.xml | 30 ++++++- app/src/main/res/values-b+ta/strings.xml | 65 +++++++++++++- app/src/main/res/values-b+vi/strings.xml | 14 +++ app/src/main/res/values-be/strings.xml | 26 +++++- 8 files changed, 346 insertions(+), 28 deletions(-) diff --git a/app/src/main/res/values-b+de/strings.xml b/app/src/main/res/values-b+de/strings.xml index 8989ce795..9a67f9d20 100644 --- a/app/src/main/res/values-b+de/strings.xml +++ b/app/src/main/res/values-b+de/strings.xml @@ -744,9 +744,19 @@ Medieninfo Quellname Alle herunterladen - Möchtest du Episode%s herunter laden? + Möchtest du Episode %s herunter laden? %d aktiver Download %d aktive Downloads + Möchtest du alle Downloads in der Warteschlange abbrechen? + Alles abbrechen + + %d Download in Warteschlange + %d Downloads in Warteschlange + + Download in Warteschlange + Es befinden sich keine Downloads in der Warteschlange. + Quellpriorität + Entscheide, wie Videoquellen im Player sortiert werden sollen diff --git a/app/src/main/res/values-b+fil/strings.xml b/app/src/main/res/values-b+fil/strings.xml index d4844d1d7..be54aa959 100644 --- a/app/src/main/res/values-b+fil/strings.xml +++ b/app/src/main/res/values-b+fil/strings.xml @@ -52,4 +52,108 @@ Itago ang napiling quality ng video sa mga resulta ng paghahanap Pumili ng mode upang i-filter ang pag-download ng mga plugin Awtomatikong i-install ang lahat ng hindi pa naka-install na plugin mula sa mga idinagdag na repository. + Tauhan: %s + Kabanata %d ay ipapalabas sa + Season %1$d Kabanata %2$d ay ipapalabas sa + %1$da %2$do %3$dm + %1$do %2$dm + %dm + %1$do %2$dm %3$ds + %1$dm %2$ds + %1$ds + Poster ng Kabanata + Susunod na Kahit Ano + Bumalik + Ipatugtog mula sa Simula + Baguhin ang Tagatustos + Background ng Pasilip + Bilis (%.2fx) + Na-rate: %.1f + May nakitang bagong update!\n%1$s -> %2$s + %d min + Ipatugtog sa CloudStream + Bahay + Maghanap + Mga Download + Nakapila na download + Mga pagpipilian + Maghanap… + Maghanap %s… + Magsimulang magsalita… + Walang Data + Iba pang Mga Pagpipilian + Susunod na kabanata + Mga Genre + Ibahagi + Browser + Laktawan ang paglo-load + Naglo-load… + Pinapanood + Nakabinbin + Nakumpleto + Pinaplanong Panoorin + Pinapanood muli + Ipatugtog ang Pelikula + Ipatugtog ang Trailer + Ipatugtog ang Livestream + Ipatugtog ang Buong Serye + Mga Mapagkukunan + Mga saling-teksto + Subukang muli kumonekta… + Bumalik + Ipatugtog ang Kabanata + I-stream ang Torrent + I-download + Na-download + Dina-download + Hininto ang pag-download + Sinimulan ang pag-download + Nabigong ma-download + Kinansela ang pag-download + Natapos ang pag-download + Kasalukuyang walang nakapilang mga download. + Sinimulan ang pag-update + Buksan ang local video + Nagkamali sa paglo-load ng mga link + Ini-load muli ang mga link + Imbakan + Dub + Sub + Burahin ang File + Ipatugtog ang File + Ipagpatuloy ang pag-download + Ihinto ang pag-download + Higit pang impormasyon + Itago + Ipatugtog + Impormasyon + I-filter ang Mga Bookmark + Mga Bookmark + Alisin + Itakda ang kapalaran ng panonood + Ilapat + Ikopya + Isara + Alisin + I-save + Pangalan ng repository at URL + nakopya! + Bagong notipikasyon ng kabanata + Maghanap sa iba pang mga extension + Ipakita ang mga rekomendasyon + Bilis ng Manlalaro + Pagpipilian sa Saling-teksto + Kulay ng Teksto + Kulay ng Balangkas + Kulay ng Likuran + Kulay ng Bintana + Kataasan ng Saling-teksto + Font + Laki ng Font + Maghanap gamit ang mga tagatustos + Maghanap gamit ang mga uri + %d Benenes naibigay sa mga dev + Walang Benenes na binigay + Awtomatikong Piliin ang Wika + Mag-download ng Mga Wika diff --git a/app/src/main/res/values-b+hu/strings.xml b/app/src/main/res/values-b+hu/strings.xml index ae018207b..8bd2ac7ac 100644 --- a/app/src/main/res/values-b+hu/strings.xml +++ b/app/src/main/res/values-b+hu/strings.xml @@ -591,4 +591,66 @@ Elérhető offline megtekintésre Mindet Kiválaszt Mindent Kijelölés Eltávolítása + %1$dh %2$dm %3$ds + %1$dm %2$ds + %1$ds + Letöltési sor + Beszédfelismerés nem elérhető + Kezdjen beszélni… + Teljes sorozat lejátszása + Jelenleg nincs sorban álló letöltés. + Extra fényerő + A fényerő szűrő engedélyezése, ha a kijelző fényereje meghaladja a 100% -ot + extra_brightness_enabled + Keresési javaslatok + Mutasson keresési javaslatok gépelés közben + Javaslatok törlése + Szereplők panel megjelenítése + Telepít kiadás előtti verziót + Az előzetes verzió már telepítve van. + Az előzetes verzió telepítése sikertelen. + Fájlok törlése + Biztosan törölni szeretné az alábbi sorozat összes megjelenését?\n\n%s + %s \nmaradék + Zene + Hangoskönyv + Média + Hang + Podcast + Kódolási hiba + Hiba nem támogatott + Törlés (%1$d | %2$s) + Figyelem + Biztosan véglegesen szeretné törölni a következő tételeket?\n\n%s + Biztosan véglegesen szeretné törölni a következő epizódokat?\n\n%2$s + A következő sorozatok összes epizódját is véglegesen törli:\n\n%s + Lejátszás tükrözve" + Értékelési címke + Epizód szövege + Biztonság + Fiókok + Helyi hitelesítés + Töltse be az első létezőt + QR kód képe + Média információ + Plugin törlése + Mindig kérdezzen + Válassza ki a lejátszó eszközt + Hiba történt a vágólap elérésénél, kérjük, próbálkozzon újra. + Hiba történt a másolás során. Kérjük, másolja a logcat fájlt, és vegye fel a kapcsolatot az alkalmazás ügyfélszolgálatával. + Rendben + Elutasítás + Jelszó/PIN-kód hitelesítés + A biometrikus hitelesítés nem támogatott ezen az eszközön + Az alkalmazás feloldása ujjlenyomat, arcfelismerés, PIN-kód, minta vagy jelszó segítségével. + Néhány sikertelen kísérlet után a parancssor bezárul. Egyszerűen indítsa újra az alkalmazást, és próbálja meg újra. + Visszaállítás + CloudStream Wiki + Látogasson el %s okostelefonján vagy számítógépén, és írja be a fenti kódot + Nem működik az eszköz PIN-kódja, próbálkozzon helyi hitelesítéssel + A PIN-kód most lejárt! + A kód %1$dm %2$ds után lejár + Kiadás dátuma (újabbaktól régebbi felé) + Kiadás dátuma (régitől újig) + A lejátszó vezérlői neveinek elrejtése diff --git a/app/src/main/res/values-b+ko/strings.xml b/app/src/main/res/values-b+ko/strings.xml index 90504ec95..04c113b5b 100644 --- a/app/src/main/res/values-b+ko/strings.xml +++ b/app/src/main/res/values-b+ko/strings.xml @@ -109,7 +109,7 @@ 플레이어 자막 설정 Chromecast 자막 Chromecast 자막 설정 - 배속 모드 + Playback 속도 스와이프하여 탐색 좌우로 스와이프하여 동영상 위치 제어하기 스와이프하여 설정 변경 @@ -142,7 +142,7 @@ 추가된 저장소에서 아직 설치되지 않은 모든 플러그인을 자동으로 설치합니다. 앱 업데이트 표시 앱을 시작한 후 새 업데이트를 자동으로 검색합니다. - 일부 휴대폰은 새 패키지 설치 프로그램을 지원하지 않습니다. 업데이트가 설치되지 않으면 레거시 옵션을 사용해 보세요. + 일부 장치는 새 패키지 설치 프로그램을 지원하지 않습니다. 업데이트가 설치되지 않으면 레거시 옵션을 사용해보십시오. 같은 개발자가 만든 라이트 노벨 앱 같은 개발자가 만든 애니메이션 앱 Discord에 참여하기 @@ -198,12 +198,12 @@ 이 업데이트 건너뛰기 선호하는 화질 (WiFi) 선호하는 화질 (모바일 데이터) - 동영상 플레이어 해상도 + 본문 바로가기 동영상 버퍼 크기 동영상 및 이미지 캐시 지우기 DNS over HTTPS GitHub에 연결할 수 없습니다. jsDelivr 프록시를 켜는 중… - jsDelivr을 사용하여 GitHub 차단을 우회합니다. 업데이트가 며칠 지연될 수 있습니다. + JsDelivr를 사용하여 원시 github URL 차단을 우회하십시오. 몇 일 지연 될 업데이트가 발생할 수 있습니다. 복제 사이트 사이트 삭제 다른 URL을 사용하여 기존 사이트의 복제본을 추가합니다 @@ -217,10 +217,10 @@ 일반 플레이어 기능 기능 - 소스 언어 + 확장 언어 앱 레이아웃 선호하는 미디어 - 지원되는 공급업체에서 19금 사용 설정 + 지원된 연장에 NSFW 활성화 자막 인코딩 소스 소스 테스트 @@ -305,11 +305,11 @@ 커뮤니티 저장소 보기 공개 목록 모든 자막 대문자화 - 이 저장소에서 모든 플러그인을 다운로드하시겠습니까?경고: CloudStream 3은 타 사 확장 프로그램 사용에 대해 책임을 지지 않으며 이를 지원하지 않습니다! + 경고: CloudStream은 제3자 확장을 이용하여 어떠한 책임도 지지 않습니다! %s (사용불가) 저장소 추가 - 저장소 이름 - 저장소 URL + 저장소 이름 (선택 사항) + 저장소 URL 또는 단축 코드 플러그인이 로드됨 플러그인 다운로드 플러그인 삭제됨 @@ -380,7 +380,7 @@ 다시 표시하지 않음 업데이트를 찾을 수 없음 업데이트 - raw.githubusercontent.com 프록시 + GitHub 프록시 동영상 버퍼 길이 저장소에 동영상 캐시 Android TV와 같이 메모리가 부족한 디바이스에서 너무 높게 설정하면 충돌이 발생할 수 있습니다. @@ -397,12 +397,12 @@ 앱 업데이트 확장 기능 로그아웃 - 사이트 URL + https://example.com 비밀번호 계정 사용자 이름 언어 코드 (ko) - 사이트 이름 + 새사이트이름 %1$s %2$s 자막이 %d ms 너무 늦게 표시되는 경우, 사용하세요 자막 지연 없음 @@ -497,16 +497,16 @@ 동작 외형 랜덤 버튼 - 홈페이지에 랜덤 버튼 표시 + 홈페이지 및 도서관에서 임의 버튼 표시 포스터 아래에 제목을 이동 내려감 올라감 다람쥐 헌 쳇바퀴에 타고파 자막에서 부풀림 제거 엑스트라 - 스트림 링크 + https://example.com/example.mp4 트랙 - 레퍼러 + Referer (선택) 요약 시청함으로 표시 되돌리기 @@ -600,7 +600,7 @@ 플러그인 다운로드를 필터링할 모드 선택 데이터가 백업되었습니다. 장치에 따라 동작이 다를 수 있으며 앱 접근이 차단될 경우 앱 데이터를 완전히 지우고 백업에서 복원하세요. 이로 인해 발생하는 불편을 사과드립니다. 스마트폰이나 컴퓨터에서 %s를 방문하여 위의 코드를 입력하세요 - 구독 TV 프로그램에 대한 중단 없는 다운로드 및 알림을 보장하기 위해 CloudStream은 백그라운드에서 실행할 수 있는 권한이 필요합니다. 확인을 누르면 App info로 이동합니다. 거기서 𝘼𝙥𝙥 𝙗𝙖𝙩𝙩𝙚𝙧𝙮 𝙪𝙨𝙖𝙜𝙚로 스크롤하여 배터리 사용량을 𝙐𝙣𝙧𝙚𝙨𝙩𝙧𝙞𝙘𝙩𝙚𝙙로 설정합니다. 이 권한은 CS3가 배터리를 소모한다는 의미가 아닙니다. 알림을 받거나 공식 확장에서 동영상을 다운로드하는 등 필요할 때만 백그라운드에서 작동합니다. 취소를 선택한 경우 나중에 𝙂𝙚𝙣𝙚𝙧𝙖𝙡 𝙎𝙚𝙩𝙩𝙞𝙣𝙜𝙨에서 이 설정을 조정할 수 있습니다. + 구독 된 TV 프로그램에 대한 특이성 다운로드 및 알림을 보려면 클라우드 스트림은 배경에서 오른쪽으로 실행할 권리가 필요합니다. 확인을 눌러 요청 대화 상자를 표시하십시오. 필요한 경우 필요에 따라 CP3에 제한되지 않고 공식을 다운로드하거나 공식화에서 확대를 누릴 필요가 있습니다. 여기서 소스의 순서를 변경할 수 있습니다. 비디오의 우선 순위가 높은 경우에는 소스 선택에 더 높게 나타납니다. 소스 우선 순위와 품질 우선 순위의 합이 비디오 우선 순위입니다. \n \n참고 A: 3 @@ -651,7 +651,7 @@ 이 동영상은 토렌트이므로 동영상 활동이 추적될 수 있습니다.\n계속하기 전에 토렌트에 대해 충분히 이해했는지 확인하세요. 오디오 팟캐스트 - 음성 시작… + 시작하기 … 인코딩 오류 지원되지 않는 오류 음성 인식 사용 불가 @@ -732,4 +732,31 @@ 왼쪽 위 중앙 위 오른쪽 위 + 비디오 소스가 플레이어에서 정렬되어야하는 방법 결정 + 100% 표시 광도가 초과될 때 Enable 광도 여과기 + 모든 퀴즈 다운로드를 취소하시겠습니까? + 에피소드를 다운로드 하시겠습니까 %s? + 현재 누락된 다운로드가 없습니다. + + %d 활성 다운로드 + + + %d 다운로드 + + 검색 결과 표시 + 회사 소개 + 배경 반경 + Reload 공급자 + 검색 제안 + 자주 묻는 질문 + 미디어 정보 + 근원 이름 + 추가 밝기 + 다운로드 queue + 다운로드 + 모든 것 + 근원 우선권 + 구름 많음 + 관련 상품 + 추가_brightness_enabled diff --git a/app/src/main/res/values-b+nl/strings.xml b/app/src/main/res/values-b+nl/strings.xml index 10588a3fc..30b8b2def 100644 --- a/app/src/main/res/values-b+nl/strings.xml +++ b/app/src/main/res/values-b+nl/strings.xml @@ -249,7 +249,7 @@ Update Voorkeurskwaliteit voor kijken (WiFi) Maximaal aantal tekens voor titel van videospeler - Videospeler Resolutie + Toon spelerinformatie Grootte videobuffer Lengte videobuffer Video cache op schijf @@ -289,7 +289,7 @@ Gebruikersnaam hello@Wereld.com 127.0.0.1 - MyCoolSite + NieuweSiteNaam https://voorbeeld.com Taalcode (nl) %1$s %2$s @@ -432,7 +432,7 @@ Repository naam (Optioneel) Plugin Gedownload Mislukt - Omzeilt de blokkering van GitHub met behulp van jsDelivr, waardoor updates enkele dagen vertraging kunnen oplopen. + Omzeil de blokkering van ruwe GitHub-URL’s via jsDelivr. Hierdoor kunnen updates enkele dagen vertraagd zijn. Repository URL of Shortcode Download %1$d %2$s voltooid HLS Afspeellijst @@ -658,4 +658,28 @@ Ga naar %s op je smartphone of computer en voeren de bovenstaande code in PIN-code is nu verlopen! Code verloopt in %1$dm %2$ds + Downloadwachtrij + Er staan momenteel geen downloads in de wachtrij. + Extra helderheid + Schakel het helderheidsfilter in zodra de schermhelderheid boven 100% komt + extra_helderheid_ingeschakeld + Zoeksuggesties + Zoeksuggesties laten zien tijdens het typen + Suggesties verwijderen + Castpaneel weergeven + Pre-releaseversie installeren + Pre-releaseversie is al geïnstaleerd. + Installatie van de pre-release is mislukt. + Schermspiegeling + Spiegeling spelen" + Beoordelingslabel + Afleveringstekst + Deze test is alleen bedoeld voor ontwikkelaars en bevestigt noch ontkent de werking van een extensie. + Lokale authenticatie + Media info + Waarschuwing: Cloudstream is niet verandwoordelijk voor het gebruik van extensies van derden en biedt hier ook geen ondersteuning voor! + Cast apparaat selecteren + Fout bij toegang tot het Klembord, Probeer het opnieuw. + Fout bij het kopiëren. Kopieer alsjeblieft de logcat en neem contact op met de app-ondersteuning. + Afwijzen diff --git a/app/src/main/res/values-b+ta/strings.xml b/app/src/main/res/values-b+ta/strings.xml index e223f6c60..626554c18 100644 --- a/app/src/main/res/values-b+ta/strings.xml +++ b/app/src/main/res/values-b+ta/strings.xml @@ -278,7 +278,7 @@ தற்குறிப்பு -30 ஒளிதோற்றம் - வீடியோ பிளேயர் தீர்மானம் + பிளேயர் தகவலைக் காட்டு வீடியோ இடையக அளவு நகலி தளம் அறிவிலிமையம் பதிலாள் @@ -286,7 +286,7 @@ களஞ்சியம் கிடைக்கவில்லை, முகவரி ஐ சரிபார்த்து VPN ஐ முயற்சிக்கவும் தொகுதி பதிவிறக்கம் சொருகு - எச்சரிக்கை: கிளவுட்ச்ட்ரீம் 3 மூன்றாம் தரப்பு நீட்டிப்புகளைப் பயன்படுத்துவதற்கான எந்தப் பொறுப்பையும் ஏற்காது, அவர்களுக்கு எந்த ஆதரவையும் வழங்காது! + எச்சரிக்கை: மூன்றாம் தரப்பு நீட்டிப்புகளைப் பயன்படுத்துவதற்கு CloudStream எந்தப் பொறுப்பையும் ஏற்காது, அவற்றிற்கு எந்த ஆதரவையும் வழங்காது! மொழி திரும்பவும் %s இலிருந்து குழுவிலகப்பட்டது @@ -555,7 +555,7 @@ புதுப்பிப்பு விடுபதிகை விரலிடைத் தோல் - சில தொலைபேசிகள் புதிய தொகுப்பு நிறுவியை ஆதரிக்கவில்லை. புதுப்பிப்புகள் நிறுவப்படாவிட்டால் மரபு விருப்பத்தை முயற்சிக்கவும். + சில சாதனங்கள் புதிய தொகுப்பு நிறுவியை ஆதரிக்கவில்லை. புதுப்பிப்புகள் நிறுவப்படவில்லை என்றால், மரபு விருப்பத்தை முயற்சிக்கவும். சந்தா தொலைக்காட்சி நிகழ்ச்சிகளுக்கான தடையற்ற பதிவிறக்கங்கள் மற்றும் அறிவிப்புகளை உறுதிப்படுத்த, கிளவுட்ச்ட்ரீம் பின்னணியில் இயங்க இசைவு தேவை. சரி என்பதை அழுத்துவதன் மூலம், உங்களுக்கு கோரிக்கை உரையாடல் காண்பிக்கப்படும். தயவுசெய்து \'இசைவு\' என்பதை அழுத்தவும். \n\nதயவுசெய்து கவனிக்கவும், இந்த இசைவு CS3 உங்கள் பேட்டரியை வெளியேற்றும் என்று அர்த்தமல்ல. அறிவிப்புகளைப் பெறும்போது அல்லது உத்தியோகபூர்வ நீட்டிப்புகளிலிருந்து வீடியோக்களைப் பதிவிறக்குவது போன்ற பின்னணியில் மட்டுமே இது செயல்படும். பயன்பாட்டு பேட்டரி பயன்பாடு ஏற்கனவே கட்டுப்பாடற்றதாக அமைக்கப்பட்டுள்ளது பயன்பாட்டு புதுப்பிப்பை நிறுவுகிறது… @@ -648,7 +648,7 @@ அமைப்புகள்/வழங்குநர்கள்/விருப்பமான ஊடகங்களில் டொரெண்டை இயக்கவும் பயன்பாட்டை மறுதொடக்கம் செய்து, தொடர ச்ட்ரீம் டொரண்ட் பாப்-அப் ஏற்றுக்கொள்ளுங்கள். மென்பொருள் டிகோடிங் - மென்பொருள் டிகோடிங் உங்கள் தொலைபேசியால் ஆதரிக்கப்படாத வீடியோ கோப்புகளை இயக்க பிளேயருக்கு உதவுகிறது, ஆனால் உயர் தெளிவுத்திறனில் பின்னடைவு அல்லது நிலையற்ற பின்னணியை ஏற்படுத்தக்கூடும் + மென்பொருள் டிகோடிங் உங்கள் சாதனத்தால் ஆதரிக்கப்படாத வீடியோ கோப்புகளை பிளேயருக்கு இயக்க உதவுகிறது, ஆனால் உயர் தெளிவுத்திறனில் பின்னடைவு அல்லது நிலையற்ற பிளேபேக்கை ஏற்படுத்தலாம். தொகுதி 100% ஐ தாண்டியுள்ளது 100% க்கு அப்பால் செல்ல மீண்டும் சறுக்கவும் செருகுநிரல்களைப் புதுப்பிக்கவும் @@ -676,4 +676,61 @@ %1$d மணி %2$d நிமிடம் %3$d விநாடி %1$d நிமிடம் %2$d விநாடி %1$d விநாடி + பதிவிறக்க வரிசை + முழு தொடரையும் விளையாடு + தற்போது வரிசைப்படுத்தப்பட்ட பதிவிறக்கங்கள் எதுவும் இல்லை. + கூடுதல் ஒளி + 100% காட்சி பிரகாசத்தை மீறும் போது பிரகாச வடிப்பானை இயக்கவும் + கூடுதல்_பிரகாசம்_செயல்படுத்தப்பட்டது + தேடல் பரிந்துரைகள் + தட்டச்சு செய்யும் போது தேடல் பரிந்துரைகளைக் காட்டு + தெளிவான பரிந்துரைகள் + காச்ட் பேனலைக் காட்டு + வெளியீட்டிற்கு முந்தைய பதிப்பை நிறுவவும் + முன் வெளியீடு ஏற்கனவே நிறுவப்பட்டுள்ளது. + முன் வெளியீட்டை நிறுவுவதில் தோல்வி. + கண்ணாடியை விளையாடு" + மதிப்பீடு சிட்டை + எபிசோட் உரை + மீடியா செய்தி + எப்போதும் கேளுங்கள் + மூல முன்னுரிமை + பிளேயரில் வீடியோ ஆதாரங்கள் எவ்வாறு வரிசைப்படுத்தப்பட வேண்டும் என்பதைத் தீர்மானிக்கவும் + கணக்கு இல்லை + LongPress விரைவு மாற்று + 2x வேகத்தைப் பெற அழுத்திப் பிடிக்கவும் + சுயவிவரப் படத்தைத் திருத்து + சுயவிவரப் பட முகவரி ஐ உள்ளிடவும் + முகவரி இல்லை + தவறான முகவரி அல்லது படம் + படம் வெற்றிகரமாக புதுப்பிக்கப்பட்டது + இந்த எபிசோட் வரை பார்த்ததாகக் குறிக்கவும் + இந்த எபிசோட் வரை பார்த்ததை அகற்று + மீண்டும் ஏற்றப்பட்டது + மறுஏற்றம் வழங்குநர் + பெயர் + மூலப் பெயர் + தீர்மானம் மற்றும் பெயர் + அனைத்தையும் பதிவிறக்கவும் + அனைத்தையும் ரத்து வெற்றி + அத்தியாயம் %s ஐ பதிவிறக்க விரும்புகிறீர்களா? + வரிசைப்படுத்தப்பட்ட அனைத்து பதிவிறக்கங்களையும் ரத்துசெய்ய விரும்புகிறீர்களா? + வசன சீரமைப்பு + கீழே இடது + கீழ் நடுவண் + கீழ் வலது + நடுத்தர இடது + நடுத்தர நடுவண் + நடுத்தர வலது + மேல் இடது + மேல் நடுவண் + மேல் வலது + + %d செயலில் பதிவிறக்கம் + %d செயலில் உள்ள பதிவிறக்கங்கள் + + + %d பதிவிறக்கம் வரிசையில் உள்ளது + %d பதிவிறக்கங்கள் வரிசையில் உள்ளன + diff --git a/app/src/main/res/values-b+vi/strings.xml b/app/src/main/res/values-b+vi/strings.xml index a4804c9d4..7999feb99 100644 --- a/app/src/main/res/values-b+vi/strings.xml +++ b/app/src/main/res/values-b+vi/strings.xml @@ -757,4 +757,18 @@ Thông tin âm thanh Độ sáng bổ sung Tên nguồn + Hàng đợi tải xuống + Hiện tại không có tệp nào đang chờ tải xuống. + Hãy quyết định cách sắp xếp các nguồn video trong trình phát. + Ưu tiên nguồn + Tải xuống tất cả + Hủy tất cả + Bạn có muốn tải xuống tập %s không? + Bạn có muốn hủy tất cả các lượt tải xuống đang chờ xử lý không? + + %d đang tải xuống + + + %d lượt tải xuống đang chờ xử lý + diff --git a/app/src/main/res/values-be/strings.xml b/app/src/main/res/values-be/strings.xml index 4c9a88313..690296ca6 100644 --- a/app/src/main/res/values-be/strings.xml +++ b/app/src/main/res/values-be/strings.xml @@ -179,10 +179,10 @@ Паказваць плакаты з Kitsu Схаваць выбраную якасць відэа з вынікаў пошуку Аўтаматычнае абнаўленне пашырэнняў - Аўтаматычная спампоўка ўбудоваў + Аўтаматычная спампоўка ўбудоў Некаторыя прылады не падтрымліваюць новы ўсталёўшчык пакетаў. Калі абнаўленні не ўсталёўваюцца, паспрабуйце ранейшую версію. Github - Выберыце рэжым фільтравання спампоўвання убудоваў + Выберыце рэжым фільтравання спампоўвання убудоў Прапановы пошуку Паказваць прапановы пошуку падчас уводу тэксту Ачысціць прапановы @@ -673,7 +673,7 @@ Абнавіць убудовы ўручную Пачынаецца абнаўленне ўбудоў! Абноўлена %d убудова(ы/ў)! - Не абнавілася ніводная ўбудова. + Ніводнай убудовы не было абноўлена. Апавяшчэнні прайгравальніка Апавяшчэнне прайгравальніка для кіравання прайграваннем у фонавым рэжыме Убудаваны @@ -715,4 +715,24 @@ Зверху злева Зверху па цэнтру Зверху справа + Чагра спампоўванняў + У чарзе пакуль што няма спампоўванняў. + Прыярытэт крыніц + Выберыце, як сартаваць крыніцы відэа ў прайгравальніку + Спампаваць усё + Скасаваць усё + Спампаваць %s серыю? + Скасаваць усе спампоўванні ў чарзе? + + %d актыўнае спампоўванне + %d актыўных спампоўвання + %d актыўных спампоўванняў + %d актыўных спампоўванняў + + + %d спампоўванне ў чарзе + %d спампоўвання ў чарзе + %d спампоўванняў у чарзе + %d спампоўванняў у чарзе + From b0d3731faa16cabaa51b6d01ce6b500ebdcfcf82 Mon Sep 17 00:00:00 2001 From: Bnyro Date: Wed, 11 Mar 2026 15:19:57 +0100 Subject: [PATCH 062/236] feat(extractors): add vide0 doodstream mirror (#2558) --- .../com/lagradost/cloudstream3/extractors/DoodExtractor.kt | 6 ++++-- .../kotlin/com/lagradost/cloudstream3/utils/ExtractorApi.kt | 2 ++ 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/DoodExtractor.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/DoodExtractor.kt index 0cef9eb4c..25c8ea9e7 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/DoodExtractor.kt +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/DoodExtractor.kt @@ -4,11 +4,9 @@ import com.lagradost.cloudstream3.SubtitleFile import com.lagradost.cloudstream3.app import com.lagradost.cloudstream3.utils.ExtractorApi import com.lagradost.cloudstream3.utils.ExtractorLink -import com.lagradost.cloudstream3.utils.INFER_TYPE import com.lagradost.cloudstream3.utils.getQualityFromName import com.lagradost.cloudstream3.utils.newExtractorLink import java.net.URI -import kotlin.random.Random class Doodspro : DoodLaExtractor() { override var mainUrl = "https://doods.pro" @@ -81,6 +79,10 @@ class Ds2video : DoodLaExtractor() { override var mainUrl = "https://ds2video.com" } +class Vide0Net: DoodLaExtractor() { + override var mainUrl = "https://vide0.net" +} + class MyVidPlay : DoodLaExtractor() { override var mainUrl = "https://myvidplay.com" } diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/ExtractorApi.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/ExtractorApi.kt index f796f3fdd..3dc0cc7ff 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/ExtractorApi.kt +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/ExtractorApi.kt @@ -281,6 +281,7 @@ import com.lagradost.cloudstream3.extractors.Vidoza import com.lagradost.cloudstream3.extractors.VinovoSi import com.lagradost.cloudstream3.extractors.VinovoTo import com.lagradost.cloudstream3.extractors.VidNest +import com.lagradost.cloudstream3.extractors.Vide0Net import com.lagradost.cloudstream3.extractors.VkExtractor import com.lagradost.cloudstream3.extractors.Voe import com.lagradost.cloudstream3.extractors.Voe1 @@ -1227,6 +1228,7 @@ val extractorApis: MutableList = arrayListOf( ByseVepoin(), ByseBuho(), MyVidPlay(), + Vide0Net(), Up4Stream(), Up4FunTop(), GUpload(), From ccc0a45065de49c888651b7dac23608413b9eb0d Mon Sep 17 00:00:00 2001 From: Osten <11805592+LagradOst@users.noreply.github.com> Date: Fri, 13 Mar 2026 22:54:11 +0100 Subject: [PATCH 063/236] Fixed shared pool, closes #2082 (#2553) --- .../lagradost/cloudstream3/CommonActivity.kt | 11 ------- .../lagradost/cloudstream3/ui/BaseAdapter.kt | 29 +++++++++++++++++++ .../ui/home/HomeChildItemAdapter.kt | 7 ++--- .../cloudstream3/ui/home/HomeFragment.kt | 1 + .../ui/home/HomeParentItemAdapter.kt | 6 ++-- .../ui/home/HomeParentItemAdapterPreview.kt | 1 + .../cloudstream3/ui/home/HomeViewModel.kt | 5 ---- .../cloudstream3/ui/player/GeneratorPlayer.kt | 2 +- .../ui/quicksearch/QuickSearchFragment.kt | 1 + .../cloudstream3/ui/result/ActorAdaptor.kt | 4 +-- .../cloudstream3/ui/result/EpisodeAdapter.kt | 13 ++++----- .../cloudstream3/ui/result/ImageAdapter.kt | 4 +-- .../ui/result/ResultFragmentPhone.kt | 4 +-- .../ui/result/ResultFragmentTv.kt | 2 +- .../cloudstream3/ui/search/SearchAdaptor.kt | 4 +-- .../cloudstream3/ui/search/SearchFragment.kt | 1 + .../cloudstream3/ui/settings/SettingsUI.kt | 2 +- .../ui/settings/extensions/PluginAdapter.kt | 7 ++--- .../ui/settings/extensions/PluginsFragment.kt | 1 + 19 files changed, 57 insertions(+), 48 deletions(-) diff --git a/app/src/main/java/com/lagradost/cloudstream3/CommonActivity.kt b/app/src/main/java/com/lagradost/cloudstream3/CommonActivity.kt index abf56dcbd..c806cac63 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/CommonActivity.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/CommonActivity.kt @@ -234,19 +234,8 @@ object CommonActivity { fun init(act: Activity) { setActivityInstance(act) ioSafe { Torrent.deleteAllFiles() } - - // Clear all pools to apply the correct theme - for (pool in arrayOf( - PluginAdapter.sharedPool, HomeChildItemAdapter.sharedPool, - ParentItemAdapter.sharedPool, ActorAdaptor.sharedPool, EpisodeAdapter.sharedPool, - SearchAdapter.sharedPool, ImageAdapter.sharedPool - )) { - pool.clear() - } - val componentActivity = activity as? ComponentActivity ?: return - componentActivity.updateLocale() componentActivity.updateTv() AccountManager.initMainAPI() diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/BaseAdapter.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/BaseAdapter.kt index 2bc1af833..4ebb7564c 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/BaseAdapter.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/BaseAdapter.kt @@ -1,5 +1,6 @@ package com.lagradost.cloudstream3.ui +import android.content.Context import android.view.View import android.view.ViewGroup import android.widget.ImageView @@ -11,6 +12,7 @@ import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView.ViewHolder import androidx.viewbinding.ViewBinding import coil3.dispose +import java.util.WeakHashMap import java.util.concurrent.CopyOnWriteArrayList open class ViewHolderState(val view: ViewBinding) : ViewHolder(view.root) { @@ -22,6 +24,33 @@ abstract class NoStateAdapter( diffCallback: DiffUtil.ItemCallback = BaseDiffCallback() ) : BaseAdapter(0, diffCallback) +/** Creates a new shared pool, using the supplied lambda as a constructor. + * + * The reason for this complicated structure is that a pool should not be shared between contexts + * as it makes coil fuck up, and theming. + * */ +fun newSharedPool(lambda: RecyclerView.RecycledViewPool.() -> Unit = { }): Pair, RecyclerView.RecycledViewPool.() -> Unit> = + WeakHashMap() to lambda + +/** Sets the shared pool of the recyclerview */ +fun RecyclerView.setRecycledViewPool(pool: Pair, RecyclerView.RecycledViewPool.() -> Unit>) { + val ctx = context ?: return + synchronized(pool.first) { + this.setRecycledViewPool(pool.first.getOrPut(ctx) { + RecyclerView.RecycledViewPool().apply(pool.second) + }) + } +} + +/** Clears the shared pool of views */ +fun Pair, RecyclerView.RecycledViewPool.() -> Unit>.clear() { + synchronized(this.first) { + for (pool in this.first.values) { + pool?.clear() + } + } +} + /** * BaseAdapter is a persistent state stored adapter that supports headers and footers. * This should be used for restoring eg scroll or focus related to a view when it is recreated. diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeChildItemAdapter.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeChildItemAdapter.kt index 4cd4197df..43f6d19ff 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeChildItemAdapter.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeChildItemAdapter.kt @@ -5,12 +5,8 @@ import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.widget.FrameLayout -import android.widget.ImageView -import androidx.fragment.app.Fragment import androidx.preference.PreferenceManager -import androidx.recyclerview.widget.RecyclerView import androidx.viewbinding.ViewBinding -import coil3.load import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.SearchResponse import com.lagradost.cloudstream3.databinding.HomeRemoveGridBinding @@ -20,6 +16,7 @@ import com.lagradost.cloudstream3.databinding.HomeResultGridExpandedBinding import com.lagradost.cloudstream3.ui.BaseAdapter import com.lagradost.cloudstream3.ui.BaseDiffCallback import com.lagradost.cloudstream3.ui.ViewHolderState +import com.lagradost.cloudstream3.ui.newSharedPool import com.lagradost.cloudstream3.ui.search.SEARCH_ACTION_LOAD import com.lagradost.cloudstream3.ui.search.SearchClickCallback import com.lagradost.cloudstream3.ui.search.SearchResultBuilder @@ -165,7 +162,7 @@ open class HomeChildItemAdapter( // The vast majority of the lag comes from creating the view // This simply shares the views between all HomeChildItemAdapter val sharedPool = - RecyclerView.RecycledViewPool().apply { this.setMaxRecycledViews(CONTENT, 20) } + newSharedPool { setMaxRecycledViews(CONTENT, 20) } var minPosterSize: Int = 0 var maxPosterSize: Int = 0 diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeFragment.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeFragment.kt index a254e1aec..375b2313f 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeFragment.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeFragment.kt @@ -52,6 +52,7 @@ import com.lagradost.cloudstream3.ui.search.SEARCH_ACTION_LOAD import com.lagradost.cloudstream3.ui.search.SEARCH_ACTION_PLAY_FILE import com.lagradost.cloudstream3.ui.search.SearchAdapter import com.lagradost.cloudstream3.ui.search.SearchHelper.handleSearchClickCallback +import com.lagradost.cloudstream3.ui.setRecycledViewPool import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR import com.lagradost.cloudstream3.ui.settings.Globals.PHONE import com.lagradost.cloudstream3.ui.settings.Globals.TV diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeParentItemAdapter.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeParentItemAdapter.kt index 0d08dc898..6bdd1bf49 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeParentItemAdapter.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeParentItemAdapter.kt @@ -6,10 +6,8 @@ import android.os.Parcelable import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import androidx.fragment.app.Fragment import androidx.recyclerview.widget.RecyclerView import androidx.viewbinding.ViewBinding -import com.lagradost.cloudstream3.HomePageList import com.lagradost.cloudstream3.LoadResponse import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.databinding.HomepageParentBinding @@ -17,9 +15,11 @@ import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.ui.BaseAdapter import com.lagradost.cloudstream3.ui.BaseDiffCallback import com.lagradost.cloudstream3.ui.ViewHolderState +import com.lagradost.cloudstream3.ui.newSharedPool import com.lagradost.cloudstream3.ui.result.FOCUS_SELF import com.lagradost.cloudstream3.ui.result.setLinearListLayout import com.lagradost.cloudstream3.ui.search.SearchClickCallback +import com.lagradost.cloudstream3.ui.setRecycledViewPool import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR import com.lagradost.cloudstream3.ui.settings.Globals.PHONE import com.lagradost.cloudstream3.ui.settings.Globals.TV @@ -48,7 +48,7 @@ open class ParentItemAdapter( ) { companion object { val sharedPool = - RecyclerView.RecycledViewPool().apply { this.setMaxRecycledViews(CONTENT, 4) } + newSharedPool { setMaxRecycledViews(CONTENT, 4) } } data class ParentItemHolder(val binding: ViewBinding) : ViewHolderState(binding) { diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeParentItemAdapterPreview.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeParentItemAdapterPreview.kt index 26e3477ef..a292c2da2 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeParentItemAdapterPreview.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeParentItemAdapterPreview.kt @@ -60,6 +60,7 @@ import com.lagradost.cloudstream3.utils.UIHelper.fixPaddingStatusbarMargin import com.lagradost.cloudstream3.utils.UIHelper.fixPaddingStatusbarView import com.lagradost.cloudstream3.utils.UIHelper.populateChips import androidx.core.graphics.toColorInt +import com.lagradost.cloudstream3.ui.setRecycledViewPool class HomeParentItemAdapterPreview( val fragment: LifecycleOwner, diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeViewModel.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeViewModel.kt index a066bf151..e0609c0e5 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeViewModel.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeViewModel.kt @@ -15,7 +15,6 @@ import com.lagradost.cloudstream3.HomePageList import com.lagradost.cloudstream3.LoadResponse import com.lagradost.cloudstream3.MainAPI import com.lagradost.cloudstream3.MainActivity -import com.lagradost.cloudstream3.MainActivity.Companion.lastError import com.lagradost.cloudstream3.SearchResponse import com.lagradost.cloudstream3.amap import com.lagradost.cloudstream3.mvvm.Resource @@ -57,7 +56,6 @@ import kotlinx.coroutines.Job import kotlinx.coroutines.withContext import java.util.EnumSet import java.util.concurrent.CopyOnWriteArrayList -import kotlin.collections.set class HomeViewModel : ViewModel() { companion object { @@ -518,9 +516,6 @@ class HomeViewModel : ViewModel() { return@ioSafe } - HomeChildItemAdapter.sharedPool.clear() - ParentItemAdapter.sharedPool.clear() - val api = getApiFromNameNull(preferredApiName) if (preferredApiName == noneApi.name) { // just set to random 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 7138e8dad..d20e85707 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 @@ -90,6 +90,7 @@ import com.lagradost.cloudstream3.ui.result.ResultFragment import com.lagradost.cloudstream3.ui.result.ResultViewModel2 import com.lagradost.cloudstream3.ui.result.SyncViewModel import com.lagradost.cloudstream3.ui.result.setLinearListLayout +import com.lagradost.cloudstream3.ui.setRecycledViewPool import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR import com.lagradost.cloudstream3.ui.settings.Globals.PHONE import com.lagradost.cloudstream3.ui.settings.Globals.TV @@ -128,7 +129,6 @@ import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import java.io.Serializable import java.util.Calendar -import kotlin.math.abs @OptIn(UnstableApi::class) class GeneratorPlayer : FullScreenPlayer() { diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/quicksearch/QuickSearchFragment.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/quicksearch/QuickSearchFragment.kt index 724276ab7..cf9bc9975 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/quicksearch/QuickSearchFragment.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/quicksearch/QuickSearchFragment.kt @@ -32,6 +32,7 @@ import com.lagradost.cloudstream3.ui.search.SearchAdapter import com.lagradost.cloudstream3.ui.search.SearchClickCallback import com.lagradost.cloudstream3.ui.search.SearchHelper import com.lagradost.cloudstream3.ui.search.SearchViewModel +import com.lagradost.cloudstream3.ui.setRecycledViewPool import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR import com.lagradost.cloudstream3.ui.settings.Globals.PHONE import com.lagradost.cloudstream3.ui.settings.Globals.TV diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/result/ActorAdaptor.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/result/ActorAdaptor.kt index 733933913..056588d0b 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/result/ActorAdaptor.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/result/ActorAdaptor.kt @@ -6,7 +6,6 @@ import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.core.view.isVisible -import androidx.recyclerview.widget.RecyclerView import com.lagradost.cloudstream3.ActorData import com.lagradost.cloudstream3.ActorRole import com.lagradost.cloudstream3.R @@ -14,6 +13,7 @@ import com.lagradost.cloudstream3.databinding.CastItemBinding import com.lagradost.cloudstream3.ui.BaseDiffCallback import com.lagradost.cloudstream3.ui.NoStateAdapter import com.lagradost.cloudstream3.ui.ViewHolderState +import com.lagradost.cloudstream3.ui.newSharedPool import com.lagradost.cloudstream3.ui.settings.Globals.PHONE import com.lagradost.cloudstream3.ui.settings.Globals.isLayout import com.lagradost.cloudstream3.utils.ImageLoader.loadImage @@ -26,7 +26,7 @@ class ActorAdaptor( })) { companion object { val sharedPool = - RecyclerView.RecycledViewPool().apply { this.setMaxRecycledViews(CONTENT, 10) } + newSharedPool { setMaxRecycledViews(CONTENT, 10) } } // Easier to store it here than to store it in the ActorData diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/result/EpisodeAdapter.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/result/EpisodeAdapter.kt index 7ff3904d8..5e5504164 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/result/EpisodeAdapter.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/result/EpisodeAdapter.kt @@ -1,6 +1,5 @@ package com.lagradost.cloudstream3.ui.result -import android.annotation.SuppressLint import android.content.Context import android.view.LayoutInflater import android.view.ViewGroup @@ -8,8 +7,6 @@ import androidx.core.view.isGone import androidx.core.view.isVisible import androidx.core.view.setPadding import androidx.preference.PreferenceManager -import androidx.recyclerview.widget.DiffUtil -import androidx.recyclerview.widget.RecyclerView import coil3.dispose import com.lagradost.cloudstream3.APIHolder.unixTimeMS import com.lagradost.cloudstream3.CommonActivity @@ -24,6 +21,7 @@ import com.lagradost.cloudstream3.ui.ViewHolderState import com.lagradost.cloudstream3.ui.download.DOWNLOAD_ACTION_DOWNLOAD import com.lagradost.cloudstream3.ui.download.DOWNLOAD_ACTION_LONG_CLICK import com.lagradost.cloudstream3.ui.download.DownloadClickEvent +import com.lagradost.cloudstream3.ui.newSharedPool import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR import com.lagradost.cloudstream3.ui.settings.Globals.PHONE import com.lagradost.cloudstream3.ui.settings.Globals.TV @@ -93,11 +91,10 @@ class EpisodeAdapter( } val sharedPool = - RecyclerView.RecycledViewPool() - .apply { - this.setMaxRecycledViews(HAS_POSTER or CONTENT, 10) - this.setMaxRecycledViews(HAS_NO_POSTER or CONTENT, 10) - } + newSharedPool { + setMaxRecycledViews(HAS_POSTER or CONTENT, 10) + setMaxRecycledViews(HAS_NO_POSTER or CONTENT, 10) + } } override fun onClearView(holder: ViewHolderState) { diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/result/ImageAdapter.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/result/ImageAdapter.kt index 0513564fe..54657ed57 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/result/ImageAdapter.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/result/ImageAdapter.kt @@ -2,11 +2,11 @@ package com.lagradost.cloudstream3.ui.result import android.view.LayoutInflater import android.view.ViewGroup -import androidx.recyclerview.widget.RecyclerView import com.lagradost.cloudstream3.databinding.ResultMiniImageBinding import com.lagradost.cloudstream3.ui.BaseDiffCallback import com.lagradost.cloudstream3.ui.NoStateAdapter import com.lagradost.cloudstream3.ui.ViewHolderState +import com.lagradost.cloudstream3.ui.newSharedPool import com.lagradost.cloudstream3.ui.settings.Globals.TV import com.lagradost.cloudstream3.ui.settings.Globals.isLayout import com.lagradost.cloudstream3.utils.ImageLoader.loadImage @@ -27,7 +27,7 @@ class ImageAdapter( ) { companion object { val sharedPool = - RecyclerView.RecycledViewPool().apply { this.setMaxRecycledViews(CONTENT, 10) } + newSharedPool { setMaxRecycledViews(CONTENT, 10) } } override fun onCreateContent(parent: ViewGroup): ViewHolderState { diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultFragmentPhone.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultFragmentPhone.kt index 74285f552..c9da385f6 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultFragmentPhone.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultFragmentPhone.kt @@ -30,7 +30,6 @@ import com.discord.panels.PanelState import com.discord.panels.PanelsChildGestureRegionObserver import com.google.android.gms.cast.framework.CastButtonFactory import com.google.android.gms.cast.framework.CastContext -import com.google.android.gms.cast.framework.CastState import com.google.android.material.bottomsheet.BottomSheetDialog import com.google.android.material.button.MaterialButton import com.lagradost.cloudstream3.APIHolder @@ -66,6 +65,7 @@ import com.lagradost.cloudstream3.ui.result.ResultFragment.getStoredData import com.lagradost.cloudstream3.ui.result.ResultFragment.updateUIEvent import com.lagradost.cloudstream3.ui.search.SearchAdapter import com.lagradost.cloudstream3.ui.search.SearchHelper +import com.lagradost.cloudstream3.ui.setRecycledViewPool import com.lagradost.cloudstream3.utils.AppContextUtils.getNameFull import com.lagradost.cloudstream3.utils.AppContextUtils.isCastApiAvailable import com.lagradost.cloudstream3.utils.AppContextUtils.loadCache @@ -88,9 +88,9 @@ import com.lagradost.cloudstream3.utils.UIHelper.hideKeyboard import com.lagradost.cloudstream3.utils.UIHelper.popCurrentPage import com.lagradost.cloudstream3.utils.UIHelper.populateChips import com.lagradost.cloudstream3.utils.UIHelper.popupMenuNoIconsAndNoStringRes -import com.lagradost.cloudstream3.utils.downloader.DownloadObjects import com.lagradost.cloudstream3.utils.UIHelper.setListViewHeightBasedOnItems import com.lagradost.cloudstream3.utils.UIHelper.setNavigationBarColorCompat +import com.lagradost.cloudstream3.utils.downloader.DownloadObjects import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager import com.lagradost.cloudstream3.utils.getImageFromDrawable import com.lagradost.cloudstream3.utils.setText 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 19f85bf3e..70ca11743 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 @@ -42,6 +42,7 @@ import com.lagradost.cloudstream3.ui.result.ResultFragment.updateUIEvent import com.lagradost.cloudstream3.ui.search.SEARCH_ACTION_FOCUSED import com.lagradost.cloudstream3.ui.search.SearchAdapter import com.lagradost.cloudstream3.ui.search.SearchHelper +import com.lagradost.cloudstream3.ui.setRecycledViewPool import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR import com.lagradost.cloudstream3.ui.settings.Globals.TV import com.lagradost.cloudstream3.ui.settings.Globals.isLayout @@ -61,7 +62,6 @@ import com.lagradost.cloudstream3.utils.UIHelper.hideKeyboard import com.lagradost.cloudstream3.utils.UIHelper.navigate import com.lagradost.cloudstream3.utils.UIHelper.populateChips import com.lagradost.cloudstream3.utils.UIHelper.setNavigationBarColorCompat -import com.lagradost.cloudstream3.utils.UiImage import com.lagradost.cloudstream3.utils.getImageFromDrawable import com.lagradost.cloudstream3.utils.setText import com.lagradost.cloudstream3.utils.setTextHtml diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchAdaptor.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchAdaptor.kt index 9338d4942..7b63b6ede 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchAdaptor.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchAdaptor.kt @@ -4,7 +4,6 @@ import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.widget.FrameLayout -import androidx.recyclerview.widget.RecyclerView import com.lagradost.cloudstream3.SearchResponse import com.lagradost.cloudstream3.databinding.SearchResultGridBinding import com.lagradost.cloudstream3.databinding.SearchResultGridExpandedBinding @@ -12,6 +11,7 @@ import com.lagradost.cloudstream3.ui.AutofitRecyclerView import com.lagradost.cloudstream3.ui.BaseDiffCallback import com.lagradost.cloudstream3.ui.NoStateAdapter import com.lagradost.cloudstream3.ui.ViewHolderState +import com.lagradost.cloudstream3.ui.newSharedPool import com.lagradost.cloudstream3.utils.UIHelper.isBottomLayout import kotlin.math.roundToInt @@ -43,7 +43,7 @@ class SearchAdapter( })) { companion object { val sharedPool = - RecyclerView.RecycledViewPool().apply { this.setMaxRecycledViews(CONTENT, 10) } + newSharedPool { setMaxRecycledViews(CONTENT, 10) } } var hasNext: Boolean = false diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchFragment.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchFragment.kt index 6bbd569b7..b79ba1707 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchFragment.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchFragment.kt @@ -56,6 +56,7 @@ import com.lagradost.cloudstream3.ui.home.HomeViewModel import com.lagradost.cloudstream3.ui.home.ParentItemAdapter import com.lagradost.cloudstream3.ui.result.FOCUS_SELF import com.lagradost.cloudstream3.ui.result.setLinearListLayout +import com.lagradost.cloudstream3.ui.setRecycledViewPool import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR import com.lagradost.cloudstream3.ui.settings.Globals.PHONE import com.lagradost.cloudstream3.ui.settings.Globals.TV diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsUI.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsUI.kt index 9e61a0b40..f4c522bf9 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsUI.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsUI.kt @@ -12,13 +12,13 @@ import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.SearchQuality import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.ui.BasePreferenceFragmentCompat +import com.lagradost.cloudstream3.ui.clear import com.lagradost.cloudstream3.ui.home.HomeChildItemAdapter import com.lagradost.cloudstream3.ui.home.ParentItemAdapter import com.lagradost.cloudstream3.ui.search.SearchAdapter import com.lagradost.cloudstream3.ui.search.SearchResultBuilder 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.updateTv import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.getPref import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.hideOn diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/PluginAdapter.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/PluginAdapter.kt index 47b0b3da3..d0f9ff565 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/PluginAdapter.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/PluginAdapter.kt @@ -8,7 +8,6 @@ import android.view.ViewGroup import androidx.appcompat.app.AppCompatActivity import androidx.core.view.isGone import androidx.core.view.isVisible -import androidx.recyclerview.widget.RecyclerView import androidx.viewbinding.ViewBinding import com.lagradost.cloudstream3.CloudStreamApp.Companion.getActivity import com.lagradost.cloudstream3.PROVIDER_STATUS_DOWN @@ -16,15 +15,13 @@ import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.TvType import com.lagradost.cloudstream3.databinding.RepositoryItemBinding import com.lagradost.cloudstream3.plugins.PluginManager -import com.lagradost.cloudstream3.plugins.VotingApi.getVotes import com.lagradost.cloudstream3.ui.BaseDiffCallback import com.lagradost.cloudstream3.ui.NoStateAdapter import com.lagradost.cloudstream3.ui.ViewHolderState +import com.lagradost.cloudstream3.ui.newSharedPool import com.lagradost.cloudstream3.ui.settings.Globals.TV import com.lagradost.cloudstream3.ui.settings.Globals.isLayout import com.lagradost.cloudstream3.utils.AppContextUtils.html -import com.lagradost.cloudstream3.utils.Coroutines.ioSafe -import com.lagradost.cloudstream3.utils.Coroutines.main import com.lagradost.cloudstream3.utils.ImageLoader.loadImage import com.lagradost.cloudstream3.utils.SubtitleHelper.getNameNextToFlagEmoji import com.lagradost.cloudstream3.utils.UIHelper.toPx @@ -204,7 +201,7 @@ class PluginAdapter( companion object { // A high count as we can render in the entire list as the same time val sharedPool = - RecyclerView.RecycledViewPool().apply { this.setMaxRecycledViews(CONTENT, 15) } + newSharedPool { setMaxRecycledViews(CONTENT, 15) } private tailrec fun findClosestBase2(target: Int, current: Int = 16, max: Int = 512): Int { if (current >= max) return max diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/PluginsFragment.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/PluginsFragment.kt index ee333abad..534ffa62a 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/PluginsFragment.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/PluginsFragment.kt @@ -15,6 +15,7 @@ import com.lagradost.cloudstream3.ui.BaseFragment import com.lagradost.cloudstream3.ui.home.HomeFragment.Companion.bindChips import com.lagradost.cloudstream3.ui.result.FOCUS_SELF import com.lagradost.cloudstream3.ui.result.setLinearListLayout +import com.lagradost.cloudstream3.ui.setRecycledViewPool import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR import com.lagradost.cloudstream3.ui.settings.Globals.TV import com.lagradost.cloudstream3.ui.settings.Globals.isLayout From 8d3846d2a3ead72efdef26982d5e0f76e14842f7 Mon Sep 17 00:00:00 2001 From: Bnyro Date: Fri, 13 Mar 2026 22:59:59 +0100 Subject: [PATCH 064/236] feat(extractors): add support for vidara.to (#2556) * feat(extractors): add support for vidara.to * Allow soft subtitle failure in Streamup --- .../cloudstream3/extractors/Streamup.kt | 28 ++++++++++++++++--- .../cloudstream3/utils/ExtractorApi.kt | 2 ++ 2 files changed, 26 insertions(+), 4 deletions(-) diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Streamup.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Streamup.kt index b043186ed..ea85a005e 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Streamup.kt +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Streamup.kt @@ -3,6 +3,7 @@ package com.lagradost.cloudstream3.extractors import com.fasterxml.jackson.annotation.JsonProperty import com.lagradost.cloudstream3.SubtitleFile import com.lagradost.cloudstream3.app +import com.lagradost.cloudstream3.newSubtitleFile import com.lagradost.cloudstream3.utils.ExtractorApi import com.lagradost.cloudstream3.utils.ExtractorLink import com.lagradost.cloudstream3.utils.ExtractorLinkType @@ -13,10 +14,17 @@ class Streamix(): Streamup() { override val mainUrl = "https://streamix.so" } +class Vidara(): Streamup() { + override val name: String = "Vidara" + override val mainUrl = "https://vidara.to" + override val apiPath: String = "/api/stream" +} + open class Streamup() : ExtractorApi() { override val name: String = "Streamup" override val mainUrl: String = "https://strmup.to" override val requiresReferer: Boolean = false + open val apiPath: String = "/ajax/stream" override suspend fun getUrl( url: String, @@ -25,7 +33,7 @@ open class Streamup() : ExtractorApi() { callback: (ExtractorLink) -> Unit ) { val fileCode = url.substringAfterLast("/") - val fileInfo = app.get("$mainUrl/ajax/stream?filecode=$fileCode") + val fileInfo = app.get("$mainUrl$apiPath?filecode=$fileCode") .parsed() callback.invoke( @@ -36,6 +44,12 @@ open class Streamup() : ExtractorApi() { type = ExtractorLinkType.M3U8 ) ) + + fileInfo.subtitles?.forEach { subtitle -> + subtitleCallback.invoke( + newSubtitleFile(subtitle.language, subtitle.filePath) + ) + } } private data class StreamUpFileInfo( @@ -43,7 +57,13 @@ open class Streamup() : ExtractorApi() { val thumbnail: String, @JsonProperty("streaming_url") val streamingUrl: String, - // subtitles seems to always be empty - // val subtitles: List + val subtitles: List? ) -} \ No newline at end of file + + private data class StreamUpSubtitle( + @JsonProperty("file_path") + val filePath: String, + @JsonProperty("language") + val language: String, + ) +} diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/ExtractorApi.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/ExtractorApi.kt index 3dc0cc7ff..0a3c33ff2 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/ExtractorApi.kt +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/ExtractorApi.kt @@ -281,6 +281,7 @@ import com.lagradost.cloudstream3.extractors.Vidoza import com.lagradost.cloudstream3.extractors.VinovoSi import com.lagradost.cloudstream3.extractors.VinovoTo import com.lagradost.cloudstream3.extractors.VidNest +import com.lagradost.cloudstream3.extractors.Vidara import com.lagradost.cloudstream3.extractors.Vide0Net import com.lagradost.cloudstream3.extractors.VkExtractor import com.lagradost.cloudstream3.extractors.Voe @@ -1101,6 +1102,7 @@ val extractorApis: MutableList = arrayListOf( StreamoUpload(), Streamup(), Streamix(), + Vidara(), GamoVideo(), Gdriveplayerapi(), From 904dda0c60fff774ea94c4cf69897cc62dc59be9 Mon Sep 17 00:00:00 2001 From: Bnyro Date: Fri, 13 Mar 2026 23:03:27 +0100 Subject: [PATCH 065/236] feat(extractors): add new extractor for vidsonic.net (#2557) --- .../cloudstream3/extractors/Vidsonic.kt | 61 +++++++++++++++++++ .../cloudstream3/utils/ExtractorApi.kt | 2 + 2 files changed, 63 insertions(+) create mode 100644 library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Vidsonic.kt diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Vidsonic.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Vidsonic.kt new file mode 100644 index 000000000..5c871b54b --- /dev/null +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Vidsonic.kt @@ -0,0 +1,61 @@ +package com.lagradost.cloudstream3.extractors + +import com.lagradost.api.Log +import com.lagradost.cloudstream3.SubtitleFile +import com.lagradost.cloudstream3.app +import com.lagradost.cloudstream3.utils.ExtractorApi +import com.lagradost.cloudstream3.utils.ExtractorLink +import com.lagradost.cloudstream3.utils.ExtractorLinkType +import com.lagradost.cloudstream3.utils.newExtractorLink + +class Vidsonic() : ExtractorApi() { + override val name: String = "Vidsonic" + override val mainUrl: String = "https://vidsonic.net" + override val requiresReferer: Boolean = false + + override suspend fun getUrl( + url: String, + referer: String?, + subtitleCallback: (SubtitleFile) -> Unit, + callback: (ExtractorLink) -> Unit + ) { + // Extracted JavaScript code that decodes the encrypted m3u8 stream URL: + // + // const _0x1 = '3363616238|3638666534|6264323565|3666616366|6636333662|6230626339|30613d3564|6d26743130|6b74693170|336563793d|64695f656c|6966263634|3332363033|3737313d73|6572697078|6526333d64|695f726576|7265733f38|75336d2e72|657473616d|2f7431306b|7469317033|6563792f38|392f657275|6365732f74|656e2e6369|6e6f736469|762e31302d|73752d7473|2f2f3a7370|747468'; + // const _0x2 = function(_0x3) { + // const _0x4 = _0x3.split('|').join(''); + // let _0x5 = ''; + // for (let _0x6 = 0; _0x6 < _0x4.length; _0x6 += 2) { + // _0x5 += String.fromCharCode(parseInt(_0x4.substr(_0x6, 2), 16)); + // } + // return _0x5.split('').reverse().join(''); + // }; + // const _0x7 = _0x2(_0x1); <-- now contains the stream URL + + val response = app.get(url).text + val encodedStreamUrl = response + .substringAfter("const _0x1 = ") + .substringBefore(";") + .replace("'", "") + + // (improved) Java implementation of the JavaScript code from above + val streamUrl = encodedStreamUrl + .replace("|", "") + // always two base16 digits together build one ASCII char + .chunked(2) + .map { + Integer.parseInt(it, 16).toChar() + } + .joinToString("") + .reversed() + + callback.invoke( + newExtractorLink( + source = name, + name = name, + url = streamUrl, + type = ExtractorLinkType.M3U8 + ) + ) + } +} \ No newline at end of file diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/ExtractorApi.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/ExtractorApi.kt index 0a3c33ff2..a21087601 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/ExtractorApi.kt +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/ExtractorApi.kt @@ -283,6 +283,7 @@ import com.lagradost.cloudstream3.extractors.VinovoTo import com.lagradost.cloudstream3.extractors.VidNest import com.lagradost.cloudstream3.extractors.Vidara import com.lagradost.cloudstream3.extractors.Vide0Net +import com.lagradost.cloudstream3.extractors.Vidsonic import com.lagradost.cloudstream3.extractors.VkExtractor import com.lagradost.cloudstream3.extractors.Voe import com.lagradost.cloudstream3.extractors.Voe1 @@ -1213,6 +1214,7 @@ val extractorApis: MutableList = arrayListOf( Wishonly(), Ds2play(), Ds2video(), + Vidsonic(), InternetArchive(), VidStack(), GDMirrorbot(), From ef07f761d73711ad45d98c1dc1e668be9f75fb2b Mon Sep 17 00:00:00 2001 From: Nivin <89772187+NivinCNC@users.noreply.github.com> Date: Sat, 14 Mar 2026 03:47:53 +0530 Subject: [PATCH 066/236] Prefer player default live position for HLS/DASH (#2547) --- .../com/lagradost/cloudstream3/ui/player/CS3IPlayer.kt | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/CS3IPlayer.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/CS3IPlayer.kt index 7f6586912..a134ae911 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/CS3IPlayer.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/CS3IPlayer.kt @@ -1868,6 +1868,13 @@ class CS3IPlayer : IPlayer { ) } + // For DASH or HLS single streams (non-playlist), prefer the player's default + // live position instead of starting at 0. Use TIME_UNSET to let ExoPlayer pick + // the live/default position when no explicit start position was provided. + if (playbackPosition == 0L && (link.type == ExtractorLinkType.M3U8 || link.type == ExtractorLinkType.DASH)) { + playbackPosition = TIME_UNSET + } + val provider = getApiFromNameNull(link.source) val interceptor: Interceptor? = provider?.getVideoInterceptor(link) From 86cca03dd7d8c2669ce377b90b8c7da31397e859 Mon Sep 17 00:00:00 2001 From: Luna712 <142361265+Luna712@users.noreply.github.com> Date: Fri, 13 Mar 2026 16:39:34 -0600 Subject: [PATCH 067/236] Use a new method to pass if debug to fix debug logging in library (#2330) * Use a new method to pass if debug to fix debug logging in library --- app/build.gradle.kts | 16 +++++----------- .../com/lagradost/cloudstream3/CloudStreamApp.kt | 4 ++++ library/build.gradle.kts | 13 ++++--------- .../kotlin/com/lagradost/cloudstream3/MainAPI.kt | 9 +++++++++ .../cloudstream3/mvvm/ArchComponentExt.kt | 12 ++++++------ .../com/lagradost/cloudstream3/utils/AppDebug.kt | 9 +++++++++ 6 files changed, 37 insertions(+), 26 deletions(-) create mode 100644 library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/AppDebug.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 5f6f55575..722fcf58e 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -226,16 +226,7 @@ dependencies { implementation(libs.work.runtime.ktx) implementation(libs.nicehttp) // HTTP Lib - implementation(project(":library") { - // There does not seem to be a good way of getting the android flavor. - val isDebug = gradle.startParameter.taskRequests.any { task -> - task.args.any { arg -> - arg.contains("debug", true) - } - } - - this.extra.set("isDebug", isDebug) - }) + implementation(project(":library")) } tasks.register("androidSourcesJar") { @@ -272,8 +263,11 @@ tasks.withType { compilerOptions { jvmTarget.set(javaTarget) jvmDefault.set(JvmDefaultMode.ENABLE) - optIn.add("com.lagradost.cloudstream3.Prerelease") freeCompilerArgs.add("-Xannotation-default-target=param-property") + optIn.addAll( + "com.lagradost.cloudstream3.InternalAPI", + "com.lagradost.cloudstream3.Prerelease", + ) } } diff --git a/app/src/main/java/com/lagradost/cloudstream3/CloudStreamApp.kt b/app/src/main/java/com/lagradost/cloudstream3/CloudStreamApp.kt index b78327998..ffd5ea812 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/CloudStreamApp.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/CloudStreamApp.kt @@ -13,6 +13,7 @@ import coil3.ImageLoader import coil3.PlatformContext import coil3.SingletonImageLoader import com.lagradost.api.setContext +import com.lagradost.cloudstream3.BuildConfig import com.lagradost.cloudstream3.mvvm.safe import com.lagradost.cloudstream3.mvvm.safeAsync import com.lagradost.cloudstream3.plugins.PluginManager @@ -20,6 +21,7 @@ import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR import com.lagradost.cloudstream3.ui.settings.Globals.TV import com.lagradost.cloudstream3.ui.settings.Globals.isLayout import com.lagradost.cloudstream3.utils.AppContextUtils.openBrowser +import com.lagradost.cloudstream3.utils.AppDebug import com.lagradost.cloudstream3.utils.Coroutines.runOnMainThread import com.lagradost.cloudstream3.utils.DataStore.getKey import com.lagradost.cloudstream3.utils.DataStore.getKeys @@ -81,6 +83,8 @@ class CloudStreamApp : Application(), SingletonImageLoader.Factory { exceptionHandler = it Thread.setDefaultUncaughtExceptionHandler(it) } + + AppDebug.isDebug = BuildConfig.DEBUG } override fun attachBaseContext(base: Context?) { diff --git a/library/build.gradle.kts b/library/build.gradle.kts index e73ed970d..14ef644f0 100644 --- a/library/build.gradle.kts +++ b/library/build.gradle.kts @@ -46,7 +46,10 @@ kotlin { sourceSets { all { - languageSettings.optIn("com.lagradost.cloudstream3.Prerelease") + languageSettings { + optIn("com.lagradost.cloudstream3.InternalAPI") + optIn("com.lagradost.cloudstream3.Prerelease") + } } commonMain.dependencies { @@ -73,14 +76,6 @@ buildkonfig { exposeObjectWithName = "BuildConfig" defaultConfigs { - val isDebug = kotlin.runCatching { extra.get("isDebug") }.getOrNull() == true - if (isDebug) { - logger.quiet("Compiling library with debug flag") - } else { - logger.quiet("Compiling library with release flag") - } - buildConfigField(FieldSpec.Type.BOOLEAN, "DEBUG", isDebug.toString()) - // Reads local.properties val localProperties = gradleLocalProperties(rootDir, project.providers) buildConfigField( diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/MainAPI.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/MainAPI.kt index 91cd375db..2be9f61fd 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/MainAPI.kt +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/MainAPI.kt @@ -46,6 +46,15 @@ import kotlin.math.roundToInt ) annotation class Prerelease +@Retention(AnnotationRetention.BINARY) // This is only an IDE hint, and will not be used in the runtime +@RequiresOptIn( + message = "This API is marked as internal and should not be used by extensions. " + + "Using it could cause catastrophic build or runtime errors and may " + + "be changed or removed at any time.", + level = RequiresOptIn.Level.ERROR +) +annotation class InternalAPI + /** * Defines the constant for the all languages preference, if this is set then it is * the equivalent of all languages being set diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/mvvm/ArchComponentExt.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/mvvm/ArchComponentExt.kt index 97aaf357d..e13bcf5ec 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/mvvm/ArchComponentExt.kt +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/mvvm/ArchComponentExt.kt @@ -1,8 +1,8 @@ package com.lagradost.cloudstream3.mvvm -import com.lagradost.api.BuildConfig import com.lagradost.api.Log import com.lagradost.cloudstream3.ErrorLoadingException +import com.lagradost.cloudstream3.utils.AppDebug import kotlinx.coroutines.* import java.io.InterruptedIOException import java.net.SocketTimeoutException @@ -18,31 +18,31 @@ const val DEBUG_PRINT = "DEBUG PRINT" class DebugException(message: String) : Exception("$DEBUG_EXCEPTION\n$message") inline fun debugException(message: () -> String) { - if (BuildConfig.DEBUG) { + if (AppDebug.isDebug) { throw DebugException(message.invoke()) } } inline fun debugPrint(tag: String = DEBUG_PRINT, message: () -> String) { - if (BuildConfig.DEBUG) { + if (AppDebug.isDebug) { Log.d(tag, message.invoke()) } } inline fun debugWarning(message: () -> String) { - if (BuildConfig.DEBUG) { + if (AppDebug.isDebug) { logError(DebugException(message.invoke())) } } inline fun debugAssert(assert: () -> Boolean, message: () -> String) { - if (BuildConfig.DEBUG && assert.invoke()) { + if (AppDebug.isDebug && assert.invoke()) { throw DebugException(message.invoke()) } } inline fun debugWarning(assert: () -> Boolean, message: () -> String) { - if (BuildConfig.DEBUG && assert.invoke()) { + if (AppDebug.isDebug && assert.invoke()) { logError(DebugException(message.invoke())) } } diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/AppDebug.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/AppDebug.kt new file mode 100644 index 000000000..e07f32c0a --- /dev/null +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/AppDebug.kt @@ -0,0 +1,9 @@ +package com.lagradost.cloudstream3.utils + +import com.lagradost.cloudstream3.InternalAPI + +@InternalAPI +object AppDebug { + @Volatile + var isDebug: Boolean = false +} From 19efb1ffc3f7516e3d80284335d0b8646e99a5d7 Mon Sep 17 00:00:00 2001 From: Luna712 <142361265+Luna712@users.noreply.github.com> Date: Fri, 13 Mar 2026 17:21:12 -0600 Subject: [PATCH 068/236] Fix BuildConfig import (#2566) --- .../com/lagradost/cloudstream3/ui/player/FullScreenPlayer.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/FullScreenPlayer.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/FullScreenPlayer.kt index 2dfbb5598..bc10285e3 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/FullScreenPlayer.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/FullScreenPlayer.kt @@ -54,7 +54,7 @@ import androidx.media3.ui.AspectRatioFrameLayout import androidx.preference.PreferenceManager import androidx.recyclerview.widget.SimpleItemAnimator import com.google.android.material.button.MaterialButton -import com.lagradost.api.BuildConfig +import com.lagradost.cloudstream3.BuildConfig import com.lagradost.cloudstream3.CommonActivity.keyEventListener import com.lagradost.cloudstream3.CommonActivity.playerEventListener import com.lagradost.cloudstream3.CommonActivity.screenHeightWithOrientation From 51bd1c4a6c85bbc170e8ce7dead5542adae07108 Mon Sep 17 00:00:00 2001 From: Hosted Weblate Date: Fri, 20 Mar 2026 10:09:56 +0100 Subject: [PATCH 069/236] Translated using Weblate (Slovak) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Currently translated at 62.6% (454 of 725 strings) Translated using Weblate (Esperanto) Currently translated at 23.7% (172 of 725 strings) Translated using Weblate (Chinese (Traditional Han script)) Currently translated at 100.0% (725 of 725 strings) Translated using Weblate (Chinese (Traditional Han script)) Currently translated at 100.0% (725 of 725 strings) Translated using Weblate (Bulgarian) Currently translated at 99.1% (719 of 725 strings) Translated using Weblate (Latvian) Currently translated at 81.2% (589 of 725 strings) Merge remote-tracking branch 'origin/master' Merge remote-tracking branch 'origin/master' Merge remote-tracking branch 'origin/master' Merge remote-tracking branch 'origin/master' Merge remote-tracking branch 'origin/master' Merge remote-tracking branch 'origin/master' Translated using Weblate (Esperanto) Currently translated at 17.5% (127 of 725 strings) Translated using Weblate (Portuguese) Currently translated at 100.0% (725 of 725 strings) Translated using Weblate (Vietnamese) Currently translated at 99.8% (724 of 725 strings) Translated using Weblate (Hungarian) Currently translated at 82.0% (595 of 725 strings) Translated using Weblate (Korean) Currently translated at 100.0% (725 of 725 strings) Translated using Weblate (Belarusian) Currently translated at 99.5% (722 of 725 strings) Merge remote-tracking branch 'origin/master' Merge remote-tracking branch 'origin/master' Merge remote-tracking branch 'origin/master' Merge remote-tracking branch 'origin/master' Merge remote-tracking branch 'origin/master' Merge remote-tracking branch 'origin/master' Merge remote-tracking branch 'origin/master' Merge remote-tracking branch 'origin/master' Translated using Weblate (Belarusian) Currently translated at 99.5% (722 of 725 strings) Translated using Weblate (Filipino) Currently translated at 21.2% (154 of 725 strings) Translated using Weblate (Hungarian) Currently translated at 77.3% (561 of 725 strings) Translated using Weblate (Dutch) Currently translated at 89.1% (646 of 725 strings) Translated using Weblate (Tamil) Currently translated at 100.0% (725 of 725 strings) Translated using Weblate (German) Currently translated at 100.0% (725 of 725 strings) Translated using Weblate (Tamil) Currently translated at 100.0% (725 of 725 strings) Translated using Weblate (Hungarian) Currently translated at 75.4% (547 of 725 strings) Co-authored-by: Aron Folkerts Co-authored-by: Daniel Konstantinov Co-authored-by: David Hermann Co-authored-by: Hosted Weblate Co-authored-by: Jen Xie Co-authored-by: Nguyễn Tiến Đạt Co-authored-by: Romhányi-Kakucska Viktor Co-authored-by: Sasha Glazko Co-authored-by: Wacky Wars Co-authored-by: clearstripe Co-authored-by: jpkaster 77 Co-authored-by: programutox Co-authored-by: tomas293 Co-authored-by: தமிழ்நேரம் Co-authored-by: ℂ𝕠𝕠𝕠𝕝 (𝕘𝕚𝕥𝕙𝕦𝕓.𝕔𝕠𝕞/ℂ𝕠𝕠𝕠𝕝) Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/be/ Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/bg/ Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/de/ Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/eo/ Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/fil/ Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/hu/ Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/ko/ Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/lv/ Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/nl/ Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/pt/ Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/sk/ Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/ta/ Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/vi/ Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/zh_Hant/ Translation: Cloudstream/App --- app/src/main/res/values-b+bg/strings.xml | 60 +++++++++- app/src/main/res/values-b+eo/strings.xml | 57 ++++++++- app/src/main/res/values-b+lv/strings.xml | 29 +++-- app/src/main/res/values-b+pt/strings.xml | 31 ++++- app/src/main/res/values-b+sk/strings.xml | 44 +++++-- app/src/main/res/values-b+zh+TW/strings.xml | 123 +++++++++++++++++--- 6 files changed, 303 insertions(+), 41 deletions(-) diff --git a/app/src/main/res/values-b+bg/strings.xml b/app/src/main/res/values-b+bg/strings.xml index 2c238b968..096e9f66b 100644 --- a/app/src/main/res/values-b+bg/strings.xml +++ b/app/src/main/res/values-b+bg/strings.xml @@ -252,7 +252,7 @@ Актуализация Предпочитано качество за гледане (през WiFi) Максимален брой знаци за заглавие във видеоплейъра - Разделителна способност на видео плейъра + Покажи информация за плейъра Размер на видео буфера Дължина на видео буфера Видео кеш на диск @@ -411,7 +411,7 @@ Вижте хранилищата на общността Публичен списък Всички субтитри с главни букви - Предупреждение: CloudStream 3 не носи отговорност за използването на трети страни разширения и не предоставя поддръжка за тях! + Предупреждение: CloudStream не носи отговорност за използването на трети страни разширения и не предоставя поддръжка за тях! %s (Деактивиран) Потоци Аудио потоци @@ -456,7 +456,7 @@ Изчистване на историята Автоматично инсталиране на всички все още неинсталирани добавки от добавени хранилища. APK Инсталатор - Някои телефони не поддържат новия пакет за инсталиране. Опитайте предпоследената опция, ако актуализациите не се инсталират. + Някои устройства не поддържат новия пакет за инсталиране. Опитайте предпоследената опция, ако актуализациите не се инсталират. Пусни трейлър Връзки Актуализации на приложението @@ -639,7 +639,7 @@ Няма актуализирани плъгини. Are you sure you wСигурни ли сте, че искате трайно да изтриете всички епизоди от тази поредица?\n\n%s Рестартирайте приложението и приемете изскачащото съобщение за Stream Torrent, за да продължите. - Софтуерното декодиране позволява на плейъра да възпроизвежда видео файлове, които не се поддържат от телефона ви, но може да причини забавяне или нестабилно възпроизвеждане при висока резолюция + Софтуерното декодиране позволява на плейъра да възпроизвежда видео файлове, които не се поддържат от устройството ви, но може да причини забавяне или нестабилно възпроизвеждане при висока резолюция Известия на плейъра Епизод (низходящо) Започва процесът на актуализация на плъгините! @@ -700,4 +700,56 @@ Винаги изпращай запитване Задръжте, за да удвоите скоростта Дълго задържане за смяна на скоростта + Опашка за изтегляне + Пусни цялата поредица + В момента няма изтегляния в опашката. + Допълнителна яркост + Активирай филтъра за яркост, когато яркостта на екрана надвиши 100% + Предложения за търсене + Показвай предложения за търсене по време на писане + Изтрий предложения + Покажи панела за излъчване + Инсталирай предварителна версия + Предварителна версия вече е изтеглена. + Неуспешна инсталация на предварителната версия. + Етикет за рейтинг + Текст на епизода + Информация за медията + Приоритет на източника + Определи как източниците на видеото да се сортират в плейъра + Няма акаунт + Редактирай профилната снимка + Въведи линк за профилна снимка + Няма намерен URL + Невалиден URL или снимка + Успешно актуализирана снимка + Маркирай като гледано до този епизод + Премахни маркирането като гледано до този епизод + Презаредено + Презареди доставчика + Име + Име на източника + Резолюция и име + Изтегли всички + Откажи всички + Искаш ли да изтеглиш епизод %s? + Искаш ли да отмениш всички изтегляния в опашката? + Подравняване на субтитрите + Долу вляво + Долу в центъра + Долу вдясно + В средата вляво + В средата в центъра + В средата вдясно + Горе вляво + Горе в центъра + Горе вдясно + + %d активно изтегляне + %d активни изтегляния + + + %d изтегляне в опашката + %d изтегляния в опашката + diff --git a/app/src/main/res/values-b+eo/strings.xml b/app/src/main/res/values-b+eo/strings.xml index f957da076..6809ceb7d 100644 --- a/app/src/main/res/values-b+eo/strings.xml +++ b/app/src/main/res/values-b+eo/strings.xml @@ -49,7 +49,7 @@ Ĝenroj Ĉiuj lingvoj Serĉi - Kontoj + Kontoj kaj Sekureco GitHub Sezono Epizodo @@ -127,4 +127,59 @@ Elŝutite Elŝutante Elŝuto Malsukcesite + %1$dh %2$dm %3$ds + %1$dm %2$ds + %1$ds + Sezono %1$d epizodo %2$d publikiĝos en + Legi ekde la komenco + Elsûtovico + La parolrekono ne haveblas + Komencu paroli… + Ne estas elŝuto ĉimomente. + Selekti Ĉion + Malselekti Ĉion + Malfermi lokan videon + Forigi Dosieron + Daŭrigi Elŝuton + Paŭzigi Elŝuton + Pli da informoj + Kaŝi + Filtri Legosignojn + Legosignoj + Nomo kaj URL de la deponejo + Kopiita! + Sciigo de novo epizodo + Serĉi en aliaj kromprogramoj + Montri la rekomendojn + Subtekstaj Agordoj + Kontura Koloro + Fona Koloro + Randa Tipo + Serĉi uzante provizantojn + Serĉi uzante tipojn + Subteksta Lingvo + Pli da informoj + \@string/home_play + Priskribo + Neniu Priskribo Trovita + Forigi nigrajn borderaĵojn + Subtekstoj + Serĉaj Sugestoj + Aligi Diskordon + Neniu Ligilo Trovita + %1$s %2$d%3$s + Neniu Sezono + %1$d-%2$d + %1$d %2$s + Neniu Epizodo Trovita + Forigi + Forigi Dosieron + Forigi Dosierojn + Forigi (%1$d | %2$s) + Nuligi + Paŭzigi + Daŭrigi + Daŭro + Sinoptiko + Neniu Subteksto diff --git a/app/src/main/res/values-b+lv/strings.xml b/app/src/main/res/values-b+lv/strings.xml index 18d3177f5..055732644 100644 --- a/app/src/main/res/values-b+lv/strings.xml +++ b/app/src/main/res/values-b+lv/strings.xml @@ -199,16 +199,16 @@ \natlikušas Pabeigts Statuss - gads - Reitings + Gads + Vērtējums Ilgums - Saite - Synopsis - Gaida + Vietne + Konspekts + ievietots rindā Lietotie Aplikācija Filmas - Seriāli + Seriāli, raidījumi Animācija Anime Torrenti @@ -219,7 +219,7 @@ NSFW Citi Filmas - Sērijas + Seriāls, raidījums Animācija Anime OVA @@ -464,7 +464,7 @@ Anulēts %s abonements %d sērija izlaista! Apk insteletājs - Github + GitHub Nav subtitru Atskaņot epizodi Iet @@ -596,4 +596,17 @@ Padarīt visus subtitrus slīprakstā Pievieno atskaņošanas ātruma izvēli atskaņotājā Dzēst (%1$d | %2$s) + + %d aktīvas lejupielādes + %d aktīva lejupielāde + %d aktīvas lejupielādes + + Lejupielādes rinda + Automātiski pagriezt + Atbloķēt CloudStream + Paroles/PIN autentifikācija + Atskaņot visas epizodes + Vai tiešām vēlaties neatgriezeniski dzēst šīs %1$s epizodes?\n\n%2$s + Jūs arī neatgriezeniski izdzēsīsiet visas šī seriāla, raidījuma epizodes:\n\n%s + Vai tiešām vēlaties neatgriezeniski dzēst visas šī seriāla, raidījuma epizodes?\n\n%s diff --git a/app/src/main/res/values-b+pt/strings.xml b/app/src/main/res/values-b+pt/strings.xml index e7b3623e6..a1abfa338 100644 --- a/app/src/main/res/values-b+pt/strings.xml +++ b/app/src/main/res/values-b+pt/strings.xml @@ -242,7 +242,7 @@ Atualizar Qualidade Preferida (WiFi) Máximo de caracteres do título no player de video - Resolução do player de vídeo + Mostrar informações do player de vídeo Tamanho do buffer do vídeo Comprimento do buffer do vídeo Cache do vídeo em disco @@ -663,7 +663,7 @@ Ativar torrent nas Configurações/Provedores/Mídia preferida Reinicie a aplicação e aceite o pop-up do Stream Torrent para continuar. Descodificação por software - Descodificação por software permite que o leitor reproduza ficheiros não suportados pelo seu dispositivo, mas pode resultar numa reprodução desfasada ou instável em altas resoluções + Descodificação por software permite que o leitor reproduza ficheiros não suportados pelo seu dispositivo, mas pode resultar numa reprodução desfasada ou instável em altas resoluções. Incorporada Online Episódio (Ascendente) @@ -734,4 +734,31 @@ Versão de pré-lançamento já instalada. Falha ao instalar pré-lançamento. Texto do Episódio + Fila de downloads + Não há downloads na fila no momento. + Brilho extra + Ativar filtro de brilho quando o brilho da tela exceder 100% + extra_brightness_enabled + Sugestões de pesquisa + Mostrar sugestões de pesquisa enquanto digita + Limpar Sugestões + Mostrar Painel de Elenco + Informações da mídia + Prioridade da fonte + Decida como as fontes de vídeo devem ser classificadas no player + Nome da fonte + Baixar tudo + Cancelar tudo + Deseja baixar o episódio %s? + Deseja cancelar todos os downloads em fila? + + %d download ativo + %d downloads ativos + %d downloads ativos + + + %d download na fila + %d downloads na fila + %d downloads na fila + diff --git a/app/src/main/res/values-b+sk/strings.xml b/app/src/main/res/values-b+sk/strings.xml index 93505971c..462a02ad5 100644 --- a/app/src/main/res/values-b+sk/strings.xml +++ b/app/src/main/res/values-b+sk/strings.xml @@ -118,7 +118,7 @@ Prehliadač Zhrnutie sa nenašlo Dvojitým ťuknutím pozastaviť - Aktualizácie a zálohovanie + Aktualizácie a Zálohovanie Informácie Rozšírené vyhľadávanie Zobraziť plagáty z Kitsu @@ -126,7 +126,7 @@ Skryť vybranú kvalitu videa vo výsledkoch vyhľadávania Zobraziť výplňovú epizódu pre anime APK inštalátor - Niektoré telefóny nepodporujú nový inštalátor balíčkov. Ak sa aktualizácie nenainštalujú, skúste použiť staršiu možnosť. + Niektoré zariadenia nepodporujú nový inštalátor balíčkov. Ak sa aktualizácie nenainštalujú, skúste použiť staršiu možnosť. Nenáročná aplikácia pre romány od rovnakých vývojárov Jazyk aplikácie Nenašli sa žiadne odkazy @@ -157,7 +157,7 @@ Nastavenia titulkov prehrávača Spustiť ďalšiu epizódu po skončení aktuálnej Chromecast titulky - Eigengravy režim + Rýchlosť prehrávania (Eigengravy režim) Potiahnutím pretočiť Automaticky prehrať ďalšiu epizódu Aktualizovať priebeh sledovania @@ -168,7 +168,7 @@ Knižnica GitHub Hľadať - Účty + Účty a Zabezpečenie Nastavenia Chromecast titulkov Potiahnutím zo strany na stranu môžete ovládať svoju pozíciu vo videu Nepodarilo sa obnoviť dáta zo súboru %s @@ -203,7 +203,7 @@ Rozloženie aplikácie ahoj@svet.sk Úspešné - MojeSuperMeno + Meno Seriály Seriál E @@ -227,7 +227,7 @@ Prehrať v aplikácii %1$d-%2$d Spôsobuje zlyhania, ak je nastavená príliš vysoko na zariadeniach s nízkou pamäťou, ako je Android TV. - raw.githubusercontent.com Proxy + GitHub Proxy Trvanie Aplikácia /%d @@ -242,7 +242,7 @@ %d / 10 Priblížiť Torrenty - Rozlíšenie prehrávača + Informácie o prehrávači Umiestniť názov pod plagát Preferovaná kvalita sledovania (WiFi) Rozšírenia @@ -260,11 +260,11 @@ Žiadna sezóna Epizóda Znova načítať odkazy - Jazyky poskytovateľa + Jazyky rozšírenia Spustiť Živé prenosy Stiahnuť titulky - Povoliť NSFW u podporovaných poskytovateľov + Povoliť NSFW u podporovaných rozšírení Obchádzanie ISP Prepnúť UI prvky na plagáte Rozloženie @@ -290,7 +290,7 @@ Vzhľad %1$s %2$d%3$s Obchádza blokovanie GitHubu pomocou jsDelivr. Môže spôsobiť oneskorenie aktualizácií o niekoľko dní. - Zobraziť náhodné tlačidlo na domovskej stránke + Zobraziť náhodné tlačidlo na domovskej stránke a Knižnici Odhlásiť sa Aktualizovať Stránka @@ -305,7 +305,7 @@ Klonovať stránku OVA Filmy - príklad.sk + https://example.com Vyrovnávacia pamäť Nepodarilo sa pripojiť na GitHub. Zapína sa proxy jsDelivr… Nenašli sa žiadne epizódy @@ -328,7 +328,7 @@ Poskytovatelia TV rozloženie Kód jazyka (sk) - MôjSuperWeb + NovyNazovWebu %1$s %2$s Vylúčenie zodpovednosti NSFW @@ -457,4 +457,24 @@ Podcast Všetko Chyba kódovania + %1$dh %2$dm %3$ds + Poradie sťahovania + %1$ds + %1$dm %2$ds + Rozpoznanie reči nie je k dispozícii + Začnite hovoriť… + Momentálne nie sú žiadne sťahovania v poradí. + Vyhľadávanie v iných rozšíreniach + Extra Jas + Spustiť filter jasu, keď je prekročený 100% jas displeja + extra_jas_zapnuty + Návrhy vyhľadávania + Zobraziť návrhy vyhľadávania pri písaní + Vyčistiť Návrhy + Nainštalujte pre-release verziu + Pre-release je už nainštalovaný. + Nepodarilo sa nainštalovať pre-release. + Nepodporovaná chyba + Text Epizódy + Lokálna verifikácia diff --git a/app/src/main/res/values-b+zh+TW/strings.xml b/app/src/main/res/values-b+zh+TW/strings.xml index 7dc4b48f2..c8f9df9a2 100644 --- a/app/src/main/res/values-b+zh+TW/strings.xml +++ b/app/src/main/res/values-b+zh+TW/strings.xml @@ -155,7 +155,7 @@ 顯示預告片 顯示來自 Kitsu 的封面 在搜尋結果中隱藏所選的影片畫質 - 自動更新外掛程式 + 自動更新外掛 顯示應用程式更新 啟動應用程式後自動搜尋更新。 Github @@ -258,7 +258,7 @@ 更新 偏好播放畫質 (WiFi) 影片播放器標題最大字數 - 影片播放器標題 + 顯示播放器資訊 影片緩衝大小 影片緩衝長度 磁碟上的影片快取 @@ -397,16 +397,16 @@ 資源庫名稱(選填) 資源庫 URL 或簡碼 外掛程式已載入 - 外掛程式已刪除 + 外掛已刪除 無法載入 %s 18+ 開始下載 %1$d %2$s … 已下載 %1$d %2$s 全部 %s 已經下載 批次下載 - 外掛程式 - 外掛程式 - 這也將刪除所有在資源庫中的外掛程式 + 外掛 + 外掛 + 這也將刪除所有在資源庫中的外掛 刪除資源庫 下載你所需的片源 已下載:%d @@ -419,14 +419,14 @@ 查看 公開清單 字幕全大寫 - 警告:CloudStream 3 不對使用第三方擴充功能承擔任何責任,也不提供任何支援! + 警告:CloudStream 不對使用第三方擴充功能承擔任何責任,也不提供任何支援! %s (停用) 軌道 音頻軌道 影片軌道 重新啟動應用程式以查看變更。 安全模式已啟用 - 由於程式崩潰,所有外掛程式皆已關閉,以協助您找到導致問題的程式。 + 由於程式當掉,為了協助您找到導致問題的程式,所有外掛皆已關閉。 查看程式崩潰資訊 評分:%s 簡介 @@ -436,7 +436,7 @@ 作者 類型 語言 - 請先安裝外掛程式 + 請先安裝外掛 HLS 播放清單 偏好影片播放器 內部播放器 @@ -452,13 +452,13 @@ 介紹 清除歷史紀錄 歷史紀錄 - 自動下載外掛程式 + 自動下載外掛 你確定要離開? 從新增的資源庫自動安裝所有尚未安裝的外掛程式。 在開始/結束顯示跳過彈出視窗 無法安裝新版本的應用程式 APK 安裝器 - 有些手機不支援新的軟體包安裝程式。 如果未安裝更新,請嘗試使用舊版選項。 + 部分裝置不支援新的軟體安裝程式。 如果未安裝更新,請嘗試使用舊版選項。 文字太多。 無法儲存到剪貼簿。 正在下載應用程式更新… @@ -619,7 +619,7 @@ 無法開啟 CloudStream 的應用程式資訊頁面。 您的 CloudStream 資料已完成備份。儘管可能性非常低,但因不同裝置的行為都有所不同,在極少數情況下,您可能會無法存取本應用程式。此時請完全清除本應用程式的資料,再使用已有的備份進行還原。若因此造成任何不便,我們深感抱歉。 此測試是供開發人員參考,而不是用以驗證任何擴充功能的正常運作與否。 - 為了確保下載與通知已訂閱的電視節目的不間斷,CloudStream 需要取得在背景執行的權限。若點選「確定」,將移至「應用程式資訊」,請找到「應用程式電池使用」並將電池用量設置為「無限制」。請注意,取得此權限並不表示 CS3 會明顯增加電池用量,而是只在必要時在背景執行,例如取得通知或使用官方擴充功能下載影片時。若選擇「取消」,您可以稍後在「一般設定」中調整此設定。 + 為了確保已訂閱的電視節目的不間斷下載與通知,CloudStream 需要取得在背景執行的權限。若點選「確定」,將顯示「請求權限」,請選擇「允許」。\n\n請注意,取得此權限並不表示本程式會明顯增加電池用量,而是只在必要時在背景執行,例如取得通知或使用擴充功能下載影片。 CloudStream Wiki 此裝置不支援生物特徵認證 無法取得裝置 PIN 碼,請嘗試本機驗證 @@ -663,9 +663,104 @@ subs_edge_size 音樂 編碼錯誤 - 因為不支持造成的錯誤 + 因為不支援造成錯誤 播客 軟體解碼 - 不接受的種子 + 重開程式並「同意」線上播放 Torrent 視窗。 載入第一個可用的 + %1$d小時 %2$d分鐘 %3$d秒 + %1$d分鐘 %2$d秒 + %1$d秒 + 下載佇列 + 不支援語音辨識 + 開始說話…… + 播放全劇 + 目前佇列中無下載影片。 + 額外亮度 + 超過100%亮度時開啟亮度調整 + 已開啟額外亮度 + 搜尋建議 + 輸入時顯示搜尋建議 + 清除建議 + 顯示投影控制板 + 安裝提前發行版 + 已安裝提前發行版。 + 提前發行版安裝失敗。 + 鏡像播放" + 評分標籤 + 影劇文本 + 媒體資訊 + 總是詢問 + 成功更新了 %d 外掛! + 沒有需要更新的外掛。 + 播放器通知 + 控制背景播放的通知 + 內嵌 + 線上 + 所有字幕變為粗體 + 所有字幕變為斜體 + 背景寬度 + 允許同時下載幾個項目 + 同時下載 + 同時連接量 + 下載時,每個下載可同時使用的連接量 + 前往下載 + 無網際網路。\n\n請連接網路後重試,或者以離線模式觀看已下載項目。 + 更改螢幕邊界 + 放大符合螢幕 + 更改圖片尺寸 + 圖片尺寸 + 長按以切換速度 + 2 倍速(按住) + 編輯帳戶圖片 + 輸入需使用的網址 URL 以更改帳戶圖片 + 未找到該網址 + 無效網址或圖片 + 已成功更新圖片 + 將這集及之前集數標示為「已觀看」 + 移除至此前所有集數「已觀看」狀態 + 已重新載入 + 重新載入來源 + 名稱 + 來源名 + 解析度與名稱 + 全部下載 + 全部取消 + 需要下載第 %s 集嗎? + 需取消佇列中的所有下載嗎? + 字幕對齊 + 左下 + 底部置中 + 右下 + 左中 + 正中 + 右中 + 左上 + 頂部置中 + 右上 + + %d 個正在下載 + + + 佇列中有 %d 個下載 + + 集數(由舊至新) + 集數(由新至舊) + 評分(最高) + 評分(最低) + 播放日期(最新) + 播放日期(最舊) + 第 %s 集 + 評分 %s + 日期 %s + 讓音量超過 100%(再次上滑) + 更新外掛 + 手動更新外掛 + 正在更新外掛! + 來源優先順序 + 決定影片來源的排列順序 + 帳戶不存在 + 在「設定/影片來源/首選媒體」中啟用 Torrent下載 + 軟體解碼使程式可以播放裝置不支援的影片,但可能導致播放高解析的影片時的延遲或不穩定。 + 音量已超過 100% From ee1e90e0f476823d5c4a15059ed172d3c4839d7f Mon Sep 17 00:00:00 2001 From: Osten <11805592+LagradOst@users.noreply.github.com> Date: Sun, 22 Mar 2026 08:34:43 +0100 Subject: [PATCH 070/236] Emergency fix for OOM and leaks --- .../lagradost/cloudstream3/CommonActivity.kt | 12 ++++++++++++ .../cloudstream3/extractors/Videa.kt | 18 ++++++++++++++---- .../cloudstream3/utils/UnshortenUrl.kt | 19 ++++++++++++------- 3 files changed, 38 insertions(+), 11 deletions(-) diff --git a/app/src/main/java/com/lagradost/cloudstream3/CommonActivity.kt b/app/src/main/java/com/lagradost/cloudstream3/CommonActivity.kt index c806cac63..dddcd4892 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/CommonActivity.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/CommonActivity.kt @@ -9,6 +9,8 @@ import android.content.res.Configuration import android.content.res.Resources import android.Manifest import android.os.Build +import android.os.Handler +import android.os.Looper import android.util.DisplayMetrics import android.util.Log import android.view.Gravity @@ -191,6 +193,16 @@ object CommonActivity { currentToast = toast toast.show() + val handler = Handler(Looper.getMainLooper()) + val ref = WeakReference(toast) + + /* Clean up activity leak */ + handler.postDelayed({ + if (ref.get() == currentToast) { + currentToast = null + } + }, 10_000) + } catch (e: Exception) { logError(e) } diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Videa.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Videa.kt index 121e221ad..bbcce2755 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Videa.kt +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Videa.kt @@ -25,12 +25,17 @@ class Videa : ExtractorApi() { ) { var currentUrl = url var key = "" - var lastUrl: String? = null // Handle redirect loop until we get valid XML - while (true) { + val visitedUrls = mutableSetOf() + var count = 10 + while (!visitedUrls.contains(currentUrl) && count > 0) { + visitedUrls += currentUrl + count -= 1 + val webUrl = getXmlUrl(currentUrl) { cookie -> /* no-op, cookie not used */ } ?: return val response = app.get(webUrl) - val rawBytes = response.body.bytes() + val body = response.body // TODO CLOSE? + val rawBytes = body.bytes() // Check if response starts with XML declaration val isXml = rawBytes.size >= 5 && @@ -53,7 +58,6 @@ class Videa : ExtractorApi() { val redirectMatch = """(.*)""".toRegex().find(videaXml) if (redirectMatch != null && redirectMatch.groupValues[1] != currentUrl) { - lastUrl = currentUrl currentUrl = redirectMatch.groupValues[1] } else { parseVideoSources(videaXml, callback) @@ -64,6 +68,12 @@ class Videa : ExtractorApi() { private suspend fun getXmlUrl(url: String, cookieCallback: (String) -> Unit = {}): String? { val response = app.get(url) + val size = response.size + /* OOM Protection */ + if(size != null && size > 5_000_000) { + // You tried to use a video here + return null + } val html = response.text // Extract sl cookie if present diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/UnshortenUrl.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/UnshortenUrl.kt index 1a9867f50..206b0f29f 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/UnshortenUrl.kt +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/UnshortenUrl.kt @@ -51,16 +51,18 @@ object ShortLink { suspend fun unshorten(uri: String, type: String? = null): String { var currentUrl = uri - while (true) { - val oldurl = currentUrl + val visitedUrls = mutableSetOf() + var count = 10 + while (!visitedUrls.contains(currentUrl) && count > 0) { + visitedUrls += currentUrl + count -= 1 + val domain = - URI(currentUrl.trim()).host ?: throw IllegalArgumentException("No domain found in URI!") + URI(currentUrl.trim()).host + ?: throw IllegalArgumentException("No domain found in URI!") currentUrl = shortList.firstOrNull { it.regex.find(domain) != null || type == it.type }?.function?.let { it(currentUrl) } ?: break - if (oldurl == currentUrl) { - break - } } return currentUrl.trim() } @@ -112,8 +114,10 @@ object ShortLink { uri.contains("delta") -> uri = uri.replace("/delta/", "/adelta/") (uri.contains("/ga/") || uri.contains("/ga2/")) -> uri = base64Decode(uri.split('/').last()).trim() + uri.contains("/speedx/") -> uri = uri.replace("http://linkup.pro/speedx", "http://speedvideo.net") + else -> { r = app.get(uri, allowRedirects = true) uri = r.url @@ -187,8 +191,9 @@ object ShortLink { fun unshortenDavisonbarker(uri: String): String { return URLDecoder.decode(uri.substringAfter("dest="), "UTF-8") } + suspend fun unshortenIsecure(uri: String): String { val doc = app.get(uri).document - return doc.selectFirst("iframe")?.attr("src")?.trim()?: uri + return doc.selectFirst("iframe")?.attr("src")?.trim() ?: uri } } \ No newline at end of file From a74a0840d60925fbc7bb2b72d7f0ca8d1ea726c8 Mon Sep 17 00:00:00 2001 From: Osten <11805592+LagradOst@users.noreply.github.com> Date: Sun, 22 Mar 2026 13:04:22 +0100 Subject: [PATCH 071/236] Fixed BackPressedCallbackHelper activity leak --- .../lagradost/cloudstream3/utils/BackPressedCallbackHelper.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/BackPressedCallbackHelper.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/BackPressedCallbackHelper.kt index d68b254b0..10736e13e 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/BackPressedCallbackHelper.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/BackPressedCallbackHelper.kt @@ -56,7 +56,7 @@ object BackPressedCallbackHelper { fun ComponentActivity.detachBackPressedCallback(id: String) { val callbackMap = backPressedCallbacks[this] ?: return callbackMap[id]?.let { callback -> - callback.isEnabled = false + callback.remove() callbackMap.remove(id) } From 45699b72a8c8d0a0a74867ff75ef5be78c2221df Mon Sep 17 00:00:00 2001 From: Osten <11805592+LagradOst@users.noreply.github.com> Date: Sun, 22 Mar 2026 13:41:12 +0100 Subject: [PATCH 072/236] Added .close to m3u8 hslLazy --- .../kotlin/com/lagradost/cloudstream3/extractors/Videa.kt | 3 ++- .../kotlin/com/lagradost/cloudstream3/utils/M3u8Helper.kt | 8 ++++++-- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Videa.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Videa.kt index bbcce2755..47840a08a 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Videa.kt +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Videa.kt @@ -34,8 +34,9 @@ class Videa : ExtractorApi() { val webUrl = getXmlUrl(currentUrl) { cookie -> /* no-op, cookie not used */ } ?: return val response = app.get(webUrl) - val body = response.body // TODO CLOSE? + val body = response.body val rawBytes = body.bytes() + body.close() // Check if response starts with XML declaration val isXml = rawBytes.size >= 5 && diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/M3u8Helper.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/M3u8Helper.kt index a852bdc03..23226418b 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/M3u8Helper.kt +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/M3u8Helper.kt @@ -233,7 +233,9 @@ object M3u8Helper2 { val ts = allTsLinks[index] val tsResponse = app.get(ts.url, headers = headers, verify = false) - val tsData = tsResponse.body.bytes() + val body = tsResponse.body + val tsData = body.bytes() + body.close() if (tsData.isEmpty()) throw ErrorLoadingException("no data") return if (isEncrypted) { @@ -329,7 +331,9 @@ object M3u8Helper2 { encryptionIv = match[3].toByteArray() val encryptionKeyResponse = app.get(encryptionUri, headers = playlistStream.headers, verify = false) - encryptionData = encryptionKeyResponse.body.bytes() + val body = encryptionKeyResponse.body + encryptionData = body.bytes() + body.close() } else { encryptionState = false } From f5b46949ecd15ac5c37ea2762cc02e32b54de4e7 Mon Sep 17 00:00:00 2001 From: Luna712 <142361265+Luna712@users.noreply.github.com> Date: Sun, 22 Mar 2026 17:16:39 -0600 Subject: [PATCH 073/236] Add support for configuration cache with keystore (#2328) --- app/build.gradle.kts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 722fcf58e..20ce54077 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -12,8 +12,6 @@ plugins { } val javaTarget = JvmTarget.fromTarget(libs.versions.jvmTarget.get()) -val tmpFilePath = System.getProperty("user.home") + "/work/_temp/keystore/" -val prereleaseStoreFile: File? = File(tmpFilePath).listFiles()?.first() fun getGitCommitHash(): String { return try { @@ -46,9 +44,14 @@ android { } signingConfigs { - if (prereleaseStoreFile != null) { + // We just use SIGNING_KEY_ALIAS here since it won't change + // so won't kill the configuration cache. + if (System.getenv("SIGNING_KEY_ALIAS") != null) { create("prerelease") { - storeFile = file(prereleaseStoreFile) + val tmpFilePath = System.getProperty("user.home") + "/work/_temp/keystore/" + val prereleaseStoreFile: File? = File(tmpFilePath).listFiles()?.first() + + storeFile = prereleaseStoreFile?.let { file(it) } storePassword = System.getenv("SIGNING_STORE_PASSWORD") keyAlias = System.getenv("SIGNING_KEY_ALIAS") keyPassword = System.getenv("SIGNING_KEY_PASSWORD") From f674b427ac709d56a6a0ab4414f7f1b013063701 Mon Sep 17 00:00:00 2001 From: Luna712 <142361265+Luna712@users.noreply.github.com> Date: Mon, 23 Mar 2026 15:48:48 -0600 Subject: [PATCH 074/236] Use assemblePrereleaseRelease for building prerelease (#2365) --- .github/workflows/build_to_archive.yml | 3 ++- .github/workflows/prerelease.yml | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build_to_archive.yml b/.github/workflows/build_to_archive.yml index 07096014a..f72dd10c6 100644 --- a/.github/workflows/build_to_archive.yml +++ b/.github/workflows/build_to_archive.yml @@ -61,13 +61,14 @@ jobs: cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} - name: Run Gradle - run: ./gradlew assemblePrerelease + run: ./gradlew assemblePrereleaseRelease env: SIGNING_KEY_ALIAS: "key0" SIGNING_KEY_PASSWORD: ${{ steps.fetch_keystore.outputs.key_pwd }} SIGNING_STORE_PASSWORD: ${{ steps.fetch_keystore.outputs.key_pwd }} SIMKL_CLIENT_ID: ${{ secrets.SIMKL_CLIENT_ID }} SIMKL_CLIENT_SECRET: ${{ secrets.SIMKL_CLIENT_SECRET }} + MDL_API_KEY: ${{ secrets.MDL_API_KEY }} - uses: actions/checkout@v6 with: diff --git a/.github/workflows/prerelease.yml b/.github/workflows/prerelease.yml index c7dee13eb..03cb68cbc 100644 --- a/.github/workflows/prerelease.yml +++ b/.github/workflows/prerelease.yml @@ -52,7 +52,7 @@ jobs: cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} - name: Run Gradle - run: ./gradlew assemblePrerelease build androidSourcesJar makeJar + run: ./gradlew assemblePrereleaseRelease androidSourcesJar makeJar env: SIGNING_KEY_ALIAS: "key0" SIGNING_KEY_PASSWORD: ${{ steps.fetch_keystore.outputs.key_pwd }} From d06afa32fdeab8264d5cf6c7880b07d5ad0dc7ad Mon Sep 17 00:00:00 2001 From: Phisher98 <153359846+phisher98@users.noreply.github.com> Date: Tue, 24 Mar 2026 03:44:48 +0530 Subject: [PATCH 075/236] Tracks naming fix and minor UI improvements (#2480) --- .../cloudstream3/ui/player/GeneratorPlayer.kt | 140 ++++++++++++------ 1 file changed, 97 insertions(+), 43 deletions(-) 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 d20e85707..a2cef1122 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 @@ -1434,45 +1434,42 @@ class GeneratorPlayer : FullScreenPlayer() { val audioArrayAdapter = ArrayAdapter(ctx, R.layout.sort_bottom_single_choice) - audioArrayAdapter.addAll(currentAudioTracks.mapIndexed { index, track -> - val language = track.language?.let { fromTagToLanguageName(it) ?: it } - ?: track.label - ?: "Audio" - - val codec = track.sampleMimeType?.let { mimeType -> - when { - mimeType.contains("mp4a") || mimeType.contains("aac") -> "aac" - mimeType.contains("ac-3") || mimeType.contains("ac3") -> "ac3" - mimeType.contains("eac3-joc") -> "Dolby Atmos" - mimeType.contains("eac3") -> "eac3" - mimeType.contains("opus") -> "opus" - mimeType.contains("vorbis") -> "vorbis" - mimeType.contains("mp3") || mimeType.contains("mpeg") -> "mp3" - mimeType.contains("flac") -> "flac" - mimeType.contains("dts") -> "dts" - else -> mimeType.substringAfter("/") + audioArrayAdapter.addAll( + currentAudioTracks.mapIndexed { _, track -> + + val language = ( + track.language?.trim()?.let { raw -> + fromTagToLanguageName(raw) + ?: fromTagToLanguageName(raw.replace('_','-').substringBefore('-').lowercase()) + ?: raw + } + ?: track.label + ?: "Audio" + ).replaceFirstChar { it.uppercaseChar() } + + val codec = audioCodecName(track.sampleMimeType) + + val channelCount = track.channelCount + + val channels = when { + // May be below 1 or null when unknown + channelCount == null || channelCount <= 0 -> "" + channelCount == 1 -> "Mono" + channelCount == 2 -> "Stereo" + channelCount == 6 -> "5.1" + channelCount == 8 -> "7.1" + else -> "${channelCount}ch" } - } ?: "codec?" - - val channels: Int = track.channelCount ?: 0 - val channelConfig = when (channels) { - 1 -> "mono" - 2 -> "stereo" - 6 -> "5.1" - 8 -> "7.1" - else -> "${channels}Ch" + listOfNotNull( + language.takeIf { it.isNotBlank() }?.replaceFirstChar { it.uppercaseChar() }, + channels.takeIf { it.isNotBlank() }, + codec.takeIf { it.isNotBlank() }?.uppercase() + ).joinToString(" • ") + + } - - listOfNotNull( - "[$index]", - language.replaceFirstChar { it.uppercaseChar() }, - codec.uppercase(), - channelConfig.replaceFirstChar { it.uppercaseChar() } - ).joinToString(" • ") - - "[$index] $language $codec $channelConfig" - }) + ) audioList.adapter = audioArrayAdapter audioList.choiceMode = AbsListView.CHOICE_MODE_SINGLE @@ -1830,6 +1827,42 @@ class GeneratorPlayer : FullScreenPlayer() { } } + + private fun videoCodecName(mime: String?): String? { + val m = mime?.lowercase() ?: return null + return when { + m.contains("avc") || m.contains("h264") -> "AVC" + m.contains("hevc") || m.contains("h265") -> "HEVC" + m.contains("av1") -> "AV1" + m.contains("vp9") -> "VP9" + m.contains("vp8") -> "VP8" + "/" in m -> m.substringAfter("/").uppercase() + else -> m.uppercase() + } + } + + private fun audioCodecName(mime: String?): String { + val m = mime?.lowercase()?.trim().orEmpty() + if (m.isBlank()) return "" + return when { + m.contains("eac3-joc") -> "Dolby Atmos" + m.contains("truehd") -> "TrueHD" + m.contains("eac3") -> "E-AC3" + m.contains("ac-3") || m.contains("ac3") -> "AC3" + m.contains("aac") || m.contains("mp4a") -> "AAC" + m.contains("opus") -> "Opus" + m.contains("vorbis") -> "Vorbis" + m.contains("mp3") -> "MP3" + m.contains("flac") -> "FLAC" + m.contains("dts") -> "DTS" + m.contains("pcm") -> "PCM" + m.contains("alac") -> "ALAC" + m.contains("amr") -> "AMR" + m.contains("/") -> m.substringAfter("/").uppercase().takeIf { it.isNotBlank() } ?: "" + else -> "" + } + } + private fun updatePlayerInfo() { val tracks = player.getVideoTracks() @@ -1840,14 +1873,35 @@ class GeneratorPlayer : FullScreenPlayer() { val prefs = PreferenceManager.getDefaultSharedPreferences(ctx) showMediaInfo = prefs.getBoolean(ctx.getString(R.string.show_media_info_key), false) - val videoCodec = videoTrack?.sampleMimeType?.substringAfterLast('/')?.uppercase() - val audioCodec = audioTrack?.sampleMimeType?.substringAfterLast('/')?.uppercase() - val language = listOfNotNull( - audioTrack?.label, - fromTagToLanguageName(audioTrack?.language)?.let { "[$it]" } - ).joinToString(" ") + val videoCodec = videoCodecName(videoTrack?.sampleMimeType) + val audioCodec = audioCodecName(audioTrack?.sampleMimeType) + val languageName = fromTagToLanguageName(audioTrack?.language) + val label = audioTrack?.label - val stats = arrayOf(videoCodec, audioCodec, language).filter { !it.isNullOrBlank() }.joinToString(" • ") + val channelCount = audioTrack?.channelCount + + val channels = when { + // May be below 1 or null when unknown + channelCount == null || channelCount <= 0 -> "" + channelCount == 1 -> "Mono" + channelCount == 2 -> "Stereo" + channelCount == 6 -> "5.1" + channelCount == 8 -> "7.1" + else -> "${channelCount}ch" + } + + val language = languageName?.takeIf { it.isNotBlank() }?.let { lang -> + label?.takeIf { it.isNotBlank() && !it.equals(lang, true) } + ?.let { lang } + ?: lang + } ?: label?.takeIf { it.isNotBlank() } + + val stats = arrayOf( + videoCodec, + language, + channels, + audioCodec + ).filter { !it.isNullOrBlank() }.joinToString(" • ") playerBinding?.playerVideoInfo?.apply { text = stats From 89400be5e5e7f49ac1fbb14f163d18ecdd68d467 Mon Sep 17 00:00:00 2001 From: Osten <11805592+LagradOst@users.noreply.github.com> Date: Wed, 25 Mar 2026 18:31:57 +0100 Subject: [PATCH 076/236] Remove google dependenciesInfo + bumb nicehttp --- app/build.gradle.kts | 8 ++++++++ gradle/libs.versions.toml | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 20ce54077..3be4e2ea7 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -39,6 +39,14 @@ android { unitTests.isReturnDefaultValues = true } + // Looks like google likes to add metadata only they can read https://gitlab.com/IzzyOnDroid/repo/-/work_items/491 + dependenciesInfo { + // Disables dependency metadata when building APKs. + includeInApk = false + // Disables dependency metadata when building Android App Bundles. + includeInBundle = false + } + viewBinding { enable = true } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 1e5a2ade9..c79726d01 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -31,7 +31,7 @@ media3 = "1.9.2" navigationKtx = "2.9.7" newpipeextractor = "v0.25.2" nextlibMedia3 = "1.9.1-0.11.0" -nicehttp = "0.4.16" +nicehttp = "0.4.17" overlappingpanels = "0.1.5" paletteKtx = "1.0.0" preferenceKtx = "1.2.1" From a23c136d81d12035091751d36a070e7b68ef6896 Mon Sep 17 00:00:00 2001 From: Osten <11805592+LagradOst@users.noreply.github.com> Date: Thu, 26 Mar 2026 01:13:13 +0100 Subject: [PATCH 077/236] Fixed partial downloads + resume bugs --- .../utils/downloader/DownloadManager.kt | 67 +++++++++++++++---- .../utils/downloader/DownloadObjects.kt | 4 +- 2 files changed, 57 insertions(+), 14 deletions(-) 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 fd1715e22..a561dbbe8 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 @@ -21,6 +21,7 @@ import androidx.core.net.toUri import androidx.preference.PreferenceManager import com.lagradost.cloudstream3.APIHolder.getApiFromNameNull import com.lagradost.cloudstream3.BuildConfig +import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey import com.lagradost.cloudstream3.CommonActivity.showToast import com.lagradost.cloudstream3.CloudStreamApp.Companion.removeKey import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey @@ -182,6 +183,13 @@ object VideoDownloadManager { /** the process failed due to some reason, so we retry and also try the next mirror */ private val DOWNLOAD_FAILED = DownloadStatus(retrySame = true, tryNext = true, success = false) + /** The download only downloaded partial */ + private val DOWNLOAD_PARTIAL_SUCCESS = + DownloadStatus(retrySame = true, tryNext = false, success = true) + + /** 10MB minimum size */ + const val DOWNLOAD_PARTIAL_MIN_SIZE = 1_048_576L * 10L + /** bad config, skip all mirrors as every call to download will have the same bad config */ private val DOWNLOAD_BAD_CONFIG = DownloadStatus(retrySame = false, tryNext = false, success = false) @@ -523,6 +531,7 @@ object VideoDownloadManager { /** This class handles the notifications, as well as the relevant key */ data class DownloadMetaData( private val id: Int?, + private val linkHash : Int, var bytesDownloaded: Long = 0, var bytesWritten: Long = 0, @@ -534,7 +543,7 @@ object VideoDownloadManager { private val createNotificationCallback: (CreateNotificationMetadata) -> Unit, private var internalType: DownloadType = DownloadType.IsPending, - + val isHLS : Boolean, // how many segments that we have downloaded var hlsProgress: Int = 0, // how many segments that exist @@ -552,13 +561,17 @@ object VideoDownloadManager { lastDownloadedBytes = length } + /** Returns the appropriate failed status based on download progress */ + fun failedStatus() = if (this.bytesWritten > DOWNLOAD_PARTIAL_MIN_SIZE) + DOWNLOAD_PARTIAL_SUCCESS + else + DOWNLOAD_FAILED + val approxTotalBytes: Long get() = totalBytes ?: hlsTotal?.let { total -> (bytesDownloaded * (total / hlsProgress.toFloat())).toLong() } ?: bytesDownloaded - private val isHLS get() = hlsTotal != null - private var stopListener: (() -> Unit)? = null /** on cancel button pressed or failed invoke this once and only once */ @@ -593,11 +606,32 @@ object VideoDownloadManager { private fun updateFileInfo() { if (id == null) return downloadFileInfoTemplate?.let { template -> + /** This looks strange, but fixes an issue where we do an instant retry, and it fails immediately, + * eg. by turning off wifi */ + val totalBytesValue = if (approxTotalBytes <= bytesDownloaded) { + val prevInfo = getKey( + KEY_DOWNLOAD_INFO, + id.toString() + ) + + /** If this link is the same as the last cached video link metadata */ + if (prevInfo != null && prevInfo.linkHash == linkHash) { + /** Try to use totalBytes if it exists, otherwise the max of the prev data, + * and download size to ensure total >= downloaded */ + totalBytes ?: maxOf(prevInfo.totalBytes, bytesDownloaded) + } else { + approxTotalBytes + } + } else { + approxTotalBytes + } + setKey( KEY_DOWNLOAD_INFO, id.toString(), template.copy( - totalBytes = approxTotalBytes, + linkHash = linkHash, + totalBytes = totalBytesValue, extraInfo = if (isHLS) hlsWrittenProgress.toString() else null ) ) @@ -982,6 +1016,8 @@ object VideoDownloadManager { bytesDownloaded = 0, createNotificationCallback = createNotificationCallback, id = parentId, + linkHash = link.url.hashCode(), + isHLS = false ) try { // get the file path @@ -1171,7 +1207,7 @@ object VideoDownloadManager { if (!stream.exists) metadata.type = DownloadType.IsStopped if (metadata.type == DownloadType.IsFailed) { - return@withContext DOWNLOAD_FAILED + return@withContext metadata.failedStatus() } if (metadata.type == DownloadType.IsStopped) { @@ -1201,11 +1237,11 @@ object VideoDownloadManager { throw e } catch (t: Throwable) { // some sort of network error, will error - + logError(t) // note that when failing we don't want to delete the file, // only user interaction has that power metadata.type = DownloadType.IsFailed - return@withContext DOWNLOAD_FAILED + return@withContext metadata.failedStatus() } finally { fileStream?.closeQuietly() //requestStream?.closeQuietly() @@ -1227,7 +1263,9 @@ object VideoDownloadManager { val metadata = DownloadMetaData( createNotificationCallback = createNotificationCallback, - id = parentId + id = parentId, + linkHash = link.url.hashCode(), + isHLS = true ) var fileStream: OutputStream? = null try { @@ -1385,7 +1423,7 @@ object VideoDownloadManager { if (!stream.exists) metadata.type = DownloadType.IsStopped if (metadata.type == DownloadType.IsFailed) { - return@withContext DOWNLOAD_FAILED + return@withContext metadata.failedStatus() } if (metadata.type == DownloadType.IsStopped) { @@ -1401,7 +1439,7 @@ object VideoDownloadManager { } catch (t: Throwable) { logError(t) metadata.type = DownloadType.IsFailed - return@withContext DOWNLOAD_FAILED + return@withContext metadata.failedStatus() } finally { fileStream?.closeQuietly() metadata.close() @@ -1983,7 +2021,8 @@ object VideoDownloadManager { linkLoadingJob?.join() // Remove link loading notification - NotificationManagerCompat.from(context).cancel(DOWNLOAD_NOTIFICATION_TAG, downloadItem.episode.id) + NotificationManagerCompat.from(context) + .cancel(DOWNLOAD_NOTIFICATION_TAG, downloadItem.episode.id) if (linkLoadingJob?.isCancelled == true) { // Same as if no links, but no toast. @@ -2009,8 +2048,10 @@ object VideoDownloadManager { } // Profiles should always contain a download type - val profile = QualityDataHelper.getProfiles().first { it.types.contains( - QualityDataHelper.QualityProfileType.Download) + val profile = QualityDataHelper.getProfiles().first { + it.types.contains( + QualityDataHelper.QualityProfileType.Download + ) } val sortedLinks = currentLinks.sortedBy { link -> diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/downloader/DownloadObjects.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/downloader/DownloadObjects.kt index 1d945a6b4..25a9fdf2a 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/downloader/DownloadObjects.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/downloader/DownloadObjects.kt @@ -133,7 +133,9 @@ object DownloadObjects { @JsonProperty("relativePath") val relativePath: String, @JsonProperty("displayName") val displayName: String, @JsonProperty("extraInfo") val extraInfo: String? = null, - @JsonProperty("basePath") val basePath: String? = null // null is for legacy downloads. See getBasePath() + @JsonProperty("basePath") val basePath: String? = null, // null is for legacy downloads. See getBasePath() + // Hash of the link associated with this DownloadFile, used so not override old data in the DownloadedFileInfo + @JsonProperty("linkHash") val linkHash : Int? = null ) data class DownloadedFileInfoResult( From 07eb9973f8f32dcba0756c1d55e8740a8345441c Mon Sep 17 00:00:00 2001 From: Osten <11805592+LagradOst@users.noreply.github.com> Date: Sun, 29 Mar 2026 13:54:51 +0200 Subject: [PATCH 078/236] Increased DOWNLOAD_PARTIAL_MIN_SIZE to 50MB --- .../cloudstream3/utils/downloader/DownloadManager.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 a561dbbe8..11c35e9ec 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 @@ -187,8 +187,8 @@ object VideoDownloadManager { private val DOWNLOAD_PARTIAL_SUCCESS = DownloadStatus(retrySame = true, tryNext = false, success = true) - /** 10MB minimum size */ - const val DOWNLOAD_PARTIAL_MIN_SIZE = 1_048_576L * 10L + /** 50MB minimum size */ + const val DOWNLOAD_PARTIAL_MIN_SIZE = 1_048_576L * 50L /** bad config, skip all mirrors as every call to download will have the same bad config */ private val DOWNLOAD_BAD_CONFIG = From 235863f9d29b8c8d78ea603fcfe261712860182c Mon Sep 17 00:00:00 2001 From: Osten <11805592+LagradOst@users.noreply.github.com> Date: Sun, 29 Mar 2026 14:06:17 +0200 Subject: [PATCH 079/236] Bump to 4.7.0 --- app/build.gradle.kts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 3be4e2ea7..97d24c4fb 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -73,8 +73,8 @@ android { applicationId = "com.lagradost.cloudstream3" minSdk = libs.versions.minSdk.get().toInt() targetSdk = libs.versions.targetSdk.get().toInt() - versionCode = 67 - versionName = "4.6.2" + versionCode = 68 + versionName = "4.7.0" resValue("string", "commit_hash", getGitCommitHash()) From 81c7d90a5f30015749bccbb0126433bd5959df3b Mon Sep 17 00:00:00 2001 From: Nguyen Van Nam Date: Mon, 30 Mar 2026 01:15:34 +0700 Subject: [PATCH 080/236] Fix: Subtitle deletion matches on substring extension, can delete non-subtitle files (#2584) --- .../java/com/lagradost/cloudstream3/utils/SubtitleUtils.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/SubtitleUtils.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/SubtitleUtils.kt index 18d465e3c..c0068f91a 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/SubtitleUtils.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/SubtitleUtils.kt @@ -43,7 +43,7 @@ object SubtitleUtils { cleanDisplay: String ): Boolean { // Check if the file has a valid subtitle extension - val hasValidExtension = allowedExtensions.any { name.contains(it, ignoreCase = true) } + val hasValidExtension = allowedExtensions.any { name.endsWith(it, ignoreCase = true) } // We can't have the exact same file as a subtitle val isNotDisplayName = !name.equals(display, ignoreCase = true) @@ -57,4 +57,4 @@ object SubtitleUtils { fun cleanDisplayName(name: String): String { return name.substringBeforeLast('.').trim() } -} \ No newline at end of file +} From 76a2feb79ce6a56bbedbae265e81742790c1e218 Mon Sep 17 00:00:00 2001 From: PiterDev <71133634+PiterWeb@users.noreply.github.com> Date: Sun, 29 Mar 2026 20:33:36 +0200 Subject: [PATCH 081/236] Add fallback url for kitsu sync (#2552) --- .../syncproviders/providers/KitsuApi.kt | 82 ++++++++++++++----- 1 file changed, 63 insertions(+), 19 deletions(-) diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/KitsuApi.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/KitsuApi.kt index 3f079d9d5..29c3c0c17 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/KitsuApi.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/KitsuApi.kt @@ -5,11 +5,11 @@ import androidx.annotation.StringRes import com.fasterxml.jackson.annotation.JsonProperty import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey -import com.lagradost.cloudstream3.app import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.Score import com.lagradost.cloudstream3.ShowStatus import com.lagradost.cloudstream3.TvType +import com.lagradost.cloudstream3.app import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.syncproviders.AuthData import com.lagradost.cloudstream3.syncproviders.AuthLoginRequirement @@ -22,18 +22,16 @@ import com.lagradost.cloudstream3.ui.SyncWatchType import com.lagradost.cloudstream3.ui.library.ListSorting import com.lagradost.cloudstream3.utils.AppUtils.toJson import com.lagradost.cloudstream3.utils.txt -import kotlinx.coroutines.flow.asFlow -import kotlinx.coroutines.flow.collect -import kotlinx.coroutines.flow.onEach -import kotlinx.coroutines.flow.withIndex +import okhttp3.Interceptor +import okhttp3.Request import okhttp3.RequestBody.Companion.toRequestBody +import okhttp3.Response import java.text.SimpleDateFormat import java.time.Instant import java.time.LocalDate import java.time.format.DateTimeFormatter import java.util.Date import java.util.Locale -import kotlin.collections.set const val KITSU_MAX_SEARCH_LIMIT = 20 @@ -42,7 +40,9 @@ class KitsuApi: SyncAPI() { override val idPrefix = "kitsu" private val apiUrl = "https://kitsu.io/api/edge" + private val fallbackApiUrl = "https://kitsu.app/api/edge" private val oauthUrl = "https://kitsu.io/api/oauth" + private val fallbackOauthUrl = "https://kitsu.app/api/oauth" override val hasInApp = true override val mainUrl = "https://kitsu.app" override val icon = R.drawable.kitsu_icon @@ -63,6 +63,33 @@ class KitsuApi: SyncAPI() { email = true ) + private class FallbackInterceptor(private val apiUrl: String, private val fallbackApiUrl: String) : Interceptor { + override fun intercept(chain: Interceptor.Chain): Response { + val request: Request = chain.request() + + try { + + val response = chain.proceed(request); + + if (response.isSuccessful) return response + + response.close() + + } catch (_: Exception) { + } + + val fallbackRequest: Request = request.newBuilder() + .url(request.url.toString().replaceFirst(apiUrl, fallbackApiUrl)) + .build() + + return chain.proceed(fallbackRequest) + + } + } + + private val apiFallbackInterceptor = FallbackInterceptor(apiUrl, fallbackApiUrl) + private val oauthFallbackInterceptor = FallbackInterceptor(oauthUrl, fallbackOauthUrl) + override suspend fun login(form: AuthLoginResponse): AuthToken? { val username = form.email ?: return null val password = form.password ?: return null @@ -75,8 +102,10 @@ class KitsuApi: SyncAPI() { "grant_type" to grantType, "username" to username, "password" to password - ) + ), + interceptor = oauthFallbackInterceptor ).parsed() + return AuthToken( accessTokenLifetime = unixTime + token.expiresIn.toLong(), refreshToken = token.refreshToken, @@ -90,7 +119,8 @@ class KitsuApi: SyncAPI() { data = mapOf( "grant_type" to "refresh_token", "refresh_token" to token.refreshToken!! - ) + ), + interceptor = oauthFallbackInterceptor ).parsed() return AuthToken( @@ -105,7 +135,8 @@ class KitsuApi: SyncAPI() { "$apiUrl/users?filter[self]=true", headers = mapOf( "Authorization" to "Bearer ${token?.accessToken ?: return null}" - ), cacheTime = 0 + ), cacheTime = 0, + interceptor = apiFallbackInterceptor ).parsed() if (user.data.isEmpty()) { @@ -123,11 +154,14 @@ class KitsuApi: SyncAPI() { val auth = auth?.token?.accessToken ?: return null val animeSelectedFields = arrayOf("titles","canonicalTitle","posterImage","episodeCount") val url = "$apiUrl/anime?filter[text]=$query&page[limit]=$KITSU_MAX_SEARCH_LIMIT&fields[anime]=${animeSelectedFields.joinToString(",")}" + val res = app.get( url, headers = mapOf( "Authorization" to "Bearer $auth", - ), cacheTime = 0 + ), cacheTime = 0, + interceptor = apiFallbackInterceptor ).parsed() + return res.data.map { val attributes = it.attributes @@ -160,7 +194,8 @@ class KitsuApi: SyncAPI() { val anime = app.get( url, headers = mapOf( "Authorization" to "Bearer $auth" - ) + ), + interceptor = apiFallbackInterceptor ).parsed().data.attributes return SyncResult( @@ -201,7 +236,8 @@ class KitsuApi: SyncAPI() { val anime = app.get( url, headers = mapOf( "Authorization" to "Bearer $accessToken" - ) + ), + interceptor = apiFallbackInterceptor ).parsed().data.firstOrNull()?.attributes if (anime == null) { @@ -224,7 +260,8 @@ class KitsuApi: SyncAPI() { val animeSelectedFields = arrayOf("titles","canonicalTitle") val url = "$apiUrl/anime?filter[text]=$title&page[limit]=$KITSU_MAX_SEARCH_LIMIT&fields[anime]=${animeSelectedFields.joinToString(",")}" - val res = app.get(url).parsed() + + val res = app.get(url, interceptor = apiFallbackInterceptor).parsed() return res.data.firstOrNull()?.id @@ -269,8 +306,10 @@ class KitsuApi: SyncAPI() { headers = mapOf( "Authorization" to "Bearer ${auth.token.accessToken}" ), + interceptor = apiFallbackInterceptor ) + return res.isSuccessful } @@ -316,7 +355,8 @@ class KitsuApi: SyncAPI() { "content-type" to "application/vnd.api+json", "Authorization" to "Bearer ${auth.token.accessToken}" ), - requestBody = data.toJson().toRequestBody() + requestBody = data.toJson().toRequestBody(), + interceptor = apiFallbackInterceptor ) return res.isSuccessful @@ -349,9 +389,11 @@ class KitsuApi: SyncAPI() { "content-type" to "application/vnd.api+json", "Authorization" to "Bearer ${auth.token.accessToken}" ), - requestBody = data.toJson().toRequestBody() + requestBody = data.toJson().toRequestBody(), + interceptor = apiFallbackInterceptor ) + return res.isSuccessful } @@ -365,6 +407,7 @@ class KitsuApi: SyncAPI() { headers = mapOf( "Authorization" to "Bearer ${auth.token.accessToken}" ), + interceptor = apiFallbackInterceptor ).parsed().data.firstOrNull() ?: return null return res.id.toInt() @@ -439,7 +482,8 @@ class KitsuApi: SyncAPI() { val res = app.get( url, headers = mapOf( "Authorization" to "Bearer ${token.accessToken}", - ) + ), + interceptor = apiFallbackInterceptor ).parsed() return res } @@ -474,7 +518,7 @@ class KitsuApi: SyncAPI() { val animeId = animeItem?.id - val description: String? = animeItem?.attributes?.synopsis + val synopsis: String? = animeItem?.attributes?.synopsis return LibraryItem( canonicalTitle ?: titles?.enJp ?: titles?.jaJp.orEmpty(), @@ -489,7 +533,7 @@ class KitsuApi: SyncAPI() { posterImage?.large ?: posterImage?.medium, null, null, - plot = description, + plot = synopsis, releaseDate = if (startDate == null) null else try { Date.from( Instant.from( @@ -770,4 +814,4 @@ query { val canonical: String? = null ) } -} \ No newline at end of file +} From 7a2222b252c98a5e422f98fd59d068eff9cca2ed Mon Sep 17 00:00:00 2001 From: Phisher98 <153359846+phisher98@users.noreply.github.com> Date: Mon, 30 Mar 2026 03:41:58 +0530 Subject: [PATCH 082/236] Adding Metdata on Player (Initial Draft) (TV) (#2461) --- .../ui/player/FullScreenPlayer.kt | 68 ++++++++++++++- .../cloudstream3/ui/player/GeneratorPlayer.kt | 50 +++++++++++ .../cloudstream3/utils/AppContextUtils.kt | 8 ++ .../bg_player_metadata_scrim_netflix.xml | 7 ++ .../res/drawable/metadata_overlay_icon.xml | 36 ++++++++ .../main/res/layout/player_custom_layout.xml | 72 ++++++++++++++++ .../res/layout/player_custom_layout_tv.xml | 83 +++++++++++++++++-- .../main/res/layout/trailer_custom_layout.xml | 73 +++++++++++++++- .../res/values/donottranslate-strings.xml | 1 + app/src/main/res/values/strings.xml | 1 + app/src/main/res/xml/settings_ui.xml | 5 ++ 11 files changed, 392 insertions(+), 12 deletions(-) create mode 100644 app/src/main/res/drawable/bg_player_metadata_scrim_netflix.xml create mode 100644 app/src/main/res/drawable/metadata_overlay_icon.xml diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/FullScreenPlayer.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/FullScreenPlayer.kt index bc10285e3..90274c938 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/FullScreenPlayer.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/FullScreenPlayer.kt @@ -35,6 +35,7 @@ import android.view.animation.AccelerateDecelerateInterpolator import android.view.animation.AlphaAnimation import android.view.animation.Animation import android.view.animation.AnimationUtils +import android.view.animation.DecelerateInterpolator import android.widget.LinearLayout import androidx.annotation.OptIn import androidx.appcompat.app.AlertDialog @@ -97,6 +98,8 @@ import kotlin.math.max import kotlin.math.min import kotlin.math.round import kotlin.math.roundToInt +import com.lagradost.cloudstream3.utils.AppContextUtils.shouldShowPlayerMetadata + // You can zoom out more than 100%, but it will zoom back into 100% const val MINIMUM_ZOOM = 0.95f @@ -133,7 +136,7 @@ open class FullScreenPlayer : AbstractPlayerFragment() { private var uiShowingBeforeGesture = false protected var isLocked = false protected var timestampShowState = false - + private var metadataVisibilityToken = 0 protected var hasEpisodes = false private set // protected val hasEpisodes @@ -235,10 +238,55 @@ open class FullScreenPlayer : AbstractPlayerFragment() { requestUpdateBrightnessOverlayOnNextLayout() } } - return root } + private fun scheduleMetadataVisibility() { + val metadataScrim = playerBinding?.playerMetadataScrim ?: return + val ctx = metadataScrim.context ?: return + + if (!ctx.shouldShowPlayerMetadata()) { + metadataScrim.isVisible = false + metadataVisibilityToken++ + return + } + + if (isLayout(PHONE)) { + metadataScrim.isVisible = false + metadataVisibilityToken++ + return + } + + val isPaused = currentPlayerStatus == CSPlayerLoading.IsPaused + val token = ++metadataVisibilityToken + + if (isPaused) { + metadataScrim.postDelayed({ + if (token != metadataVisibilityToken) return@postDelayed + metadataScrim.alpha = 0f + metadataScrim.isVisible = true + metadataScrim.animate() + .alpha(1f) + .setDuration(500L) + .setInterpolator(DecelerateInterpolator()) + .start() + hidePlayerUI() + }, 8000L) + } else { + if (metadataScrim.isVisible) { + metadataScrim.animate() + .alpha(0f) + .setDuration(300L) + .setInterpolator(AccelerateDecelerateInterpolator()) + .withEndAction { + metadataScrim.alpha = 0f // force final state + metadataScrim.isVisible = false + } + .start() + } + } + } + @SuppressLint("UnsafeOptInUsageError") override fun playerUpdated(player: Any?) { super.playerUpdated(player) @@ -456,6 +504,12 @@ open class FullScreenPlayer : AbstractPlayerFragment() { start() } } + playerBinding?.playerMetadataScrim?.let { + ObjectAnimator.ofFloat(it, "translationY", 1f).apply { + duration = 200 + start() + } + } val playerBarMove = if (isShowing) 0f else 50.toPx.toFloat() playerBinding?.bottomPlayerBar?.let { @@ -522,7 +576,7 @@ open class FullScreenPlayer : AbstractPlayerFragment() { override fun subtitlesChanged() { val tracks = player.getVideoTracks() val isBuiltinSubtitles = tracks.currentTextTracks.all { track -> - track.sampleMimeType == MimeTypes.APPLICATION_MEDIA3_CUES + track.sampleMimeType == MimeTypes.APPLICATION_MEDIA3_CUES } // Subtitle offset is not possible on built-in media3 tracks playerBinding?.playerSubtitleOffsetBtt?.isGone = @@ -1013,7 +1067,6 @@ open class FullScreenPlayer : AbstractPlayerFragment() { // BOTTOM playerLockHolder.startAnimation(fadeAnimation) // player_go_back_holder?.startAnimation(fadeAnimation) - shadowOverlay.isVisible = true shadowOverlay.startAnimation(fadeAnimation) } @@ -1084,6 +1137,7 @@ open class FullScreenPlayer : AbstractPlayerFragment() { override fun playerStatusChanged() { super.playerStatusChanged() + scheduleMetadataVisibility() delayHide() } @@ -2177,6 +2231,12 @@ open class FullScreenPlayer : AbstractPlayerFragment() { } protected fun uiReset() { + metadataVisibilityToken++ + playerBinding?.playerMetadataScrim?.let { + it.animate().cancel() + it.alpha = 0f + it.isVisible = false + } isShowing = false toggleEpisodesOverlay(false) // if nothing has loaded these buttons should not be visible 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 a2cef1122..de1b32467 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 @@ -87,6 +87,7 @@ import com.lagradost.cloudstream3.ui.result.EpisodeAdapter import com.lagradost.cloudstream3.ui.result.FOCUS_SELF import com.lagradost.cloudstream3.ui.result.ResultEpisode import com.lagradost.cloudstream3.ui.result.ResultFragment +import com.lagradost.cloudstream3.ui.result.ResultFragment.bindLogo import com.lagradost.cloudstream3.ui.result.ResultViewModel2 import com.lagradost.cloudstream3.ui.result.SyncViewModel import com.lagradost.cloudstream3.ui.result.setLinearListLayout @@ -98,6 +99,7 @@ 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 import com.lagradost.cloudstream3.utils.AppContextUtils.html import com.lagradost.cloudstream3.utils.AppContextUtils.sortSubs import com.lagradost.cloudstream3.utils.Coroutines.ioSafe @@ -1546,6 +1548,54 @@ class GeneratorPlayer : FullScreenPlayer() { return } loadLink(links.first(), false) + showPlayerMetadata() + } + + private fun showPlayerMetadata() { + val overlay = playerBinding?.playerMetadataScrim ?: return + + val titleView = overlay.findViewById(R.id.player_movie_title) + val logoView = overlay.findViewById(R.id.player_movie_logo) + val metaView = overlay.findViewById(R.id.player_movie_meta) + val descView = overlay.findViewById(R.id.player_movie_overview) + + val load = viewModel.getLoadResponse() ?: return + val episode = currentMeta as? ResultEpisode + titleView.text = load.name + + bindLogo( + url = load.logoUrl, + headers = load.posterHeaders, + titleView = titleView, + logoView = logoView + ) + + val meta = arrayOf( + load.tags?.takeIf { it.isNotEmpty() }?.joinToString(", "), + load.year?.toString(), + if (!load.type.isMovieType()) + context?.getShortSeasonText( + episode = episode?.episode, + season = episode?.season + ) + else null, + load.score?.let { "⭐ $it" } + ).filterNotNull() + .joinToString(" • ") + + metaView.text = meta + metaView.isVisible = meta.isNotBlank() + + + val description = load.plot + + if (!description.isNullOrBlank()) { + descView.isVisible = true + descView.text = description + } else { + descView.isVisible = false + + } } override fun nextEpisode() { diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/AppContextUtils.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/AppContextUtils.kt index 6a9ab28d8..1377ccd08 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/AppContextUtils.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/AppContextUtils.kt @@ -449,6 +449,14 @@ object AppContextUtils { return settingsManager.getBoolean(this.getString(R.string.show_trailers_key), true) } + fun Context.shouldShowPlayerMetadata(): Boolean { + val prefs = PreferenceManager.getDefaultSharedPreferences(this) + return prefs.getBoolean( + getString(R.string.show_player_metadata_key), + true + ) + } + fun Context.filterProviderByPreferredMedia(hasHomePageIsRequired: Boolean = true): List { // We are getting the weirdest crash ever done: // java.lang.ClassCastException: com.lagradost.cloudstream3.TvType cannot be cast to com.lagradost.cloudstream3.TvType diff --git a/app/src/main/res/drawable/bg_player_metadata_scrim_netflix.xml b/app/src/main/res/drawable/bg_player_metadata_scrim_netflix.xml new file mode 100644 index 000000000..b4701e42a --- /dev/null +++ b/app/src/main/res/drawable/bg_player_metadata_scrim_netflix.xml @@ -0,0 +1,7 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/metadata_overlay_icon.xml b/app/src/main/res/drawable/metadata_overlay_icon.xml new file mode 100644 index 000000000..6d1b6510a --- /dev/null +++ b/app/src/main/res/drawable/metadata_overlay_icon.xml @@ -0,0 +1,36 @@ + + + + + + + diff --git a/app/src/main/res/layout/player_custom_layout.xml b/app/src/main/res/layout/player_custom_layout.xml index 7974159c4..72024a918 100644 --- a/app/src/main/res/layout/player_custom_layout.xml +++ b/app/src/main/res/layout/player_custom_layout.xml @@ -7,6 +7,78 @@ android:orientation="vertical" tools:orientation="vertical"> + + + + + + + + + + + + + + + + + + + + + - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - autoplay_next_key display_sub_key show_fillers_key + show_player_metadata_key show_trailers_key show_kitsu_posters_key show_cast_in_details_key diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a1a4fdc3f..e9dd2748f 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -190,6 +190,7 @@ Show search suggestions while typing Clear Suggestions Show filler episode for anime + Show Player Metadata Overlay Show trailers Show posters from Kitsu Show cast panel diff --git a/app/src/main/res/xml/settings_ui.xml b/app/src/main/res/xml/settings_ui.xml index 83d0e83c0..1b516ffa3 100644 --- a/app/src/main/res/xml/settings_ui.xml +++ b/app/src/main/res/xml/settings_ui.xml @@ -84,6 +84,11 @@ android:icon="@drawable/ic_baseline_skip_next_24" android:key="@string/show_fillers_key" android:title="@string/show_fillers_settings" /> + Date: Sun, 29 Mar 2026 22:12:16 +0000 Subject: [PATCH 083/236] chore(locales): fix locale issues --- app/src/main/res/values-b+eo/strings.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/res/values-b+eo/strings.xml b/app/src/main/res/values-b+eo/strings.xml index 6809ceb7d..ccd18eae3 100644 --- a/app/src/main/res/values-b+eo/strings.xml +++ b/app/src/main/res/values-b+eo/strings.xml @@ -159,7 +159,7 @@ Serĉi uzante tipojn Subteksta Lingvo Pli da informoj - \@string/home_play + @string/home_play Priskribo Neniu Priskribo Trovita Forigi nigrajn borderaĵojn From c26f2362027197f36e5591653ccb1ebb66ed20b7 Mon Sep 17 00:00:00 2001 From: firelight <147925818+fire-light42@users.noreply.github.com> Date: Mon, 30 Mar 2026 00:29:27 +0200 Subject: [PATCH 084/236] Fix: Minor UX bugs with #2461 --- .../ui/player/FullScreenPlayer.kt | 64 +++++++++++++++++++ .../cloudstream3/ui/player/GeneratorPlayer.kt | 13 ++-- 2 files changed, 73 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/FullScreenPlayer.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/FullScreenPlayer.kt index 90274c938..fad4a53e1 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/FullScreenPlayer.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/FullScreenPlayer.kt @@ -241,6 +241,53 @@ open class FullScreenPlayer : AbstractPlayerFragment() { return root } + /** + * Wet code but this can not be made into a function as it is a setter. + * + * The reason for this setter is to fix a bug with the titlecard popup, as we want it to autohide + * when pressing back. + * + * Note that we move the call to autoHide after field assignment with prevField to avoid inf recursion. */ + protected var selectSourceDialog: Dialog? = null + set(value) { + val prevField = field + field = value + if (value == null && prevField != null) { + autoHide() + } + } + protected var selectTrackDialog: Dialog? = null + set(value) { + val prevField = field + field = value + if (value == null && prevField != null) { + autoHide() + } + } + protected var selectSpeedDialog: Dialog? = null + set(value) { + val prevField = field + field = value + if (value == null && prevField != null) { + autoHide() + } + } + protected var selectSubtitlesDialog: Dialog? = null + set(value) { + val prevField = field + field = value + if (value == null && prevField != null) { + autoHide() + } + } + + /** Checks if any top level dialog is open and showing */ + fun isDialogOpen() = + selectSourceDialog?.isShowing == true + || selectTrackDialog?.isShowing == true + || selectSpeedDialog?.isShowing == true + || selectSubtitlesDialog?.isShowing == true + private fun scheduleMetadataVisibility() { val metadataScrim = playerBinding?.playerMetadataScrim ?: return val ctx = metadataScrim.context ?: return @@ -262,7 +309,15 @@ open class FullScreenPlayer : AbstractPlayerFragment() { if (isPaused) { metadataScrim.postDelayed({ + /** Make sure the user has not interacted with anything */ if (token != metadataVisibilityToken) return@postDelayed + /** If already visible, then do not rerun the animation */ + if (metadataScrim.isVisible) return@postDelayed + /** Failsafe, as this should only be shown when paused */ + if (currentPlayerStatus != CSPlayerLoading.IsPaused) return@postDelayed + /** We do not want to show the logo in the background when the user is within another screen */ + if (isDialogOpen()) return@postDelayed + metadataScrim.alpha = 0f metadataScrim.isVisible = true metadataScrim.animate() @@ -751,6 +806,7 @@ open class FullScreenPlayer : AbstractPlayerFragment() { val dialog = Dialog(ctx, R.style.DialogFullscreenPlayer).apply { setContentView(binding.root) } + this.selectSubtitlesDialog = dialog dialog.show() val isPortrait = @@ -840,20 +896,24 @@ open class FullScreenPlayer : AbstractPlayerFragment() { } dialog.setOnDismissListener { + selectSubtitlesDialog = null if (isFullScreenPlayer) activity?.hideSystemUI() } applyBtt.setOnClickListener { + selectSubtitlesDialog = null subtitleDelay = currentOffset dialog.dismissSafe(activity) player.seekTime(1L) } resetBtt.setOnClickListener { + selectSubtitlesDialog = null subtitleDelay = 0 dialog.dismissSafe(activity) player.seekTime(1L) } cancelBtt.setOnClickListener { + selectSubtitlesDialog = null dialog.dismissSafe(activity) } } @@ -916,6 +976,7 @@ open class FullScreenPlayer : AbstractPlayerFragment() { if (isPlaying) { player.handleEvent(CSPlayerEvent.Play, PlayerEventSource.UI) } + selectSpeedDialog = null } // if (isLayout(PHONE)) { @@ -930,6 +991,7 @@ open class FullScreenPlayer : AbstractPlayerFragment() { .setView(binding.root) builder.setOnDismissListener(dismiss) val dialog = builder.create() + this.selectSpeedDialog = dialog dialog.show() //} } @@ -1124,8 +1186,10 @@ open class FullScreenPlayer : AbstractPlayerFragment() { private var currentTapIndex = 0 protected fun autoHide() { + metadataVisibilityToken++ currentTapIndex++ delayHide() + scheduleMetadataVisibility() } protected fun hidePlayerUI() { 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 de1b32467..ad7c8915f 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 @@ -896,6 +896,7 @@ class GeneratorPlayer : FullScreenPlayer() { viewModel.addSubtitles(subtitleData.toSet()) selectSourceDialog?.dismissSafe() + selectSourceDialog = null showToast( String.format(ctx.getString(R.string.player_loaded_subtitles), selectedSubtitle.name), @@ -936,10 +937,6 @@ class GeneratorPlayer : FullScreenPlayer() { } } - private var selectSourceDialog: Dialog? = null - // var selectTracksDialog: AlertDialog? = null - - /** Will toast both when an error is found and when a subtitle is selected, * so only use from a user click and not a background process */ private fun addFirstSub(query: SubtitleSearch) = @@ -1072,6 +1069,7 @@ class GeneratorPlayer : FullScreenPlayer() { loadFromOpenSubsFooter.setOnClickListener { shouldDismiss = false sourceDialog.dismissSafe(activity) + selectSourceDialog = null openOnlineSubPicker(it.context, currentLoadResponse) { dismiss() } @@ -1092,6 +1090,7 @@ class GeneratorPlayer : FullScreenPlayer() { loadFromFirstSubsFooter.setOnClickListener { sourceDialog.dismissSafe(activity) + selectSourceDialog = null showToast(R.string.loading) addFirstSub( SubtitleSearch( @@ -1267,6 +1266,7 @@ class GeneratorPlayer : FullScreenPlayer() { binding.cancelBtt.setOnClickListener { sourceDialog.dismissSafe(activity) + this.selectSourceDialog = null } fun setProfileName(profile: Int) { @@ -1317,6 +1317,7 @@ class GeneratorPlayer : FullScreenPlayer() { shouldDismiss = false sourceDialog.dismissSafe(activity) + selectSourceDialog = null val index = prefValues.indexOf(currentPrefMedia) activity?.showDialog( @@ -1355,6 +1356,7 @@ class GeneratorPlayer : FullScreenPlayer() { } } sourceDialog.dismissSafe(activity) + selectSourceDialog = null } } } catch (e: Exception) { @@ -1378,6 +1380,7 @@ class GeneratorPlayer : FullScreenPlayer() { val binding: PlayerSelectTracksBinding = PlayerSelectTracksBinding.inflate(LayoutInflater.from(ctx), null, false) val trackDialog = Dialog(ctx, R.style.DialogFullscreenPlayer) + this.selectTrackDialog = trackDialog trackDialog.setContentView(binding.root) trackDialog.show() @@ -1486,6 +1489,7 @@ class GeneratorPlayer : FullScreenPlayer() { binding.cancelBtt.setOnClickListener { trackDialog.dismissSafe(activity) + this.selectTrackDialog = null } binding.applyBtt.setOnClickListener { @@ -1503,6 +1507,7 @@ class GeneratorPlayer : FullScreenPlayer() { player.setMaxVideoSize(width, height, currentVideo?.id) } trackDialog.dismissSafe(activity) + this.selectTrackDialog = null } } } catch (e: Exception) { From d23fb0ac4ca0f81d6d0de16322b3a63dec9e691e Mon Sep 17 00:00:00 2001 From: Hosted Weblate Date: Mon, 30 Mar 2026 17:10:00 +0200 Subject: [PATCH 085/236] Translated using Weblate (Swedish) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Currently translated at 100.0% (726 of 726 strings) Translated using Weblate (Italian) Currently translated at 100.0% (726 of 726 strings) Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 100.0% (726 of 726 strings) Translated using Weblate (Czech) Currently translated at 100.0% (726 of 726 strings) Translated using Weblate (Ukrainian) Currently translated at 99.8% (725 of 726 strings) Translated using Weblate (Polish) Currently translated at 100.0% (726 of 726 strings) Translated using Weblate (Arabic) Currently translated at 100.0% (725 of 725 strings) Translated using Weblate (Arabic) Currently translated at 100.0% (4 of 4 strings) Translated using Weblate (Korean) Currently translated at 100.0% (725 of 725 strings) Merge remote-tracking branch 'origin/master' Merge remote-tracking branch 'origin/master' Translated using Weblate (Korean) Currently translated at 100.0% (725 of 725 strings) Translated using Weblate (Korean) Currently translated at 100.0% (725 of 725 strings) Translated using Weblate (Slovak) Currently translated at 62.6% (454 of 725 strings) Translated using Weblate (Esperanto) Currently translated at 23.7% (172 of 725 strings) Translated using Weblate (Chinese (Traditional Han script)) Currently translated at 100.0% (725 of 725 strings) Translated using Weblate (Chinese (Traditional Han script)) Currently translated at 100.0% (725 of 725 strings) Translated using Weblate (Bulgarian) Currently translated at 99.1% (719 of 725 strings) Translated using Weblate (Latvian) Currently translated at 81.2% (589 of 725 strings) Merge remote-tracking branch 'origin/master' Merge remote-tracking branch 'origin/master' Merge remote-tracking branch 'origin/master' Merge remote-tracking branch 'origin/master' Merge remote-tracking branch 'origin/master' Merge remote-tracking branch 'origin/master' Translated using Weblate (Esperanto) Currently translated at 17.5% (127 of 725 strings) Translated using Weblate (Portuguese) Currently translated at 100.0% (725 of 725 strings) Translated using Weblate (Vietnamese) Currently translated at 99.8% (724 of 725 strings) Translated using Weblate (Hungarian) Currently translated at 82.0% (595 of 725 strings) Translated using Weblate (Korean) Currently translated at 100.0% (725 of 725 strings) Translated using Weblate (Belarusian) Currently translated at 99.5% (722 of 725 strings) Merge remote-tracking branch 'origin/master' Merge remote-tracking branch 'origin/master' Merge remote-tracking branch 'origin/master' Merge remote-tracking branch 'origin/master' Merge remote-tracking branch 'origin/master' Merge remote-tracking branch 'origin/master' Merge remote-tracking branch 'origin/master' Merge remote-tracking branch 'origin/master' Translated using Weblate (Belarusian) Currently translated at 99.5% (722 of 725 strings) Translated using Weblate (Filipino) Currently translated at 21.2% (154 of 725 strings) Translated using Weblate (Hungarian) Currently translated at 77.3% (561 of 725 strings) Translated using Weblate (Dutch) Currently translated at 89.1% (646 of 725 strings) Translated using Weblate (Tamil) Currently translated at 100.0% (725 of 725 strings) Translated using Weblate (German) Currently translated at 100.0% (725 of 725 strings) Translated using Weblate (Tamil) Currently translated at 100.0% (725 of 725 strings) Translated using Weblate (Hungarian) Currently translated at 75.4% (547 of 725 strings) Co-authored-by: Ardev Prisec Co-authored-by: Aron Folkerts Co-authored-by: Daniel Konstantinov Co-authored-by: David Hermann Co-authored-by: Fjuro Co-authored-by: Hosted Weblate Co-authored-by: Jen Xie Co-authored-by: Massimo Pissarello Co-authored-by: Matthaiks Co-authored-by: Nguyễn Tiến Đạt Co-authored-by: Romhányi-Kakucska Viktor Co-authored-by: Sasha Glazko Co-authored-by: Wacky Wars Co-authored-by: bittin1ddc447d824349b2 Co-authored-by: clearstripe Co-authored-by: hollow Co-authored-by: hou1234 Co-authored-by: jpkaster 77 Co-authored-by: programutox Co-authored-by: tomas293 Co-authored-by: தமிழ்நேரம் Co-authored-by: ℂ𝕠𝕠𝕠𝕝 (𝕘𝕚𝕥𝕙𝕦𝕓.𝕔𝕠𝕞/ℂ𝕠𝕠𝕠𝕝) Co-authored-by: 大王叫我来巡山 Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/ar/ Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/be/ Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/bg/ Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/cs/ Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/de/ Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/eo/ Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/fil/ Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/hu/ Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/it/ Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/ko/ Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/lv/ Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/nl/ Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/pl/ Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/pt/ Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/sk/ Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/sv/ Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/ta/ Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/uk/ Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/vi/ Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/zh_Hans/ Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/zh_Hant/ Translate-URL: https://hosted.weblate.org/projects/cloudstream/fastlane/ar/ Translation: Cloudstream/App Translation: Cloudstream/Fastlane --- app/src/main/res/values-b+ar/strings.xml | 24 ++++ app/src/main/res/values-b+cs/strings.xml | 1 + app/src/main/res/values-b+it/strings.xml | 1 + app/src/main/res/values-b+ko/strings.xml | 126 +++++++++--------- app/src/main/res/values-b+pl/strings.xml | 1 + app/src/main/res/values-b+sv/strings.xml | 1 + app/src/main/res/values-b+uk/strings.xml | 58 ++++---- app/src/main/res/values-b+zh/strings.xml | 1 + .../metadata/android/ar/full_description.txt | 8 +- 9 files changed, 124 insertions(+), 97 deletions(-) diff --git a/app/src/main/res/values-b+ar/strings.xml b/app/src/main/res/values-b+ar/strings.xml index c68a5a649..91f8f0e64 100644 --- a/app/src/main/res/values-b+ar/strings.xml +++ b/app/src/main/res/values-b+ar/strings.xml @@ -756,4 +756,28 @@ نص الحلقة معلومات الوسائط اسم المصدر + شريط التنزيلات + لا يوجد تنزيلات قيد الانتظار حاليا. + أولوية المصدر + حَدِّد كيف يجب ترتيب مصادر الفيديو في المُشَّغِل + تنزيل الكل + إلغاء الكل + هل ترغب في تنزيل الحلقة %s ؟ + هل ترغب في إلغاء جميع التنزيلات قيد الانتظار؟ + + %d لا يوجد تنزيل نشط + %d تنزيل واحد نشط + %d تنزيلان نشطان + %d تنزيلات نشطة + %d تنزيل نشط + %d تنزيل نشط + + + %d لا يوجد تنزيلات في قائمة الانتظار + %d تنزيل واحد قيد الانتظار + %d تنزيلان قيد الانتظار + %d تنزيلات قيد الانتظار + %d تنزيل قيد الانتظار + %d تنزيل قيد الانتظار + diff --git a/app/src/main/res/values-b+cs/strings.xml b/app/src/main/res/values-b+cs/strings.xml index 3f7675534..96110d9c1 100644 --- a/app/src/main/res/values-b+cs/strings.xml +++ b/app/src/main/res/values-b+cs/strings.xml @@ -778,4 +778,5 @@ Priorita zdrojů Rozhodněte, jak mají být řazeny zdroje videí v přehrávači + Zobrazit překrytí metadat v přehrávači diff --git a/app/src/main/res/values-b+it/strings.xml b/app/src/main/res/values-b+it/strings.xml index e75b4eb8c..08a1572d6 100644 --- a/app/src/main/res/values-b+it/strings.xml +++ b/app/src/main/res/values-b+it/strings.xml @@ -783,4 +783,5 @@ Priorità sorgente Decidi come le sorgenti video devono essere ordinate nel lettore + Mostra sovrapposizione metadati lettore diff --git a/app/src/main/res/values-b+ko/strings.xml b/app/src/main/res/values-b+ko/strings.xml index 04c113b5b..868e2736e 100644 --- a/app/src/main/res/values-b+ko/strings.xml +++ b/app/src/main/res/values-b+ko/strings.xml @@ -1,11 +1,11 @@ 출연: %s - 에피소드 %d이(가) 공개됩니다 + 에피소드 %d이(가) 공개 예정 포스터 에피소드 포스터 메인 포스터 - 다음 랜덤 + 다음 추천 뒤로가기 소스 변경 미리보기 배경 @@ -33,7 +33,7 @@ 시청 보류 시청 완료 - 포기 + 시청 포기 시청 예정 다시보기 영화 재생 @@ -46,10 +46,10 @@ 에피소드 재생 다운로드 파일 재생 - 계속 다운로드 + 다운로드 재개 다운로드 일시정지 상세 정보 - 닫기 + 숨기기 재생 정보 시청 상태 설정 @@ -109,7 +109,7 @@ 플레이어 자막 설정 Chromecast 자막 Chromecast 자막 설정 - Playback 속도 + 재생 속도 스와이프하여 탐색 좌우로 스와이프하여 동영상 위치 제어하기 스와이프하여 설정 변경 @@ -150,13 +150,13 @@ 바나나 줌 앱 언어 링크를 찾을 수 없음 - 클립보드에 링크 복사됨 + 클립보드에 링크 복사함 에피소드 재생 기본값으로 재설정 에피소드 %1$d-%2$d - 진행중 - 시청 완료 + 방영 중 + 완결 상태 평점 @@ -183,7 +183,7 @@ 앱에서 재생 %s에서 재생 자동 다운로드 - 다운로드 미러 + 다운로드 가능 목록 보기 링크 새로고침 자막 다운로드 화질 탭 @@ -198,7 +198,7 @@ 이 업데이트 건너뛰기 선호하는 화질 (WiFi) 선호하는 화질 (모바일 데이터) - 본문 바로가기 + 플레이어 내 표시 정보 동영상 버퍼 크기 동영상 및 이미지 캐시 지우기 DNS over HTTPS @@ -217,10 +217,10 @@ 일반 플레이어 기능 기능 - 확장 언어 + 확장프로그램 언어 앱 레이아웃 선호하는 미디어 - 지원된 연장에 NSFW 활성화 + 확장프로그램에서 NSFW 활성화 자막 인코딩 소스 소스 테스트 @@ -277,7 +277,7 @@ 잘못된 데이터 잘못된 URL 오류 - 자막에서 선택 캡션 제거 + 자막에서 청각 장애인용 자막 요소 제거 선호하는 미디어 언어로 필터링 예고편 다음 @@ -324,7 +324,7 @@ 에피소드 %d 공개! Picture-in-picture 플레이어 크기 조정 버튼 - 다른 앱 위에 있는 미니어처 플레이어에서 재생을 계속합니다 + 미니플레이어를 통해 다른 앱 상단에서 계속 재생됩니다 검은색 테두리 제거 오른쪽 또는 왼쪽을 두 번 탭하여 앞뒤로 탐색하기 자막 @@ -353,8 +353,8 @@ 실패 평점 평점: %s - 평점 (높음에서 낮음으로) - 평점 (낮음에서 높음으로) + 평점 (높은순) + 평점 (낮은순) 19금 다큐멘터리 라이브 방송 @@ -461,8 +461,8 @@ 앱 종료시 업데이트됩니다 정렬 기준 정렬 - 업데이트됨 (새로움에서 오래된 순) - 업데이트 (오래됨에서 새로운 순) + 업데이트 (최신순) + 업데이트 (오래된순) 알파벳순 (A에서 Z) 알파벳순 (Z에서 A) 다음으로 열기 @@ -497,12 +497,12 @@ 동작 외형 랜덤 버튼 - 홈페이지 및 도서관에서 임의 버튼 표시 + 홈페이지 및 라이브러리에서 랜덤 버튼 표시 포스터 아래에 제목을 이동 내려감 올라감 다람쥐 헌 쳇바퀴에 타고파 - 자막에서 부풀림 제거 + 자막에서 불필요한 요소/코드 제거 엑스트라 https://example.com/example.mp4 트랙 @@ -515,13 +515,13 @@ 구독 %s 구독 취소 %s 보안 - 장부 + 계정 리포지토리에서 플러그인을 찾을 수 없습니다 복사됨! 레포지토리 이름 및 URL 본 테스트는 개발자만을 대상으로 하며, 확장자의 작업을 확인하거나 거부하지 않습니다. 클라우스스트림 위키 - 다시 기록된 링크 + 링크 새로고침 완료 백업 빈도 즐겨찾기 QR 이미지 @@ -608,12 +608,12 @@ \n총 비디오 우선 순위는 10입니다. \n \n참고: 합이 10 이상이면 해당 링크가 로드되면 플레이어는 자동으로 로드를 건너뜁니다! - 시즌 %1$d 에피소드 %2$d이(가) 출시됩니다 + 시즌 %1$d 에피소드 %2$d이(가) 공개 예정 다른 확장자에서 검색 새로운 에피소드 알림 - 권장 사항 표시 + 추천목록 보기 플레이어에 속도 옵션을 추가합니다 - %s로 출시 예정 + %s 후 공개 예정 %s \n남음 잠재적 중복 발견 @@ -623,8 +623,8 @@ 플러그인 삭제 경고 탐색바 미리보기 - 탐색바에서 미리보기 화면 활성화 - 처음부터 시작 + 탐색바에서 화면 미리보기 활성화 + 처음부터 재생 현재 다운로드가 없습니다. 삭제할 항목을 선택하십시오 오프라인 시청가능 @@ -645,10 +645,10 @@ 다음 시리즈의 모든 에피소드를 영구적으로 삭제 하시겠습니까?? \n \n%s - 출시일 (새로운 것부터 오래된 것) - 출시일 (오래된것부터 새로운것) - 플레이어 컨트롤 이름 숨기기 - 이 동영상은 토렌트이므로 동영상 활동이 추적될 수 있습니다.\n계속하기 전에 토렌트에 대해 충분히 이해했는지 확인하세요. + 공개일 (최신순) + 공개일 (오래된순) + 플레이어 내 버튼명 숨기기 + 이 동영상은 토렌트이므로 시청 활동이 추적될 수 있습니다.\n계속하기 전에 토렌트에 대해 충분히 이해했는지 확인하세요. 오디오 팟캐스트 시작하기 … @@ -662,7 +662,7 @@ 출시 전 버전 설치 출시 전 버전이 이미 설치되어 있습니다. 출시 전 버전 설치 실패. - 미러 재생" + 재생 가능 목록 보기" 평가 라벨 에피소드 본문 사용 가능한 자막 불러오기 @@ -671,24 +671,24 @@ 에피소드 (내림차순) 평가 (높은순) 평가 (낮은순) - 방영 날짜 (최신순) - 방영 날짜 (오래된순) + 공개일 (최신순) + 공개일 (오래된순) 에피소드 %s 평가 %s 날짜 %s 계정 없음 - 자막을 아직 불러오지 않았습니다 + 자막이 아직 로드되지 않음 백업 폴더 위치 커스텀 나가기 전에 확인 - 이 문항을 앱에서 나가기 전에 보이기 + 이 알림을 앱 종료 전에 표시 보이기 보이지 않기 테두리 크기 Settings/Providers/Preferred media 에서 토렌트 활성화 진행하려면 앱 재시작 후 토렌트 스트리밍 팝업란의 수락이 필요합니다. 소프트웨어 디코딩 - 소프트웨어 디코딩은 당신의 기기에서 지원되지 않는 영상 파일들을 재생할 수 있지만, 높은 화질에서 렉 또는 불안정한 재생을 유발할 수 있습니다. + 소프트웨어 디코딩은 해당 기기에서 지원되지 않는 영상 파일들을 재생할 수 있지만, 높은 화질에서 렉 또는 불안정한 재생을 유발할 수 있습니다. 볼륨이 100%를 초과하였습니다 100% 너머로 높이려면 한번 더 슬라이드 하십시오 플러그인 업데이트하기 @@ -697,18 +697,18 @@ 성공적으로 %d 플러그인을 업데이트 하였습니다! 업데이트 된 플러그인이 없습니다. 플레이어 알림 - 백그라운드에서 재생을 조종할 수 있는 플레이어 알림 - 내장된 + 백그라운드에서 재생을 제어하기 위한 플레이어 알림 + 내장 자막 온라인 - 모든 자막 굵게 - 모든 자막 기울기 + 자막 글꼴 굵게 표시 + 자막 글꼴 기울게 표시 병렬로 다운로드 할 수 있는 아이템의 수 병렬 다운로드 동시 연결수 다운로드 시 각 항목마다 사용할 수 있는 동시 연결의 수 다운로드로 가기 인터넷 연결 없음.\n\n인터넷에 연결 한 후 재시도 하거나, 혹은 이미 다운로드 된 항목을 재생하십시오. - 화면 경계 조정 + 화면의 잘림 현상을 방지하기 위해 경계를 조정합니다 포스터 크기 변경 포스터 크기 길게 눌러 배속 활성화 @@ -718,8 +718,8 @@ URL을 찾을 수 없습니다 잘못된 URL 혹은 이미지 입니다 이미지 업데이트 성공 - 이 에피소드 까지 봤음 표시 - 이 에피소드 까지 봤음 표시 제거 + 이 에피소드까지 시청함으로 표시 + 이 에피소드까지 시청함 표시 제거 이름 해상도 및 이름 자막 정렬 @@ -732,9 +732,9 @@ 왼쪽 위 중앙 위 오른쪽 위 - 비디오 소스가 플레이어에서 정렬되어야하는 방법 결정 - 100% 표시 광도가 초과될 때 Enable 광도 여과기 - 모든 퀴즈 다운로드를 취소하시겠습니까? + 비디오 소스가 플레이어에서 정렬되는 순서 설정 + 디스플레이 밝기가 100%를 초과하면 밝기 필터를 활성화합니다 + 모든 다운로드 큐를 취소하시겠습니까? 에피소드를 다운로드 하시겠습니까 %s? 현재 누락된 다운로드가 없습니다. @@ -743,20 +743,20 @@ %d 다운로드 - 검색 결과 표시 - 회사 소개 - 배경 반경 - Reload 공급자 - 검색 제안 - 자주 묻는 질문 + 입력하는 동안 검색어 제안 표시 + 출연진 정보 표시 + 배경 모서리 곡률 + 공급자 새로고침 + 검색어 제안 + 제안 삭제 미디어 정보 - 근원 이름 - 추가 밝기 - 다운로드 queue - 다운로드 - 모든 것 - 근원 우선권 - 구름 많음 - 관련 상품 - 추가_brightness_enabled + 소스 이름 + 최대 밝기 확장 + 다운로드 큐 + 모두 다운로드 + 모두 취소 + 소스 우선순위 + 오버스캔(화면 경계) 설정 + 새로고침 + 최대 밝기 확장 활성화 diff --git a/app/src/main/res/values-b+pl/strings.xml b/app/src/main/res/values-b+pl/strings.xml index fc167da5a..c8126f2fe 100644 --- a/app/src/main/res/values-b+pl/strings.xml +++ b/app/src/main/res/values-b+pl/strings.xml @@ -766,4 +766,5 @@ Priorytet źródła Zdecyduj, jak mają być sortowane źródła wideo w odtwarzaczu + Pokaż nakładkę metadanych odtwarzacza diff --git a/app/src/main/res/values-b+sv/strings.xml b/app/src/main/res/values-b+sv/strings.xml index ddc7636b1..e388b67e1 100644 --- a/app/src/main/res/values-b+sv/strings.xml +++ b/app/src/main/res/values-b+sv/strings.xml @@ -759,4 +759,5 @@ Gör alla undertexter fetstilta Gör alla undertexter kursivstila Bakgrundsradie + Visa spelarens metadata överlägg diff --git a/app/src/main/res/values-b+uk/strings.xml b/app/src/main/res/values-b+uk/strings.xml index b97c16a7e..cec3b6738 100644 --- a/app/src/main/res/values-b+uk/strings.xml +++ b/app/src/main/res/values-b+uk/strings.xml @@ -45,8 +45,8 @@ Завантаження Завершено Дуб. Суб. - Видалити файл - Відновити завантаження + Видалити Файл + Відновити Завантаження Приховати Переглянути Подробиці @@ -57,28 +57,28 @@ Скопіювати Закрити Зберегти - Швидкість плеєра - Колір вікна - Тип обведення + Швидкість Плеєра + Колір Вікна + Тип Межі Шрифт - Розмір шрифту + Розмір Шрифту Пошук за постачальниками Пошук за типами - Жодного банана не надано - Автовибір мови - Завантажити мови - Мова субтитрів + Жодного Benenes не надано + Авто-Вибір Мови + Завантажити Мови + Мова Субтитрів Утримуйте, щоби скинути до типових налаштувань Імпортуйте шрифти, помістивши їх до %s - Продовжити перегляд + Продовжити Перегляд Вилучити Докладніше Цей постачальник є торентом, рекомендується використовувати VPN Опис - Сюжет не знайдено - Опис не знайдено + Сюжет Не Знайдено + Опис Не Знайдено Показати Logcat 🐈 - Продовжувати відтворення в малому програвачі поверх інших застосунків + Продовжувати відтворення в мініатюрному програвачі поверх інших застосунків Прибрати чорні смуги Субтитри Субтитри Chromecast @@ -106,19 +106,19 @@ Завантаження не Вдалося Оновлення Розпочато Помилка Завантаження Посилань - Призупинити завантаження - Переглянути файл + Призупинити Завантаження + Переглянути Файл Докладніше - Фільтр закладок + Фільтрувати Закладки Очистити - Налаштування субтитрів - Колір тла - Висота субтитрів - Колір тексту - Колір обведення + Налаштування Субтитрів + Колір Тла + Висота Субтитрів + Колір Тексту + Колір Обведення Автовідтворення наступного епізоду Проведіть збоку в бік, щоби керувати часом відтворення у відео - %d бананів надано розробникам + %d Benenes надано розробникам Кнопка зміни розміру програвача @string/home_play Для коректної роботи цього постачальника може знадобитися VPN @@ -128,7 +128,7 @@ Провести, щоби перемотати Натиснути двічі, щоби перемотати Натиснути двічі, щоби призупинити - Крок перемотування (у секундах) + Крок перемотування (Секунди) Натисніть двічі посередині, щоби призупинити відтворення Використовувати системну яскравість Оновлювати прогрес перегляду @@ -138,10 +138,10 @@ Дані збережено Помилка резервного копіювання %s Пошук - Облікові записи та безпека - Оновлення та резервне копіювання + Облікові Записи та Безпека + Оновлення та Резервне Копіювання Подробиці - Розширений пошук + Розширений Пошук Показувати результати пошуку, розділені за постачальниками Показувати наповнювачі для аніме Показувати трейлери @@ -490,7 +490,7 @@ Провалено Пройдено Перезапустити - Журнал + Лог Відновити Зупинити Перевірка постачальників @@ -706,7 +706,7 @@ Попередня версія вже встановлена. Не вдалося встановити попередню версію. Текст епізоду - Пропозиції пошуку + Пропозиції Пошуку Показувати підказки пошуку під час введення тексту Очистити пропозиції Додаткова яскравість diff --git a/app/src/main/res/values-b+zh/strings.xml b/app/src/main/res/values-b+zh/strings.xml index e8e02d51f..bc7c2ca0e 100644 --- a/app/src/main/res/values-b+zh/strings.xml +++ b/app/src/main/res/values-b+zh/strings.xml @@ -771,4 +771,5 @@ 源优先级 确定在播放器中如何排列视频源的顺序 已启用额外亮度 + 显示播放器元数据遮罩层 diff --git a/fastlane/metadata/android/ar/full_description.txt b/fastlane/metadata/android/ar/full_description.txt index 9bbe01ef2..81859f665 100644 --- a/fastlane/metadata/android/ar/full_description.txt +++ b/fastlane/metadata/android/ar/full_description.txt @@ -3,10 +3,8 @@ يأتي التطبيق بدون أي إعلانات وتحليلات. و يدعم العديد من مواقع البث الاولي(التريلر) والأفلام والمزيد. وتشمل الميزات: -إشارات مرجعية +الإشارات المرجعية -قم بتنزيل ودفق الأفلام والبرامج التلفزيونية والأنيمي +تنزيل الترجمات -تنزيلات الترجمة - -دعم كروم كاست +دعم الكروم كاست (Chromecast) From 736c6374a6502f057a08866a75f189759e755457 Mon Sep 17 00:00:00 2001 From: Nguyen Van Nam Date: Tue, 31 Mar 2026 04:23:02 +0700 Subject: [PATCH 086/236] Fix: thread-safe HashMap for image bitmap cache --- .../cloudstream3/utils/downloader/DownloadUtils.kt | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/downloader/DownloadUtils.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/downloader/DownloadUtils.kt index b436bb49c..9f2c31d9a 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/downloader/DownloadUtils.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/downloader/DownloadUtils.kt @@ -20,16 +20,17 @@ import com.lagradost.cloudstream3.utils.downloader.DownloadFileManagement.getFol import com.lagradost.cloudstream3.utils.txt import kotlinx.coroutines.Job import kotlinx.coroutines.runBlocking +import java.util.concurrent.ConcurrentHashMap /** Separate object with helper functions for the downloader */ object DownloadUtils { - private val cachedBitmaps = hashMapOf() + private val cachedBitmaps = ConcurrentHashMap() internal fun Context.getImageBitmapFromUrl( url: String, headers: Map? = null ): Bitmap? = safe { - if (cachedBitmaps.containsKey(url)) { - return@safe cachedBitmaps[url] + cachedBitmaps[url]?.let { + return@safe it } val imageLoader = SingletonImageLoader.get(this) @@ -50,7 +51,7 @@ object DownloadUtils { } bitmap?.let { - cachedBitmaps[url] = it + cachedBitmaps.putIfAbsent(url, it) } return@safe bitmap From db154a8cd25aaad2f942931f33714ca2423f395c Mon Sep 17 00:00:00 2001 From: Phisher98 <153359846+phisher98@users.noreply.github.com> Date: Fri, 3 Apr 2026 20:06:42 +0530 Subject: [PATCH 087/236] Adding IntroDB (#2599) --- .../lagradost/cloudstream3/utils/AniSkip.kt | 91 +++++++++++++++++++ 1 file changed, 91 insertions(+) diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/AniSkip.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/AniSkip.kt index 820a01f9f..bbdadbf3f 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/AniSkip.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/AniSkip.kt @@ -2,8 +2,10 @@ package com.lagradost.cloudstream3.utils import android.util.Log import androidx.annotation.StringRes +import com.fasterxml.jackson.annotation.JsonProperty import com.fasterxml.jackson.databind.annotation.JsonSerialize import com.lagradost.cloudstream3.* +import com.lagradost.cloudstream3.LoadResponse.Companion.getImdbId import com.lagradost.cloudstream3.LoadResponse.Companion.getMalId import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.ui.result.ResultEpisode @@ -86,7 +88,57 @@ object EpisodeSkip { out.addAll(list) } } + } else if (data.type == TvType.TvSeries || data.type == TvType.AsianDrama) { + val season = episode.season + val imdbId = data.getImdbId() + + if (season != null && imdbId != null) { + val result = IntroDbSkip.getResult( + imdbId, + season, + episode.episode + ) + + result?.let { res -> + listOfNotNull( + res.intro?.let { + val start = it.startMs ?: return@let null + val end = it.endMs ?: return@let null + SkipStamp( + type = SkipType.Opening, + skipToNextEpisode = hasNextEpisode && + shouldSkipToNextEpisode(end, episodeDurationMs), + startMs = start, + endMs = end + ) + }, + res.recap?.let { + val start = it.startMs ?: return@let null + val end = it.endMs ?: return@let null + SkipStamp( + type = SkipType.Recap, + skipToNextEpisode = hasNextEpisode && + shouldSkipToNextEpisode(end, episodeDurationMs), + startMs = start, + endMs = end + ) + }, + res.outro?.let { + val start = it.startMs ?: return@let null + val end = it.endMs ?: return@let null + SkipStamp( + type = SkipType.Credits, + skipToNextEpisode = hasNextEpisode && + shouldSkipToNextEpisode(end, episodeDurationMs), + startMs = start, + endMs = end + ) + } + ).let { out.addAll(it) } + } + } } + if (out.isNotEmpty()) cachedStamps[episode.id] = out return out @@ -136,4 +188,43 @@ object AniSkip { @JsonSerialize val startTime: Double, @JsonSerialize val endTime: Double ) +} + +object IntroDbSkip { + private const val TAG = "IntroDb" + + suspend fun getResult( + imdbId: String, + season: Int, + episode: Int, + ): IntroDbResponse? { + return try { + val url = + "https://api.introdb.app/segments?imdb_id=$imdbId&season=$season&episode=$episode" + app.get(url).parsed() + } catch (t: Throwable) { + Log.i(TAG, "error = ${t.message}") + logError(t) + null + } + } + + data class IntroDbResponse( + @JsonProperty("imdb_id") val imdbId: String?, + val season: Int?, + val episode: Int?, + val intro: Segment?, + val recap: Segment?, + val outro: Segment?, + ) + + data class Segment( + @JsonProperty("start_sec") val startSec: Double?, + @JsonProperty("end_sec") val endSec: Double?, + @JsonProperty("start_ms") val startMs: Long?, + @JsonProperty("end_ms") val endMs: Long?, + val confidence: Double?, + @JsonProperty("submission_count") val submissionCount: Int?, + @JsonProperty("updated_at") val updatedAt: String?, + ) } \ No newline at end of file From ba9413e972e5586bceecc91bfc74b9de2a46fec5 Mon Sep 17 00:00:00 2001 From: Luna712 <142361265+Luna712@users.noreply.github.com> Date: Sat, 4 Apr 2026 12:26:17 -0600 Subject: [PATCH 088/236] Change param name in interface to match everywhere else (#2611) --- .../main/java/com/lagradost/cloudstream3/ui/player/IPlayer.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/IPlayer.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/IPlayer.kt index 183f26f73..08b8ee795 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/IPlayer.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/IPlayer.kt @@ -307,7 +307,7 @@ interface IPlayer { fun setMaxVideoSize(width: Int = Int.MAX_VALUE, height: Int = Int.MAX_VALUE, id: String? = null) /** If no trackLanguage is set it'll default to first track. Specifying the id allows for track overrides as the language can be identical. */ - fun setPreferredAudioTrack(trackLanguage: String?, id: String? = null, trackIndex: Int? = null) + fun setPreferredAudioTrack(trackLanguage: String?, id: String? = null, formatIndex: Int? = null) /** Get the current subtitle cues, for use with syncing */ fun getSubtitleCues(): List From bb295ded09ee4059a1a180b9c319a162239b7ffe Mon Sep 17 00:00:00 2001 From: Hosted Weblate Date: Sat, 4 Apr 2026 20:26:35 +0200 Subject: [PATCH 089/236] Translated using Weblate (Vietnamese) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Currently translated at 100.0% (726 of 726 strings) Translated using Weblate (Korean) Currently translated at 100.0% (726 of 726 strings) Translated using Weblate (Belarusian) Currently translated at 99.5% (723 of 726 strings) Translated using Weblate (Vietnamese) Currently translated at 100.0% (726 of 726 strings) Translated using Weblate (French) Currently translated at 100.0% (726 of 726 strings) Translated using Weblate (Vietnamese) Currently translated at 100.0% (726 of 726 strings) Translated using Weblate (Ukrainian) Currently translated at 100.0% (726 of 726 strings) Translated using Weblate (Ukrainian) Currently translated at 100.0% (726 of 726 strings) Translated using Weblate (Ukrainian) Currently translated at 100.0% (726 of 726 strings) Translated using Weblate (Indonesian) Currently translated at 100.0% (726 of 726 strings) Translated using Weblate (Korean) Currently translated at 100.0% (726 of 726 strings) Translated using Weblate (Vietnamese) Currently translated at 100.0% (726 of 726 strings) Translated using Weblate (Vietnamese) Currently translated at 100.0% (726 of 726 strings) Translated using Weblate (Latvian) Currently translated at 80.8% (587 of 726 strings) Translated using Weblate (Ukrainian) Currently translated at 100.0% (726 of 726 strings) Co-authored-by: Ardev Prisec Co-authored-by: Dan Co-authored-by: Hosted Weblate Co-authored-by: Man Co-authored-by: Posemartonis Co-authored-by: Sasha Glazko Co-authored-by: blueocean2308 Co-authored-by: hou1234 Co-authored-by: opakholis Co-authored-by: ℂ𝕠𝕠𝕠𝕝 (𝕘𝕚𝕥𝕙𝕦𝕓.𝕔𝕠𝕞/ℂ𝕠𝕠𝕠𝕝) Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/be/ Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/fr/ Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/id/ Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/ko/ Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/lv/ Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/uk/ Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/vi/ Translation: Cloudstream/App --- app/src/main/res/values-b+fr/strings.xml | 1 + app/src/main/res/values-b+in/strings.xml | 1 + app/src/main/res/values-b+ko/strings.xml | 125 ++++++------ app/src/main/res/values-b+lv/strings.xml | 76 ++++---- app/src/main/res/values-b+uk/strings.xml | 235 ++++++++++++----------- app/src/main/res/values-b+vi/strings.xml | 192 +++++++++--------- app/src/main/res/values-be/strings.xml | 5 +- 7 files changed, 318 insertions(+), 317 deletions(-) diff --git a/app/src/main/res/values-b+fr/strings.xml b/app/src/main/res/values-b+fr/strings.xml index f39de53f7..1cbee687f 100644 --- a/app/src/main/res/values-b+fr/strings.xml +++ b/app/src/main/res/values-b+fr/strings.xml @@ -753,4 +753,5 @@ %d téléchargements en attente %d téléchargements en attente + Afficher les métadata de l\'overlay du lecteur vidéo diff --git a/app/src/main/res/values-b+in/strings.xml b/app/src/main/res/values-b+in/strings.xml index 7fa837b23..d5bf2d4b0 100644 --- a/app/src/main/res/values-b+in/strings.xml +++ b/app/src/main/res/values-b+in/strings.xml @@ -764,4 +764,5 @@ Batalkan semua Apakah kamu ingin mengunduh episode %s? Apakah kamu ingin membatalkan semua unduhan dalam antrean? + Tampilkan overlay metadata pemutar diff --git a/app/src/main/res/values-b+ko/strings.xml b/app/src/main/res/values-b+ko/strings.xml index 868e2736e..29a0a703e 100644 --- a/app/src/main/res/values-b+ko/strings.xml +++ b/app/src/main/res/values-b+ko/strings.xml @@ -1,14 +1,14 @@ 출연: %s - 에피소드 %d이(가) 공개 예정 + 에피소드 %d 공개 예정 포스터 에피소드 포스터 메인 포스터 다음 추천 뒤로가기 소스 변경 - 미리보기 배경 + 배경 미리보기 속도 (%.2fx) 평점: %.1f 새로운 업데이트! @@ -55,14 +55,14 @@ 시청 상태 설정 저장 재생 속도 - 글자 색깔 - 외곽선 색깔 - 배경 색깔 - 창 색깔 - 가장자리 타입 + 글자 색상 + 윤곽선 색상 + 배경 색상 + 배경 색상 + 윤곽선 유형 자막 높이 폰트 - 폰트 크기 + 자막 크기 다운로드됨 다운로드중 다운로드 일시정지 @@ -119,7 +119,7 @@ 두 번 탭하여 탐색 두 번 탭하여 일시정지 플레이어 탐색 시간 (초) - 가운데를 두 번 탭하여 일시중지 + 가운데를 두 번 탭하여 일시정지 시스템 밝기 사용 어두운 오버레이 대신 앱 플레이어의 시스템 밝기를 사용합니다 시청 진행 상황 업데이트 @@ -135,14 +135,14 @@ 계정 및 보안 소스별로 구분된 검색 결과를 제공합니다 예고편 보기 - Kitsu에서 포스터 보기 + Kitsu에서 포스터 가져오기 검색 결과에서 선택한 동영상 품질 숨기기 플러그인 자동 다운로드 플러그인 자동 업데이트 추가된 저장소에서 아직 설치되지 않은 모든 플러그인을 자동으로 설치합니다. 앱 업데이트 표시 앱을 시작한 후 새 업데이트를 자동으로 검색합니다. - 일부 장치는 새 패키지 설치 프로그램을 지원하지 않습니다. 업데이트가 설치되지 않으면 레거시 옵션을 사용해보십시오. + 일부 기기에서는 최신 방식의 설치 프로그램이 작동하지 않을 수 있습니다. 업데이트가 안 된다면 \'기본 방식\' 설정을 사용해 보세요. 같은 개발자가 만든 라이트 노벨 앱 같은 개발자가 만든 애니메이션 앱 Discord에 참여하기 @@ -181,29 +181,29 @@ 토렌트 Chromecast 미러링 앱에서 재생 - %s에서 재생 + %s부터 재생 자동 다운로드 - 다운로드 가능 목록 보기 + 다운로드 소스 목록 링크 새로고침 자막 다운로드 - 화질 탭 - 더빙 탭 - 자막 탭 + 화질 라벨 + 더빙 라벨 + 자막 라벨 제목 업데이트 확인 잠금 크기 조정 소스 - 오프닝 건너뛰기 + 오프닝 스킵 이 업데이트 건너뛰기 선호하는 화질 (WiFi) 선호하는 화질 (모바일 데이터) 플레이어 내 표시 정보 동영상 버퍼 크기 - 동영상 및 이미지 캐시 지우기 + 비디오 및 이미지 캐시 삭제 DNS over HTTPS GitHub에 연결할 수 없습니다. jsDelivr 프록시를 켜는 중… - JsDelivr를 사용하여 원시 github URL 차단을 우회하십시오. 몇 일 지연 될 업데이트가 발생할 수 있습니다. + jsDelivr를 사용하여 차단된 GitHub 주소를 우회합니다. 단, 업데이트 반영이 며칠 정도 늦어질 수 있습니다. 복제 사이트 사이트 삭제 다른 URL을 사용하여 기존 사이트의 복제본을 추가합니다 @@ -251,7 +251,7 @@ 전부 최대 최소 - 윤곽선 + 윤곽선 효과 그림자 자막 동기화 1000 ms @@ -277,7 +277,7 @@ 잘못된 데이터 잘못된 URL 오류 - 자막에서 청각 장애인용 자막 요소 제거 + 청각 장애인용 자막 요소 제거 선호하는 미디어 언어로 필터링 예고편 다음 @@ -304,8 +304,8 @@ \nDiscord에 가입하거나 온라인으로 검색해 보세요. 커뮤니티 저장소 보기 공개 목록 - 모든 자막 대문자화 - 경고: CloudStream은 제3자 확장을 이용하여 어떠한 책임도 지지 않습니다! + 자막 대문자화 표시 + 경고: CloudStream은 외부 확장 프로그램 사용에 대해 어떠한 책임도 지지 않으며, 관련 기술 지원을 제공하지 않습니다! %s (사용불가) 저장소 추가 저장소 이름 (선택 사항) @@ -322,13 +322,13 @@ 충돌 정보 보기 언어 에피소드 %d 공개! - Picture-in-picture + PIP 모드 플레이어 크기 조정 버튼 - 미니플레이어를 통해 다른 앱 상단에서 계속 재생됩니다 - 검은색 테두리 제거 + 미니플레이어를 통해 다른 앱 상단에서 계속 재생합니다 + 레터박스 제거 오른쪽 또는 왼쪽을 두 번 탭하여 앞뒤로 탐색하기 자막 - 로드된 백업 파일 + 백업 파일을 성공적으로 로드하였습니다 정보 고급 검색 설정 프로세스 다시 실행 @@ -374,7 +374,7 @@ 애니 OVA 원격 오류 - 다운로드 오류, 저장 권한 확인 + 다운로드 오류, 저장소 권한을 확인하세요 Chromecast 에피소드 예기치 않은 플레이어 오류 다시 표시하지 않음 @@ -382,7 +382,7 @@ 업데이트 GitHub 프록시 동영상 버퍼 길이 - 저장소에 동영상 캐시 + 디스크 비디오 캐시 Android TV와 같이 메모리가 부족한 디바이스에서 너무 높게 설정하면 충돌이 발생할 수 있습니다. 화면 크기에 맞춤 Android TV와 같이 저장 공간이 부족한 기기에서 너무 높게 설정하면 문제가 발생할 수 있습니다. @@ -456,7 +456,7 @@ 앱 업데이트 다운로드 중… 앱 업데이트 설치 중… 새 버전의 앱을 설치할 수 없습니다 - 레거시 + 기본 방식 패키지 인스톨러 앱 종료시 업데이트됩니다 정렬 기준 @@ -489,20 +489,20 @@ 애니메이션용 필러 에피소드 표시 통과 계속 - 동영상 플레이어 제목 최대 글자 수 - 표시된 플레이어 - 빨리 감기 및 되감기 초 - 플레이어가 보일 때 사용되는 탐색량 - 플레이어 숨김 - 빨리 감기 및 되감기 초 - 플레이어가 숨겨져 있을 때 사용되는 탐색량 + 플레이어 표시 제목의 최대 글자 수 + 플레이어 표시 시 탐색 시간 + 플레이어 표시 중 탐색 간격 + 플레이어 미표시 시 탐색 시간 + 플레이어 미표시 시 탐색 간격 동작 외형 랜덤 버튼 홈페이지 및 라이브러리에서 랜덤 버튼 표시 포스터 아래에 제목을 이동 - 내려감 - 올라감 + 음각 + 양각 다람쥐 헌 쳇바퀴에 타고파 - 자막에서 불필요한 요소/코드 제거 + 불필요한 요소/코드 제거 엑스트라 https://example.com/example.mp4 트랙 @@ -531,7 +531,7 @@ 취소 저장소 열기 현재 PIN 입력 - 비디오 방향에 따라 화면 방향을 자동으로 전환합니다 + 비디오 방향에 따라 화면 방향을 자동으로 회전합니다 장치 PIN 코드를 가져올 수 없습니다, 로컬 인증을 시도하세요 PIN 코드가 만료되었습니다! 코드 만료까지 남은 시간: %1$dm %2$ds @@ -546,7 +546,7 @@ 프로필 확인 배터리 최적화 사용 안 함 - 앱 배터리 사용량이 이미 무제한으로 설정되었습니다 + 배터리 사용량이 \'제한 없음\'으로 이미 설정되어 있습니다 CloudStream의 App 정보를 열 수 없습니다. 즐겨찾기에 %s 추가 프로필 %d @@ -567,7 +567,7 @@ 모바일 데이터 사용 불가능 캐스트 장치 선택 - 복사하는 중 오류가 발생했습니다. 로그캣을 복사하고 문의하십시오. + 복사하는 중 오류가 발생했습니다. 로그캣을 복사하고 개발자에게 문의하십시오. 구독 취소 기본값 설정 구독 @@ -586,7 +586,7 @@ 계정 선택 기본 계정 사용 회전 - 화면 방향을 전환할 토글 버튼 표시 + 화면 방향 전환 버튼 표시 계정 관리 프로필 잠금 잘못된 PIN입니다. 다시 시도하세요. @@ -598,17 +598,11 @@ 여러 번 실패하면 프롬프트가 닫힙니다. 다시 시도하려면 앱을 다시 시작하세요. 재설정 플러그인 다운로드를 필터링할 모드 선택 - 데이터가 백업되었습니다. 장치에 따라 동작이 다를 수 있으며 앱 접근이 차단될 경우 앱 데이터를 완전히 지우고 백업에서 복원하세요. 이로 인해 발생하는 불편을 사과드립니다. + CloudStream 데이터 백업이 완료되었습니다. 드문 경우지만, 기기에 따라 앱 접속이 안 되는 오류가 발생할 수 있습니다. 만약 앱이 열리지 않는다면, 앱 데이터를 완전히 삭제(초기화)한 후 이 백업 파일로 복구해 주시기 바랍니다. 이용에 불편을 드려 대단히 죄송합니다. 스마트폰이나 컴퓨터에서 %s를 방문하여 위의 코드를 입력하세요 - 구독 된 TV 프로그램에 대한 특이성 다운로드 및 알림을 보려면 클라우드 스트림은 배경에서 오른쪽으로 실행할 권리가 필요합니다. 확인을 눌러 요청 대화 상자를 표시하십시오. 필요한 경우 필요에 따라 CP3에 제한되지 않고 공식을 다운로드하거나 공식화에서 확대를 누릴 필요가 있습니다. - 여기서 소스의 순서를 변경할 수 있습니다. 비디오의 우선 순위가 높은 경우에는 소스 선택에 더 높게 나타납니다. 소스 우선 순위와 품질 우선 순위의 합이 비디오 우선 순위입니다. -\n -\n참고 A: 3 -\n품질 B: 7 -\n총 비디오 우선 순위는 10입니다. -\n -\n참고: 합이 10 이상이면 해당 링크가 로드되면 플레이어는 자동으로 로드를 건너뜁니다! - 시즌 %1$d 에피소드 %2$d이(가) 공개 예정 + 구독 중인 TV 쇼의 알림을 받고 다운로드를 끊김 없이 완료하려면, CloudStream의 백그라운드 실행 권한이 필요합니다. \'확인\'을 누른 후 나타나는 요청 창에서 \'허용\'을 선택해 주세요.\n\n참고로, 이 권한을 허용한다고 해서 배터리가 계속 소모되는 것은 아닙니다. 알림을 받거나 공식 확장 프로그램에서 영상을 다운로드할 때처럼 꼭 필요한 상황에서만 백그라운드 작업을 수행합니다. + 여기서 소스의 순서를 변경할 수 있습니다. 비디오의 우선 순위가 높은 경우에는 소스 선택 화면에 더 높게 나타납니다. 소스 우선 순위와 품질 우선 순위의 합이 비디오 우선 순위입니다. \n \n참고 A: 3 \n품질 B: 7 \n총 비디오 우선 순위는 10입니다. \n \n참고: 합이 10 이상이면 해당 링크가 로드되면 플레이어는 자동으로 로드를 건너뜁니다! + 시즌 %1$d 에피소드 %2$d 공개 예정 다른 확장자에서 검색 새로운 에피소드 알림 추천목록 보기 @@ -619,7 +613,7 @@ 잠재적 중복 발견 %s의 PIN 입력 즐겨찾기에서 제거 - 캐스트미러 + Cast 소스 목록 플러그인 삭제 경고 탐색바 미리보기 @@ -627,7 +621,7 @@ 처음부터 재생 현재 다운로드가 없습니다. 삭제할 항목을 선택하십시오 - 오프라인 시청가능 + 오프라인 시청 가능 모두 선택 모두 선택해제 로컬 비디오 열기 @@ -653,7 +647,7 @@ 팟캐스트 시작하기 … 인코딩 오류 - 지원되지 않는 오류 + 미지원 오류 음성 인식 사용 불가 %1$d시간 %2$d분 %3$d초 %1$d분 %2$d초 @@ -662,7 +656,7 @@ 출시 전 버전 설치 출시 전 버전이 이미 설치되어 있습니다. 출시 전 버전 설치 실패. - 재생 가능 목록 보기" + 재생 소스 목록" 평가 라벨 에피소드 본문 사용 가능한 자막 불러오기 @@ -684,7 +678,7 @@ 이 알림을 앱 종료 전에 표시 보이기 보이지 않기 - 테두리 크기 + 윤곽선 굵기 Settings/Providers/Preferred media 에서 토렌트 활성화 진행하려면 앱 재시작 후 토렌트 스트리밍 팝업란의 수락이 필요합니다. 소프트웨어 디코딩 @@ -700,19 +694,19 @@ 백그라운드에서 재생을 제어하기 위한 플레이어 알림 내장 자막 온라인 - 자막 글꼴 굵게 표시 - 자막 글꼴 기울게 표시 + 자막 굵게 표시 + 자막 기울게 표시 병렬로 다운로드 할 수 있는 아이템의 수 병렬 다운로드 동시 연결수 다운로드 시 각 항목마다 사용할 수 있는 동시 연결의 수 - 다운로드로 가기 + 다운로드로 이동 인터넷 연결 없음.\n\n인터넷에 연결 한 후 재시도 하거나, 혹은 이미 다운로드 된 항목을 재생하십시오. 화면의 잘림 현상을 방지하기 위해 경계를 조정합니다 포스터 크기 변경 포스터 크기 길게 눌러 배속 활성화 - 길게 눌러 2배속 + 길게 눌러 2배속 재생 프로필 사진 변경 프로필 사진 URL 입력 URL을 찾을 수 없습니다 @@ -734,9 +728,9 @@ 오른쪽 위 비디오 소스가 플레이어에서 정렬되는 순서 설정 디스플레이 밝기가 100%를 초과하면 밝기 필터를 활성화합니다 - 모든 다운로드 큐를 취소하시겠습니까? + 모든 다운로드 작업을 취소하시겠습니까? 에피소드를 다운로드 하시겠습니까 %s? - 현재 누락된 다운로드가 없습니다. + 다운로드 대기열이 비어 있습니다. %d 활성 다운로드 @@ -745,18 +739,19 @@ 입력하는 동안 검색어 제안 표시 출연진 정보 표시 - 배경 모서리 곡률 + 배경 테두리 곡률 공급자 새로고침 검색어 제안 제안 삭제 미디어 정보 소스 이름 최대 밝기 확장 - 다운로드 큐 + 다운로드 작업 모두 다운로드 모두 취소 소스 우선순위 오버스캔(화면 경계) 설정 새로고침 최대 밝기 확장 활성화 + 플레이어에 메타데이터 오버레이 표시 diff --git a/app/src/main/res/values-b+lv/strings.xml b/app/src/main/res/values-b+lv/strings.xml index 055732644..89003317a 100644 --- a/app/src/main/res/values-b+lv/strings.xml +++ b/app/src/main/res/values-b+lv/strings.xml @@ -2,11 +2,11 @@ Plakāts %1$s Ep %2$d - Cast: %s + Lomās: %s Plakāts Epizodes plakāts Galvenais plakāts - Nākamais random + Nākamais nejaušais Iet atpakaļ Nomainīt dvēju Apskatīt background @@ -15,15 +15,15 @@ Jauns atjauninājums atrasts! \n%1$s -> %2$s %d galvenais - Claudstream + CloudStream Atskaņo ar cloudstream - Mājas + Sākums Meklēt Meklēt %s… Nav datu Vairāk opcijas Nākamā epizode - Internets + Pārlūks Izlaist ladešanos Lādējas… Skaties @@ -42,14 +42,14 @@ Iet atpakaļ Palaist epizodi Ieladēt - Lādēšana pauzēta + Lejupielāde iepauzēta Lādēšana sakās - Ielādēt neizdevās - Ielādēšana atcelta - Pabeidza ieladēt - Atjauninājums sakās + Lejupielāde neizdevās + Lejupielāde atcelta + Lejupielāde pabeigta + Atjaunināšana sākta Tīkla plūsma - Kļūda padejot linkus + Kļūda, ielādējot saites Iekšējā atmiņa Dub Dzēst datni @@ -78,13 +78,13 @@ Meklēt izmantojot devējus Meklēt izmantojot tipus %d Banāni iedoti veidotājiem - Episode %d būs izlaista + %d. epizode būs pieejama Filtrs - Ieladētas + Lejupielādes Meklēt… - Settingi + Iestatījumi Žanrs - Dalities + Kopīgot Atvērt pārlūkā Ieladēts Lādējas @@ -95,9 +95,9 @@ Iztīrīt Teksta krāsa Automātiski-iestādīt valodu - %1$dh %2$dm - %dm - %1$dd %2$dh %3$dm + %1$dst. %2$dmin. + %dmin. + %1$dd. %2$dst. %3$dmin. Ielādēt valodas Subtitru valoda Tūri lai restartētu uz sākumu @@ -115,7 +115,7 @@ Rādīt Logcat 🐈 Log Bilde bildē - Turpina spēlēt mazā lodziņā virs aplikācijām + Turpina atskaņošanu miniatūrā atskaņotājā virs citām lietotnēm Players izmēra poga Noņemt melnās malas Subtitri @@ -135,7 +135,7 @@ Uzpied divreiz pa labi vai kreisi lai palaistu atpakaļ vai uz priekšu Uzpied divreiz vidū lai pauzētu Lietot sistēmas gaišums - Lietot sistēmas gaišumu aplikācijas playerī nevis tumšunu + Izmanto sistēmas spilgtumu, nevis atskaņotāja tumšo pārklājumu Atjaunināt skatīšanos progresu Automātiski sync savu pašreizējo epizodes progresu Atgūt datus no backupa @@ -160,15 +160,15 @@ Automātiski lejupielādēt papildinājumus Automātiski uzstāda visus vēl neuzstādītos papildinājumus no pievienotajiem repozitorijiem. Rādīt lietotņu atjauninājumus - Automātiski meklēt jaunus atjauninājumus kad palaiž aplikāciju. + Automātiski pārbauda atjauninājumus, kad atver lietotni. Atsākt uzstādīšanas procesu Dažas ierīces neatbalsta jauno pakotnes uzstādītāju. Izmantojiet legacy (veco) uzstādītāju, ja atjauninājumus nevar uzstādīt. - Noveles aplikācija no šiem izstrādātājiem - Anime aplikāciju no tiem pašiem izstradatājiem + Viegla romānu lietotne no šiem pašiem izstrādātājiem + Anime lietotne no šiem pašiem izstrādātājiem Ienāc discordā Iedot banānu izstrādātājiem Iedotie banāni - Aplikācijas valoda + Lietotnes valoda Šim devējam nav Chromecast pieņemšana Nav linku strastu Links kopēts cliobordā @@ -206,7 +206,7 @@ Konspekts ievietots rindā Lietotie - Aplikācija + Lietotne Filmas Seriāli, raidījumi Animācija @@ -235,7 +235,7 @@ Ielādēšanas kļūda, pārbaudi atmiņas atļauju Chromecast epizode Chromecast morror - Palaist aplikācijā + Atskaņot lietotnē Atskaņot uekšā %s Automātiski ielādēt Ielādēt spoguli @@ -277,7 +277,7 @@ Atruna ISP Izlaists Links - Aplikācijas atjauninājumus + Lietotnes atjauninājumi Dublējums Papildinājumi Akcijas @@ -294,7 +294,7 @@ Randomā poga Rādīt izlases pogu Sākums un Bibliotēka sadaļās Papildinājuma valodas - Aplikācijas izskats + Lietotnes izkārtojums Izvēlētā media Iespējot nepiedienīgu, izaicinošu saturu (NSFW) atbalstītajos papildinājumos Subtitru kodējums @@ -345,7 +345,7 @@ Fogts čuhņā mīļi lenc - ģērbj, žvadz, pūkšķ Ielādēti %s Ielādēt no datnes - Aplikācijas theme + Lietotnes motīvs Lejupielādēt no interneta Lejupielādēta datne Galvenais @@ -430,7 +430,7 @@ HLS atskaņošanas saraksts Vēlamais video atskaņotājs Iekšējais atskaņotājs - Aplikācijs nav atrasta + Lietotne nav atrasta Visas valodas Beigas Kopsavilkums @@ -498,7 +498,7 @@ Lejupielādējiet to vietņu sarakstu, kuras vēlaties izmantot Vispirms uzstādīt papildinājumu Atvēršana - Sākums + Ievads Izlaist %s Noņemt no skatītajiem Atzīmēt kā skatītu @@ -509,16 +509,16 @@ Abonēto šovu atjaunināšana Abonēts Abonēts %s - %1$d. sezona un %2$d. sērija tiks izlaista pēc - %1$dh %2$dm %3$ds - %1$dm %2$ds - %1$ds + %1$d. sezonas %2$d. epizode būs pieejama + %1$dst. %2$dmin. %3$dsek. + %1$dmin. %2$dsek. + %1$dsek. Atskaņot no sākuma Runas atpazīšana nav pieejama Sāciet runāt… Šis video ir torrenta fails, kas nozīmē, ka jūsu video aktivitātes var izsekot.\nPirms turpināt, pārliecinieties, ka saprotat torrenta failu lietošanu. - Atlasiet dzēšamos vienumus - Pašlaik nav lejupielāžu. + Atlasiet vienumus, ko dzēst + Pašlaik nav pieejama neviena lejupielāde. Pieejams skatīšanai bezsaistē Bezvadu (Wi-Fi) Izmantot @@ -609,4 +609,6 @@ Vai tiešām vēlaties neatgriezeniski dzēst šīs %1$s epizodes?\n\n%2$s Jūs arī neatgriezeniski izdzēsīsiet visas šī seriāla, raidījuma epizodes:\n\n%s Vai tiešām vēlaties neatgriezeniski dzēst visas šī seriāla, raidījuma epizodes?\n\n%s + Pašlaik nav nevienas rindā ievietotas lejupielādes. + Atvērt vietējo video diff --git a/app/src/main/res/values-b+uk/strings.xml b/app/src/main/res/values-b+uk/strings.xml index cec3b6738..2eb6e2451 100644 --- a/app/src/main/res/values-b+uk/strings.xml +++ b/app/src/main/res/values-b+uk/strings.xml @@ -6,10 +6,10 @@ Змінити Постачальника Назад Рейтинг: %.1f - Актори: %s + У ролях: %s Епізод %d вийде через Плакат - %1$s Еп. %2$d + %1$s Еп %2$d %1$dд %2$dгод %3$dхв %1$dгод %2$dхв %dхв @@ -148,23 +148,23 @@ Приховати вибрану якість відео у результатах пошуку Автозавантаження розширень Показувати оновлення застосунку - Налаштувати повторно - Установлювач APK + Налаштувати повторно процес встановлення + Встановлювач APK Github Застосунок для ранобе від тих самих розробників Застосунок для аніме від тих самих розробників - Дати банан розробникам - Мова застосунку + Дати benene розробникам + Мова Застосунку Цей постачальник не має підтримування Chromecast - Посилань не знайдено - Переглянути епізод + Посилань Не Знайдено + Переглянути Епізод Скинути до типових значень - Немає сезона - Епізодів + Немає Сезону + Епізоди %1$d %2$s С Е - Видалити файл + Видалити Файл Видалити Скасувати Відновити @@ -185,15 +185,15 @@ Мультфільми Аніме OVA - Азіатські драми - Прямі трансляції + Азіатські Драми + Прямі Трансляції Інші Серіал Мультфільм Аніме - Документальний фільм - Азіатська драма - Пряма трансляція + Документальний Фільм + Азіатська Драма + Пряма Трансляція Відео Помилка джерела Віддалена помилка @@ -203,7 +203,7 @@ Переглянути в %s Автозавантаження Завантажити дзеркало - Перевірити наявність оновлень + Перевірити Наявність Оновлень Забл./Розбл. Пропустити ОП Не показувати знову @@ -211,7 +211,7 @@ Бажана якість перегляду (WiFi) Заголовок Перемкнути елементи інтерфейсу на плакаті - Оновлення не знайдено + Оновлення Не Знайдено Натисніть двічі праворуч або ліворуч, щоби перемотати вперед або назад Використовувати системну яскравість у програвачі замість темного накладання Завантажено файл резервної копії @@ -220,12 +220,12 @@ Немає дозволу на зберігання. Спробуйте ще раз. Показувати плакати від Kitsu Автооновлення розширень - Автоматично встановлювати всі розширення, які ще не встановлено, з доданих репозиторіїв. + Автоматично встановлювати всі розширення, які ще не встановлено, з доданих сховищ. Автоматично перевіряти нові оновлення після запуску застосунку. Покликання скопійовано до буфера обміну Деякі пристрої не підтримують новий інсталятор пакетів. Спробуйте старий варіант, якщо оновлення не встановлюються. Приєднуйтеся до Discord - Дано бананів + Дано benene Рік +30 %1$s %2$d%3$s @@ -239,8 +239,8 @@ Змінити розмір Стислий зміст Фільми - Перезавантажити покликання - Документальні фільми + Перезавантажити посилання + Документальні Фільми NSFW Фільм OVA @@ -249,25 +249,25 @@ NSFW Несподівана помилка програвача Помилка завантаження, перевірте дозвіл на зберігання - Дивитися через Chromecast + Chromecast епізод Мітка субтитрів Джерело Завантажити субтитри Мітка дубляжу - Пропустити це оновлення + Пропустити це Оновлення Усе На весь екран - Заповнити + Розтягнути Збільшити Доріжки Оновлення застосунку Кеш Жести - Особливості програвача + Функції програвача Субтитри Типово Вигляд - Особливості + Функції Загальні Випадкова кнопка Показувати кнопку випадкового вибору на головній сторінці та бібліотеці @@ -275,14 +275,14 @@ Макет застосунку Бажані медіа Автоматично - Макет телевізора - Макет телефону - Макет емулятора + Телевізійна Обгортка + Телефона обгортка + Емуляторна обгортка Основний колір Тема застосунку Розташування назви плаката Розмістити назву під плакатом - Пароль123 + password123 Імʼя користувача hello@world.com НоваНазваСайту @@ -296,7 +296,7 @@ %d / 10 /%d %s автентифіковано - Не вдалося ввійти в %s + Не вдалося увійти в %s Нічого Звичайний Мін. @@ -319,7 +319,7 @@ HDR SDR Web - Зображення плаката + Зображення Плаката Програвач Роздільна здатність та заголовок Недійсний ID @@ -341,21 +341,21 @@ DNS через HTTPS Шлях завантаження Додайте двійника наявного сайту, з іншою URL-адресою - Показувати мітку Дубляж/Субтитри для аніме + Показувати Дубльоване/З Субтитрами Аніме Застереження Розширення Дії 127.0.0.1 Макет Кодування субтитрів - Увімкнути NSFW вміст на підтримуваних розширеннях - Макет + Увімкнути NSFW вміст на підтримуваних Розширеннях + Обгортка Постачальники https://example.com - %2$s %1$s + %1$s %2$s Опущені обліковий запис - Створити + Створити обліковий запис Додано %s /?? Рейтинг @@ -396,7 +396,7 @@ Готово Розширення Додати репозиторій - Назва репозиторію (необов’язково) + Назва репозиторію (Опціонально) URL-адреса репозиторію або короткий код Розширення завантажено Розширення завантажено @@ -404,17 +404,17 @@ Почалося завантаження %1$d %2$s… Завантажено %1$d %2$s Усі %s вже завантажено - Завантажити пакунки + Завантажити пакунком розширення - розширень - Видалити репозиторій + розширення + Видалити сховище Завантажте список сайтів, які ви хочете використовувати Завантажено: %d Вимкнено: %d Не завантажено: %d Оновлено %d розширень - Типово у CloudStream немає жодного встановленого сайту. Вам потрібно встановити сайти з репозиторіїв.\n\nПриєднуйтеся до нашого Discord або шукайте в інтернеті. - Переглянути репозиторії спільноти + Типово у CloudStream немає жодного встановленого сайту. Вам потрібно встановити сайти зі сховищ.\n\nПриєднуйтеся до нашого Discord або шукайте в інтернеті. + Переглянути сховища спільноти Публічний список Усі субтитри великими літерами %s (вимкнено) @@ -449,10 +449,10 @@ Установлення оновлення застосунку… Не вдалося встановити нову версію застосунку Застарілий - Установлювач пакунків + Встановлювач Пакунків Застосунок буде оновлено після виходу - Це також призведе до видалення всіх розширень репозиторію - Усі мови + Це також призведе до видалення всіх розширень сховища + Усі Мови Назад Змініть вигляд застосунку відповідно до вашого пристрою Розширення видалено @@ -467,24 +467,24 @@ Застосунок не знайдено Змішаний опенінґ Вилучити з переглянутого - Оновленням (від старого до нового) - Оновленням (від нового до старого) + Оновленням (від Старого до Нового) + Оновленням (від Нового до Старого) Бібліотека Сортувати - Рейтингом (від високого до низького) + Рейтингом (від Високого до Низького) Сортувати за Алфавітом (від А до Я) - Рейтингом (від низького до високого) + Рейтингом (від Низького до Високого) Ваша бібліотека порожня :(\nУвійдіть в обліковий запис бібліотеки або додайте щось до вашої локальної бібліотеки. Алфавітом (від Я до А) - Оберіть бібліотеку - Відкрити + Оберіть Бібліотеку + Відкрити з Браузер Цей список порожній. Спробуйте перейти до іншого. Файл безпечного режиму знайдено!\nРозширення не завантажуватимуться під час запуску, доки файл не буде видалено. Android TV - Прогрвач приховано – крок перемотування - Програвач показано – крок перемотування + Прогрвач Приховано – Крок Перемотування + Програвач Показано – Крок Перемотування Крок перемотування, який використовується, коли програвач видимий Крок перемотування, який використовується, коли плеєр прихований Провалено @@ -500,15 +500,15 @@ Ви відписалися від %s Епізод %d випущено! Повернути - GitHub проксі + GitHub Проксі Не вдалось отримати доступ до GitHub. Увімкнення проксі-сервера jsDelivr… Обходи ISP - Обхід блокування GitHub за допомогою jsDelivr. Можлива затримка оновлень на кілька днів. + Обхід блокування чистих gitHub URLs за допомогою jsDelivr. Можлива затримка оновлень на кілька днів. Бажана якість перегляду (мобільні дані) - Змінити на типові + Встановити типові Профілі Довідка - Тут можна змінити порядок джерел. Відео з вищим пріоритетом з’являтиметься вище в списку джерел. Сума пріоритету джерела та пріоритету якості утворює пріоритет відео.\n\nДжерело А: 3\nЯкість Б: 7\nЗагальний пріоритет відео дорівнюватиме 10.\n\nПРИМІТКА: Якщо сума пріоритетів дорівнюватиме 10 або більше, програвач автоматично пропустить завантаження цього покликання! + Тут можна змінити порядок джерел. Відео з вищим пріоритетом з’являтиметься вище в списку джерел. Сума пріоритету джерела та пріоритету якості утворює пріоритет відео.\n\nДжерело А: 3\nЯкість Б: 7\nЗагальний пріоритет відео дорівнюватиме 10.\n\nПРИМІТКА: Якщо сума пріоритетів дорівнюватиме 10 або більше, програвач автоматично пропустить завантаження цього посилання! Профіль %d Wi-Fi Мобільні дані @@ -523,11 +523,11 @@ Не знайдено жодного розширення в репозиторії Ви вже проголосували Частота резервного копіювання - %s вилучено з обраного - Обране - %s додано до обраного + %s вилучено з вподобаних + Вподобані + %s додано до вподобаних У вашій бібліотеці виявлено можливі дублікати:\n\n%s\n\nУсе одно хочете додати цей елемент, замінити наявні чи скасувати дію? - Знайдено можливий дублікат + Знайдено Можливий Дублікат Заблокувати профіль Додати до обраного Замінити все @@ -538,16 +538,16 @@ Додати Підписатися Вилучити з обраного - Оберіть обліковий запис - Схоже, що у вашій бібліотеці вже є можливий дублікат: «%s.»\n\nУсе одно хочете додати цей елемент, замінити наявний чи скасувати дію? + Оберіть Обліковий Запис + Схоже, що у вашій бібліотеці вже є можливий дублікат: \'%s.\'\n\nУсе одно хочете додати цей елемент, замінити наявний чи скасувати дію? Уведіть PIN-код PIN-код Уведіть поточний PIN-код Увійшли як %s Уведіть PIN-код для %s - Використовувати типовий обліковий запис + Використовувати Типовий Обліковий Запис Пропускати вибір облікового запису під час запуску - Керувати обліковими записами + Керувати Обліковими Записами Редагувати обліковий запис Показувати кнопку перемикання орієнтації екрана Обернути @@ -555,28 +555,28 @@ Автообертання Увімкнути автоматичну зміну орієнтації екрана відповідно до відео Додати налаштування швидкості до програвача - Перевірити всі розширення + Перевірити всі Розширення Пошук в інших розширеннях Показати рекомендації - Ця перевірка лише для розробників і не підтверджує або заперечує роботу жодного розширення. + Ця Перевірка лише для розробників і не підтверджує або заперечує роботу жодного розширення. Сповіщення про новий епізод - Автентифікація за паролем/PIN-кодом + Автентифікація за Паролем/PIN-кодом Розблокуйте CloudStream - Біометричне блокування - Розблоковуйте застосунок за допомогою відбитка пальця, Face ID, PIN-коду, графічного ключа або пароля. + Біометричне Блокування + Розблоковуйте застосунок за допомогою відбитка пальця, Face ID, PIN-коду, Графічного Ключа або Пароля. Щойно було виконано резервне копіювання даних CloudStream. Хоча ймовірність цього вкрай мала, усі пристрої можуть поводитися по-різному. У рідкісних випадках, якщо ви втратите доступ до застосунку, повністю очистіть дані застосунку та відновіть їх із резервної копії. Просимо вибачення за будь-які незручності, що можуть виникнути. Біометрична автентифікація не підтримується на цьому пристрої Після кількох невдалих спроб вікно запиту зникне. Перезапустіть застосунок, щоби спробувати ще раз. %s\nзалишилося - Вилучити з обраного - Додати до обраного + Вилучити з вподобаного + Додати до вподобаного скопійовано! Назва репозиторію та URL - Помилка копіювання, скопіюйте logcat та зверніться до служби підтримки застосунку. - Помилка доступу до буфера обміну, спробуйте ще раз. - Гаразд + Помилка копіювання, Будь-ласка скопіюйте logcat та зверніться до служби підтримки застосунку. + Помилка доступу до буфера обміну, Будь-ласка спробуйте ще раз. + OK Вимкнути оптимізацію батареї - Щоби забезпечити безперервне завантаження та сповіщення про підписані телепередачі, CloudStream потребує дозволу на роботу у фоновому режимі. Натиснувши «Гаразд», ви побачите діалогове вікно запиту. Натисніть «Дозволити».\n\nЗверніть увагу, що цей дозвіл не означає, що CS3 розряджатиме ваш акумулятор. Він працюватиме у фоновому режимі лише за необхідності, наприклад, під час отримання сповіщень або завантаження відео з офіційних розширень. + Щоби забезпечити безперервне завантаження та сповіщення про підписані телепередачі, CloudStream потребує дозволу на роботу у фоновому режимі. Натиснувши OK, ви побачите діалогове вікно запиту. Натисніть \'Дозволити\'.\n\nЗверніть увагу, що цей дозвіл не означає, що CS3 розряджатиме ваш акумулятор. Він працюватиме у фоновому режимі лише за необхідності, наприклад, під час отримання сповіщень або завантаження відео з офіційних розширень. Споживання батареї застосунком уже змінено на необмежене Не вдається відкрити подробиці про застосунок CloudStream. Аудіокнига @@ -587,16 +587,16 @@ Сезон %1$d Епізод %2$d вийде через Оберіть пристрій для трансляції Трансляція через дзеркало - Довідник CloudStream + CloudStream Wiki Безпека Облікові записи Зображення QR-коду - Відкрити репозиторій + Відкрити сховище Відвідайте %s на своєму смартфоні або комп\'ютері та введіть вищевказаний код - Не вдається отримати PIN-код пристрою, спробуйте локальну автентифікацію - PIN-код застарів! + Не вдається отримати PIN-код пристрою, спробуйте локальну аутентифікацію + PIN-код зараз закінчився ! Термін дії коду закінчується через %1$dхв %2$dс - Локальна автентифікація + Локальна Аутентифікація Відхилити Відтворити з Початку Попередження @@ -604,25 +604,25 @@ Наразі завантажень немає. Приховати назви елементів керування в програвачі Відкрити локальне відео - Датою випуску (від нових до старих) - Датою випуску (від старих до нових) + Датою випуску (від Нових до Старих) + Датою випуску (від Старих до Нових) Оберіть Елементи для Видалення Обрати Все Зняти Вибір Всіх Видалити (%1$d | %2$s) - Ви впевнені, що хочете назавжди видалити такі епізоди «%1$s»?\n\n%2$s + Ви впевнені, що хочете назавжди видалити такі епізоди в %1$s?\n\n%2$s Ви також назавжди видалите всі епізоди в такому серіалі:\n\n%s Доступно для перегляду в оффлайн режимі - Видалити файли + Видалити Файли Ви впевнені, що хочете назавжди видалити такі елементи?\n\n%s Ви впевнені, що хочете назавжди видалити всі епізоди в такому серіалі?\n\n%s Попередній перегляд на шкалі перегляду Увімкнути мініатюру попереднього перегляду на шкалі перегляду Субтитри ще не завантажено Підтвердіть перед виходом - Показувати + Відобразити Показувати діалог перед виходом із застосунку - Не показувати + Не відображати Розташування теки для резервних копій Власний Це відео – Торрент, це означає, що ваша відео діяльність може відстежуватися.\nПереконайтеся, що розумієте, що таке Торрент, перед тим як продовжити. @@ -631,21 +631,21 @@ Подкаст Непідтримувана помилка Помилка кодування - Завантажити перші доступні - Увімкніть торент у Налаштування/Постачальники/Бажані медіа + Завантажити перший доступний + Увімкніть торент в Налаштування/Постачальники/Бажані медіа Перезапустіть застосунок та прийміть спливне вікно Stream Torrent, щоби продовжити. Програмне декодування Програмне декодування дозволяє плеєру відтворювати відеофайли, які не підтримуються вашим пристроєм, але може спричинити затримки або нестабільне відтворення у високій роздільній здатності. - Датою виходу (найновіша) - Епізодом (за зростанням) - Рейтингом (найнижчий) + Датою виходу (Найновіша) + Епізодом (За Зростанням) + Рейтингом (Найнижчий) Рейтинг %s - Епізодом (за спаданням) - Рейтингом (найвищий) - Датою виходу (найстаріша) + Епізодом (за Спаданням) + Рейтингом (Найвищий) + Датою виходу (Найстаріша) Еп. %s Дата %s - Оновити розширення + Оновити Розширення Успішно оновлено %d розширення(-ь)! Оновити розширення вручну Починається оновлення розширень! @@ -656,22 +656,22 @@ Почніть Говорити… Вбудовані Мережеві - Радіус тла - Зробити всі субтитри жирним + Радіус Тла + Зробити всі субтитри потовщеним Зробити всі субтитри курсивом Гучність перевищила 100% Ще раз проведіть угору, щоби перевищити 100% Одночасних з’єднань Змінює межі екрана Обрізання зображення - Перейти до завантажень + Перейти до Завантажень Немає підключення до Інтернету.\n\nБудь ласка, підключіться до Інтернету та спробуйте ще раз або перегляньте завантажені відео офлайн. Скільки різних елементів можна завантажити паралельно Паралельних завантажень Скільки одночасних з’єднань може використовувати кожне завантаження Зміна розміру плакатів Розмір постера - Завжди запитуйте + Завжди запитувати Змінювати швидкість при утриманні Утримуйте, щоб отримати 2-кратну швидкість %1$dгод %2$dхв %3$dс @@ -679,19 +679,19 @@ %1$dс Мітка рейтингу Немає облікового запису - Редагувати зображення профілю - Введіть URL-адресу зображення профілю - URL-адресу не знайдено - Недійсна URL-адреса або зображення - Зображення успішно оновлено + Редагувати Зображення Профілю + Введіть URL-адресу Зображення Профілю + URL-адресу Не Знайдено + Недійсна URL-адреса або Зображення + Зображення Успішно Оновлено Позначити як переглянуте до цього епізоду Вилучити переглянуті до цього епізоду Перезавантажено - Постачальник послуг поповнення рахунку - Грати в дзеркало" - Ім\'я + Перезавантажити Постачальника Послуг + Переглянути в дзеркалі" + Назва Роздільна здатність та назва - Вирівнювання субтитрів + Вирівнювання Субтитрів Внизу ліворуч Внизу по центру Внизу праворуч @@ -702,18 +702,18 @@ Верхній центр Угорі праворуч Відтворити Повні Серії - Встановити передрелізну версію - Попередня версія вже встановлена. - Не вдалося встановити попередню версію. - Текст епізоду + Встановити перед-релізну версію + Перед-релізна версія вже встановлена. + Не вдалося встановити перед-релізну версію. + Текст Епізоду Пропозиції Пошуку Показувати підказки пошуку під час введення тексту - Очистити пропозиції + Очистити Пропозиції Додаткова яскравість Увімкнути фільтр яскравості при перевищенні 100% яскравості дисплея extra_brightness_enabled Показати панель трансляції - Інформація про медіа + Інформація Про Медіа Назва джерела Черга завантаження Наразі немає завантажень у черзі. @@ -735,4 +735,5 @@ Пріоритетне джерело Виберіть спосіб сортування джерел відео у програвачі + Показувати Накладання Метаданих Програвача diff --git a/app/src/main/res/values-b+vi/strings.xml b/app/src/main/res/values-b+vi/strings.xml index 7999feb99..895184652 100644 --- a/app/src/main/res/values-b+vi/strings.xml +++ b/app/src/main/res/values-b+vi/strings.xml @@ -6,17 +6,17 @@ Diễn viên: %s Tập %d sẽ ra mắt sau %1$dng %2$dg %3$dph - %1$dgi %2$dph + %1$dh %2$dm %dm Poster - Ảnh bìa - Episode Poster - Main Poster - Next Random - Quay trở lại - Change Provider - Preview Background + Poster + Poster tập phim + Poster chính + Tập tiếp theo ngẫu nhiên + Quay lại + Thay đổi nguồn phát + Xem trước hình nền Tốc độ (%.2fx) Đánh giá: %.1f @@ -38,12 +38,12 @@ Thể loại Chia sẻ Mở bằng trình duyệt - Bỏ qua + Bỏ qua quá trình tải Đang tải… Đang xem Đang chờ Đã xem - Bỏ qua + Bỏ xem Xem sau Xem lại Xem Ngay @@ -58,11 +58,11 @@ Tải xuống Đã tải Đang tải - Tạm dừng + Đã tạm dừng tải xuống Đã bắt đầu tải Tải lỗi - Đã hủy - Tải thành công + Đã hủy tải xuống + Tải xuống thành công Luồng mạng Lỗi khi tải liên kết Bộ nhớ trong @@ -76,17 +76,17 @@ Ẩn Xem ngay Thông tin - Lọc theo danh sách đã lưu - Danh sách đã lưu + Lọc danh sách của tôi + Danh sách của tôi Xóa Đặt trạng thái xem Áp dụng Sao chép Đóng - Huỷ bỏ + Xóa Lưu Tốc độ phát - Cài đặt hiển thị phụ đề + Cài đặt phụ đề Màu chữ Màu viền chữ Màu nền @@ -97,45 +97,45 @@ Kích thước chữ Tìm kiếm theo nguồn phim Tìm kiếm theo thể loại - %d lời cảm ơn đã được gửi tặng nhà phát triển - Hãy tặng cho nhà phát triển một lời cảm ơn + %d lượt ủng hộ đã gửi đến nhà phát triển + Không có lượt ủng hộ đã nhận Tự động chọn ngôn ngữ Ngôn ngữ khi tải xuống Ngôn ngữ phụ đề - Giữ để làm mới toàn bộ + Nhấn giữ để đặt lại về mặc định Thêm phông chữ tại %s Tiếp tục xem Loại bỏ Thông tin thêm @string/home_play - Bạn có thể sẽ cần sử dụng VPN để xem phim này - Phim này được chiếu dưới dạng Torrent. Hãy sử dụng VPN để xem + Có thể cần dùng VPN để nguồn này hoạt động đúng + Nguồn này là một torrent, khuyến nghị dùng VPN Thông tin phim - Đang cập nhật - Không tìm thấy thông tin + Không tìm thấy nội dung + Không tìm thấy thông tin chi tiết Hiển thị Logcat 🐈 - Chế độ cửa sổ nhỏ - Tiếp tục xem phim khi thoát ứng dụng hoặc khi tìm kiếm - Bật nút thu phóng khi xem - Xóa khoảng đen của phim + Hình trong hình + Tiếp tục phát trong trình phát thu nhỏ trên các ứng dụng khác + Nút thay đổi kích cỡ trình phát + Xóa bỏ các viền đen Phụ đề Cài đặt phụ đề Phụ đề Chromecast Cài đặt phụ đề Chromecast Tốc độ phát Vuốt để tua nhanh - Vuốt sang trái hoặc phải để tua video - Vuốt để chỉnh độ sáng và âm lượng - Vuốt lên hoặc vuốt xuống ở hai bên để điều chỉnh độ sáng và âm lượng + Vuốt ngang qua lại để tua video + Vuốt để thay đổi cài đặt + Vuốt lên hoặc xuống cạnh trái hoặc phải để điều chỉnh độ sáng hoặc âm lượng Tự động phát tập tiếp theo Phát tập tiếp theo sau khi hết tập hiện tại Nhấn 2 lần để tua Nhấn 2 lần để tạm dừng Thời lượng tua (Giây) - Nhấn 2 lần vào bên trái hoặc bên phải màn hình để tua trước hoặc sau + Nhấn 2 lần vào cạnh trái hoặc phải để tua về trước hoặc sau Nhấn vào giữa hai lần để tạm dừng Sử dụng độ sáng hệ thống - Sử dụng độ sáng hệ thống trong trình phát ứng dụng + Dùng độ sáng hệ thống thay cho lớp phủ tối trong trình phát ứng dụng Cập nhật tiến trình xem Tự động đồng bộ tiến trình hiện tại của bạn Khôi phục dữ liệu từ bản sao lưu @@ -182,28 +182,27 @@ Xóa Tệp Xóa Hủy bỏ - Tạm Dừng - Tiếp Tục + Tạm dừng + Tiếp tục -30 +30 %s sẽ bị xoá vĩnh viễn \nBạn có chắc chắn muốn xóa? - %dm -\ncòn lại + %d phút\ncòn lại Đang chiếu - Hoàn Thành + Hoàn thành Trạng Thái Năm - Đánh Giá - Thời Lượng + Đánh giá + Thời lượng Nguồn Thông tin Hàng chờ Không có phụ đề - Mặc Định + Mặc định Còn trống Đã sử dụng - App + Ứng dụng Phim Lẻ Phim Bộ @@ -229,14 +228,14 @@ NSFW Video Lỗi nguồn phim - Lỗi kết nối tới máy chủ - Không thể render + Lỗi nguồn từ xa + Lỗi kết xuất Đã có lỗi xảy ra. Vui lòng thử lại sau Lỗi tải xuống. Hãy kiểm tra quyền truy cập bộ nhớ của ứng dụng Tập Chromecast Chiếu Chromecast - Xem với trình phát mặc định - Xem với trình phát %s + Xem trong ứng dụng + Xem trong %s Tự động tải xuống Nguồn tải xuống Lấy link mới nhất @@ -244,45 +243,45 @@ Nhãn chất lượng phim Nhãn lồng tiếng Nhãn phụ đề - Tiêu đề - Thay đổi giao diện trên poster + Tên + Thành phần giao diện trên poster Bạn đang dùng phiên bản mới nhất Kiểm tra cập nhật Khóa - Thu Phóng - Nguồn & Phụ đề - Bỏ qua OP + Thu phóng + Nguồn + Bỏ qua giới thiệu Không hiện lại Bỏ qua bản cập nhật này Cập nhật Chất lượng xem ưu tiên (WiFi) - Kí tự tối đa trên tiêu đề + Số ký tự tối đa trên tiêu đề trình phát video Hiện thông tin trình phát Kích thước bộ nhớ đệm video Thời lượng bộ nhớ đệm Lưu bộ nhớ đệm video trên ổ cứng Xoá bộ nhớ đệm hình ảnh và video - Sẽ gây lỗi nếu đặt quá cao trên máy có dung lượng ram thấp như Android TV. + Sẽ gây lỗi nếu đặt quá cao trên thiết bị có bộ nhớ thấp như Android TV. Sẽ gây lỗi nếu đặt quá cao trên máy có dung lượng lưu trữ thấp như Android TV. DNS over HTTPS Rất hữu ích để bỏ chặn ISP Sao chép trang web Xoá trang web - Thêm bản sao của một trang web, với một địa chỉ khác + Thêm bản sao của trang hiện có bằng một URL khác Đường dẫn tải xuống Địa chỉ máy chủ Nginx - Hiển thị nhãn Phụ đề hoặc Thuyết minh + Hiển thị Anime Lồng tiếng/Phụ đề Vừa màn hình Kéo dãn Phóng to - Disclaimer + Tuyên bố miễn trừ trách nhiệm Tổng quan Nút ngẫu nhiên Hiện nút ngẫu nhiên trên Trang chủ và Thư viện Ngôn ngữ mở rộng - Giao diện App + Bố cục ứng dụng Thể loại ưu tiên - Kích hoạt NSFW trên các tiện ích mở rộng được hỗ trợ + Kích hoạt NSFW trên các Tiện ích mở rộng được hỗ trợ Mã hoá phụ đề Nguồn phim Giao diện @@ -291,7 +290,7 @@ Giao diện điện thoại Giao diện giả lập Màu chính - Chủ đề App + Chủ đề ứng dụng Vị trí tiêu đề Đặt tiêu đề dưới poster @@ -299,7 +298,7 @@ Tài khoản Email 127.0.0.1 - Tên mới + Tên trang mới https://example.com Mã ngôn ngữ (vi) %1$s %2$s @@ -329,10 +328,10 @@ Đổ bóng Nâng Chỉnh phụ đề - 1000ms + 1000 mili giây Độ trễ phụ đề - Dùng nếu phụ đề bị nhanh %dms - Dùng nếu phụ đề bị trễ %dms + Dùng nếu phụ đề bị nhanh %d mili giây + Dùng nếu phụ đề bị trễ %d mili giây Không chỉnh Poster Poster @@ -15,7 +15,7 @@ Poster chính Tập tiếp theo ngẫu nhiên Quay lại - Thay đổi nguồn phát + Thay đổi Nguồn phim Xem trước hình nền Tốc độ (%.2fx) @@ -25,10 +25,10 @@ Bộ lọc %d phút CloudStream - Mở với CloudStream + Phát bằng CloudStream Trang Chủ Tìm Kiếm - Tải Về + Tải xuống Cài Đặt Tìm kiếm… Tìm kiếm %s… @@ -38,7 +38,7 @@ Thể loại Chia sẻ Mở bằng trình duyệt - Bỏ qua quá trình tải + Bỏ tải Đang tải… Đang xem Đang chờ @@ -46,35 +46,35 @@ Bỏ xem Xem sau Xem lại - Xem Ngay - Phát trực tiếp - Xem Torrent - Nguồn Phim + Phát + Phát Livestream + Phát Torrent + Nguồn phim Phụ đề Thử kết nối lại… Quay lại - Xem Tập Phim + Phát Tập phim Tải xuống - Đã tải - Đang tải + Đã tải xuống + Đang tải xuống Đã tạm dừng tải xuống - Đã bắt đầu tải - Tải lỗi - Đã hủy tải xuống + Tải xuống đã bắt đầu + Tải xuống thất bại + Tải xuống đã hủy Tải xuống thành công Luồng mạng - Lỗi khi tải liên kết + Lỗi tải liên kết Bộ nhớ trong Lồng tiếng Phụ đề Xóa Tệp - Xem Tệp - Tiếp tục tải - Tạm dừng tải + Phát Tệp + Tiếp tục tải xuống + Tạm dừng tải xuống Thông tin thêm Ẩn - Xem ngay + Phát Thông tin Lọc danh sách của tôi Danh sách của tôi @@ -92,7 +92,7 @@ Màu nền Màu cửa sổ Kiểu viền - Độ nâng + Độ nâng phụ đề Kiểu chữ Kích thước chữ Tìm kiếm theo nguồn phim @@ -100,12 +100,12 @@ %d lượt ủng hộ đã gửi đến nhà phát triển Không có lượt ủng hộ đã nhận Tự động chọn ngôn ngữ - Ngôn ngữ khi tải xuống + Ngôn ngữ tải xuống Ngôn ngữ phụ đề Nhấn giữ để đặt lại về mặc định Thêm phông chữ tại %s Tiếp tục xem - Loại bỏ + Xóa Thông tin thêm @string/home_play Có thể cần dùng VPN để nguồn này hoạt động đúng @@ -116,14 +116,14 @@ Hiển thị Logcat 🐈 Hình trong hình Tiếp tục phát trong trình phát thu nhỏ trên các ứng dụng khác - Nút thay đổi kích cỡ trình phát + Nút thay đổi kích thước trình phát Xóa bỏ các viền đen Phụ đề Cài đặt phụ đề Phụ đề Chromecast Cài đặt phụ đề Chromecast Tốc độ phát - Vuốt để tua nhanh + Vuốt để tua Vuốt ngang qua lại để tua video Vuốt để thay đổi cài đặt Vuốt lên hoặc xuống cạnh trái hoặc phải để điều chỉnh độ sáng hoặc âm lượng @@ -140,44 +140,44 @@ Tự động đồng bộ tiến trình hiện tại của bạn Khôi phục dữ liệu từ bản sao lưu Sao lưu dữ liệu - Đã tải dữ liệu sao lưu + Đã tải tệp sao lưu Không thể khôi phục dữ liệu từ %s - Sao lưu dữ liệu thành công - Thiếu quyền truy cập bộ nhớ, hãy thử lại. + Dữ liệu đã lưu + Thiếu quyền truy cập bộ nhớ. Vui lòng thử lại. Lỗi khi sao lưu %s Tìm kiếm Tài khoản và Bảo mật Cập nhật và Sao lưu Thông tin Tìm kiếm nâng cao - Cho phép tìm kiếm theo bộ lọc từng nhà cung cấp + Cung cấp cho bạn kết quả tìm kiếm được phân loại theo từng nguồn phim Hiển thị tập phụ cho anime Hiển thị trailer Hiển thị poster từ Kitsu Ẩn chất lượng video trong kết quả tìm kiếm - Tự động cập nhật plugin + Tự động cập nhật tiện ích mở rộng Hiển thị thông báo cập nhật ứng dụng Tự động tìm kiếm bản cập nhật mới sau khi khởi động ứng dụng. Github Ứng dụng đọc tiểu thuyết của cùng nhà phát triển Ứng dụng xem Anime của cùng nhà phát triển Tham gia cộng đồng trên Discord - Gửi lời cảm ơn tới nhà phát triển - Gửi lời cảm ơn + Gửi ủng hộ tới nhà phát triển + Gửi ủng hộ Ngôn ngữ ứng dụng Nguồn phim này chưa hỗ trợ Chromecast Không tìm thấy liên kết Đã sao chép liên kết vào bộ nhớ tạm - Xem Phim - Thiết lập lại giá trị mặc định + Phát Tập phim + Đặt lại giá trị mặc định Mùa Không có mùa nào Tập Tập %1$d-%2$d %1$d %2$s - M - T + Mùa + Tập Không có tập nào Xóa Tệp Xóa @@ -196,7 +196,7 @@ Đánh giá Thời lượng Nguồn - Thông tin + Tóm tắt phim Hàng chờ Không có phụ đề Mặc định @@ -231,20 +231,20 @@ Lỗi nguồn từ xa Lỗi kết xuất Đã có lỗi xảy ra. Vui lòng thử lại sau - Lỗi tải xuống. Hãy kiểm tra quyền truy cập bộ nhớ của ứng dụng - Tập Chromecast - Chiếu Chromecast - Xem trong ứng dụng - Xem trong %s + Lỗi tải xuống. Hãy kiểm tra quyền truy cập bộ nhớ + Phát tập phim bằng Chromecast + Phản chiếu màn hình bằng Chromecast + Phát bằng ứng dụng + Phát bằng %s Tự động tải xuống - Nguồn tải xuống - Lấy link mới nhất - Tải phụ đề + Nguồn tải dự phòng + Tải lại các liên kết + Tải xuống phụ đề Nhãn chất lượng phim Nhãn lồng tiếng Nhãn phụ đề - Tên - Thành phần giao diện trên poster + Tên phim + Hiển thị các thông tin trên poster Bạn đang dùng phiên bản mới nhất Kiểm tra cập nhật Khóa @@ -255,17 +255,17 @@ Bỏ qua bản cập nhật này Cập nhật Chất lượng xem ưu tiên (WiFi) - Số ký tự tối đa trên tiêu đề trình phát video + Số ký tự tối đa tiêu đề trình phát Hiện thông tin trình phát Kích thước bộ nhớ đệm video Thời lượng bộ nhớ đệm - Lưu bộ nhớ đệm video trên ổ cứng + Bộ nhớ đệm video trên thiết bị Xoá bộ nhớ đệm hình ảnh và video Sẽ gây lỗi nếu đặt quá cao trên thiết bị có bộ nhớ thấp như Android TV. Sẽ gây lỗi nếu đặt quá cao trên máy có dung lượng lưu trữ thấp như Android TV. DNS over HTTPS Rất hữu ích để bỏ chặn ISP - Sao chép trang web + Bản sao trang web Xoá trang web Thêm bản sao của trang hiện có bằng một URL khác Đường dẫn tải xuống @@ -291,8 +291,8 @@ Giao diện giả lập Màu chính Chủ đề ứng dụng - Vị trí tiêu đề - Đặt tiêu đề dưới poster + Vị trí tên phim + Đặt tên phim dưới poster Mật khẩu Tài khoản @@ -328,23 +328,23 @@ Đổ bóng Nâng Chỉnh phụ đề - 1000 mili giây + 1000 ms Độ trễ phụ đề - Dùng nếu phụ đề bị nhanh %d mili giây - Dùng nếu phụ đề bị trễ %d mili giây - Không chỉnh + Dùng nếu phụ đề bị nhanh %d ms + Dùng nếu phụ đề bị trễ %d ms + Không điều chỉnh - Xem trước mẫu phụ đề + Bạch kim rất quý nên sẽ dùng để lắp vô xương Được đề xuất Đã tải %s - Chọn từ máy + Chọn từ tệp Chọn từ Internet - Tệp đã tải + Tệp đã tải xuống Vai chính Vai phụ Lý lịch @@ -376,9 +376,9 @@ Lỗi dữ liệu Lỗi đường dẫn Lỗi - Xoá phụ đề đã dùng - Loại bỏ mã hoá phụ đề - Lọc theo ngôn ngữ media được chuộng hơn + Xóa bỏ chú thích chi tiết trong phụ đề + Xóa bỏ nội dung thừa trong phụ đề + Lọc theo ngôn ngữ ưu tiên Thêm Trailer https://example.com/example.mp4 @@ -394,22 +394,22 @@ Thêm kho tiện ích Tên kho tiện ích (Tùy chọn) URL kho tiện ích hoặc Mã ngắn - Đã tải plugin - Plugin đã xoá + Tiện ích mở rộng đã tải + Tiện ích mở rộng đã xoá Không tải được %s 18+ Đã bắt đầu tải xuống %1$d %2$s… Đã tải xuống %1$d %2$s Toàn bộ %s đã được tải xuống - Tải hàng loạt - plugin - plugin + Tải xuống hàng loạt + tiện ích mở rộng + tiện ích mở rộng Việc này sẽ xóa tất cả tiện ích mở rộng trong kho tiện ích Xoá kho tiện ích - Tải nguồn phim bạn muốn dùng - Đã tải: %d + Tải xuống danh sách các trang web bạn muốn sử dụng + Đã tải xuống: %d Đã vô hiệu: %d - Không tải: %d + Chưa tải xuống: %d CloudStream không có sẵn trang web nào. Bạn cần cài đặt các trang web từ kho lưu trữ. \n \nHãy tham gia Discord của chúng tôi hoặc tìm kiếm trực tuyến. @@ -418,27 +418,27 @@ In hoa toàn bộ phụ đề Cảnh báo: CloudStream không chịu trách nhiệm về các tiện ích mở rộng bên thứ ba và không cung cấp bất kỳ sự hỗ trợ nào! %s (Đã vô hiệu hoá) - Âm thanh & Chất lượng + Âm thanh & Độ phân giải Âm thanh - Chất lượng Video - Áp dụng khi khởi động lại ứng dụng. + Độ phân giải video + Khởi động lại ứng dụng để thấy câc thay đổi. Chế độ an toàn được bật - Đã xảy ra sự cố và chúng tôi đã tự động tắt tất cả các tiện ích mở rộng, hãy tìm và xóa tiện ích mở rộng đang gây ra sự cố. + Tất cả tiện ích mở rộng đã được tắt do ứng dụng bị ngừng bất thường để giúp bạn tìm ra vấn đề gây lỗi. Xem thông tin sự cố Lịch sử Đánh dấu là đã xem - Tự động tải xuống plugin - Làm lại tiến trình cài đặt - Bộ cài APK - Một số thiết bị không hỗ trợ trình cài đặt gói mới. Hãy thử tùy chọn cũ nếu các bản cập nhật không cài đặt. + Tự động tải xuống tiện ích mở rộng + Làm lại tiến trình thiết lập + Trình cài đặt APK + Một số thiết bị không hỗ trợ trình cài đặt gói mới. Hãy thử chọn chế độ tương thích cũ nếu các bản cập nhật không cài đặt. %1$s %2$d%3$s - Xem Trailer - Tự động cài đặt tất cả plugin chưa được cài đặt từ những kho lưu trữ đã thêm vào. + Phát Trailer + Tự động cài đặt tất cả tiện ích mở rộng chưa được cài đặt từ những kho tiện ích đã thêm. Bắt đầu cập nhật Liên kết Danh sách HLS Trình phát ưu tiên - Trình phát mặc định + Trình phát tích hợp Đánh giá: %s Không Phiên bản @@ -447,7 +447,7 @@ Sao lưu Tiện ích mở rộng Hành động - Cache + Bộ nhớ đệm Cử chỉ Tính năng trình phát Phụ đề @@ -464,41 +464,41 @@ Cài đặt tiện ích mở rộng trước Không thấy ứng dụng Tất cả ngôn ngữ - Tua %s + Bỏ qua %s Mở đầu Kết thúc - Tóm tắt - Các kết thúc hỗn hợp - Các mở đầu hỗn hợp + Điểm lại nội dung + Kết thúc hỗn hợp + Mở đầu hỗn hợp Danh đề Giới thiệu Xoá lịch sử Hiện các popup bỏ qua cho mở đầu/kết thúc - Văn bản quá dài. Không thể lưu vào khay nhớ tạm. + Văn bản quá dài. Không thể lưu vào bộ nhớ tạm. Xoá khỏi đã xem Bạn có chắc muốn thoát? - Đang tải bản cập nhật… + Đang tải xuống bản cập nhật… Đang cài bản cập nhật… Không thể cài đặt phiên bản mới - Ứng dụng sẽ được cập nhật khi thoát + Ứng dụng sẽ được cập nhật sau khi thoát Thư viện Trình duyệt - Plugin đã tải - Legacy + Tiện ích mở rộng đã tải xuống + Chế độ tương thích cũ Đã cập nhật (Mới đến Cũ) Đã cập nhật (Cũ đến Mới) Thư viện của bạn đang trống :( \nĐăng nhập vào tài khoản thư viện hoặc thêm phim vào thư viện cục bộ. - Mở với + Mở bằng Siêu dữ liệu không được cung cấp bởi trang web, video sẽ không tải được nếu nó không tồn tại trên trang web. - PackageInstaller + Trình cài đặt gói Sắp xếp Xếp hạng (Cao đến Thấp) Xếp hạng (Thấp đến Cao) Chữ cái (Z đến A) Sắp xếp theo - Danh sách này trống, hãy thử chuyển sang danh sách khác. + Danh sách này trống. Vui lòng thử chuyển sang danh sách khác. Chữ cái (A đến Z) Chọn Thư viện Nhật ký @@ -514,18 +514,18 @@ Đã đăng kí %s Tập %d đã ra mắt! Đã đăng kí - Dừng lại + Dừng Bỏ chặn nhà mạng Đã bỏ đăng ký %s Tìm thấy tệp Safe mode! \nKhông tải bất cứ tiện ích mở rộng nào khi khởi động cho đến khi loại bỏ tệp. - Đảo ngược lại + Hoàn tác Đang cập nhật các phim đã đăng kí Bỏ chặn các URL gốc của GitHub bằng jsDelivr. Có thể làm cập nhật bị trễ vài ngày. - Thời lượng tua khi trình phát bị ẩn - Thời lượng tua - Lượng tua thêm được sử dụng khi trình phát hiện lên - Thời lượng tua + Lượng thời gian tua được dùng khi trình phát đang bị ẩn + Thời lượng tua khi trình phát đang ẩn + Lượng thời gian tua được dùng khi trình phát đang hiển thị + Thời lượng tua khi trình phát đang hiện Hồ sơ %d Dữ liệu di động Đặt mặc định @@ -542,14 +542,14 @@ \nSẽ có mức độ ưu tiên video kết hợp là 10. \n \nLƯU Ý: Nếu tổng là 10 hoặc nhiều hơn, trình phát sẽ tự động bỏ tải khi liên kết đó được tải! - Các chất lượng + Chất lượng Bạn đã bình chọn Vô hiệu hoá Không tìm thấy kho tiện ích, hãy kiểm tra URL và thử lại với VPN Không tìm thấy tiện ích mở rộng Không thể khởi tạo UI, đây là một LỖI LỚN và cần được báo cáo ngay lập tức tới %s - Chọn chế độ để lọc plugin tải xuống - %s đã loại bỏ khỏi mục yêu thích + Chọn chế độ lọc tiện ích mở rộng tải xuống + %s đã xóa khỏi mục yêu thích Yêu thích %s đã thêm vào mục yêu thích Các mục có thể trùng lặp đã được tìm thấy trong thư viện của bạn: @@ -562,60 +562,60 @@ Khóa hồ sơ Thêm vào mục yêu thích Thay thế tất cả - Mã PIN không chính xác. Vui lòng thử lại. + Mã PIN không đúng. Vui lòng thử lại. Hủy đăng ký Mã PIN phải có 4 ký tự Thay thế Thêm vào Đăng ký - Loại bỏ khỏi mục yêu thích - Chọn một tài khoản + Xóa khỏi mục yêu thích + Ai đang xem Có vẻ như một mục có khả năng trùng lặp đã tồn tại trong thư viện của bạn: \'%s.\' \n \nBạn vẫn muốn thêm mục này, thay thế mục hiện có hay hủy hành động? - Nhập PIN + Nhập mã PIN PIN Nhập mã PIN hiện tại Đã đăng nhập với tư cách %s Nhập mã PIN cho %s - Sử dụng tài khoản mặc định - Bỏ qua lựa chọn tài khoản khi khởi động - Quản lý tài khoản - Chỉnh sửa tài khoản + Sử dụng Hồ sơ mặc định + Bỏ qua lựa chọn hồ sơ lúc khởi động + Quản lý hồ sơ + Chỉnh sửa hồ sơ Tải lại liên kết Tìm kiếm tiện ích mở rộng khác Hiển thị đề xuất Kiểm tra tất cả Tiện ích mở rộng Xoay Thông báo tập mới - Chỉnh tốc độ trong trình phát + Thêm tùy chọn tốc độ phát trong trình phát Hiển thị nút xoay màn hình - Kích hoạt chế độ xoay màn hình tự động + Bật tự động xoay màn hình theo hướng của video Tự động xoay đã sao chép! - Vấn đề truy cập Bảng ghi tạm, Hãy thử lại. - Lỗi sao chép, Hãy sao chép logcat và liên hệ hỗ trợ ứng dụng. + Lỗi truy cập Bộ nhớ tạm, Vui lòng thử lại. + Lỗi sao chép, Vui lòng sao chép logcat và liên hệ hỗ trợ ứng dụng. Yêu thích OK - Vô hiệu Tối ưu pin - Không thể mở thông tin ứng dụng của CloudStream. - Không thích + Tắt tối ưu hóa pin + Không thể mở thông tin ứng dụng CloudStream. + Bỏ yêu thích Mở khóa Cloudstream Nhạc Sách nói - Khóa với sinh trắc học + Khóa bằng sinh trắc học %s\ncòn lại Xác thực bằng sinh trắc học không được hỗ trợ trên thiết bị này - Mật khẩu/PIN Xác thực + Xác thực bằng Mật khẩu/PIN Dữ liệu CloudStream của bạn hiện đã được sao lưu. Mặc dù khả năng xảy ra điều này là rất thấp nhưng tất cả các thiết bị đều có thể hoạt động khác nhau. Trong trường hợp hiếm gặp là bạn bị khóa truy cập ứng dụng, hãy xóa hoàn toàn dữ liệu ứng dụng và khôi phục từ bản sao lưu. Chúng tôi rất xin lỗi vì bất kỳ sự bất tiện nào phát sinh từ việc này. Mở khóa ứng dụng bằng Vân tay, Khuôn mặt, PIN, Hình vẽ và Mật khẩu. - Màn hình bị đóng sau nhiều lần thử thất bại. Hãy khởi động lại ứng dụng. + Sau vài lần thử thất bại, hộp thoại sẽ tự đóng. Chỉ cần khởi động lại ứng dụng để thử lại. Bài kiểm tra này chỉ dành cho các nhà phát triển và không xác nhận hay phủ nhận việc hoạt động của bất kỳ tiện ích mở rộng nào. Chế độ tiêu thụ pin của ứng dụng đã được đặt ở mức không giới hạn Phương tiện Tên và URL kho tiện ích Đặt lại - Để đảm bảo quá trình tải xuống và thông báo cho các chương trình truyền hình đã đăng ký không bị gián đoạn, CloudStream cần có quyền chạy ở chế độ nền. Bằng cách nhấn OK, một hộp thoại yêu cầu sẽ hiển thị. Hãy nhấn \"Cho phép\".\n\nXin lưu ý, quyền này không có nghĩa là CS3 sẽ làm hao pin của bạn. Nó sẽ chỉ hoạt động ở chế độ nền khi cần thiết, chẳng hạn như khi nhận được thông báo hoặc tải xuống video từ các tiện ích mở rộng chính thức. + Để đảm bảo quá trình tải xuống và thông báo cho các chương trình truyền hình đã đăng ký không bị gián đoạn, CloudStream cần có quyền chạy ở chế độ nền. Bằng cách nhấn OK, một hộp thoại yêu cầu sẽ hiển thị. Vui lòng nhấn \"Cho phép\".\n\nXin lưu ý, quyền này không có nghĩa là CS3 sẽ làm hao pin của bạn. Nó sẽ chỉ hoạt động ở chế độ nền khi cần thiết, chẳng hạn như khi nhận được thông báo hoặc tải xuống video từ các tiện ích mở rộng chính thức. Mùa %1$d Tập %2$d sẽ được phát hành vào Sắp tới sau %s Chọn thiết bị truyền @@ -627,12 +627,12 @@ CloudStream Wiki Truy cập %s trên điện thoại hoặc máy tính và nhập mã bên trên Mã PIN đã hết hạn! - Mã sẽ hết hạn trong %1$dm %2$ds - Không lấy được mã PIN, hãy thử xác thực cục bộ - Hiện không có bản tải xuống nào. + Mã sẽ hết hạn trong %1$d phút %2$d giây + Không lấy được mã PIN, vui lòng thử xác thực cục bộ + Không có tải xuống nào. Xác thực cục bộ Phản chiếu màn hình - Xem từ đầu + Phát từ đầu Mở video có sẵn Cảnh báo Ngày phát hành (Mới đến Cũ) @@ -648,20 +648,18 @@ Bạn có chắc chắn muốn xóa vĩnh viễn các tập trong %1$s? \n \n%2$s - Bạn cũng sẽ xóa vĩnh viễn tất cả các tập trong loạt phim sau: -\n -\n%s + Bạn cũng sẽ xóa vĩnh viễn tất cả các tập trong loạt phim: \n \n%s Bạn có chắc chắn muốn xóa vĩnh viễn tất cả các tập trong loạt phim này không? \n \n%s - Xóa plugin + Xóa tiện ích mở rộng Ngày phát hành (Cũ đến mới) - Ẩn tên các nút điều khiển - Bật chế độ xem trước hình thu nhỏ trên seekbar - Xem trước Seekbar - Chưa tải phụ đề + Ẩn tên các nút điều khiển trình phát + Bật xem trước hình thu nhỏ trên thanh tua + Xem trước trên thanh tua + Chưa tải phụ đề nào Xác nhận trước khi thoát - Hiện hộp thoại xác nhận thoát ứng dụng + Hiện hộp thoại xác nhận trước khi thoát ứng dụng Không hiển thị Hiển thị Vị trí thư mục sao lưu @@ -683,16 +681,16 @@ Xếp hạng %s Ngày %s Tập %s - Cập nhật plugin - Không có plugin nào được cập nhật. - Ngày phát hành (Cũ nhất) - Đã cập nhật thành công %d plugin! + Cập nhật tiện ích mở rộng + Không có tiện ích mở rộng nào được cập nhật. + Ngày phát sóng (Cũ nhất) + Đã cập nhật thành công %d tiện ích mở rộng! Xếp hạng (Cao nhất) - Cập nhật plugin thủ công + Cập nhật tiện ích mở rộng thủ công Tập (Giảm dần) - Bắt đầu quá trình cập nhật plugin! + Bắt đầu quá trình cập nhật tiện ích mở rộng! Thông báo trình phát - Thông báo trình phát để điều khiển phát lại từ nền + Điều khiển phát lại trong nền bằng thông báo của trình phát Bắt đầu nói… Tìm kiếm giọng nói không khả dụng Trực tuyến @@ -705,33 +703,33 @@ Luôn hỏi Nhãn đánh giá Số lượng mục khác nhau có thể tải xuống cùng lúc - Tải xuống song song + Tải xuống đồng thời Kết nối đồng thời - Số lượng kết nối đồng thời có thể sử dụng cho mỗi lượt tải - Đến mục tải xuống + Số lượng kết nối đồng thời mà mỗi lần tải xuống có thể sử dụng + Đến mục Tải xuống Không có kết nối Internet. \n\nVui lòng kết nối Internet rồi thử lại, hoặc xem các nội dung đã tải xuống khi đang ngoại tuyến. Thay đổi khung hiển thị màn hình Vượt khung - Thay đổi kích thước của hình poster + Thay đổi kích thước poster Kích thước poster Tăng tốc độ phát khi nhấn giữ Nhấn giữ để tăng tốc độ phát 2x - %1$dh %2$dm %3$ds - %1$dm %2$ds - %1$ds + %1$d giờ %2$d phút %3$d giây + %1$d phút %2$d giây + %1$d giây Không có tài khoản - Đổi hình đại điện - Nhập url hình đại diện - Không tìm thấy url - URL hoặc hình không hợp lệ - Tải hình lên thành công + Chỉnh sửa ảnh Hồ sơ + Nhập URL ảnh Hồ sơ + Không tìm thấy URL + URL hoặc ảnh không hợp lệ + Đã cập nhật ảnh thành công Đánh dấu là đã xem đến tập này Xóa những tập đã xem đến tập này Đã tải lại - Tải lại nguồn phát + Tải lại nguồn phim Tên Độ phân giải và tên - Xem phản chiếu" + Phản chiếu màn hình" Căn chỉnh phụ đề Dưới trái Dưới giữa @@ -749,25 +747,25 @@ Cài đặt phiên bản phát hành trước Bản phát hành trước đã được cài đặt. Cài đặt bản phát hành trước thất bại. - Thông tin tập + Tập Bật bộ lọc độ sáng khi độ sáng màn hình vượt quá 100% - Hiển thị bảng điều khiển + Hiển thị bảng diễn viên Thông tin video Độ sáng bổ sung Tên nguồn Hàng đợi tải xuống - Hiện tại không có tệp nào đang chờ tải xuống. - Hãy quyết định cách sắp xếp các nguồn video trong trình phát. + Không có tải xuống đang chờ nào. + Quyết định cách sắp xếp các nguồn video trong trình phát. Ưu tiên nguồn Tải xuống tất cả Hủy tất cả Bạn có muốn tải xuống tập %s không? - Bạn có muốn hủy tất cả các lượt tải xuống đang chờ xử lý không? + Bạn có muốn hủy tất cả tải xuống đang chờ không? %d đang tải xuống - %d lượt tải xuống đang chờ xử lý + %d tải xuống đang chờ Đã bật độ sáng bổ sung Hiển thị lớp phủ siêu dữ liệu trình phát diff --git a/app/src/main/res/values-b+zh+TW/strings.xml b/app/src/main/res/values-b+zh+TW/strings.xml index c8f9df9a2..78ba57310 100644 --- a/app/src/main/res/values-b+zh+TW/strings.xml +++ b/app/src/main/res/values-b+zh+TW/strings.xml @@ -763,4 +763,5 @@ 在「設定/影片來源/首選媒體」中啟用 Torrent下載 軟體解碼使程式可以播放裝置不支援的影片,但可能導致播放高解析的影片時的延遲或不穩定。 音量已超過 100% + 顯示播放器元資料遮罩層 diff --git a/app/src/main/res/values-sq/strings.xml b/app/src/main/res/values-sq/strings.xml new file mode 100644 index 000000000..1aa5f0a92 --- /dev/null +++ b/app/src/main/res/values-sq/strings.xml @@ -0,0 +1,417 @@ + + + %1$s Ep %2$d + Kasti: %s + Episodi %d do të publikohet në + Sezoni %1$d Episodi %2$d do të publikohet në + %1$dd %2$do %3$dm + %1$do %2$dm + %dm + %1$do %2$dm %3$ds + %1$dm %2$ds + %1$ds + Posteri + Posteri + Posteri i episodit + Posteri kryesor + Tjetër rastësore + Kthehu prapa + Luaj nga fillimi + Ndrysho ofruesin + Parashikim sfondi + Shpejtësia (%.2fx) + Vlerësuar: %.1f + U gjet update i ri!\n%1$s -> %2$s + Filler + %d min + CloudStream + Luaj me CloudStream + Kryefaqja + Kërko + Shkarkimet + Radhë shkarkimesh + Cilësimet + Kërko… + Kërko %s… + Njohja e të folurit nuk është e disponueshme + Fillo të flasësh… + Nuk ka të dhëna + Më shumë opsione + Episodi i radhës + Zhanri + Shpërndaje + Hap në Browser + Browser + Anashkalo ngarkimin + Duke u ngarkuar… + Duke parë + Në pritje + Përfunduar + Braktisur + Planifikoni të shikoni + Duke rishikuar + Luaj Filmin + Luaj Trailerin + Luaj Transmetimin Live + Transmeto Torrent + Luaj serinë e plotë + Kjo video është një Torrent, kjo do të thotë që aktiviteti juaj i videos mund të gjurmohet.\nSigurohuni që kuptoni Torrent-in para se të vazhdoni. + Burime + Titrat + Ripovo lidhjen… + Kthehu mbrapa + Luaj Episodin + Shkarko + U shkarkua + Duke u shkarkuar + Shkarkimi u ndërpre + Shkarkimi filloi + Shkarkimi dështoi + Shkarkimi u anulua + Shkarkimi u krye + Zgjidhni artikuj për të fshirë + Momentalisht nuk ka shkarkime. + Momentalisht nuk ka shkarkime në radhë. + E disponueshme për shikim offline + Zgjidh të gjitha + Çzgjidh të gjitha + Update-i filloi + Transmetim në rrjet + Hap video lokale + Gabim gjatë ngarkimit të lidhjeve + Lidhjet u ringarkuan + Memorie e brendshme + Dublim + Titra + Fshi Skedarin + Luaj skedarin + Rifillo shkarkimin + Ndalo shkarkimin + Më shumë info + Fsheh + Luaj + Informacion + Filtro të ruajturat + Të ruajturat + Hiq + Vendos statusin e shikueshmërisë + Apliko + Kopjo + Mbyll + Fshi + Ruaj + Emri i repository-t dhe URL + U kopjua! + Njoftimi i episodeve të reja + Kërko në shtesat e tjera + Shfaq rekomandime + Shpejtësia e luajtësit + Cilësimet e titrave + Ngjyra e tekstit + Ngjyra e konturit + Ngjyra e sfondit + Ngjyra e faqes + Lloji i konturit + Lartësia e titrave + Stili i shkrimit + Madhësia e shkrimit + Kërko duke përdorur ofruesit + Kërko duke përdorur tipet + %d Banane të dhëna per developer-at + Asnjë Banane e dhënë + Zgjedhje automatike e gjuhës + Shkarko gjuhët + Gjuha e titrave + Mbaje shtupur për ti rikthyer në gjendjen fillestare + Importo stilin e shkrimit duke i vendosur në %s + Vazhdo shikimin + Hiq + Më shumë informacion + \@string/home_play + Një VPN mund të nevojitet që ky ofrues të funksionojë në rregull + Ky ofrues është Torrent, rekomandohet një VPN + Metadata nuk ofrohet nga kjo faqe, ngarkimi i videos do të dështojë nëse nuk ekziston në këtë faqe. + Përshkrimi + Skenari nuk u gjet + Përshkrimi nuk u gjet + Shfaq regjistrin Logcat 🐈 + Rregjistër + Imazh-brenda-imazhit + Vazhdo shikimin në një luajtës të vogël mbi aplikacionet e tjera + Butoni për ndryshimin e madhësisë së luajtësit + Hiq kufijtë e errët + Titrat + Cilësimet e titrave të luajtësit + Titrat e Chromecast + Cilësimet e titrave të Chromecast + Shpejtësia e rikthimit + Shto një opsion shpejtësie në luajtës + Rrëshqit për të kaluar + Rrëshqit nga njëra anë në tjetrën për të lëvizur pozicionin e shikimit në video + Rrëshqit per te ndryshuar cilësimet + Rrëshqit lart ose poshtë në të djathtë ose të majtë për të ndryshuar nivelin e ndriçimit dhe volumin + Luaj episodin tjetër automatikisht + Luaj episodin tjetër kur episodi aktual mbaron + Shtyp dy herë për të kaluar + Shtyp dy herë për të ndaluar + Sasia e kalimit të luajtësit (sekonda) + Shtyp dy herë në të djathtë ose të majtë për ta kaluar para ose mbrapa + Shtyp dy herë në mes për të ndaluar + Përdor nivelin e ndriçimit të sitemit + Përdor ndriçimin e sistemit në luajtësin e aplikacionit në vend të një mbivendosjeje të errët + Ndriçim ekstra + Aktivizo filtrin e ndriçimit kur ndriçimi i ekranit kalon 100% + extra_brightness_enabled + Përditëso progresin e shikimit + Sinkronizo automatikisht progresin e episodit aktual + Rikthe të dhënat nga kopja rezervë + Të dhënat e kopjes rezervë + Shpeshtësia e kopjimit rezervë + Kopja rezervë u ngarkua + Dështoi rikthimi i të dhënave nga skedari %s + Të dhënat u ruajtën + Lejet për ruajtje mungojnë. Ju lutem provoni përsëri. + Gabim gjatë kopjimit rezervë të %s + Kërko + Bilblioteka + Llogaritë dhe Siguria + Përditësime dhe Kopje Rezervë + Informacion + Kërkim i avancuar + Tregon rezultatet e kërkimit të ndara sipas ofruesit + Kërkime të sugjeruara + Trego sugjerimet ndërkohë që shkruani + Pastro sugjerimet + Trego episodin Filler për anime + Shfaq informacionin e Metadata-s mbi video + Shfaq trailerat + Shfaq posterat nga Kitsu + Shfaq panelin e aktorëve + Mos shfaq cilësinë e videos së përzgjedhur në rezultatet e kërkimit + Përditësim automatik i shtesave + Shkarkim automatik i shtesave + Zgjidh modalitetin për të filtruar shtesat që shkarkohen + Instalo automatikisht të gjitha shtesat që janë shtuar dhe ende nuk janë instaluar nga repository-t. + Shfaq përditësimet e aplikacionit + Kërko automatikisht për përditësime të reja kur hapet aplikacioni. + Ribëj procesin e konfigurimit + Instalo versione beta + Versioni beta është i instaluar. + Dështoi instalimi i versionit beta. + Instaluesi i APK-ve + Disa pajisje nuk mbështesin instaluesin e ri të paketës. Provoni opsionin e vjetër nëse përditësimet nuk instalohen. + Github + Aplikacioni Light Novel nga të njëjtit developer-a + Aplikacioni i Anime-ve nga të njëjtit developer-a + Na u bashko në Discord + Jep një banane për developer-at + Banane të dhëna + Gjuha e aplikacionit + Ky ofrues nuk suporton Chromecast + Nuk u gjetën lidhje + Lidhja u kopjua në kujtesën e përkohshme + Luaj Episodin + Rikthe në vlerën fillestare + Sezoni + %1$s %2$d%3$s + Asnje sezon + Episodi + Episodet + %1$d-%2$d + %1$d %2$s + Vazhdon në %s + S + E + Nuk u gjet asnjë episod + Fshij + Fshij skedarin + Fshij skedarët + Fshij (%1$d | %2$s) + Anulo + Ndalo + Fillo + Dështoi + Kaloi + Paralajmërim + Vazhdo + -30 + +30 + Kjo do të fshijë përgjithmonë %s\nJeni të sigurt? + Jeni të sigurt që dëshironi të fshini përgjithmonë artikujt e mëposhtëm?\n\n%s + Jeni të sigurt që dëshironi të fshini përgjithmonë episodet e mëposhtme në %1$s?\n\n%2$s + Do të fshini gjithashtu përgjithmonë të gjitha episodet në seritë e mëposhtme:\n\n%s + Jeni të sigurt që dëshironi të fshini përfundimisht të gjitha episodet në serinë e mëposhtme?\n\n%s + %dm\ntë mbetura + %s\ntë mbetura + Në vazhdim + Përfunduar + Statusi + Viti + Vlerësimi + Kohëzgjatja + Faqja + Sinopsisi + në rradhë + Pa titra + Parazgjedhur + Falas + Përdorur + Aplikacion + Filmat + Seriale televizive + Vizatimore + Anime + Torrents + Dokumentarë + OVA + Drama aziatike + Transmetime live + NSFW + Tjera + Film + Seri + Vizatimorë + Anime + OVA + Torrent + Dokumentar + Dramë aziatike + Transmetim live + NSFW + Video + Muzikë + Libër audio + Media + Audio + Podkast + Gabim në burim + Gabim në distancë + Gabim në renderer + Gabim në enkodim + Gabim i pasuportueshëm + Gabim i papritur i luajtësit + Gabim në shkarkim, kontrolloni lejet e memories + Episod Chromecast-i + Pasqyrim Chromecast + Pasqyrim Cast + Luaj në aplikacion + Luaj pasqyrimin" + Luaj në %s + Shkarko automatikisht + Pasqyrim shkarkimi + Ringarko lidhjet + Shkarko titrat + Etiketa e cilësisë + Etiketa e dublimit + Etiketa e titrave + Etiketa e vlerësimit + Titulli + Teksti i episodit + Zgjidh elementët e ndërfaqes mbi poster + Nuk u gjet asnjë përditësim + Kontrollo për përditësim + Çelës + Ndrysho madhësinë + Burimi + Kalo Intron + Mos e shfaq përsëri + Anashkalo këtë përditësim + Përditëso + Cilësia e preferuar e shikimit (WiFi) + Cilësia e preferuar e shikimit (Mobile Data) + Numri maksimal i karaktereve të titullit + Shfaq informacionin e luajtësit + Madhësia e bufferit të videos + Gjatësia e bufferit të videos + Video cache në disk + Pastro cache-në e videove dhe imazheve + Sasia e kalimit - Luajtësi i dukshëm + Sasia e kalimit kur luajtësi është i dukshëm + Sasia e kalimit - Luajtësi i fshehur + Sasia e kalimit kur luajtësi është i fshehur + Shkakton keqfunksionim nëse vendoset shumë lartë në pajisje me memorie të ulët, si Android TV. + Shkakton probleme nëse vendoset shumë lartë në pajisje me memorie të ulët, si Android TV. + DNS në vend të HTTPS + E dobishme për të anashkaluar bllokimet e ISP-së + GitHub Proxy + Nuk mund të arrihej GitHub. Po aktivizohet proxy jsDelivr… + Anashkalon bllokimin e URL-ve të GitHub duke përdorur jsDelivr. Mund të shkaktojë vonesa të disa ditëve në përditësime. + Klono faqen + Fshi faqen + Shto një klonim të një faqeje ekzistuese, me një URL tjetër + Destinacioni i shkarkimit + URL e serverit NGINX + Shfaq Anime të Dubluar/me Titra + Përshtat me ekranin + Shtrij + Zmadho + Mohim përgjegjësie + Tejkalues ISP-je + Lidhjet + Përditësimet e aplikacionit + Kopja rezervë + Shtesat + Veprimet + Cache + Android TV + Gjeste + Siguria + Llogaritë + Veçoritë e luajtësit + Titrat + Paraqitja + Parazgjedhjet + Pamja + Veçoritë + Të përgjithshme + Buton i rastësishëm + Shfaq butonin e rastësishëm në Faqen kryesore dhe Bibliotekë + Zgjidh Bibliotekën + Biblioteka juaj është bosh :(\nHyni në një llogari biblioteke ose shtoni shfaqje në bibliotekën tuaj lokale. + Gjuha e shtesave + Paraqitja e aplikacionit + Media e preferuar + Aktivizo NSFW për shtesat që e suportojnë + Enkodimi i titrave + Ofruesit + Testim i ofruesve + Testo të gjitha shtesat + Ky test është vetëm për developer-a dhe nuk verifikon apo mohon funksionimin e asnjë shtese. + Paraqitja + Automatikisht + Paraqitja për TV + Paraqitja për Celular + Paraqitja për emulator + Ngjyra kryesore + Pamja e aplikacionit + Vendodhja e titullit te posterit + Vendos titullin poshte posterit + Fjalëkalimi123 + Emri i përdoruesit + përshëndetje@shqipëri.com + 127.0.0.1 + EmriRiFaqes + https://shembull.com + Kodi i gjuhës (al) + %1$s %2$s + llogaria + Çkycu + Hyr + Autentifikohu lokalisht + Ndrysho llogari + Shto llogari + Krijo një llogari + Shto gjurmim + U shtua %s + Rifresko + Vlerësuar + %d / 10 + /?? + /%d + %s u autentifikua + Nuk mund të hysh në %s + Çaktivizuar + Asnjë + Normal + Të gjitha + diff --git a/fastlane/metadata/android/sq/changelogs/2.txt b/fastlane/metadata/android/sq/changelogs/2.txt new file mode 100644 index 000000000..209d5f4c7 --- /dev/null +++ b/fastlane/metadata/android/sq/changelogs/2.txt @@ -0,0 +1 @@ +- U shtua regjistri i ndryshimeve! diff --git a/fastlane/metadata/android/sq/full_description.txt b/fastlane/metadata/android/sq/full_description.txt new file mode 100644 index 000000000..f9d6e6f9e --- /dev/null +++ b/fastlane/metadata/android/sq/full_description.txt @@ -0,0 +1,10 @@ +CloudStream-3 ju lejon të shikoni dhe shkarkoni filma, seriale televizive dhe anime. + +Aplikacioni vjen pa asnjë reklamë dhe analitikë +dhe suporton faqe të shumta trailerash dhe filmash dhe më shumë, p.sh. + +Të ruajturat + +Shkarkime titrash + +Chromecast diff --git a/fastlane/metadata/android/sq/short_description.txt b/fastlane/metadata/android/sq/short_description.txt new file mode 100644 index 000000000..a2a07e19c --- /dev/null +++ b/fastlane/metadata/android/sq/short_description.txt @@ -0,0 +1 @@ +Shiko dhe shkarko filma, seriale televizive dhe anime. diff --git a/fastlane/metadata/android/sq/title.txt b/fastlane/metadata/android/sq/title.txt new file mode 100644 index 000000000..dde89d58f --- /dev/null +++ b/fastlane/metadata/android/sq/title.txt @@ -0,0 +1 @@ +CloudStream From f51885fb6ed19c606bad21f94341bb048534406a Mon Sep 17 00:00:00 2001 From: Luna712 <142361265+Luna712@users.noreply.github.com> Date: Wed, 8 Apr 2026 17:00:53 -0600 Subject: [PATCH 097/236] Fix MotionEvent gestures getting stuck in player (#2629) --- .../lagradost/cloudstream3/ui/player/FullScreenPlayer.kt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/FullScreenPlayer.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/FullScreenPlayer.kt index fad4a53e1..eb5ac8f36 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/FullScreenPlayer.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/FullScreenPlayer.kt @@ -1660,7 +1660,7 @@ open class FullScreenPlayer : AbstractPlayerFragment() { playerBinding?.playerIntroPlay?.isGone = true // Handle pan with two fingers - if (event.pointerCount == 2 && !isLocked && isFullScreenPlayer && !hasTriggeredSpeedUp && currentTouchAction == null) { + if ((event.pointerCount == 2 || lastPan != null) && !isLocked && isFullScreenPlayer && !hasTriggeredSpeedUp && currentTouchAction == null) { holdhandler.removeCallbacks(holdRunnable) // remove 2x speed // Gesture detectors for zoom & pan @@ -1695,7 +1695,7 @@ open class FullScreenPlayer : AbstractPlayerFragment() { lastPan = newPan } - MotionEvent.ACTION_POINTER_UP, MotionEvent.ACTION_UP -> { + MotionEvent.ACTION_CANCEL, MotionEvent.ACTION_POINTER_UP, MotionEvent.ACTION_UP -> { // Reset touch lastPan = null currentTouchStart = null @@ -1777,7 +1777,7 @@ open class FullScreenPlayer : AbstractPlayerFragment() { } } - MotionEvent.ACTION_UP -> { + MotionEvent.ACTION_CANCEL, MotionEvent.ACTION_UP -> { holdhandler.removeCallbacks(holdRunnable) if (hasTriggeredSpeedUp) { player.setPlaybackSpeed(DataStoreHelper.playBackSpeed) From 1d03b05a7cceeb9656d28f27227f957f8097251d Mon Sep 17 00:00:00 2001 From: firelight <147925818+fire-light42@users.noreply.github.com> Date: Wed, 8 Apr 2026 23:04:39 +0000 Subject: [PATCH 098/236] Refactor: New SkipAPI for SkipStamp (#2601) --- .../ui/player/AbstractPlayerFragment.kt | 6 +- .../cloudstream3/ui/player/CS3IPlayer.kt | 15 +- .../cloudstream3/ui/player/GeneratorPlayer.kt | 6 +- .../cloudstream3/ui/player/IPlayer.kt | 8 +- .../ui/player/PlayerGeneratorViewModel.kt | 9 +- .../lagradost/cloudstream3/utils/AniSkip.kt | 230 ------------------ .../cloudstream3/utils/videoskip/AniSkip.kt | 68 ++++++ .../utils/videoskip/IntroDbSkip.kt | 77 ++++++ .../cloudstream3/utils/videoskip/SkipAPI.kt | 104 ++++++++ 9 files changed, 271 insertions(+), 252 deletions(-) delete mode 100644 app/src/main/java/com/lagradost/cloudstream3/utils/AniSkip.kt create mode 100644 app/src/main/java/com/lagradost/cloudstream3/utils/videoskip/AniSkip.kt create mode 100644 app/src/main/java/com/lagradost/cloudstream3/utils/videoskip/IntroDbSkip.kt create mode 100644 app/src/main/java/com/lagradost/cloudstream3/utils/videoskip/SkipAPI.kt diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/AbstractPlayerFragment.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/AbstractPlayerFragment.kt index ea6babb20..1e6d827e6 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/AbstractPlayerFragment.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/AbstractPlayerFragment.kt @@ -54,10 +54,10 @@ import com.lagradost.cloudstream3.ui.subtitles.SubtitlesFragment import com.lagradost.cloudstream3.utils.AppContextUtils import com.lagradost.cloudstream3.utils.AppContextUtils.requestLocalAudioFocus import com.lagradost.cloudstream3.utils.DataStoreHelper -import com.lagradost.cloudstream3.utils.EpisodeSkip import com.lagradost.cloudstream3.utils.UIHelper import com.lagradost.cloudstream3.utils.UIHelper.hideSystemUI import com.lagradost.cloudstream3.utils.UIHelper.popCurrentPage +import com.lagradost.cloudstream3.utils.videoskip.VideoSkipStamp import java.net.SocketTimeoutException enum class PlayerResize(@StringRes val nameRes: Int) { @@ -127,11 +127,11 @@ abstract class AbstractPlayerFragment( throw NotImplementedError() } - open fun onTimestamp(timestamp: EpisodeSkip.SkipStamp?) { + open fun onTimestamp(timestamp: VideoSkipStamp?) { } - open fun onTimestampSkipped(timestamp: EpisodeSkip.SkipStamp) { + open fun onTimestampSkipped(timestamp: VideoSkipStamp) { } diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/CS3IPlayer.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/CS3IPlayer.kt index a134ae911..43b281a28 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/CS3IPlayer.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/CS3IPlayer.kt @@ -95,14 +95,13 @@ import com.lagradost.cloudstream3.utils.Coroutines.ioSafe import com.lagradost.cloudstream3.utils.Coroutines.runOnMainThread import com.lagradost.cloudstream3.utils.DataStoreHelper.currentAccount import com.lagradost.cloudstream3.utils.DrmExtractorLink -import com.lagradost.cloudstream3.utils.EpisodeSkip import com.lagradost.cloudstream3.utils.ExtractorLink import com.lagradost.cloudstream3.utils.ExtractorLinkPlayList import com.lagradost.cloudstream3.utils.ExtractorLinkType import com.lagradost.cloudstream3.utils.PLAYREADY_UUID import com.lagradost.cloudstream3.utils.SubtitleHelper.fromTagToLanguageName import com.lagradost.cloudstream3.utils.WIDEVINE_UUID -import io.github.anilbeesetti.nextlib.media3ext.ffdecoder.NextRenderersFactory +import com.lagradost.cloudstream3.utils.videoskip.VideoSkipStamp import kotlinx.coroutines.delay import okhttp3.Interceptor import org.chromium.net.CronetEngine @@ -884,10 +883,10 @@ class CS3IPlayer : IPlayer { private var currentTextRenderer: TextRenderer? = null } - private fun getCurrentTimestamp(writePosition: Long? = null): EpisodeSkip.SkipStamp? { + private fun getCurrentTimestamp(writePosition: Long? = null): VideoSkipStamp? { val position = writePosition ?: this@CS3IPlayer.getPosition() ?: return null for (lastTimeStamp in lastTimeStamps) { - if (lastTimeStamp.startMs <= position && (position + (toleranceBeforeUs / 1000L) + 1) < lastTimeStamp.endMs) { + if (lastTimeStamp.timestamp.startMs <= position && (position + (toleranceBeforeUs / 1000L) + 1) < lastTimeStamp.timestamp.endMs) { return lastTimeStamp } } @@ -999,7 +998,7 @@ class CS3IPlayer : IPlayer { if (lastTimeStamp.skipToNextEpisode) { handleEvent(CSPlayerEvent.NextEpisode, source) } else { - seekTo(lastTimeStamp.endMs + 1L) + seekTo(lastTimeStamp.timestamp.endMs + 1L) } event(TimestampSkippedEvent(timestamp = lastTimeStamp, source = source)) } @@ -1578,9 +1577,9 @@ class CS3IPlayer : IPlayer { } } - private var lastTimeStamps: List = emptyList() + private var lastTimeStamps: List = emptyList() - override fun addTimeStamps(timeStamps: List) { + override fun addTimeStamps(timeStamps: List) { lastTimeStamps = timeStamps timeStamps.forEach { timestamp -> exoPlayer?.createMessage { _, _ -> @@ -1589,7 +1588,7 @@ class CS3IPlayer : IPlayer { // onTimestampInvoked?.invoke(payload) } ?.setLooper(Looper.getMainLooper()) - ?.setPosition(timestamp.startMs) + ?.setPosition(timestamp.timestamp.startMs) //?.setPayload(timestamp) ?.setDeleteAfterDelivery(false) ?.send() 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 ad7c8915f..16b03e4f6 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 @@ -106,7 +106,6 @@ import com.lagradost.cloudstream3.utils.Coroutines.ioSafe import com.lagradost.cloudstream3.utils.Coroutines.runOnMainThread import com.lagradost.cloudstream3.utils.DataStoreHelper import com.lagradost.cloudstream3.utils.DataStoreHelper.getViewPos -import com.lagradost.cloudstream3.utils.EpisodeSkip import com.lagradost.cloudstream3.utils.ExtractorLink import com.lagradost.cloudstream3.utils.ExtractorLinkType import com.lagradost.cloudstream3.utils.Qualities @@ -124,6 +123,7 @@ import com.lagradost.cloudstream3.utils.UIHelper.toPx import com.lagradost.cloudstream3.utils.downloader.DownloadUtils.getImageBitmapFromUrl import com.lagradost.cloudstream3.utils.setText import com.lagradost.cloudstream3.utils.txt +import com.lagradost.cloudstream3.utils.videoskip.VideoSkipStamp import com.lagradost.safefile.SafeFile import kotlinx.coroutines.Job import kotlinx.coroutines.delay @@ -2052,11 +2052,11 @@ class GeneratorPlayer : FullScreenPlayer() { } } - override fun onTimestampSkipped(timestamp: EpisodeSkip.SkipStamp) { + override fun onTimestampSkipped(timestamp: VideoSkipStamp) { displayTimeStamp(false) } - override fun onTimestamp(timestamp: EpisodeSkip.SkipStamp?) { + override fun onTimestamp(timestamp: VideoSkipStamp?) { if (timestamp != null) { playerBinding?.skipChapterButton?.setText(timestamp.uiText) displayTimeStamp(true) diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/IPlayer.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/IPlayer.kt index 08b8ee795..43ec756ed 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/IPlayer.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/IPlayer.kt @@ -4,8 +4,8 @@ import android.content.Context import android.graphics.Bitmap import android.util.Rational import com.lagradost.cloudstream3.ui.subtitles.SaveCaptionStyle -import com.lagradost.cloudstream3.utils.EpisodeSkip import com.lagradost.cloudstream3.utils.ExtractorLink +import com.lagradost.cloudstream3.utils.videoskip.VideoSkipStamp enum class PlayerEventType(val value: Int) { Pause(0), @@ -86,13 +86,13 @@ data class ErrorEvent( /** Event when timestamps appear, null when it should disappear */ data class TimestampInvokedEvent( - val timestamp: EpisodeSkip.SkipStamp, + val timestamp: VideoSkipStamp, override val source: PlayerEventSource = PlayerEventSource.Player, ) : PlayerEvent() /** Event for when a chapter is skipped, aka when event is handled (or for future use when skip automatically ads/sponsor) */ data class TimestampSkippedEvent( - val timestamp: EpisodeSkip.SkipStamp, + val timestamp: VideoSkipStamp, override val source: PlayerEventSource = PlayerEventSource.Player, ) : PlayerEvent() @@ -254,7 +254,7 @@ interface IPlayer { fun updateSubtitleStyle(style: SaveCaptionStyle) fun saveData() - fun addTimeStamps(timeStamps: List) + fun addTimeStamps(timeStamps: List) fun loadPlayer( context: Context, 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 d8c5e777c..96468490a 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 @@ -13,9 +13,10 @@ import com.lagradost.cloudstream3.mvvm.safe import com.lagradost.cloudstream3.mvvm.safeApiCall import com.lagradost.cloudstream3.ui.result.ResultEpisode import com.lagradost.cloudstream3.utils.Coroutines.ioSafe -import com.lagradost.cloudstream3.utils.EpisodeSkip 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.coroutines.Job import kotlinx.coroutines.launch @@ -35,8 +36,8 @@ class PlayerGeneratorViewModel : ViewModel() { private val _loadingLinks = MutableLiveData>() val loadingLinks: LiveData> = _loadingLinks - private val _currentStamps = MutableLiveData>(emptyList()) - val currentStamps: LiveData> = _currentStamps + private val _currentStamps = MutableLiveData>(emptyList()) + val currentStamps: LiveData> = _currentStamps private val _currentSubtitleYear = MutableLiveData(null) val currentSubtitleYear: LiveData = _currentSubtitleYear @@ -181,7 +182,7 @@ class PlayerGeneratorViewModel : ViewModel() { if (page != null && meta is ResultEpisode) { _currentStamps.postValue(listOf()) _currentStamps.postValue( - EpisodeSkip.getStamps( + SkipAPI.videoStamps( page, meta, duration, diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/AniSkip.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/AniSkip.kt deleted file mode 100644 index bbdadbf3f..000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/AniSkip.kt +++ /dev/null @@ -1,230 +0,0 @@ -package com.lagradost.cloudstream3.utils - -import android.util.Log -import androidx.annotation.StringRes -import com.fasterxml.jackson.annotation.JsonProperty -import com.fasterxml.jackson.databind.annotation.JsonSerialize -import com.lagradost.cloudstream3.* -import com.lagradost.cloudstream3.LoadResponse.Companion.getImdbId -import com.lagradost.cloudstream3.LoadResponse.Companion.getMalId -import com.lagradost.cloudstream3.mvvm.logError -import com.lagradost.cloudstream3.ui.result.ResultEpisode -import java.lang.Long.min - -object EpisodeSkip { - private const val TAG = "EpisodeSkip" - - enum class SkipType(@StringRes name: Int) { - Opening(R.string.skip_type_op), - Ending(R.string.skip_type_ed), - Recap(R.string.skip_type_recap), - MixedOpening(R.string.skip_type_mixed_op), - MixedEnding(R.string.skip_type_mixed_ed), - Credits(R.string.skip_type_creddits), - Intro(R.string.skip_type_creddits), - } - - data class SkipStamp( - val type: SkipType, - val skipToNextEpisode: Boolean, - val startMs: Long, - val endMs: Long, - ) { - val uiText = if (skipToNextEpisode) txt(R.string.next_episode) else txt( - R.string.skip_type_format, - txt(type.name) - ) - } - - private val cachedStamps = HashMap>() - - private fun shouldSkipToNextEpisode(endMs: Long, episodeDurationMs: Long): Boolean { - return episodeDurationMs - endMs < 20_000L // some might have outro that we don't care about tbh - } - - suspend fun getStamps( - data: LoadResponse, - episode: ResultEpisode, - episodeDurationMs: Long, - hasNextEpisode: Boolean, - ): List { - cachedStamps[episode.id]?.let { list -> - return list - } - - val out = mutableListOf() - Log.i(TAG, "Requesting SkipStamp from ${data.syncData}") - - if (data is AnimeLoadResponse && (data.type == TvType.Anime || data.type == TvType.OVA)) { - data.getMalId()?.toIntOrNull()?.let { malId -> - val (resultLength, stamps) = AniSkip.getResult( - malId, - episode.episode, - episodeDurationMs - ) ?: return@let null - // because it also returns an expected episode length we use that just in case it is mismatched with like 2s next episode will still work - val dur = min(episodeDurationMs, resultLength) - stamps.mapNotNull { stamp -> - val skipType = when (stamp.skipType) { - "op" -> SkipType.Opening - "ed" -> SkipType.Ending - "recap" -> SkipType.Recap - "mixed-ed" -> SkipType.MixedEnding - "mixed-op" -> SkipType.MixedOpening - else -> null - } ?: return@mapNotNull null - val end = (stamp.interval.endTime * 1000.0).toLong() - val start = (stamp.interval.startTime * 1000.0).toLong() - SkipStamp( - type = skipType, - skipToNextEpisode = hasNextEpisode && shouldSkipToNextEpisode( - end, - dur - ), - startMs = start, - endMs = end - ) - }.let { list -> - out.addAll(list) - } - } - } else if (data.type == TvType.TvSeries || data.type == TvType.AsianDrama) { - val season = episode.season - val imdbId = data.getImdbId() - - if (season != null && imdbId != null) { - val result = IntroDbSkip.getResult( - imdbId, - season, - episode.episode - ) - - result?.let { res -> - listOfNotNull( - res.intro?.let { - val start = it.startMs ?: return@let null - val end = it.endMs ?: return@let null - SkipStamp( - type = SkipType.Opening, - skipToNextEpisode = hasNextEpisode && - shouldSkipToNextEpisode(end, episodeDurationMs), - startMs = start, - endMs = end - ) - }, - res.recap?.let { - val start = it.startMs ?: return@let null - val end = it.endMs ?: return@let null - SkipStamp( - type = SkipType.Recap, - skipToNextEpisode = hasNextEpisode && - shouldSkipToNextEpisode(end, episodeDurationMs), - startMs = start, - endMs = end - ) - }, - res.outro?.let { - val start = it.startMs ?: return@let null - val end = it.endMs ?: return@let null - SkipStamp( - type = SkipType.Credits, - skipToNextEpisode = hasNextEpisode && - shouldSkipToNextEpisode(end, episodeDurationMs), - startMs = start, - endMs = end - ) - } - ).let { out.addAll(it) } - } - } - } - - if (out.isNotEmpty()) - cachedStamps[episode.id] = out - return out - } -} - -// taken from https://github.com/saikou-app/saikou/blob/3803f8a7a59b826ca193664d46af3a22bbc989f7/app/src/main/java/ani/saikou/others/AniSkip.kt -// the following is GPLv3 code https://github.com/saikou-app/saikou/blob/main/LICENSE.md -object AniSkip { - private const val TAG = "AniSkip" - suspend fun getResult( - malId: Int, - episodeNumber: Int, - episodeLength: Long - ): Pair>? { - return try { - val url = - "https://api.aniskip.com/v2/skip-times/$malId/$episodeNumber?types[]=ed&types[]=mixed-ed&types[]=mixed-op&types[]=op&types[]=recap&episodeLength=${episodeLength / 1000L}" - Log.i(TAG, "Requesting $url") - - val a = app.get(url) - val res = a.parsed() - Log.i(TAG, "Found ${res.found} with ${res.results?.size} results") - if (res.found && !res.results.isNullOrEmpty()) (res.results[0].episodeLength * 1000).toLong() to res.results else null - } catch (t: Throwable) { - Log.i(TAG, "error = ${t.message}") - logError(t) - null - } - } - - data class AniSkipResponse( - @JsonSerialize val found: Boolean, - @JsonSerialize val results: List?, - @JsonSerialize val message: String?, - @JsonSerialize val statusCode: Int - ) - - data class Stamp( - @JsonSerialize val interval: AniSkipInterval, - @JsonSerialize val skipType: String, - @JsonSerialize val skipId: String, - @JsonSerialize val episodeLength: Double - ) - - data class AniSkipInterval( - @JsonSerialize val startTime: Double, - @JsonSerialize val endTime: Double - ) -} - -object IntroDbSkip { - private const val TAG = "IntroDb" - - suspend fun getResult( - imdbId: String, - season: Int, - episode: Int, - ): IntroDbResponse? { - return try { - val url = - "https://api.introdb.app/segments?imdb_id=$imdbId&season=$season&episode=$episode" - app.get(url).parsed() - } catch (t: Throwable) { - Log.i(TAG, "error = ${t.message}") - logError(t) - null - } - } - - data class IntroDbResponse( - @JsonProperty("imdb_id") val imdbId: String?, - val season: Int?, - val episode: Int?, - val intro: Segment?, - val recap: Segment?, - val outro: Segment?, - ) - - data class Segment( - @JsonProperty("start_sec") val startSec: Double?, - @JsonProperty("end_sec") val endSec: Double?, - @JsonProperty("start_ms") val startMs: Long?, - @JsonProperty("end_ms") val endMs: Long?, - val confidence: Double?, - @JsonProperty("submission_count") val submissionCount: Int?, - @JsonProperty("updated_at") val updatedAt: String?, - ) -} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/videoskip/AniSkip.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/videoskip/AniSkip.kt new file mode 100644 index 000000000..0db90afea --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/videoskip/AniSkip.kt @@ -0,0 +1,68 @@ +package com.lagradost.cloudstream3.utils.videoskip + +import com.fasterxml.jackson.databind.annotation.JsonSerialize +import com.lagradost.cloudstream3.AnimeLoadResponse +import com.lagradost.cloudstream3.LoadResponse +import com.lagradost.cloudstream3.LoadResponse.Companion.getMalId +import com.lagradost.cloudstream3.TvType +import com.lagradost.cloudstream3.app +import com.lagradost.cloudstream3.ui.result.ResultEpisode + +// taken from https://github.com/saikou-app/saikou/blob/3803f8a7a59b826ca193664d46af3a22bbc989f7/app/src/main/java/ani/saikou/others/AniSkip.kt +// the following is GPLv3 code https://github.com/saikou-app/saikou/blob/main/LICENSE.md +class AniSkip : SkipAPI() { + override val name: String = "AniSkip" + override val supportedTypes: Set = setOf(TvType.Anime, TvType.OVA) + + override suspend fun stamps( + data: LoadResponse, + episode: ResultEpisode, + episodeDurationMs: Long + ): List? { + if (data !is AnimeLoadResponse) return null // Filter actual anime + + val malId = data.getMalId()?.toIntOrNull() ?: return null + val url = + "https://api.aniskip.com/v2/skip-times/$malId/${episode.episode}?types[]=ed&types[]=mixed-ed&types[]=mixed-op&types[]=op&types[]=recap&episodeLength=${episodeDurationMs / 1000L}" + + val response = app.get(url).parsed() + + // because it also returns an expected episode length we use that just in case it is mismatched with like 2s next episode will still work + return response.results?.mapNotNull { stamp -> + val skipType = when (stamp.skipType) { + "op" -> SkipType.Opening + "ed" -> SkipType.Ending + "recap" -> SkipType.Recap + "mixed-ed" -> SkipType.MixedEnding + "mixed-op" -> SkipType.MixedOpening + else -> null + } ?: return@mapNotNull null + val end = (stamp.interval.endTime * 1000.0).toLong() + val start = (stamp.interval.startTime * 1000.0).toLong() + SkipStamp( + type = skipType, + startMs = start, + endMs = end, + ) + } + } + + data class AniSkipResponse( + @JsonSerialize val found: Boolean, + @JsonSerialize val results: List?, + @JsonSerialize val message: String?, + @JsonSerialize val statusCode: Int + ) + + data class Stamp( + @JsonSerialize val interval: AniSkipInterval, + @JsonSerialize val skipType: String, + @JsonSerialize val skipId: String, + @JsonSerialize val episodeLength: Double + ) + + data class AniSkipInterval( + @JsonSerialize val startTime: Double, + @JsonSerialize val endTime: Double + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/videoskip/IntroDbSkip.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/videoskip/IntroDbSkip.kt new file mode 100644 index 000000000..ce284f3fe --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/videoskip/IntroDbSkip.kt @@ -0,0 +1,77 @@ +package com.lagradost.cloudstream3.utils.videoskip + +import com.fasterxml.jackson.annotation.JsonProperty +import com.lagradost.cloudstream3.LoadResponse +import com.lagradost.cloudstream3.LoadResponse.Companion.getImdbId +import com.lagradost.cloudstream3.TvType +import com.lagradost.cloudstream3.app +import com.lagradost.cloudstream3.ui.result.ResultEpisode + +class IntroDbSkip : SkipAPI() { + override val name = "IntroDb" + + override val supportedTypes = setOf(TvType.TvSeries, TvType.AsianDrama) + + override suspend fun stamps( + data: LoadResponse, + episode: ResultEpisode, + episodeDurationMs: Long + ): List? { + val season = episode.season ?: return null + val imdbId = data.getImdbId() ?: return null + + val url = + "https://api.introdb.app/segments?imdb_id=$imdbId&season=$season&episode=${episode.episode}" + val response = app.get(url).parsed() + + return listOfNotNull( + response.intro?.let { + val start = it.startMs ?: return@let null + val end = it.endMs ?: return@let null + SkipStamp( + type = SkipType.Opening, + startMs = start, + endMs = end + ) + }, + response.recap?.let { + val start = it.startMs ?: return@let null + val end = it.endMs ?: return@let null + SkipStamp( + type = SkipType.Recap, + startMs = start, + endMs = end + ) + }, + response.outro?.let { + val start = it.startMs ?: return@let null + val end = it.endMs ?: return@let null + SkipStamp( + type = SkipType.Credits, + startMs = start, + endMs = end + ) + } + ) + } + + + data class IntroDbResponse( + @JsonProperty("imdb_id") val imdbId: String?, + val season: Int?, + val episode: Int?, + val intro: Segment?, + val recap: Segment?, + val outro: Segment?, + ) + + data class Segment( + @JsonProperty("start_sec") val startSec: Double?, + @JsonProperty("end_sec") val endSec: Double?, + @JsonProperty("start_ms") val startMs: Long?, + @JsonProperty("end_ms") val endMs: Long?, + val confidence: Double?, + @JsonProperty("submission_count") val submissionCount: Int?, + @JsonProperty("updated_at") val updatedAt: String?, + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/videoskip/SkipAPI.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/videoskip/SkipAPI.kt new file mode 100644 index 000000000..df16d77ca --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/videoskip/SkipAPI.kt @@ -0,0 +1,104 @@ +package com.lagradost.cloudstream3.utils.videoskip + +import androidx.annotation.StringRes +import com.lagradost.cloudstream3.LoadResponse +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.TvType +import com.lagradost.cloudstream3.mvvm.safeAsync +import com.lagradost.cloudstream3.ui.result.ResultEpisode +import com.lagradost.cloudstream3.utils.txt +import java.util.concurrent.ConcurrentHashMap + + +enum class SkipType(@StringRes val res: Int) { + Opening(R.string.skip_type_op), + Ending(R.string.skip_type_ed), + Recap(R.string.skip_type_recap), + MixedOpening(R.string.skip_type_mixed_op), + MixedEnding(R.string.skip_type_mixed_ed), + Credits(R.string.skip_type_creddits), + Intro(R.string.skip_type_creddits), +} + +data class SkipStamp( + val type: SkipType, + /** Start position in milliseconds of the skip, where it should start showing up */ + val startMs: Long, + /** End position in milliseconds of the skip, where it will skip to */ + val endMs: Long, + /** Custom visual label instead of using the type. Only use this for content not covered by SkipType */ + val label: String? = null, +) + +data class VideoSkipStamp( + val timestamp: SkipStamp, + val skipToNextEpisode: Boolean, + val source: String, +) { + val uiText = + if (skipToNextEpisode) txt(R.string.next_episode) else + txt( + R.string.skip_type_format, + timestamp.label?.let { txt(it) } ?: txt(timestamp.type.res) + ) +} + +abstract class SkipAPI { + open val name: String = "NONE" + + /** On what types SkipAPI should trigger on */ + abstract val supportedTypes: Set + + /** Get all video skip stamps of the associated episode */ + @Throws + open suspend fun stamps( + data: LoadResponse, + episode: ResultEpisode, + episodeDurationMs: Long, + ): List? { + throw NotImplementedError() + } + + companion object { + private val skipApis: List = listOf(AniSkip(), IntroDbSkip()) + private val cachedStamps = ConcurrentHashMap>() + + /** Get all video timestamps from an episode */ + suspend fun videoStamps( + data: LoadResponse, + episode: ResultEpisode, + episodeDurationMs: Long, + hasNextEpisode: Boolean, + ): List { + cachedStamps[episode.id]?.let { list -> + return list + } + + for (api in skipApis) { + /** Unsupported type, so we do not waste a get call */ + if (!api.supportedTypes.contains(data.type)) { + continue + } + + /** Find first non-empty stamps */ + val stamps = safeAsync { api.stamps(data, episode, episodeDurationMs) } + if (stamps.isNullOrEmpty()) { + continue + } + + return stamps.map { stamp -> + VideoSkipStamp( + timestamp = stamp, + skipToNextEpisode = hasNextEpisode && episodeDurationMs - stamp.endMs < 20_000L, + source = api.name + ) + }.also { stamps -> + /** Put in cache, this is such small data, it should be fine to never clear it */ + cachedStamps[episode.id] = stamps + } + } + return emptyList() + } + } +} + From b5109420274c6cdce5c643cd6d67fb2e33f07553 Mon Sep 17 00:00:00 2001 From: Luna712 <142361265+Luna712@users.noreply.github.com> Date: Wed, 8 Apr 2026 17:11:40 -0600 Subject: [PATCH 099/236] Bump newpipeextractor to v0.26.0 (#2624) --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 482f776b6..3b060a53c 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -29,7 +29,7 @@ lifecycleKtx = "2.10.0" material = "1.14.0-alpha09" media3 = "1.9.2" navigationKtx = "2.9.7" -newpipeextractor = "v0.25.2" +newpipeextractor = "v0.26.0" nextlibMedia3 = "1.9.1-0.11.0" nicehttp = "0.4.17" overlappingpanels = "0.1.5" From f28924f704c99fe210e6110b6d7d826ded61d54f Mon Sep 17 00:00:00 2001 From: CranberrySoup <142951702+CranberrySoup@users.noreply.github.com> Date: Wed, 8 Apr 2026 23:17:21 +0000 Subject: [PATCH 100/236] Fix intent launches (#2554) --- app/src/main/AndroidManifest.xml | 38 ++++++++++--------- .../ui/account/AccountSelectActivity.kt | 18 ++++++++- .../cloudstream3/ui/search/SearchFragment.kt | 13 +++++-- .../lagradost/cloudstream3/utils/UIHelper.kt | 6 ++- 4 files changed, 50 insertions(+), 25 deletions(-) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 56622aab9..b2c7091b0 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -108,14 +108,31 @@ android:launchMode="singleTask" is a bit experimental, it makes loading repositories from browser still stay on the same page no idea about side effects + + Not exported to prevent bypassing the AccountSelectActivity --> + android:supportsPictureInPicture="true" /> + + + + + + + + + + + + @@ -173,7 +190,7 @@ - + @@ -186,21 +203,6 @@ - - - - - - - - - - - - ( binding.searchFilter.isFocusable = true binding.searchFilter.isFocusableInTouchMode = true } - + // Hide suggestions when search view loses focus (phone only) if (isLayout(PHONE)) { binding.mainSearch.setOnQueryTextFocusChangeListener { _, hasFocus -> @@ -572,7 +573,7 @@ class SearchFragment : BaseFragment( removeKey("$currentAccount/$SEARCH_HISTORY_KEY", searchItem.key) searchViewModel.updateHistory() } - + SEARCH_HISTORY_CLEAR -> { // Show confirmation dialog (from footer button) activity?.let { ctx -> @@ -653,7 +654,11 @@ class SearchFragment : BaseFragment( sq?.let { query -> if (query.isBlank()) return@let - mainSearch.setQuery(query, true) + + // Queries are dropped if you are submitted before layout finishes + mainSearch.doOnLayout { + mainSearch.setQuery(query, true) + } // Clear the query as to not make it request the same query every time the page is opened arguments?.remove(SEARCH_QUERY) savedInstanceState?.remove(SEARCH_QUERY) @@ -674,7 +679,7 @@ class SearchFragment : BaseFragment( val hasSuggestions = suggestions.isNotEmpty() binding.searchSuggestionsRecycler.isVisible = hasSuggestions (binding.searchSuggestionsRecycler.adapter as? SearchSuggestionAdapter?)?.submitList(suggestions) - + // On non-phone layouts, redirect focus and handle back button if (!isLayout(PHONE)) { if (hasSuggestions) { diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/UIHelper.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/UIHelper.kt index ebd7b2988..c12674816 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/UIHelper.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/UIHelper.kt @@ -259,10 +259,12 @@ object UIHelper { } // Open activities from an activity outside the nav graph - fun Context.openActivity(activity: Class<*>, args: Bundle? = null) { + fun Context.openActivity(activity: Class<*>, args: Bundle? = null, baseIntent: Intent? = null) { val tag = "NavComponent" try { - val intent = Intent(this, activity) + val intent = baseIntent ?: Intent() + intent.setClass(this, activity) + if (args != null) { intent.putExtras(args) } From f6920fb05d2de026a43c9f28463b46849fe294c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Onur=20Civano=C4=9Flu?= Date: Thu, 9 Apr 2026 02:22:57 +0300 Subject: [PATCH 101/236] feat: Force landscape orientation and pillarbox portrait videos on TV and emulator devices. (#2560) --- .../lagradost/cloudstream3/ui/player/FullScreenPlayer.kt | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/FullScreenPlayer.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/FullScreenPlayer.kt index eb5ac8f36..4bec57f9c 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/FullScreenPlayer.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/FullScreenPlayer.kt @@ -2707,6 +2707,11 @@ open class FullScreenPlayer : AbstractPlayerFragment() { } override fun playerDimensionsLoaded(width: Int, height: Int) { + // On TV, don't rotate for portrait videos; display with pillarbox (black bars on sides) + if (isLayout(TV or EMULATOR)) { + isVerticalOrientation = false + return + } isVerticalOrientation = height > width updateOrientation() } @@ -2730,6 +2735,10 @@ open class FullScreenPlayer : AbstractPlayerFragment() { } private fun dynamicOrientation(): Int { + // TV should always remain in landscape mode + if (isLayout(TV or EMULATOR)) { + return ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE + } return if (autoPlayerRotateEnabled) { if (isVerticalOrientation) { ActivityInfo.SCREEN_ORIENTATION_SENSOR_PORTRAIT From e22a596d0c8f203c456c3a2ac1a4806c933239f4 Mon Sep 17 00:00:00 2001 From: Hosted Weblate Date: Thu, 9 Apr 2026 09:09:56 +0200 Subject: [PATCH 102/236] Translated using Weblate (Arabic) Currently translated at 100.0% (726 of 726 strings) Translated using Weblate (Albanian) Currently translated at 68.0% (494 of 726 strings) Co-authored-by: 007 Co-authored-by: Hosted Weblate Co-authored-by: hollow04 Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/ar/ Translate-URL: https://hosted.weblate.org/projects/cloudstream/app/sq/ Translation: Cloudstream/App --- app/src/main/res/values-b+ar/strings.xml | 1 + app/src/main/res/values-sq/strings.xml | 80 ++++++++++++++++++++++++ 2 files changed, 81 insertions(+) diff --git a/app/src/main/res/values-b+ar/strings.xml b/app/src/main/res/values-b+ar/strings.xml index 91f8f0e64..17e809d8d 100644 --- a/app/src/main/res/values-b+ar/strings.xml +++ b/app/src/main/res/values-b+ar/strings.xml @@ -780,4 +780,5 @@ %d تنزيل قيد الانتظار %d تنزيل قيد الانتظار + عرض واجهة منبثقة للبيانات الوصفية للمشغِّل diff --git a/app/src/main/res/values-sq/strings.xml b/app/src/main/res/values-sq/strings.xml index 1aa5f0a92..977fd597a 100644 --- a/app/src/main/res/values-sq/strings.xml +++ b/app/src/main/res/values-sq/strings.xml @@ -414,4 +414,84 @@ Asnjë Normal Të gjitha + Maksimumi + Minimumi + Kontur + Të zhytura + Hije + Të ngritura + Sinkronizo titrat + 1000 ms + Vonesa e titrave + Përdore nëse titrat shfaqen %d ms më herët + Përdore nëse titrat shfaqen %d ms më vonë + Pa vonesë titrash + Dhelpra e shpejtë ngjyra kafe kërcen mbi qenin dembel + E rekomanduar + U ngarkua %s + Ngarko nga skedari + Ngarko nga interneti + Ngarko të parën e disponueshme + Skedar i shkarkuar + Kryesor + Mbështetës + Figurant + Burimi + Rastësor + Së shpejti… + Kamera + Kamera + Kamera + HQ + HD + TS + TC + Blu-ray + WP + DVD + 4K + SD + UHD + HDR + SDR + Web + Imazhi i posterit + Imazhi i kodit QR + Luajtësi + Rezolucioni dhe titulli + Titulli + Rezolucioni + Informacion i medias + ID e pavlefshme + Të dhëna të pavlefshme + URL e pavlefshme + Gabim + Hiq titrat e mbyllura + Hiq të tepërtat nga titrat + Filtro sipas gjuhës së preferuar të medias + Ekstra + Traileri + https://shembull.com/shembull.mp4 + Referues (opsional) + Tjetër + Shiko videot në këto gjuhë + E mëparshme + Kalo konfigurimin + Ndrysho pamjen e aplikacionit për tu përshtatur me pajisjen tuaj + Çfarë dëshironi të shihni + U krye + Shtesat + Shto repository + Emri i repository-t (Opsional) + URL-ja ose kodi i shkurtër i repository-t + Shtesa u ngarkua + Shtesa u shkarkua + Shtesa u fshi + Nuk mund të ngarkohej %s + 18+ + Filloi shkarkimi i %1$d %2$s… + U shkarkuan %1$d %2$s + Të gjitha %s janë shkarkuar tashmë + Asnjë shtesë nuk u gjet në repository + Repository nuk u gjet, verifiko URL-në ose provoje me VPN From 04b22ba4dfe5cadc02534a3768868dd2fdaf2fd6 Mon Sep 17 00:00:00 2001 From: firelight <147925818+fire-light42@users.noreply.github.com> Date: Thu, 9 Apr 2026 10:11:15 +0000 Subject: [PATCH 103/236] Small backup fix --- .../java/com/lagradost/cloudstream3/utils/BackupUtils.kt | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/BackupUtils.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/BackupUtils.kt index 29410ab4d..88cb7481c 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/BackupUtils.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/BackupUtils.kt @@ -62,6 +62,7 @@ object BackupUtils { AccountManager.ACCOUNT_TOKEN, AccountManager.ACCOUNT_IDS, + // TODO proper getter for string res keys to ensure that they are updated "biometric_key", // can lock down users if backup is shared on a incompatible device "nginx_user", // Nginx user key @@ -103,7 +104,10 @@ object BackupUtils { // Prevent backups from automatically starting downloads KEY_RESUME_IN_QUEUE, KEY_RESUME_PACKAGES, - QUEUE_KEY + QUEUE_KEY, + + // Prevent automatic plugin download after restoring backup + "auto_download_plugins_key2" ) /** false if key should not be contained in backup */ From 8bdc1a83d706713317a17f0e5d01ca3f3186a8eb Mon Sep 17 00:00:00 2001 From: Luna712 <142361265+Luna712@users.noreply.github.com> Date: Thu, 9 Apr 2026 04:12:53 -0600 Subject: [PATCH 104/236] Use InternalAPI rather than permanent deprecations in PluginManager (#2615) --- .../lagradost/cloudstream3/MainActivity.kt | 2 - .../cloudstream3/plugins/PluginManager.kt | 49 +++++-------------- .../services/SubscriptionWorkManager.kt | 2 +- .../ui/settings/SettingsUpdates.kt | 1 - 4 files changed, 14 insertions(+), 40 deletions(-) diff --git a/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt b/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt index a7c0a8a27..709e92a41 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt @@ -274,7 +274,6 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa * @return true if the str has launched an app task (be it successful or not) * @param isWebview does not handle providers and opening download page if true. Can still add repos and login. * */ - @Suppress("DEPRECATION_ERROR") fun handleAppIntentUrl( activity: FragmentActivity?, str: String?, @@ -1169,7 +1168,6 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa } } - @Suppress("DEPRECATION_ERROR") override fun onCreate(savedInstanceState: Bundle?) { app.initClient(this) val settingsManager = PreferenceManager.getDefaultSharedPreferences(this) diff --git a/app/src/main/java/com/lagradost/cloudstream3/plugins/PluginManager.kt b/app/src/main/java/com/lagradost/cloudstream3/plugins/PluginManager.kt index ba3357102..feb0ba6d4 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/plugins/PluginManager.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/plugins/PluginManager.kt @@ -26,6 +26,7 @@ import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey import com.lagradost.cloudstream3.CloudStreamApp.Companion.removeKey import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey import com.lagradost.cloudstream3.CommonActivity.showToast +import com.lagradost.cloudstream3.InternalAPI import com.lagradost.cloudstream3.MainAPI import com.lagradost.cloudstream3.MainAPI.Companion.settingsForProvider import com.lagradost.cloudstream3.MainActivity.Companion.afterPluginsLoadedEvent @@ -259,12 +260,8 @@ object PluginManager { * DO NOT USE THIS IN A PLUGIN! It may case an infinite recursive loop lagging or crashing everyone's devices. * If you use it from a plugin, do not expect a stable jvmName, SO DO NOT USE IT! */ - @Suppress("FunctionName", "DEPRECATION_ERROR") - @Deprecated( - "Calling this function from a plugin will lead to crashes, use loadPlugin and unloadPlugin", - replaceWith = ReplaceWith("loadPlugin"), - level = DeprecationLevel.ERROR - ) + @Suppress("FunctionName") + @InternalAPI @Throws suspend fun ___DO_NOT_CALL_FROM_A_PLUGIN_updateAllOnlinePluginsAndLoadThem(activity: Activity) { assertNonRecursiveCallstack() @@ -340,12 +337,8 @@ object PluginManager { * DO NOT USE THIS IN A PLUGIN! It may case an infinite recursive loop lagging or crashing everyone's devices. * If you use it from a plugin, do not expect a stable jvmName, SO DO NOT USE IT! */ - @Suppress("FunctionName", "DEPRECATION_ERROR") - @Deprecated( - "Calling this function from a plugin will lead to crashes, use loadPlugin and unloadPlugin", - replaceWith = ReplaceWith("loadPlugin"), - level = DeprecationLevel.ERROR - ) + @Suppress("FunctionName") + @InternalAPI @Throws suspend fun ___DO_NOT_CALL_FROM_A_PLUGIN_downloadNotExistingPluginsAndLoad( activity: Activity, @@ -454,12 +447,8 @@ object PluginManager { * DO NOT USE THIS IN A PLUGIN! It may case an infinite recursive loop lagging or crashing everyone's devices. * If you use it from a plugin, do not expect a stable jvmName, SO DO NOT USE IT! */ - @Suppress("FunctionName", "DEPRECATION_ERROR") - @Deprecated( - "Calling this function from a plugin will lead to crashes, use loadPlugin and unloadPlugin", - replaceWith = ReplaceWith("loadPlugin"), - level = DeprecationLevel.ERROR - ) + @Suppress("FunctionName") + @InternalAPI @Throws suspend fun ___DO_NOT_CALL_FROM_A_PLUGIN_loadAllOnlinePlugins(context: Context) { assertNonRecursiveCallstack() @@ -480,13 +469,9 @@ object PluginManager { * DO NOT USE THIS IN A PLUGIN! It may case an infinite recursive loop lagging or crashing everyone's devices. * If you use it from a plugin, do not expect a stable jvmName, SO DO NOT USE IT! */ - @Suppress("FunctionName", "DEPRECATION_ERROR") + @Suppress("FunctionName") + @InternalAPI @Throws - @Deprecated( - "Calling this function from a plugin will lead to crashes, use loadPlugin and unloadPlugin", - replaceWith = ReplaceWith("loadPlugin"), - level = DeprecationLevel.ERROR - ) suspend fun ___DO_NOT_CALL_FROM_A_PLUGIN_hotReloadAllLocalPlugins(activity: FragmentActivity?) { assertNonRecursiveCallstack() @@ -505,12 +490,8 @@ object PluginManager { * DO NOT USE THIS IN A PLUGIN! It may case an infinite recursive loop lagging or crashing everyone's devices. * If you use it from a plugin, do not expect a stable jvmName, SO DO NOT USE IT! */ - @Suppress("FunctionName", "DEPRECATION_ERROR") - @Deprecated( - "Calling this function from a plugin will lead to crashes, use loadPlugin and unloadPlugin", - replaceWith = ReplaceWith("loadPlugin"), - level = DeprecationLevel.ERROR - ) + @Suppress("FunctionName") + @InternalAPI @Throws suspend fun ___DO_NOT_CALL_FROM_A_PLUGIN_loadAllLocalPlugins(context: Context, forceReload: Boolean) { assertNonRecursiveCallstack() @@ -814,13 +795,9 @@ object PluginManager { * DO NOT USE THIS IN A PLUGIN! It may case an infinite recursive loop lagging or crashing everyone's devices. * If you use it from a plugin, do not expect a stable jvmName, SO DO NOT USE IT! */ - @Suppress("FunctionName", "DEPRECATION_ERROR") + @Suppress("FunctionName") + @InternalAPI @Throws - @Deprecated( - "Calling this function from a plugin will lead to crashes, use loadPlugin and unloadPlugin", - replaceWith = ReplaceWith("loadPlugin"), - level = DeprecationLevel.ERROR - ) suspend fun ___DO_NOT_CALL_FROM_A_PLUGIN_manuallyReloadAndUpdatePlugins(activity: Activity) { assertNonRecursiveCallstack() diff --git a/app/src/main/java/com/lagradost/cloudstream3/services/SubscriptionWorkManager.kt b/app/src/main/java/com/lagradost/cloudstream3/services/SubscriptionWorkManager.kt index 242f08129..7134650ed 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/services/SubscriptionWorkManager.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/services/SubscriptionWorkManager.kt @@ -97,7 +97,7 @@ class SubscriptionWorkManager(val context: Context, workerParams: WorkerParamete .build() ) } - @Suppress("DEPRECATION_ERROR") + override suspend fun doWork(): Result { try { // println("Update subscriptions!") diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsUpdates.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsUpdates.kt index 2b74eab4c..118d89ac4 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsUpdates.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsUpdates.kt @@ -67,7 +67,6 @@ class SettingsUpdates : BasePreferenceFragmentCompat() { } } - @Suppress("DEPRECATION_ERROR") override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { hideKeyboard() setPreferencesFromResource(R.xml.settings_updates, rootKey) From ca96aa68916891caf4ba0324bebd52a48079f47d Mon Sep 17 00:00:00 2001 From: Luna712 <142361265+Luna712@users.noreply.github.com> Date: Thu, 9 Apr 2026 04:16:24 -0600 Subject: [PATCH 105/236] Bump actions (#2588) * Keep gradle/actions/setup-gradle@v5 for now --- .github/workflows/generate_dokka.yml | 2 +- .github/workflows/pull_request.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/generate_dokka.yml b/.github/workflows/generate_dokka.yml index e3dac3857..91f03a434 100644 --- a/.github/workflows/generate_dokka.yml +++ b/.github/workflows/generate_dokka.yml @@ -52,7 +52,7 @@ jobs: cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} - name: Set up Android SDK - uses: android-actions/setup-android@v3 + uses: android-actions/setup-android@v4 - name: Generate Dokka run: | diff --git a/.github/workflows/pull_request.yml b/.github/workflows/pull_request.yml index a9d480c02..a5a7d56e3 100644 --- a/.github/workflows/pull_request.yml +++ b/.github/workflows/pull_request.yml @@ -27,7 +27,7 @@ jobs: run: ./gradlew assemblePrereleaseDebug lint - name: Upload Artifact - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@v7 with: name: pull-request-build path: "app/build/outputs/apk/prerelease/debug/*.apk" From c9a24e198c71202a1d738c5f8d7957d94cc16260 Mon Sep 17 00:00:00 2001 From: Luna712 <142361265+Luna712@users.noreply.github.com> Date: Thu, 9 Apr 2026 04:32:41 -0600 Subject: [PATCH 106/236] Add true configuration cache support for git commit hash (#2285) Co-authored-by: firelight <147925818+fire-light42@users.noreply.github.com> --- app/build.gradle.kts | 78 +++++++++++++------ .../ui/settings/SettingsFragment.kt | 6 +- .../lagradost/cloudstream3/utils/GitInfo.kt | 20 +++++ .../cloudstream3/utils/InAppUpdater.kt | 3 +- app/src/main/res/layout/main_settings.xml | 5 +- 5 files changed, 83 insertions(+), 29 deletions(-) create mode 100644 app/src/main/java/com/lagradost/cloudstream3/utils/GitInfo.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index c1e0e1960..0b201d1cb 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -13,26 +13,52 @@ plugins { val javaTarget = JvmTarget.fromTarget(libs.versions.jvmTarget.get()) -fun getGitCommitHash(): String { - return try { - val headFile = file("${project.rootDir}/.git/HEAD") +abstract class GenerateGitHashTask : DefaultTask() { - // Read the commit hash from .git/HEAD - if (headFile.exists()) { - val headContent = headFile.readText().trim() - if (headContent.startsWith("ref:")) { - val refPath = headContent.substring(5) // e.g., refs/heads/main - val commitFile = file("${project.rootDir}/.git/$refPath") - if (commitFile.exists()) commitFile.readText().trim() else "" - } else headContent // If it's a detached HEAD (commit hash directly) - } else { - "" // If .git/HEAD doesn't exist - }.take(7) // Return the short commit hash - } catch (_: Throwable) { - "" // Just return an empty string if any exception occurs + @get:InputFile + @get:PathSensitive(PathSensitivity.RELATIVE) + abstract val headFile: RegularFileProperty + + @get:InputDirectory + @get:PathSensitive(PathSensitivity.RELATIVE) + abstract val headsDir: DirectoryProperty + + @get:OutputDirectory + abstract val outputDir: DirectoryProperty + + @TaskAction + fun generate() { + val head = headFile.get().asFile + + val hash = try { + if (head.exists()) { + // Read the commit hash from .git/HEAD + val headContent = head.readText().trim() + if (headContent.startsWith("ref:")) { + val refPath = headContent.substring(5) // e.g., refs/heads/main + val commitFile = File(head.parentFile, refPath) + if (commitFile.exists()) commitFile.readText().trim() else "" + } else headContent // If it's a detached HEAD (commit hash directly) + } else "" // If .git/HEAD doesn't exist + } catch (_: Throwable) { + "" // Just set to an empty string if any exception occurs + }.take(7) // Get the short commit hash + + val outFile = outputDir.file("git-hash.txt").get().asFile + outFile.parentFile.mkdirs() + outFile.writeText(hash) } } +val generateGitHash = tasks.register("generateGitHash") { + val gitDir = layout.projectDirectory.dir("../.git") + + headFile.set(gitDir.file("HEAD")) + headsDir.set(gitDir.dir("refs/heads")) + + outputDir.set(layout.buildDirectory.dir("generated/git")) +} + android { @Suppress("UnstableApiUsage") testOptions { @@ -47,6 +73,15 @@ android { includeInBundle = false } + androidComponents { + onVariants { variant -> + variant.sources.assets?.addGeneratedSourceDirectory( + generateGitHash, + GenerateGitHashTask::outputDir + ) + } + } + signingConfigs { // We just use SIGNING_KEY_ALIAS here since it won't change // so won't kill the configuration cache. @@ -72,8 +107,6 @@ android { versionCode = 68 versionName = "4.7.0" - resValue("string", "commit_hash", getGitCommitHash()) - manifestPlaceholders["target_sdk_version"] = libs.versions.targetSdk.get() // Reads local.properties @@ -142,11 +175,11 @@ android { } java { - // Use Java 17 toolchain even if a higher JDK runs the build. + // Use Java 17 toolchain even if a higher JDK runs the build. // We still use Java 8 for now which higher JDKs have deprecated. - toolchain { - languageVersion.set(JavaLanguageVersion.of(libs.versions.jdkToolchain.get())) - } + toolchain { + languageVersion.set(JavaLanguageVersion.of(libs.versions.jdkToolchain.get())) + } } lint { @@ -156,7 +189,6 @@ android { buildFeatures { buildConfig = true - resValues = true viewBinding = true } diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsFragment.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsFragment.kt index 097eb2c60..e41109b59 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsFragment.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsFragment.kt @@ -27,6 +27,7 @@ import com.lagradost.cloudstream3.ui.settings.Globals.TV import com.lagradost.cloudstream3.ui.settings.Globals.isLandscape import com.lagradost.cloudstream3.ui.settings.Globals.isLayout import com.lagradost.cloudstream3.utils.DataStoreHelper +import com.lagradost.cloudstream3.utils.GitInfo.currentCommitHash import com.lagradost.cloudstream3.utils.ImageLoader.loadImage import com.lagradost.cloudstream3.utils.UIHelper.clipboardHelper import com.lagradost.cloudstream3.utils.UIHelper.fixSystemBarsPadding @@ -247,7 +248,7 @@ class SettingsFragment : BaseFragment( } val appVersion = BuildConfig.VERSION_NAME - val commitInfo = getString(R.string.commit_hash) + val commitHash = activity?.currentCommitHash() ?: "" val buildTimestamp = SimpleDateFormat.getDateTimeInstance(DateFormat.LONG, DateFormat.LONG, Locale.getDefault() ).apply { timeZone = TimeZone.getTimeZone("UTC") @@ -255,8 +256,9 @@ class SettingsFragment : BaseFragment( binding.appVersion.text = appVersion binding.buildDate.text = buildTimestamp + binding.commitHash.text = commitHash binding.appVersionInfo.setOnLongClickListener { - clipboardHelper(txt(R.string.extension_version), "$appVersion $commitInfo $buildTimestamp") + clipboardHelper(txt(R.string.extension_version), "$appVersion $commitHash $buildTimestamp") true } } diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/GitInfo.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/GitInfo.kt new file mode 100644 index 000000000..58ff44bb2 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/GitInfo.kt @@ -0,0 +1,20 @@ +package com.lagradost.cloudstream3.utils + +import android.content.Context + +/** + * Simple helper to get the short commit hash from assets. + * The hash is generated at build and stored as an asset + * that can be accessed at runtime for Gradle + * configuration cache support. + */ +object GitInfo { + fun Context.currentCommitHash(): String = try { + assets.open("git-hash.txt") + .bufferedReader() + .readText() + .trim() + } catch (_: Exception) { + "" + } +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/InAppUpdater.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/InAppUpdater.kt index 057923eb0..9380285ca 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/InAppUpdater.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/InAppUpdater.kt @@ -24,6 +24,7 @@ import com.lagradost.cloudstream3.services.PackageInstallerService import com.lagradost.cloudstream3.utils.AppContextUtils.setDefaultFocus import com.lagradost.cloudstream3.utils.AppUtils.parseJson import com.lagradost.cloudstream3.utils.Coroutines.ioSafe +import com.lagradost.cloudstream3.utils.GitInfo.currentCommitHash import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import okio.BufferedSink @@ -170,7 +171,7 @@ object InAppUpdater { Log.d(LOG_TAG, "Fetched GitHub tag: $updateCommitHash") return Update( - getString(R.string.commit_hash) != updateCommitHash, + currentCommitHash() != updateCommitHash, foundAsset.browserDownloadUrl, updateCommitHash, found.body, diff --git a/app/src/main/res/layout/main_settings.xml b/app/src/main/res/layout/main_settings.xml index ba3774554..5c05599e8 100644 --- a/app/src/main/res/layout/main_settings.xml +++ b/app/src/main/res/layout/main_settings.xml @@ -134,8 +134,8 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:padding="10dp" - android:text="@string/commit_hash" - android:textColor="?attr/textColor" /> + android:textColor="?attr/textColor" + tools:text="1234567" /> - \ No newline at end of file From bb4e5da5c9c3433968fb91961e7db13f52d3bc7a Mon Sep 17 00:00:00 2001 From: Luna712 <142361265+Luna712@users.noreply.github.com> Date: Thu, 9 Apr 2026 09:07:10 -0600 Subject: [PATCH 107/236] Bump androidx libraries (#2607) --- gradle/libs.versions.toml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 3b060a53c..576ccd782 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,16 +1,16 @@ # https://docs.gradle.org/current/userguide/plugins.html#sec:version_catalog_plugin_application # https://docs.gradle.org/current/userguide/dependency_versions.html#sec:strict-version [versions] -activityKtx = "1.12.4" +activityKtx = "1.13.0" androidGradlePlugin = "8.13.2" appcompat = "1.7.1" -biometric = "1.4.0-alpha05" +biometric = "1.4.0-alpha06" buildkonfigGradlePlugin = "0.17.1" coil = "3.3.0" colorpicker = "6b46b49" conscryptAndroid = { strictly = "2.5.2" } # 2.5.3 crashes everything constraintlayout = "2.2.1" -coreKtx = "1.17.0" +coreKtx = "1.18.0" desugar_jdk_libs_nio = "2.1.5" dokkaGradlePlugin = "2.1.0" espressoCore = "3.7.0" @@ -44,7 +44,7 @@ tmdbJava = "2.13.0" torrentserver = "7861970" tvprovider = "1.1.0" video = "1.0.0" -workRuntimeKtx = "2.11.1" +workRuntimeKtx = "2.11.2" zipline = "1.24.0" jvmTarget = "1.8" From fe0829ff64fa52dba63d02d5462a900ecc719d4e Mon Sep 17 00:00:00 2001 From: Luna712 <142361265+Luna712@users.noreply.github.com> Date: Thu, 9 Apr 2026 09:11:17 -0600 Subject: [PATCH 108/236] Bump material (#2609) --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 576ccd782..c1048028b 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -26,7 +26,7 @@ juniversalchardet = "2.5.0" kotlinGradlePlugin = "2.3.0" kotlinxCoroutinesCore = "1.10.2" lifecycleKtx = "2.10.0" -material = "1.14.0-alpha09" +material = "1.14.0-alpha10" media3 = "1.9.2" navigationKtx = "2.9.7" newpipeextractor = "v0.26.0" From d7b030e7ef446155619eebe3ad5d820a779fcbb1 Mon Sep 17 00:00:00 2001 From: Luna712 <142361265+Luna712@users.noreply.github.com> Date: Thu, 9 Apr 2026 09:19:57 -0600 Subject: [PATCH 109/236] Update gradle to 9.4.1 (#2610) --- gradle/wrapper/gradle-wrapper.jar | Bin 46175 -> 48966 bytes gradle/wrapper/gradle-wrapper.properties | 4 ++-- gradlew | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index 61285a659d17295f1de7c53e24fdf13ad755c379..d997cfc60f4cff0e7451d19d49a82fa986695d07 100644 GIT binary patch delta 39855 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

SdO`o?U^bCPz6Ehh!k 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


Xdyd*p!l1Y@nCA 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 diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 0e68c05b4..17cc7e431 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,8 +1,8 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists # Binary-only ZIP Checksum: https://gradle.org/release-checksums/ -distributionSha256Sum=b266d5ff6b90eada6dc3b20cb090e3731302e553a27c5d3e4df1f0d76beaff06 -distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-bin.zip +distributionSha256Sum=2ab2958f2a1e51120c326cad6f385153bb11ee93b3c216c5fccebfdfbb7ec6cb +distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.1-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/gradlew b/gradlew index adff685a0..739907dfd 100755 --- a/gradlew +++ b/gradlew @@ -57,7 +57,7 @@ # Darwin, MinGW, and NonStop. # # (3) This script is generated from the Groovy template -# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# https://github.com/gradle/gradle/blob/2d6327017519d23b96af35865dc997fcb544fb40/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt # within the Gradle project. # # You can find Gradle at https://github.com/gradle/gradle/. From a7f5f9a35a12f0ae3c53c2327c8b0ecc0bbbc75c Mon Sep 17 00:00:00 2001 From: firelight <147925818+fire-light42@users.noreply.github.com> Date: Thu, 9 Apr 2026 19:58:50 +0000 Subject: [PATCH 110/236] Feat: TheIntroDBSkip + Bugfix (#2631) --- .../cloudstream3/utils/videoskip/SkipAPI.kt | 5 +- .../utils/videoskip/TheIntroDBSkip.kt | 76 +++++++++++++++++++ app/src/main/res/values/strings.xml | 1 + 3 files changed, 80 insertions(+), 2 deletions(-) create mode 100644 app/src/main/java/com/lagradost/cloudstream3/utils/videoskip/TheIntroDBSkip.kt diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/videoskip/SkipAPI.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/videoskip/SkipAPI.kt index df16d77ca..cd6727a24 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/videoskip/SkipAPI.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/videoskip/SkipAPI.kt @@ -17,7 +17,8 @@ enum class SkipType(@StringRes val res: Int) { MixedOpening(R.string.skip_type_mixed_op), MixedEnding(R.string.skip_type_mixed_ed), Credits(R.string.skip_type_creddits), - Intro(R.string.skip_type_creddits), + Intro(R.string.skip_type_intro), + Preview(R.string.skip_type_preview), } data class SkipStamp( @@ -60,7 +61,7 @@ abstract class SkipAPI { } companion object { - private val skipApis: List = listOf(AniSkip(), IntroDbSkip()) + private val skipApis: List = listOf(AniSkip(), TheIntroDBSkip(), IntroDbSkip()) private val cachedStamps = ConcurrentHashMap>() /** Get all video timestamps from an episode */ diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/videoskip/TheIntroDBSkip.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/videoskip/TheIntroDBSkip.kt new file mode 100644 index 000000000..cc2661cb0 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/videoskip/TheIntroDBSkip.kt @@ -0,0 +1,76 @@ +package com.lagradost.cloudstream3.utils.videoskip + +import com.fasterxml.jackson.annotation.JsonProperty +import com.lagradost.cloudstream3.LoadResponse +import com.lagradost.cloudstream3.LoadResponse.Companion.getImdbId +import com.lagradost.cloudstream3.LoadResponse.Companion.getTMDbId +import com.lagradost.cloudstream3.LoadResponse.Companion.isMovie +import com.lagradost.cloudstream3.TvType +import com.lagradost.cloudstream3.ui.result.ResultEpisode +import com.lagradost.cloudstream3.app + +/** https://theintrodb.org/docs */ +class TheIntroDBSkip : SkipAPI() { + override val name = "TheIntroDB" + override val supportedTypes = setOf( + TvType.TvSeries, TvType.Cartoon, TvType.Anime, TvType.Movie, + TvType.AsianDrama + ) + + val mainUrl = "https://api.theintrodb.org" + + override suspend fun stamps( + data: LoadResponse, + episode: ResultEpisode, + episodeDurationMs: Long + ): List? { + val idSuffix = + data.getTMDbId()?.let { tmdbId -> "tmdb_id=$tmdbId" } + ?: data.getImdbId()?.let { imdbId -> "imdb_id=$imdbId" } + ?: return null + + val url = if (data.isMovie()) { + "$mainUrl/v2/media?$idSuffix" + } else { + val season = episode.season ?: return null + "$mainUrl/v2/media?$idSuffix&season=$season&episode=${episode.episode}" + } + val root = app.get(url).parsed() + return arrayOf( + root.intro to SkipType.Intro, + root.credits to SkipType.Credits, + root.recap to SkipType.Recap, + root.preview to SkipType.Preview + ).map { (list, type) -> + list.map { stamp -> + SkipStamp( + type, + stamp.startMs ?: 0L, + stamp.endMs ?: episodeDurationMs + ) + } + }.flatten() + } + + data class Root( + @JsonProperty("tmdb_id") + val tmdbId: Long, + @JsonProperty("type") + val type: String, + @JsonProperty("intro") + val intro: List = emptyList(), + @JsonProperty("recap") + val recap: List = emptyList(), + @JsonProperty("credits") + val credits: List = emptyList(), + @JsonProperty("preview") + val preview: List = emptyList(), + ) + + data class Stamp( + @JsonProperty("start_ms") + val startMs: Long?, + @JsonProperty("end_ms") + val endMs: Long?, + ) +} \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index e9dd2748f..b7956e9d8 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -560,6 +560,7 @@ Mixed ending Mixed opening Credits + Preview Intro Clear history History From c304e8556e073cd19cf3b89232cbcf12ff4dad14 Mon Sep 17 00:00:00 2001 From: Phisher98 <153359846+phisher98@users.noreply.github.com> Date: Fri, 10 Apr 2026 01:29:31 +0530 Subject: [PATCH 111/236] Minor Fix IntroDbSkip (#2634) --- .../com/lagradost/cloudstream3/utils/videoskip/IntroDbSkip.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/videoskip/IntroDbSkip.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/videoskip/IntroDbSkip.kt index ce284f3fe..869515f43 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/videoskip/IntroDbSkip.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/videoskip/IntroDbSkip.kt @@ -47,7 +47,7 @@ class IntroDbSkip : SkipAPI() { val start = it.startMs ?: return@let null val end = it.endMs ?: return@let null SkipStamp( - type = SkipType.Credits, + type = SkipType.Ending, startMs = start, endMs = end ) From 0f1cb3a7738441ace6bc35004fc756b56ab8ba24 Mon Sep 17 00:00:00 2001 From: Luna712 <142361265+Luna712@users.noreply.github.com> Date: Thu, 9 Apr 2026 13:59:53 -0600 Subject: [PATCH 112/236] Add strictly for coil lib (#2635) --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index c1048028b..435cda8a7 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -6,7 +6,7 @@ androidGradlePlugin = "8.13.2" appcompat = "1.7.1" biometric = "1.4.0-alpha06" buildkonfigGradlePlugin = "0.17.1" -coil = "3.3.0" +coil = { strictly = "3.3.0" } # Later versions require jvmTarget 11 or later colorpicker = "6b46b49" conscryptAndroid = { strictly = "2.5.2" } # 2.5.3 crashes everything constraintlayout = "2.2.1" From b89f36c9bc04d4a07e0c2ff437d256a897be31f9 Mon Sep 17 00:00:00 2001 From: Luna712 <142361265+Luna712@users.noreply.github.com> Date: Thu, 9 Apr 2026 14:01:20 -0600 Subject: [PATCH 113/236] Bump material to 1.14.0-beta01 (#2636) --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 435cda8a7..c92a937bd 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -26,7 +26,7 @@ juniversalchardet = "2.5.0" kotlinGradlePlugin = "2.3.0" kotlinxCoroutinesCore = "1.10.2" lifecycleKtx = "2.10.0" -material = "1.14.0-alpha10" +material = "1.14.0-beta01" media3 = "1.9.2" navigationKtx = "2.9.7" newpipeextractor = "v0.26.0" From adf2ed6df3f5f3275e0f91da882d3adfc815edb6 Mon Sep 17 00:00:00 2001 From: CranberrySoup <142951702+CranberrySoup@users.noreply.github.com> Date: Sun, 12 Apr 2026 20:36:31 +0000 Subject: [PATCH 114/236] Fix livestreams (#2627) --- .../cloudstream3/ui/player/CS3IPlayer.kt | 47 ++++++++- .../ui/player/FullScreenPlayer.kt | 17 +++- .../cloudstream3/ui/player/live/LiveHelper.kt | 77 +++++++++++++++ .../ui/player/live/LiveManager.kt | 97 +++++++++++++++++++ .../ui/player/live/LivePreviewTimeBar.kt | 38 ++++++++ .../main/res/layout/player_custom_layout.xml | 20 +++- .../res/layout/player_custom_layout_tv.xml | 22 ++++- .../main/res/layout/trailer_custom_layout.xml | 19 +++- app/src/main/res/values/strings.xml | 2 + 9 files changed, 333 insertions(+), 6 deletions(-) create mode 100644 app/src/main/java/com/lagradost/cloudstream3/ui/player/live/LiveHelper.kt create mode 100644 app/src/main/java/com/lagradost/cloudstream3/ui/player/live/LiveManager.kt create mode 100644 app/src/main/java/com/lagradost/cloudstream3/ui/player/live/LivePreviewTimeBar.kt diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/CS3IPlayer.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/CS3IPlayer.kt index 43b281a28..60c87532b 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/CS3IPlayer.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/CS3IPlayer.kt @@ -42,6 +42,7 @@ import androidx.media3.datasource.cronet.CronetDataSource import androidx.media3.datasource.okhttp.OkHttpDataSource import androidx.media3.exoplayer.DecoderCounters import androidx.media3.exoplayer.DecoderReuseEvaluation +import androidx.media3.exoplayer.DefaultLivePlaybackSpeedControl import androidx.media3.exoplayer.DefaultLoadControl import androidx.media3.exoplayer.DefaultRenderersFactory import androidx.media3.exoplayer.ExoPlayer @@ -54,6 +55,7 @@ import androidx.media3.exoplayer.drm.DefaultDrmSessionManager import androidx.media3.exoplayer.drm.FrameworkMediaDrm import androidx.media3.exoplayer.drm.HttpMediaDrmCallback import androidx.media3.exoplayer.drm.LocalMediaDrmCallback +import androidx.media3.exoplayer.hls.playlist.HlsPlaylistTracker import androidx.media3.exoplayer.source.ClippingMediaSource import androidx.media3.exoplayer.source.ConcatenatingMediaSource import androidx.media3.exoplayer.source.ConcatenatingMediaSource2 @@ -83,6 +85,8 @@ import com.lagradost.cloudstream3.mvvm.debugAssert import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.mvvm.safe import com.lagradost.cloudstream3.ui.player.CustomDecoder.Companion.fixSubtitleAlignment +import com.lagradost.cloudstream3.ui.player.live.LiveHelper +import com.lagradost.cloudstream3.ui.player.live.PREFERRED_LIVE_OFFSET import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR import com.lagradost.cloudstream3.ui.settings.Globals.PHONE import com.lagradost.cloudstream3.ui.settings.Globals.isLayout @@ -272,6 +276,10 @@ class CS3IPlayer : IPlayer { } override fun hasPreview(): Boolean { + // No previews on livestreams because the previews get outdated + if (exoPlayer?.isCurrentMediaItemDynamic == true) { + return false + } return imageGenerator.hasPreview() } @@ -399,7 +407,12 @@ class CS3IPlayer : IPlayer { ?.let { group -> exoPlayer?.trackSelectionParameters ?.buildUpon() - ?.setOverrideForType(TrackSelectionOverride(group.mediaTrackGroup, trackFormatIndex)) + ?.setOverrideForType( + TrackSelectionOverride( + group.mediaTrackGroup, + trackFormatIndex + ) + ) ?.build() } ?.let { newParams -> @@ -516,10 +529,12 @@ class CS3IPlayer : IPlayer { Log.i(TAG, "setPreferredSubtitles REQUIRES_RELOAD") return true } + SubtitleStatus.NOT_FOUND -> { Log.i(TAG, "setPreferredSubtitles NOT_FOUND") return true } + SubtitleStatus.IS_ACTIVE -> { Log.i(TAG, "setPreferredSubtitles IS_ACTIVE") exoPlayer?.currentTracks?.groups @@ -1067,6 +1082,17 @@ class CS3IPlayer : IPlayer { ): ExoPlayer { val exoPlayerBuilder = ExoPlayer.Builder(context) + .setMediaSourceFactory( + DefaultMediaSourceFactory(context).setLiveTargetOffsetMs( + PREFERRED_LIVE_OFFSET + ) + ) + .setLivePlaybackSpeedControl( + DefaultLivePlaybackSpeedControl.Builder() + .setFallbackMaxPlaybackSpeed(1.03f) + .setFallbackMinPlaybackSpeed(0.97f) + .build() + ) .setRenderersFactory { eventHandler, videoRendererEventListener, audioRendererEventListener, textRendererOutput, metadataRendererOutput -> val settingsManager = PreferenceManager.getDefaultSharedPreferences(context) val current = settingsManager.getInt( @@ -1398,6 +1424,8 @@ class CS3IPlayer : IPlayer { return } + LiveHelper.registerPlayer(exoPlayer) + exoPlayer?.addListener(object : Player.Listener { override fun onTracksChanged(tracks: Tracks) { safe { @@ -1506,6 +1534,23 @@ class CS3IPlayer : IPlayer { exoPlayer?.prepare() } + // PlaylistStuckException usually happens when the player position is ahead of the live window. + // Seek to the default location in that case + error.cause is HlsPlaylistTracker.PlaylistStuckException -> { + val position = exoPlayer?.currentPosition ?: exoPlayer?.duration ?: 0 + + // Seek to live head + val aheadOfLive = LiveHelper.getLiveManager(exoPlayer)?.getTimeAheadOfLive(position) ?: 0 + + if (aheadOfLive > 100) { + exoPlayer?.seekTo(position - aheadOfLive) + } else { + exoPlayer?.seekToDefaultPosition() + } + exoPlayer?.prepare() + } + + else -> { event(ErrorEvent(error)) } diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/FullScreenPlayer.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/FullScreenPlayer.kt index 4bec57f9c..8699202b9 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/FullScreenPlayer.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/FullScreenPlayer.kt @@ -631,7 +631,7 @@ open class FullScreenPlayer : AbstractPlayerFragment() { override fun subtitlesChanged() { val tracks = player.getVideoTracks() val isBuiltinSubtitles = tracks.currentTextTracks.all { track -> - track.sampleMimeType == MimeTypes.APPLICATION_MEDIA3_CUES + track.sampleMimeType == MimeTypes.APPLICATION_MEDIA3_CUES } // Subtitle offset is not possible on built-in media3 tracks playerBinding?.playerSubtitleOffsetBtt?.isGone = @@ -738,6 +738,7 @@ open class FullScreenPlayer : AbstractPlayerFragment() { activity?.window?.attributes = lp activity?.showSystemUI() } + private fun resetZoomToDefault() { if (zoomMatrix != null) resize(PlayerResize.Fit, false) } @@ -2648,6 +2649,8 @@ open class FullScreenPlayer : AbstractPlayerFragment() { } } + exoProgress.registerPlayerView(playerView) + exoProgress.setOnTouchListener { _, event -> // this makes the bar not disappear when sliding when (event.action) { @@ -2720,10 +2723,20 @@ open class FullScreenPlayer : AbstractPlayerFragment() { val duration = player.getDuration() val position = player.getPosition() + if (playerBinding?.exoProgress?.isAtLiveEdge() == true) { + // Hide using a parentView instead? + playerBinding?.timeLeft?.alpha = 0f + playerBinding?.exoDuration?.alpha = 0f + playerBinding?.timeLive?.isVisible = true + } else { + playerBinding?.timeLeft?.alpha = 1f + playerBinding?.exoDuration?.alpha = 1f + playerBinding?.timeLive?.isVisible = false + } + if (duration != null && duration > 1 && position != null) { val remainingTimeSeconds = (duration - position + 500) / 1000 val formattedTime = "-${DateUtils.formatElapsedTime(remainingTimeSeconds)}" - playerBinding?.timeLeft?.text = formattedTime } } diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/live/LiveHelper.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/live/LiveHelper.kt new file mode 100644 index 000000000..52cd4361b --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/live/LiveHelper.kt @@ -0,0 +1,77 @@ +package com.lagradost.cloudstream3.ui.player.live + +import androidx.annotation.OptIn +import androidx.media3.common.Player +import androidx.media3.common.Timeline +import androidx.media3.common.util.UnstableApi +import com.lagradost.cloudstream3.mvvm.debugWarning +import java.util.WeakHashMap + +object LiveHelper { + private val liveManagers = WeakHashMap>() + + @OptIn(UnstableApi::class) + fun registerPlayer(player: Player?) { + if (player == null) { + debugWarning { "LiveHelper registerPlayer called with null player!" } + return + } + + // Prevent duplicates + if (liveManagers.contains(player)) { + return + } + + val liveManager = LiveManager(player) + val listener = object : Player.Listener { + override fun onTimelineChanged(timeline: Timeline, reason: Int) { + val window = Timeline.Window() + timeline.getWindow(player.currentMediaItemIndex, window) + if (window.isDynamic) { + liveManager.submitLivestreamChunk(LivestreamChunk(window.durationMs)) + } + super.onTimelineChanged(timeline, reason) + } + + override fun onPositionDiscontinuity( + oldPosition: Player.PositionInfo, + newPosition: Player.PositionInfo, + reason: Int + ) { + super.onPositionDiscontinuity(oldPosition, newPosition, reason) + val timeAheadOfLive = liveManager.getTimeAheadOfLive(newPosition.positionMs) + + // Seek back to the optimal live spot + if (timeAheadOfLive > 100) { + player.seekTo(newPosition.positionMs - timeAheadOfLive) + } + } + } + + synchronized(liveManagers) { + player.addListener(listener) + liveManagers[player] = liveManager to listener + } + } + + fun unregisterPlayer(player: Player?) { + if (player == null) { + debugWarning { "LiveHelper unregisterPlayer called with null player!" } + return + } + + // Prevent duplicates + if (!liveManagers.contains(player)) { + return + } + + synchronized(liveManagers) { + liveManagers[player]?.let { (_, listener) -> + player.removeListener(listener) + } + liveManagers.remove(player) + } + } + + fun getLiveManager(player: Player?) = liveManagers[player]?.first +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/live/LiveManager.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/live/LiveManager.kt new file mode 100644 index 000000000..8d848d46a --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/live/LiveManager.kt @@ -0,0 +1,97 @@ +package com.lagradost.cloudstream3.ui.player.live + +import androidx.media3.common.C +import androidx.media3.common.Player +import java.lang.ref.WeakReference + +// How much margin from the live point is still considered "live" +const val LIVE_MARGIN = 6_000L + +// How many ms should we be behind the real live point? +// Too low, and we cannot pre-buffer +// Too high, and we are no longer live +const val PREFERRED_LIVE_OFFSET = 5_000L + +// An extra offset from the optimal calculated timestamp +// This is to account for chunk updates not always being the same size +const val CHUNK_VARIANCE = 3000L + +// A livestream chunk from the player, the time we get it and the duration can be used to calculate +// the expected live timestamp. +class LivestreamChunk( + durationMs: Long, val receiveTimeMs: Long = System.currentTimeMillis() +) { + // We want to be PREFERRED_LIVE_OFFSET ms after the latest update, but we cannot be ahead of the middle point. + // If we are ahead of the middle point we will reach the end before the new chunk is expected to be released. + val targetPosition = maxOf(0,minOf( + durationMs - PREFERRED_LIVE_OFFSET, + durationMs / 2 - CHUNK_VARIANCE + )) + + fun isPositionLive(position: Long): Boolean { + val currentTime = System.currentTimeMillis() + val livePosition = targetPosition + (currentTime - receiveTimeMs) + val withinLive = position + LIVE_MARGIN > livePosition - PREFERRED_LIVE_OFFSET + // println("Position: $position, livePosition: ${livePosition}, Behind live: ${livePosition-position}, within live: $withinLive") + return withinLive + } + + fun getTimeAheadOfLive(position: Long): Long { + val currentTime = System.currentTimeMillis() + val livePosition = targetPosition + (currentTime - receiveTimeMs) + // println("Ahead of live: ${position-livePosition}") + return position - livePosition + } +} + +// There are two types of livestreams we need to manage +// 1. A livestream with no history, a continually sliding window. +// This livestream has no currentLiveOffset, which means we need to calculate +// the real live point based on when we receive the latest update and the size of that update. +// 2. A livestream with history. +// This livestream has a currentLiveOffset and therefore requires no calculation to get the live point. +// currentLiveOffset can however be inaccurate, and we need to be able to fall back to manual calculations. +class LiveManager { + private var _currentPlayer: WeakReference? = null + val currentPlayer: Player? get() = _currentPlayer?.get() + + constructor(player: Player?) { + _currentPlayer = WeakReference(player) + } + + private var lastLivestreamChunk: LivestreamChunk? = null + + fun submitLivestreamChunk(chunk: LivestreamChunk) { + lastLivestreamChunk = chunk + } + + /** Returns how much a position is ahead of the calculated live window. Returns 0 if not ahead of live window */ + fun getTimeAheadOfLive(position: Long): Long { + val player = currentPlayer ?: return 0 + if (!player.isCurrentMediaItemDynamic || player.duration == C.TIME_UNSET) return 0 + + // If the currentLiveOffset is wrong we fall back to manual calculations + val ahead = if (player.currentLiveOffset != C.TIME_UNSET && player.currentLiveOffset < player.duration) { + val relativeOffset = player.currentLiveOffset - player.currentPosition + position + PREFERRED_LIVE_OFFSET - relativeOffset + } else { + lastLivestreamChunk?.getTimeAheadOfLive(position) ?: 0 + } + + // Ensure min of 0 + return maxOf(0, ahead) + } + + /** Check if the stream is currently at the expected live edge, with margins */ + fun isAtLiveEdge(): Boolean { + val player = currentPlayer ?: return false + if (!player.isCurrentMediaItemDynamic || player.duration == C.TIME_UNSET) return false + + // If the currentLiveOffset is wrong we fall back to manual calculations + return if (player.currentLiveOffset != C.TIME_UNSET && player.currentLiveOffset < player.duration) { + player.currentLiveOffset < LIVE_MARGIN + PREFERRED_LIVE_OFFSET + } else { + lastLivestreamChunk?.isPositionLive(player.currentPosition) == true + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/live/LivePreviewTimeBar.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/live/LivePreviewTimeBar.kt new file mode 100644 index 000000000..3001281fd --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/live/LivePreviewTimeBar.kt @@ -0,0 +1,38 @@ +package com.lagradost.cloudstream3.ui.player.live + +import android.content.Context +import android.util.AttributeSet +import androidx.annotation.OptIn +import androidx.media3.common.Player +import androidx.media3.common.util.UnstableApi +import androidx.media3.ui.PlayerControlView +import androidx.media3.ui.PlayerView +import androidx.media3.ui.R +import com.github.rubensousa.previewseekbar.media3.PreviewTimeBar +import java.lang.ref.WeakReference + + +@OptIn(UnstableApi::class) +class LivePreviewTimeBar(val ctx: Context, attrs: AttributeSet) : PreviewTimeBar(ctx, attrs) { + + private var _currentPlayerView: WeakReference? = null + val currentPlayer: Player? get() = _currentPlayerView?.get()?.player + + fun registerPlayerView(player: PlayerView?) { + _currentPlayerView = WeakReference(player) + val controller = + _currentPlayerView?.get()?.findViewById(R.id.exo_controller) + + controller?.setProgressUpdateListener { position, bufferedPosition -> + currentPlayer?.let { player -> + if (isAtLiveEdge()) { + setPosition(player.duration) + } + } + } + } + + fun isAtLiveEdge(): Boolean { + return LiveHelper.getLiveManager(currentPlayer)?.isAtLiveEdge() == true + } +} \ No newline at end of file diff --git a/app/src/main/res/layout/player_custom_layout.xml b/app/src/main/res/layout/player_custom_layout.xml index 72024a918..407de4a3f 100644 --- a/app/src/main/res/layout/player_custom_layout.xml +++ b/app/src/main/res/layout/player_custom_layout.xml @@ -759,7 +759,7 @@ android:scaleType="centerCrop" /> - + + - + + + - + %d download queued %d downloads queued + Live + From 14d56de61ea89b422e2335bcb180188da735aac3 Mon Sep 17 00:00:00 2001 From: Phisher98 <153359846+phisher98@users.noreply.github.com> Date: Mon, 13 Apr 2026 02:10:05 +0530 Subject: [PATCH 115/236] Adding a subtle shadow and minor adjustments to make the description stand out more on a white background. (#2648) --- app/src/main/res/layout/player_custom_layout_tv.xml | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/app/src/main/res/layout/player_custom_layout_tv.xml b/app/src/main/res/layout/player_custom_layout_tv.xml index 41d45b6bb..2c536c825 100644 --- a/app/src/main/res/layout/player_custom_layout_tv.xml +++ b/app/src/main/res/layout/player_custom_layout_tv.xml @@ -10,7 +10,7 @@ @@ -74,8 +78,12 @@ android:textColor="#E6FFFFFF" android:textSize="16sp" android:lineSpacingExtra="8dp" + android:shadowColor="@android:color/black" + android:shadowDx="2" + android:shadowDy="2" + android:shadowRadius="4" android:maxLines="5" - tools:text="Brave rabbit cop Judy Hopps and her friend, the fox Nick Wilde, team up again to crack a new case."/> + tools:text="Brave rabbit cop Judy Hopps..."/>
Date: Sun, 12 Apr 2026 14:46:11 -0600 Subject: [PATCH 116/236] Upgrade media3 to 1.10.0 (#2608) --- gradle/libs.versions.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index c92a937bd..5a46edf3d 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -27,10 +27,10 @@ kotlinGradlePlugin = "2.3.0" kotlinxCoroutinesCore = "1.10.2" lifecycleKtx = "2.10.0" material = "1.14.0-beta01" -media3 = "1.9.2" +media3 = "1.10.0" navigationKtx = "2.9.7" newpipeextractor = "v0.26.0" -nextlibMedia3 = "1.9.1-0.11.0" +nextlibMedia3 = "1.9.3-0.12.0" nicehttp = "0.4.17" overlappingpanels = "0.1.5" paletteKtx = "1.0.0" From 2eb63dc334b19e5a1463158fd1f79572bfebdcf9 Mon Sep 17 00:00:00 2001 From: firelight <147925818+fire-light42@users.noreply.github.com> Date: Mon, 13 Apr 2026 21:47:12 +0000 Subject: [PATCH 117/236] Change default installer to legacy (#2653) Switching the default to the more reliable legacy installer until we fix the new installer. --- .../com/lagradost/cloudstream3/ui/settings/SettingsUpdates.kt | 3 ++- .../main/java/com/lagradost/cloudstream3/utils/InAppUpdater.kt | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsUpdates.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsUpdates.kt index 118d89ac4..9250f6f6f 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsUpdates.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsUpdates.kt @@ -205,8 +205,9 @@ class SettingsUpdates : BasePreferenceFragmentCompat() { val prefNames = resources.getStringArray(R.array.apk_installer_pref) val prefValues = resources.getIntArray(R.array.apk_installer_values) + // Use legacy installer as default until we make the new installer completely reliable val currentInstaller = - settingsManager.getInt(getString(R.string.apk_installer_key), 0) + settingsManager.getInt(getString(R.string.apk_installer_key), 1) activity?.showBottomDialog( prefNames.toList(), diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/InAppUpdater.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/InAppUpdater.kt index 9380285ca..8bcd1b88e 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/InAppUpdater.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/InAppUpdater.kt @@ -308,7 +308,7 @@ object InAppUpdater { } val currentInstaller = settingsManager.getInt( - getString(R.string.apk_installer_key), 0 + getString(R.string.apk_installer_key), 1 ) when (currentInstaller) { From 1b0fdb57a8298477cf68a321e19abed02417c8f7 Mon Sep 17 00:00:00 2001 From: Luna712 <142361265+Luna712@users.noreply.github.com> Date: Mon, 13 Apr 2026 15:50:57 -0600 Subject: [PATCH 118/236] Add permissions to workflows (#2654) https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax#permissions --- .github/workflows/build_to_archive.yml | 3 +++ .github/workflows/generate_dokka.yml | 3 +++ .github/workflows/issue_action.yml | 4 ++++ .github/workflows/prerelease.yml | 3 +++ .github/workflows/pull_request.yml | 3 +++ .github/workflows/update_locales.yml | 3 +++ 6 files changed, 19 insertions(+) diff --git a/.github/workflows/build_to_archive.yml b/.github/workflows/build_to_archive.yml index f72dd10c6..b5960d5d9 100644 --- a/.github/workflows/build_to_archive.yml +++ b/.github/workflows/build_to_archive.yml @@ -9,6 +9,9 @@ on: - '**/wcokey.txt' workflow_dispatch: +permissions: + contents: read + concurrency: group: "Archive-build" cancel-in-progress: true diff --git a/.github/workflows/generate_dokka.yml b/.github/workflows/generate_dokka.yml index 91f03a434..8ca1f9688 100644 --- a/.github/workflows/generate_dokka.yml +++ b/.github/workflows/generate_dokka.yml @@ -6,6 +6,9 @@ on: paths-ignore: - '*.md' +permissions: + contents: read + concurrency: group: "dokka" cancel-in-progress: true diff --git a/.github/workflows/issue_action.yml b/.github/workflows/issue_action.yml index 4286e6b68..a410fcfff 100644 --- a/.github/workflows/issue_action.yml +++ b/.github/workflows/issue_action.yml @@ -4,6 +4,10 @@ on: issues: types: [opened] +permissions: + contents: read + issues: write + jobs: issue-moderator: runs-on: ubuntu-latest diff --git a/.github/workflows/prerelease.yml b/.github/workflows/prerelease.yml index 03cb68cbc..d9a20a04b 100644 --- a/.github/workflows/prerelease.yml +++ b/.github/workflows/prerelease.yml @@ -12,6 +12,9 @@ concurrency: group: "pre-release" cancel-in-progress: true +permissions: + contents: write + jobs: build: runs-on: ubuntu-latest diff --git a/.github/workflows/pull_request.yml b/.github/workflows/pull_request.yml index a5a7d56e3..675ce3b2f 100644 --- a/.github/workflows/pull_request.yml +++ b/.github/workflows/pull_request.yml @@ -2,6 +2,9 @@ name: Artifact Build on: [pull_request] +permissions: + contents: read + jobs: build: runs-on: ubuntu-latest diff --git a/.github/workflows/update_locales.yml b/.github/workflows/update_locales.yml index 5b170d540..0a538d5d4 100644 --- a/.github/workflows/update_locales.yml +++ b/.github/workflows/update_locales.yml @@ -11,6 +11,9 @@ concurrency: group: "locale" cancel-in-progress: true +permissions: + contents: read + jobs: create: runs-on: ubuntu-latest From 788189c80cd2b6bba8184843d382477091dcb638 Mon Sep 17 00:00:00 2001 From: Luna712 <142361265+Luna712@users.noreply.github.com> Date: Mon, 13 Apr 2026 15:52:57 -0600 Subject: [PATCH 119/236] Bump github-script action (#2642) --- .github/workflows/issue_action.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/issue_action.yml b/.github/workflows/issue_action.yml index a410fcfff..e354d657d 100644 --- a/.github/workflows/issue_action.yml +++ b/.github/workflows/issue_action.yml @@ -33,7 +33,7 @@ jobs: - name: Label if possible duplicate if: steps.similarity.outputs.similar-issues-found =='true' - uses: actions/github-script@v8 + uses: actions/github-script@v9 with: github-token: ${{ steps.generate_token.outputs.token }} script: | @@ -79,7 +79,7 @@ jobs: - name: Label if mentions provider if: steps.provider_check.outputs.name != 'none' - uses: actions/github-script@v8 + uses: actions/github-script@v9 with: github-token: ${{ steps.generate_token.outputs.token }} script: | From fb54d02979c39b66b6423cf2c3239743e9b48a85 Mon Sep 17 00:00:00 2001 From: firelight <147925818+fire-light42@users.noreply.github.com> Date: Mon, 13 Apr 2026 22:21:52 +0000 Subject: [PATCH 120/236] Fix SSL issues (#2655) --- .../lagradost/cloudstream3/MainActivity.kt | 7 +++-- .../cloudstream3/network/RequestsHelper.kt | 27 ++++++++++++++----- .../com/lagradost/cloudstream3/MainAPI.kt | 7 +++++ .../lagradost/cloudstream3/MainActivity.kt | 17 +++++++++--- 4 files changed, 47 insertions(+), 11 deletions(-) diff --git a/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt b/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt index 709e92a41..071ce6c89 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt @@ -1169,7 +1169,10 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa } override fun onCreate(savedInstanceState: Bundle?) { - app.initClient(this) + app.initClient(this, ignoreSSL = false) + @OptIn(UnsafeSSL::class) + insecureApp.initClient(this, ignoreSSL = true) + val settingsManager = PreferenceManager.getDefaultSharedPreferences(this) setLastError(this) @@ -2059,4 +2062,4 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa false } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/network/RequestsHelper.kt b/app/src/main/java/com/lagradost/cloudstream3/network/RequestsHelper.kt index ec486d61d..6234297d0 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/network/RequestsHelper.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/network/RequestsHelper.kt @@ -2,6 +2,7 @@ package com.lagradost.cloudstream3.network import android.content.Context import androidx.preference.PreferenceManager +import com.lagradost.cloudstream3.Prerelease import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.USER_AGENT import com.lagradost.cloudstream3.mvvm.safe @@ -15,11 +16,26 @@ import org.conscrypt.Conscrypt import java.io.File import java.security.Security +// Backwards compatible constructor, mark as deprecated later fun Requests.initClient(context: Context) { this.baseClient = buildDefaultClient(context) } +/** Only use ignoreSSL if you know what you are doing*/ +@Prerelease +fun Requests.initClient(context: Context, ignoreSSL: Boolean = false) { + this.baseClient = buildDefaultClient(context, ignoreSSL) +} + + +// Backwards compatible constructor, mark as deprecated later fun buildDefaultClient(context: Context): OkHttpClient { + return buildDefaultClient(context, false) +} + +/** Only use ignoreSSL if you know what you are doing*/ +@Prerelease +fun buildDefaultClient(context: Context, ignoreSSL: Boolean = false): OkHttpClient { safe { Security.insertProviderAt(Conscrypt.newProvider(), 1) } val settingsManager = PreferenceManager.getDefaultSharedPreferences(context) @@ -27,7 +43,11 @@ fun buildDefaultClient(context: Context): OkHttpClient { val baseClient = OkHttpClient.Builder() .followRedirects(true) .followSslRedirects(true) - .ignoreAllSSLErrors() + .apply { + if (ignoreSSL) { + ignoreAllSSLErrors() + } + } .cache( // Note that you need to add a ResponseInterceptor to make this 100% active. // The server response dictates if and when stuff should be cached. @@ -52,11 +72,6 @@ fun buildDefaultClient(context: Context): OkHttpClient { return baseClient } -//val Request.cookies: Map -// get() { -// return this.headers.getCookies("Cookie") -// } - private val DEFAULT_HEADERS = mapOf("user-agent" to USER_AGENT) /** diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/MainAPI.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/MainAPI.kt index 81eabbe77..aeab8ef9f 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/MainAPI.kt +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/MainAPI.kt @@ -55,6 +55,13 @@ annotation class Prerelease ) annotation class InternalAPI +@Retention(AnnotationRetention.BINARY) // This is only an IDE hint, and will not be used in the runtime +@RequiresOptIn( + message = "Only use this if you know what you are doing and you need to bypass the SSL certificate checks. Never use this for sensitive network requests such as logins.", + level = RequiresOptIn.Level.WARNING +) +annotation class UnsafeSSL + /** * Defines the constant for the all languages preference, if this is set then it is * the equivalent of all languages being set diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/MainActivity.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/MainActivity.kt index 6502cc831..4b163867d 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/MainActivity.kt +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/MainActivity.kt @@ -8,8 +8,7 @@ import com.lagradost.nicehttp.ResponseParser import kotlin.reflect.KClass // Short name for requests client to make it nicer to use - -var app = Requests(responseParser = object : ResponseParser { +private val jacksonResponseParser = object : ResponseParser { val mapper: ObjectMapper = jacksonObjectMapper().configure( DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false @@ -30,6 +29,18 @@ var app = Requests(responseParser = object : ResponseParser { override fun writeValueAsString(obj: Any): String { return mapper.writeValueAsString(obj) } -}).apply { +} + +/** The default networking helper. This helper performs SSL checks. + * If you need to make requests to websites with invalid SSL certificates use insecureApp instead. */ +var app = Requests(responseParser = jacksonResponseParser).apply { + defaultHeaders = mapOf("user-agent" to USER_AGENT) +} + +/** Same as the default app networking helper, but this instance ignores SSL certificates. + * This should NEVER be used for sensitive networking operations such as logins. Only use this when required. */ +@Prerelease +@UnsafeSSL +var insecureApp = Requests(responseParser = jacksonResponseParser).apply { defaultHeaders = mapOf("user-agent" to USER_AGENT) } \ No newline at end of file From cfce80e93e181eb2fd7517438e2d77d1a802781f Mon Sep 17 00:00:00 2001 From: Luna712 <142361265+Luna712@users.noreply.github.com> Date: Mon, 13 Apr 2026 16:27:27 -0600 Subject: [PATCH 121/236] Bump DGP and KGP libs (#2582) Final compatibility with AGP 9 --- gradle/libs.versions.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 5a46edf3d..fc9926c93 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -12,7 +12,7 @@ conscryptAndroid = { strictly = "2.5.2" } # 2.5.3 crashes everything constraintlayout = "2.2.1" coreKtx = "1.18.0" desugar_jdk_libs_nio = "2.1.5" -dokkaGradlePlugin = "2.1.0" +dokkaGradlePlugin = "2.2.0" espressoCore = "3.7.0" fragmentKtx = "1.8.9" fuzzywuzzy = "1.4.0" @@ -23,7 +23,7 @@ junit = "4.13.2" junitKtx = "1.3.0" junitVersion = "1.3.0" juniversalchardet = "2.5.0" -kotlinGradlePlugin = "2.3.0" +kotlinGradlePlugin = "2.3.20" kotlinxCoroutinesCore = "1.10.2" lifecycleKtx = "2.10.0" material = "1.14.0-beta01" From 0bb932227622431304c47a50418c92b4a4971bd3 Mon Sep 17 00:00:00 2001 From: Luna712 <142361265+Luna712@users.noreply.github.com> Date: Tue, 14 Apr 2026 14:47:38 -0600 Subject: [PATCH 122/236] Don't explicitly enable WebContentsDebugging (#2657) "this is enabled automatically if the app is declared as `android:debuggable="true"` in its manifest; otherwise, the default is false." - which we set on CloudStream Debug but not release flavors. "Enabling web contents debugging allows the state of any WebView in the app to be inspected and modified by the user via adb. This is a security liability and should not be enabled in production builds of apps unless this is an explicitly intended use of the app." --- .../lagradost/cloudstream3/network/WebViewResolver.android.kt | 2 -- 1 file changed, 2 deletions(-) diff --git a/library/src/androidMain/kotlin/com/lagradost/cloudstream3/network/WebViewResolver.android.kt b/library/src/androidMain/kotlin/com/lagradost/cloudstream3/network/WebViewResolver.android.kt index a99d0a16a..60a4d0453 100644 --- a/library/src/androidMain/kotlin/com/lagradost/cloudstream3/network/WebViewResolver.android.kt +++ b/library/src/androidMain/kotlin/com/lagradost/cloudstream3/network/WebViewResolver.android.kt @@ -123,8 +123,6 @@ actual class WebViewResolver actual constructor( val extraRequestList = threadSafeListOf() main { - // Useful for debugging - WebView.setWebContentsDebuggingEnabled(true) try { webView = WebView( (getContext() as? Context) From 8d416fa2fc48ff9a4fca242b3c82243e441a198e Mon Sep 17 00:00:00 2001 From: Luna712 <142361265+Luna712@users.noreply.github.com> Date: Tue, 14 Apr 2026 14:48:23 -0600 Subject: [PATCH 123/236] Remove commented android.enableJetifier from gradle.properties (#2662) It is now deprecated anyway. We will never use it now, so we can just fully remove it. --- gradle.properties | 2 -- 1 file changed, 2 deletions(-) diff --git a/gradle.properties b/gradle.properties index 0168ae437..b6d502f9a 100644 --- a/gradle.properties +++ b/gradle.properties @@ -15,8 +15,6 @@ org.gradle.jvmargs=-Xmx4G -Dfile.encoding=UTF-8 # Android operating system, and which are packaged with your app"s APK # https://developer.android.com/topic/libraries/support-library/androidx-rn android.useAndroidX=true -# Automatically convert third-party libraries to use AndroidX -# android.enableJetifier=true # Kotlin code style for this project: "official" or "obsolete": kotlin.code.style=official android.nonTransitiveRClass=true From 7925e714e7a97dadf7a7e70a89a4cc45f6e914ea Mon Sep 17 00:00:00 2001 From: Luna712 <142361265+Luna712@users.noreply.github.com> Date: Tue, 14 Apr 2026 15:39:31 -0600 Subject: [PATCH 124/236] Fix editing accounts from MainActivity (#2663) --- .../ui/account/AccountSelectActivity.kt | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/account/AccountSelectActivity.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/account/AccountSelectActivity.kt index 29c35dea6..ad323c7d1 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/account/AccountSelectActivity.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/account/AccountSelectActivity.kt @@ -48,10 +48,16 @@ class AccountSelectActivity : FragmentActivity(), BiometricCallback { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + // Are we editing and coming from MainActivity? + val isEditingFromMainActivity = intent.getBooleanExtra( + "isEditingFromMainActivity", + false + ) + // Sometimes we start this activity when we have already logged in // For example when using cloudstreamsearch:// // In those cases we want to just go to the main activity instantly - if (hasLoggedIn) { + if (hasLoggedIn && !isEditingFromMainActivity) { navigateToMainActivity() return } @@ -61,12 +67,6 @@ class AccountSelectActivity : FragmentActivity(), BiometricCallback { enableEdgeToEdgeCompat() setNavigationBarColorCompat(R.attr.primaryBlackBackground) - // Are we editing and coming from MainActivity? - val isEditingFromMainActivity = intent.getBooleanExtra( - "isEditingFromMainActivity", - false - ) - val settingsManager = PreferenceManager.getDefaultSharedPreferences(this) val skipStartup = settingsManager.getBoolean( getString(R.string.skip_startup_account_select_key), false @@ -216,4 +216,4 @@ class AccountSelectActivity : FragmentActivity(), BiometricCallback { override fun onAuthenticationError() { finish() } -} \ No newline at end of file +} From c31c5764ea649f85365d5cf9c42e19557abc9ef4 Mon Sep 17 00:00:00 2001 From: Luna712 <142361265+Luna712@users.noreply.github.com> Date: Tue, 14 Apr 2026 16:22:38 -0600 Subject: [PATCH 125/236] Bump nextlibMedia3 (#2658) --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index fc9926c93..f997e4f6e 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -30,7 +30,7 @@ material = "1.14.0-beta01" media3 = "1.10.0" navigationKtx = "2.9.7" newpipeextractor = "v0.26.0" -nextlibMedia3 = "1.9.3-0.12.0" +nextlibMedia3 = "1.10.0-0.12.1" nicehttp = "0.4.17" overlappingpanels = "0.1.5" paletteKtx = "1.0.0" From cd033923642f3d146500de707ed117dc8afce543 Mon Sep 17 00:00:00 2001 From: Luna712 <142361265+Luna712@users.noreply.github.com> Date: Thu, 16 Apr 2026 17:26:52 -0600 Subject: [PATCH 126/236] Remove setup-android action from Dokka action (#2666) It shouldn't be necessary with setup-gradle. --- .github/workflows/generate_dokka.yml | 3 --- 1 file changed, 3 deletions(-) diff --git a/.github/workflows/generate_dokka.yml b/.github/workflows/generate_dokka.yml index 8ca1f9688..d67b8a519 100644 --- a/.github/workflows/generate_dokka.yml +++ b/.github/workflows/generate_dokka.yml @@ -54,9 +54,6 @@ jobs: with: cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} - - name: Set up Android SDK - uses: android-actions/setup-android@v4 - - name: Generate Dokka run: | cd $GITHUB_WORKSPACE/src/ From 636d2507f72ce571b37bce8c30766658309074c0 Mon Sep 17 00:00:00 2001 From: Luna712 <142361265+Luna712@users.noreply.github.com> Date: Thu, 16 Apr 2026 17:28:16 -0600 Subject: [PATCH 127/236] Add missing OptIn (#2668) This an error level opt in introduced in media3 1.10.0. --- .../java/com/lagradost/cloudstream3/ui/player/CS3IPlayer.kt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/CS3IPlayer.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/CS3IPlayer.kt index 60c87532b..8a643cc69 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/CS3IPlayer.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/CS3IPlayer.kt @@ -29,6 +29,7 @@ import androidx.media3.common.TrackGroup import androidx.media3.common.TrackSelectionOverride import androidx.media3.common.Tracks import androidx.media3.common.VideoSize +import androidx.media3.common.util.ExperimentalApi import androidx.media3.common.util.UnstableApi import androidx.media3.database.StandaloneDatabaseProvider import androidx.media3.datasource.DataSource @@ -1195,6 +1196,7 @@ class CS3IPlayer : IPlayer { CustomDecoder.subtitleOffset = subtitleOffset val decoder = CustomSubtitleDecoderFactory() + @OptIn(ExperimentalApi::class) val currentTextRenderer = TextRenderer( customTextOutput, eventHandler.looper, From 63368379031281d5c228f6a1ed5279b0c27cf794 Mon Sep 17 00:00:00 2001 From: Luna712 <142361265+Luna712@users.noreply.github.com> Date: Sat, 18 Apr 2026 07:40:05 -0600 Subject: [PATCH 128/236] Revert media3 to 1.9.3 (#2693) --- .../java/com/lagradost/cloudstream3/ui/player/CS3IPlayer.kt | 4 ++-- gradle/libs.versions.toml | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/CS3IPlayer.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/CS3IPlayer.kt index 8a643cc69..887777934 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/CS3IPlayer.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/CS3IPlayer.kt @@ -29,7 +29,7 @@ import androidx.media3.common.TrackGroup import androidx.media3.common.TrackSelectionOverride import androidx.media3.common.Tracks import androidx.media3.common.VideoSize -import androidx.media3.common.util.ExperimentalApi +// import androidx.media3.common.util.ExperimentalApi import androidx.media3.common.util.UnstableApi import androidx.media3.database.StandaloneDatabaseProvider import androidx.media3.datasource.DataSource @@ -1196,7 +1196,7 @@ class CS3IPlayer : IPlayer { CustomDecoder.subtitleOffset = subtitleOffset val decoder = CustomSubtitleDecoderFactory() - @OptIn(ExperimentalApi::class) + // @OptIn(ExperimentalApi::class) val currentTextRenderer = TextRenderer( customTextOutput, eventHandler.looper, diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index f997e4f6e..a4f921952 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -27,10 +27,10 @@ kotlinGradlePlugin = "2.3.20" kotlinxCoroutinesCore = "1.10.2" lifecycleKtx = "2.10.0" material = "1.14.0-beta01" -media3 = "1.10.0" +media3 = "1.9.3" navigationKtx = "2.9.7" newpipeextractor = "v0.26.0" -nextlibMedia3 = "1.10.0-0.12.1" +nextlibMedia3 = "1.9.3-0.12.0" nicehttp = "0.4.17" overlappingpanels = "0.1.5" paletteKtx = "1.0.0" From c67ba2b4859d7d1d2077c721cce28f42ddf33e10 Mon Sep 17 00:00:00 2001 From: Luna712 <142361265+Luna712@users.noreply.github.com> Date: Sat, 18 Apr 2026 07:45:55 -0600 Subject: [PATCH 129/236] Add explicit permission checks for notifications in downloader (#2667) --- .../lagradost/cloudstream3/services/DownloadQueueService.kt | 6 ++++++ .../cloudstream3/utils/downloader/DownloadManager.kt | 4 ++++ 2 files changed, 10 insertions(+) diff --git a/app/src/main/java/com/lagradost/cloudstream3/services/DownloadQueueService.kt b/app/src/main/java/com/lagradost/cloudstream3/services/DownloadQueueService.kt index 37b9a1002..028356e76 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/services/DownloadQueueService.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/services/DownloadQueueService.kt @@ -1,8 +1,10 @@ package com.lagradost.cloudstream3.services +import android.Manifest import android.app.Service import android.content.Context import android.content.Intent +import android.content.pm.PackageManager import android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC import android.os.Build.VERSION.SDK_INT import android.os.IBinder @@ -104,6 +106,10 @@ class DownloadQueueService : Service() { private fun updateNotification(context: Context, downloads: Int, queued: Int) { + if (context.checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) + != PackageManager.PERMISSION_GRANTED + ) return + val activeDownloads = resources.getQuantityString(R.plurals.downloads_active, downloads).format(downloads) val activeQueue = 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 11c35e9ec..94962de13 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 @@ -1734,6 +1734,10 @@ object VideoDownloadManager { companion object { private fun displayNotification(context: Context, id: Int, notification: Notification) { safe { + if (context.checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) + != PackageManager.PERMISSION_GRANTED + ) return@safe + NotificationManagerCompat.from(context) .notify(DOWNLOAD_NOTIFICATION_TAG, id, notification) } From e55794c200e71f10f83c86d6a7189b03679e704a Mon Sep 17 00:00:00 2001 From: Luna712 <142361265+Luna712@users.noreply.github.com> Date: Sat, 18 Apr 2026 07:52:14 -0600 Subject: [PATCH 130/236] Bump buildkonfig lib (#2643) --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index a4f921952..c02e79398 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -5,7 +5,7 @@ activityKtx = "1.13.0" androidGradlePlugin = "8.13.2" appcompat = "1.7.1" biometric = "1.4.0-alpha06" -buildkonfigGradlePlugin = "0.17.1" +buildkonfigGradlePlugin = "0.18.0" coil = { strictly = "3.3.0" } # Later versions require jvmTarget 11 or later colorpicker = "6b46b49" conscryptAndroid = { strictly = "2.5.2" } # 2.5.3 crashes everything From f175beb51b727132589d4acbeed0dbad7366e591 Mon Sep 17 00:00:00 2001 From: firelight <147925818+fire-light42@users.noreply.github.com> Date: Sun, 19 Apr 2026 12:05:24 +0000 Subject: [PATCH 131/236] Fix concurrent plugin loading (#2700) --- .../cloudstream3/plugins/PluginManager.kt | 17 +++++++++++++---- .../cloudstream3/plugins/BasePlugin.kt | 4 +++- 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/com/lagradost/cloudstream3/plugins/PluginManager.kt b/app/src/main/java/com/lagradost/cloudstream3/plugins/PluginManager.kt index feb0ba6d4..0dc65358a 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/plugins/PluginManager.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/plugins/PluginManager.kt @@ -691,16 +691,25 @@ object PluginManager { APIHolder.allProviders.removeIf { provider: MainAPI -> provider.sourcePlugin == plugin.filename } } - extractorApis.removeIf { provider: ExtractorApi -> provider.sourcePlugin == plugin.filename } + synchronized(extractorApis) { + extractorApis.removeIf { provider: ExtractorApi -> provider.sourcePlugin == plugin.filename } + } synchronized(VideoClickActionHolder.allVideoClickActions) { VideoClickActionHolder.allVideoClickActions.removeIf { action: VideoClickAction -> action.sourcePlugin == plugin.filename } } - classLoaders.values.removeIf { v -> v == plugin } + synchronized(classLoaders) { + classLoaders.values.removeIf { v -> v == plugin } + } - plugins.remove(absolutePath) - urlPlugins.values.removeIf { v -> v == plugin } + synchronized(plugins) { + plugins.remove(absolutePath) + } + + synchronized(urlPlugins) { + urlPlugins.values.removeIf { v -> v == plugin } + } } /** diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/plugins/BasePlugin.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/plugins/BasePlugin.kt index c917a55ae..61f87b8ba 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/plugins/BasePlugin.kt +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/plugins/BasePlugin.kt @@ -31,7 +31,9 @@ abstract class BasePlugin { fun registerExtractorAPI(element: ExtractorApi) { Log.i(PLUGIN_TAG, "Adding ${element.name} (${element.mainUrl}) ExtractorApi") element.sourcePlugin = this.filename - extractorApis.add(element) + synchronized(extractorApis) { + extractorApis.add(element) + } } /** From c1eef1de1de12e8da1873418b005a90caeb9961a Mon Sep 17 00:00:00 2001 From: Luna712 <142361265+Luna712@users.noreply.github.com> Date: Sun, 19 Apr 2026 06:41:05 -0600 Subject: [PATCH 132/236] Add new URL for Voe (#2701) --- .../kotlin/com/lagradost/cloudstream3/extractors/Voe.kt | 4 ++++ .../kotlin/com/lagradost/cloudstream3/utils/ExtractorApi.kt | 2 ++ 2 files changed, 6 insertions(+) diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Voe.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Voe.kt index 860f9b540..67eb49c9a 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Voe.kt +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Voe.kt @@ -45,6 +45,10 @@ class Voe1 : Voe() { override val mainUrl = "https://donaldlineelse.com" } +class Voe2 : Voe() { + override val mainUrl = "https://charlestoughrace.com" +} + open class Voe : ExtractorApi() { override val name = "Voe" override val mainUrl = "https://voe.sx" diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/ExtractorApi.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/ExtractorApi.kt index 4f3f05df6..1fd39943c 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/ExtractorApi.kt +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/ExtractorApi.kt @@ -288,6 +288,7 @@ import com.lagradost.cloudstream3.extractors.Vidsonic import com.lagradost.cloudstream3.extractors.VkExtractor import com.lagradost.cloudstream3.extractors.Voe import com.lagradost.cloudstream3.extractors.Voe1 +import com.lagradost.cloudstream3.extractors.Voe2 import com.lagradost.cloudstream3.extractors.Vtbe import com.lagradost.cloudstream3.extractors.Wibufile import com.lagradost.cloudstream3.extractors.WishembedPro @@ -1097,6 +1098,7 @@ val extractorApis: MutableList = arrayListOf( Vidmolybiz(), Voe(), Voe1(), + Voe2(), Tubeless(), Moviehab(), MoviehabNet(), From ee6a9af217ee0cad9f5076a121355b375c67b11c Mon Sep 17 00:00:00 2001 From: hrisabhy <87358494+hrisabhy@users.noreply.github.com> Date: Sun, 19 Apr 2026 18:29:44 +0530 Subject: [PATCH 133/236] Improve subtitle selection UX: Move "No Subtitles" option to bottom (#2523) --- .../cloudstream3/ui/player/GeneratorPlayer.kt | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) 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 16b03e4f6..c3d8306e6 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 @@ -1163,7 +1163,6 @@ class GeneratorPlayer : FullScreenPlayer() { val subsArrayAdapter = ArrayAdapter(ctx, R.layout.sort_bottom_single_choice) - subsArrayAdapter.add(ctx.getString(R.string.no_subtitles).html()) val subtitlesGrouped = currentSubtitles.groupBy { it.originalName }.map { (key, value) -> @@ -1173,8 +1172,13 @@ class GeneratorPlayer : FullScreenPlayer() { val subtitles = subtitlesGrouped.map { it.key.html() } - val subtitleGroupIndexStart = - subtitlesGrouped.keys.indexOf(currentSelectedSubtitles?.originalName) + 1 + val realIndex = subtitlesGrouped.keys.indexOf(currentSelectedSubtitles?.originalName) + val subtitleGroupIndexStart = if (realIndex == -1) { + // The "No Subtitles" option is outside the subtitlesGrouped list. + subtitlesGrouped.size + } else { + realIndex + } var subtitleGroupIndex = subtitleGroupIndexStart val subtitleOptionIndexStart = @@ -1183,6 +1187,7 @@ class GeneratorPlayer : FullScreenPlayer() { var subtitleOptionIndex = subtitleOptionIndexStart subsArrayAdapter.addAll(subtitles) + subsArrayAdapter.add(ctx.getString(R.string.no_subtitles).html()) subtitleList.adapter = subsArrayAdapter subtitleList.choiceMode = AbsListView.CHOICE_MODE_SINGLE @@ -1201,7 +1206,7 @@ class GeneratorPlayer : FullScreenPlayer() { val subtitleOptions = subtitlesGroupedList - .getOrNull(subtitleGroupIndex - 1)?.value?.map { subtitle -> + .getOrNull(subtitleGroupIndex)?.value?.map { subtitle -> val nameSuffix = subtitle.nameSuffix.html() nameSuffix.ifBlank { when (subtitle.origin) { @@ -1253,7 +1258,7 @@ class GeneratorPlayer : FullScreenPlayer() { } subtitleOptionList.setOnItemClickListener { _, _, which, _ -> - if (which >= (subtitlesGroupedList.getOrNull(subtitleGroupIndex - 1)?.value?.size + if (which >= (subtitlesGroupedList.getOrNull(subtitleGroupIndex)?.value?.size ?: -1) ) { val child = subtitleOptionList.adapter.getView(which, null, subtitleList) @@ -1340,10 +1345,10 @@ class GeneratorPlayer : FullScreenPlayer() { binding.applyBtt.setOnClickListener { var init = sourceIndex != startSource if (subtitleGroupIndex != subtitleGroupIndexStart || subtitleOptionIndex != subtitleOptionIndexStart) { - init = init or if (subtitleGroupIndex <= 0) { + init = init or if (subtitleGroupIndex >= subtitlesGrouped.size) { noSubtitles() } else { - subtitlesGroupedList.getOrNull(subtitleGroupIndex - 1)?.value?.getOrNull( + subtitlesGroupedList.getOrNull(subtitleGroupIndex)?.value?.getOrNull( subtitleOptionIndex )?.let { setSubtitles(it, true) From 68a1d0856c708c0c6e6faf0314296374152abc77 Mon Sep 17 00:00:00 2001 From: Luna712 <142361265+Luna712@users.noreply.github.com> Date: Sun, 19 Apr 2026 07:09:19 -0600 Subject: [PATCH 134/236] Fix STATE_IDLE issues in player (#2691) --- .../cloudstream3/ui/player/CS3IPlayer.kt | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/CS3IPlayer.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/CS3IPlayer.kt index 887777934..29a77883b 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/CS3IPlayer.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/CS3IPlayer.kt @@ -961,6 +961,22 @@ class CS3IPlayer : IPlayer { when (event) { CSPlayerEvent.Play -> { event(PlayEvent(source)) + // If the player was stopped (e.g. notification dismissed) it lands in + // STATE_IDLE. A bare play() call is a no-op in that state, re-prepare and + // then resume to the current position once we are in STATE_READY again. + if (playbackState == Player.STATE_IDLE) { + val seekPosition = currentPosition + exoPlayer?.addListener(object : Player.Listener { + private var seekApplied = false + override fun onPlaybackStateChanged(playbackState: Int) { + if (seekApplied || playbackState != Player.STATE_READY) return + seekApplied = true + exoPlayer?.seekTo(currentWindow, seekPosition) + exoPlayer?.removeListener(this) + } + }) + prepare() + } play() } From 7926e60fb00d93062acee671a088f37209edb4cb Mon Sep 17 00:00:00 2001 From: firelight <147925818+fire-light42@users.noreply.github.com> Date: Sun, 19 Apr 2026 13:29:37 +0000 Subject: [PATCH 135/236] Add plugin hash validation (#2644) --- .../cloudstream3/plugins/PluginManager.kt | 20 +++-- .../cloudstream3/plugins/RepositoryManager.kt | 87 ++++++++++++++----- .../settings/extensions/PluginsViewModel.kt | 2 + 3 files changed, 83 insertions(+), 26 deletions(-) diff --git a/app/src/main/java/com/lagradost/cloudstream3/plugins/PluginManager.kt b/app/src/main/java/com/lagradost/cloudstream3/plugins/PluginManager.kt index 0dc65358a..eae14a6c0 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/plugins/PluginManager.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/plugins/PluginManager.kt @@ -13,6 +13,7 @@ import android.os.Build import android.os.Environment import android.util.Log import android.widget.Toast +import androidx.annotation.WorkerThread import androidx.core.app.ActivityCompat import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat @@ -45,6 +46,7 @@ import com.lagradost.cloudstream3.plugins.RepositoryManager.ONLINE_PLUGINS_FOLDE import com.lagradost.cloudstream3.plugins.RepositoryManager.PREBUILT_REPOSITORIES import com.lagradost.cloudstream3.plugins.RepositoryManager.downloadPluginToFile import com.lagradost.cloudstream3.plugins.RepositoryManager.getRepoPlugins +import com.lagradost.cloudstream3.plugins.RepositoryManager.sha256 import com.lagradost.cloudstream3.ui.settings.extensions.REPOSITORIES_KEY import com.lagradost.cloudstream3.ui.settings.extensions.RepositoryData import com.lagradost.cloudstream3.utils.AppContextUtils.getApiProviderLangSettings @@ -78,6 +80,7 @@ data class PluginData( @JsonProperty("filePath") val filePath: String, @JsonProperty("version") val version: Int, ) { + @WorkerThread fun toSitePlugin(): SitePlugin { return SitePlugin( this.filePath, @@ -92,7 +95,9 @@ data class PluginData( null, null, null, - File(this.filePath).length() + File(this.filePath).length(), + // No file hash for local plugins. Local plugins have no use for the hash, and it's expensive to compute. + null ) } } @@ -302,6 +307,7 @@ object PluginManager { downloadPlugin( activity, pluginData.onlineData.second.url, + pluginData.onlineData.second.fileHash, pluginData.savedData.internalName, File(pluginData.savedData.filePath), true @@ -413,6 +419,7 @@ object PluginManager { downloadPlugin( activity, pluginData.onlineData.second.url, + pluginData.onlineData.second.fileHash, pluginData.savedData.internalName, pluginData.onlineData.first, !pluginData.isDisabled @@ -739,25 +746,27 @@ object PluginManager { suspend fun downloadPlugin( activity: Activity, pluginUrl: String, + pluginHash: String?, internalName: String, repositoryUrl: String, loadPlugin: Boolean ): Boolean { val file = getPluginPath(activity, internalName, repositoryUrl) - return downloadPlugin(activity, pluginUrl, internalName, file, loadPlugin) + return downloadPlugin(activity, pluginUrl, pluginHash, internalName, file, loadPlugin) } suspend fun downloadPlugin( activity: Activity, pluginUrl: String, + pluginHash: String?, internalName: String, file: File, - loadPlugin: Boolean + loadPlugin: Boolean, ): Boolean { try { Log.d(TAG, "Downloading plugin: $pluginUrl to ${file.absolutePath}") // The plugin file needs to be salted with the repository url hash as to allow multiple repositories with the same internal plugin names - val newFile = downloadPluginToFile(pluginUrl, file) ?: return false + val newFile = downloadPluginToFile(activity, pluginUrl, file, pluginHash) ?: return false val data = PluginData( internalName, @@ -845,6 +854,7 @@ object PluginManager { if (downloadPlugin( activity, pluginData.onlineData.second.url, + pluginData.onlineData.second.fileHash, pluginData.savedData.internalName, existingFile, true @@ -943,4 +953,4 @@ object PluginManager { return null } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/plugins/RepositoryManager.kt b/app/src/main/java/com/lagradost/cloudstream3/plugins/RepositoryManager.kt index 45ed65611..07d6aaa37 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/plugins/RepositoryManager.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/plugins/RepositoryManager.kt @@ -1,6 +1,7 @@ package com.lagradost.cloudstream3.plugins import android.content.Context +import androidx.annotation.WorkerThread import com.fasterxml.jackson.annotation.JsonProperty import com.lagradost.cloudstream3.CloudStreamApp.Companion.context import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey @@ -18,10 +19,12 @@ import com.lagradost.cloudstream3.ui.settings.extensions.RepositoryData import com.lagradost.cloudstream3.utils.AppUtils.tryParseJson import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock -import java.io.BufferedInputStream import java.io.File -import java.io.InputStream -import java.io.OutputStream +import java.nio.file.AtomicMoveNotSupportedException +import java.nio.file.Files +import java.nio.file.StandardCopyOption +import java.security.MessageDigest +import java.util.concurrent.atomic.AtomicInteger /** * Comes with the app, always available in the app, non removable. @@ -67,6 +70,7 @@ data class SitePlugin( @JsonProperty("iconUrl") val iconUrl: String?, // Automatically generated by the gradle plugin @JsonProperty("fileSize") val fileSize: Long?, + @JsonProperty("fileHash") val fileHash: String?, ) @@ -75,7 +79,26 @@ object RepositoryManager { val PREBUILT_REPOSITORIES: Array by lazy { getKey("PREBUILT_REPOSITORIES") ?: emptyArray() } - private val GH_REGEX = Regex("^https://raw.githubusercontent.com/([A-Za-z0-9-]+)/([A-Za-z0-9_.-]+)/(.*)$") + private val GH_REGEX = + Regex("^https://raw.githubusercontent.com/([A-Za-z0-9-]+)/([A-Za-z0-9_.-]+)/(.*)$") + + + /** Returns a SHA-256 string of the file content. + * Example: "sha256-b70462c264cb7f90fc2860a8e58d7544ce747ff347d1d11fa093623901853573" **/ + @WorkerThread + fun sha256(file: File): String { + val digest = MessageDigest.getInstance("SHA-256") + + file.inputStream().use { fis -> + val buffer = ByteArray(8192) + var read = fis.read(buffer) + while (read != -1) { + digest.update(buffer, 0, read) + read = fis.read(buffer) + } + } + return "sha256-" + digest.digest().joinToString("") { "%02x".format(it) } + } /* Convert raw.githubusercontent.com urls to cdn.jsdelivr.net if enabled in settings */ fun convertRawGitUrl(url: String): String { @@ -140,21 +163,52 @@ object RepositoryManager { }.flatten() } + suspend fun downloadPluginToFile( + context: Context, pluginUrl: String, - file: File + file: File, + expectedFileHash: String? ): File? { return safeAsync { - file.mkdirs() + val parentDir = file.parentFile ?: return@safeAsync null + parentDir.mkdirs() - // Overwrite if exists - if (file.exists()) { - file.delete() - } - file.createNewFile() + // Prevent corrupting the plugin file if the operation fails + val tempFile = File.createTempFile(file.name, ".tmp", context.cacheDir) val body = app.get(convertRawGitUrl(pluginUrl)).okhttpResponse.body - write(body.byteStream(), file.outputStream()) + + body.byteStream().use { body -> + tempFile.outputStream().use { fileSteam -> + body.copyTo(fileSteam) + } + } + + if (expectedFileHash != null) { + val downloadHash = sha256(tempFile) + if (expectedFileHash != downloadHash) { + tempFile.delete() + throw IllegalStateException("Extension hash mismatch when validating '${file.name}'! Expected: '$expectedFileHash', got: '$downloadHash'.") + } + } + + // We prefer the operation to be atomic + try { + Files.move( + tempFile.toPath(), + file.toPath(), + StandardCopyOption.REPLACE_EXISTING, + StandardCopyOption.ATOMIC_MOVE + ) + } catch (_: AtomicMoveNotSupportedException) { + Files.move( + tempFile.toPath(), + file.toPath(), + StandardCopyOption.REPLACE_EXISTING + ) + } + file } } @@ -202,13 +256,4 @@ object RepositoryManager { PluginManager.deleteRepositoryData(file.absolutePath) } - - private fun write(stream: InputStream, output: OutputStream) { - val input = BufferedInputStream(stream) - val dataBuffer = ByteArray(512) - var readBytes: Int - while (input.read(dataBuffer).also { readBytes = it } != -1) { - output.write(dataBuffer, 0, readBytes) - } - } } diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/PluginsViewModel.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/PluginsViewModel.kt index e0fd906b4..dfc61eba5 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/PluginsViewModel.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/PluginsViewModel.kt @@ -128,6 +128,7 @@ class PluginsViewModel : ViewModel() { PluginManager.downloadPlugin( activity, metadata.url, + metadata.fileHash, metadata.internalName, repo, metadata.status != PROVIDER_STATUS_DOWN @@ -179,6 +180,7 @@ class PluginsViewModel : ViewModel() { PluginManager.downloadPlugin( activity, metadata.url, + metadata.fileHash, metadata.internalName, repo, isEnabled From 7c1554a479a8e0679c235b2bae5d293b4fb7bd46 Mon Sep 17 00:00:00 2001 From: Luna712 <142361265+Luna712@users.noreply.github.com> Date: Sun, 19 Apr 2026 11:00:47 -0600 Subject: [PATCH 136/236] AGP 9! (#2604) --- app/build.gradle.kts | 5 +++-- build.gradle.kts | 1 - gradle/libs.versions.toml | 3 +-- 3 files changed, 4 insertions(+), 5 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 0b201d1cb..0ea37a025 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -8,7 +8,6 @@ import org.jetbrains.kotlin.gradle.tasks.KotlinJvmCompile plugins { alias(libs.plugins.android.application) alias(libs.plugins.dokka) - alias(libs.plugins.kotlin.android) } val javaTarget = JvmTarget.fromTarget(libs.versions.jvmTarget.get()) @@ -314,8 +313,10 @@ tasks.withType { dokka { moduleName = "App" dokkaSourceSets { - main { + configureEach { + suppress = name != "prereleaseDebug" analysisPlatform = KotlinPlatform.JVM + displayName = "JVM" documentedVisibilities( VisibilityModifier.Public, VisibilityModifier.Protected diff --git a/build.gradle.kts b/build.gradle.kts index cca263dd4..e35c1f611 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -4,7 +4,6 @@ plugins { alias(libs.plugins.android.multiplatform.library) apply false alias(libs.plugins.buildkonfig) apply false // Universal build config alias(libs.plugins.dokka) apply false - alias(libs.plugins.kotlin.android) apply false alias(libs.plugins.kotlin.jvm) apply false alias(libs.plugins.kotlin.multiplatform) apply false } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index c02e79398..e304fd57f 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -2,7 +2,7 @@ # https://docs.gradle.org/current/userguide/dependency_versions.html#sec:strict-version [versions] activityKtx = "1.13.0" -androidGradlePlugin = "8.13.2" +androidGradlePlugin = "9.1.1" appcompat = "1.7.1" biometric = "1.4.0-alpha06" buildkonfigGradlePlugin = "0.18.0" @@ -117,7 +117,6 @@ android-lint = { id = "com.android.lint", version.ref = "androidGradlePlugin" } android-multiplatform-library = { id = "com.android.kotlin.multiplatform.library", version.ref = "androidGradlePlugin" } buildkonfig = { id = "com.codingfeline.buildkonfig", version.ref = "buildkonfigGradlePlugin" } dokka = { id = "org.jetbrains.dokka", version.ref = "dokkaGradlePlugin" } -kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlinGradlePlugin" } kotlin-jvm = { id = "org.jetbrains.kotlin.jvm" , version.ref = "kotlinGradlePlugin" } kotlin-multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlinGradlePlugin" } From e3e995b2227ffffcbd22d2350c00f3def0fbf4a7 Mon Sep 17 00:00:00 2001 From: Luna712 <142361265+Luna712@users.noreply.github.com> Date: Sun, 19 Apr 2026 11:01:50 -0600 Subject: [PATCH 137/236] Add lint ignore (#2669) We only care about the source language with this, not translations which would mostly be false positives. --- app/lint.xml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/app/lint.xml b/app/lint.xml index 48cdec04a..b2f5e8f2b 100644 --- a/app/lint.xml +++ b/app/lint.xml @@ -5,4 +5,9 @@ + + + + + From 0ed6fd8fef2e119a64193fcdddaadc5eb081a7ca Mon Sep 17 00:00:00 2001 From: Luna712 <142361265+Luna712@users.noreply.github.com> Date: Sun, 19 Apr 2026 15:39:12 -0600 Subject: [PATCH 138/236] Bump jsoup and zipline libs (#2517) --- gradle/libs.versions.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index e304fd57f..6aaa0c43f 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -18,7 +18,7 @@ fragmentKtx = "1.8.9" fuzzywuzzy = "1.4.0" jacksonModuleKotlin = { strictly = "2.13.1" } # Later versions don't support minSdk <26 (Crashes on Android TV's and FireSticks) json = "20251224" -jsoup = "1.21.2" +jsoup = "1.22.1" junit = "4.13.2" junitKtx = "1.3.0" junitVersion = "1.3.0" @@ -45,7 +45,7 @@ torrentserver = "7861970" tvprovider = "1.1.0" video = "1.0.0" workRuntimeKtx = "2.11.2" -zipline = "1.24.0" +zipline = "1.27.0" jvmTarget = "1.8" jdkToolchain = "17" From 2264b903963e1928cd440cee468743c8570e0634 Mon Sep 17 00:00:00 2001 From: Luna712 <142361265+Luna712@users.noreply.github.com> Date: Sun, 19 Apr 2026 15:39:22 -0600 Subject: [PATCH 139/236] Bump nicehttp (#2697) --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 6aaa0c43f..19be8d6ad 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -31,7 +31,7 @@ media3 = "1.9.3" navigationKtx = "2.9.7" newpipeextractor = "v0.26.0" nextlibMedia3 = "1.9.3-0.12.0" -nicehttp = "0.4.17" +nicehttp = "0.4.18" overlappingpanels = "0.1.5" paletteKtx = "1.0.0" preferenceKtx = "1.2.1" From 590a94e3188c38b01a55a25956290c84a5207966 Mon Sep 17 00:00:00 2001 From: Luna712 <142361265+Luna712@users.noreply.github.com> Date: Sun, 19 Apr 2026 15:40:12 -0600 Subject: [PATCH 140/236] Fix typo in credits (#2703) --- .../java/com/lagradost/cloudstream3/utils/videoskip/SkipAPI.kt | 2 +- app/src/main/res/values-b+apc/strings.xml | 2 +- app/src/main/res/values-b+ar/strings.xml | 2 +- app/src/main/res/values-b+as/strings.xml | 2 +- app/src/main/res/values-b+bg/strings.xml | 2 +- app/src/main/res/values-b+cs/strings.xml | 2 +- app/src/main/res/values-b+de/strings.xml | 2 +- app/src/main/res/values-b+el/strings.xml | 2 +- app/src/main/res/values-b+es/strings.xml | 2 +- app/src/main/res/values-b+fr/strings.xml | 2 +- app/src/main/res/values-b+hr/strings.xml | 2 +- app/src/main/res/values-b+hu/strings.xml | 2 +- app/src/main/res/values-b+in/strings.xml | 2 +- app/src/main/res/values-b+it/strings.xml | 2 +- app/src/main/res/values-b+iw/strings.xml | 2 +- app/src/main/res/values-b+ja/strings.xml | 2 +- app/src/main/res/values-b+ko/strings.xml | 2 +- app/src/main/res/values-b+lv/strings.xml | 2 +- app/src/main/res/values-b+mk/strings.xml | 2 +- app/src/main/res/values-b+ms/strings.xml | 2 +- app/src/main/res/values-b+my/strings.xml | 2 +- app/src/main/res/values-b+nl/strings.xml | 2 +- app/src/main/res/values-b+no/strings.xml | 2 +- app/src/main/res/values-b+or/strings.xml | 2 +- app/src/main/res/values-b+pl/strings.xml | 2 +- app/src/main/res/values-b+pt+BR/strings.xml | 2 +- app/src/main/res/values-b+pt/strings.xml | 2 +- app/src/main/res/values-b+qt/strings.xml | 2 +- app/src/main/res/values-b+ro/strings.xml | 2 +- app/src/main/res/values-b+ru/strings.xml | 2 +- app/src/main/res/values-b+so/strings.xml | 2 +- app/src/main/res/values-b+sv/strings.xml | 2 +- app/src/main/res/values-b+ta/strings.xml | 2 +- app/src/main/res/values-b+tr/strings.xml | 2 +- app/src/main/res/values-b+uk/strings.xml | 2 +- app/src/main/res/values-b+ur/strings.xml | 2 +- app/src/main/res/values-b+vi/strings.xml | 2 +- app/src/main/res/values-b+zh+TW/strings.xml | 2 +- app/src/main/res/values-b+zh/strings.xml | 2 +- app/src/main/res/values-be/strings.xml | 2 +- app/src/main/res/values/strings.xml | 2 +- 41 files changed, 41 insertions(+), 41 deletions(-) diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/videoskip/SkipAPI.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/videoskip/SkipAPI.kt index cd6727a24..6c7126049 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/videoskip/SkipAPI.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/videoskip/SkipAPI.kt @@ -16,7 +16,7 @@ enum class SkipType(@StringRes val res: Int) { Recap(R.string.skip_type_recap), MixedOpening(R.string.skip_type_mixed_op), MixedEnding(R.string.skip_type_mixed_ed), - Credits(R.string.skip_type_creddits), + Credits(R.string.skip_type_credits), Intro(R.string.skip_type_intro), Preview(R.string.skip_type_preview), } diff --git a/app/src/main/res/values-b+apc/strings.xml b/app/src/main/res/values-b+apc/strings.xml index 9bc697acf..365a317e3 100644 --- a/app/src/main/res/values-b+apc/strings.xml +++ b/app/src/main/res/values-b+apc/strings.xml @@ -562,7 +562,7 @@ /%d @string/home_play شيلو من لايحة المحتوى الحاضرينو - الإعتمادات + الإعتمادات فيكُن هون تغيرو طريقة ترتيب المصادر. المصدر يللي عنده أولوية أكتر بينحط أعلى بلايحت تنقايت المصدر. إنتو بتنقو الأولوية يإستعمال الأرقام. حطو الرقم الأعلى للمصادر والجودات يللي بتفضلوها. \n \nمتلًا: diff --git a/app/src/main/res/values-b+ar/strings.xml b/app/src/main/res/values-b+ar/strings.xml index 17e809d8d..158748bbf 100644 --- a/app/src/main/res/values-b+ar/strings.xml +++ b/app/src/main/res/values-b+ar/strings.xml @@ -454,7 +454,7 @@ مشغل داخلي لم يتم العثور على التطبيق جميع اللغات - الإعتمادات + الإعتمادات ‌تنزيل تحديث التطبيق… ‏تثبيت تحديث التطبيق… %d دقيقة diff --git a/app/src/main/res/values-b+as/strings.xml b/app/src/main/res/values-b+as/strings.xml index eb6ad4aa4..f5338a9e5 100644 --- a/app/src/main/res/values-b+as/strings.xml +++ b/app/src/main/res/values-b+as/strings.xml @@ -493,7 +493,7 @@ মিশ্ৰিত সমাপ্তি মিশ্ৰিত উদ্‌ঘাটনী ইতিহাস পৰিস্কাৰ কৰক - স্বীকৃতি + স্বীকৃতি ভূমিকা ইতিহাস উদ্‌ঘাটনী/সমাপ্তিৰ বাবে এৰি দিয়াৰ পপআপ দেখুৱাওক diff --git a/app/src/main/res/values-b+bg/strings.xml b/app/src/main/res/values-b+bg/strings.xml index 096e9f66b..6c8e38722 100644 --- a/app/src/main/res/values-b+bg/strings.xml +++ b/app/src/main/res/values-b+bg/strings.xml @@ -451,7 +451,7 @@ Изтегля се актуализация на приложението… Смесено отваряне Смесено затваряне - Кредити + Кредити въведение Изчистване на историята Автоматично инсталиране на всички все още неинсталирани добавки от добавени хранилища. diff --git a/app/src/main/res/values-b+cs/strings.xml b/app/src/main/res/values-b+cs/strings.xml index 96110d9c1..71ddc8697 100644 --- a/app/src/main/res/values-b+cs/strings.xml +++ b/app/src/main/res/values-b+cs/strings.xml @@ -438,7 +438,7 @@ Vymazat historii Všechny jazyky Smíšený úvod - Poděkování + Poděkování Znělka Zobrazit vyskakovací okna pro přeskočení úvodu/konce Stahování aktualizace aplikace… diff --git a/app/src/main/res/values-b+de/strings.xml b/app/src/main/res/values-b+de/strings.xml index 9a67f9d20..dfcf97ce2 100644 --- a/app/src/main/res/values-b+de/strings.xml +++ b/app/src/main/res/values-b+de/strings.xml @@ -13,7 +13,7 @@ Chromecast-Mirror In App wiedergeben Gemischte Openings - Abspann + Abspann Intro Verlauf löschen Verlauf diff --git a/app/src/main/res/values-b+el/strings.xml b/app/src/main/res/values-b+el/strings.xml index 4b671644b..6839d9944 100644 --- a/app/src/main/res/values-b+el/strings.xml +++ b/app/src/main/res/values-b+el/strings.xml @@ -389,7 +389,7 @@ Web HDR Ανάμεικτοι τίτλοι αρχής - Εύσημα + Εύσημα Εισαγωγή +30 Ολοκληρώθηκε diff --git a/app/src/main/res/values-b+es/strings.xml b/app/src/main/res/values-b+es/strings.xml index 5e59477ce..e692ecc4a 100644 --- a/app/src/main/res/values-b+es/strings.xml +++ b/app/src/main/res/values-b+es/strings.xml @@ -66,7 +66,7 @@ Final Apertura mixta Resumen - Créditos + Créditos Final mixto Póster del episodio Siguiente episodio diff --git a/app/src/main/res/values-b+fr/strings.xml b/app/src/main/res/values-b+fr/strings.xml index 1cbee687f..10b8cf9ef 100644 --- a/app/src/main/res/values-b+fr/strings.xml +++ b/app/src/main/res/values-b+fr/strings.xml @@ -302,7 +302,7 @@ Ignorer %s Ouverture Récap - Crédits + Crédits Intro Effacer l\'historique Oui diff --git a/app/src/main/res/values-b+hr/strings.xml b/app/src/main/res/values-b+hr/strings.xml index 8b3a6fbf3..c629c492f 100644 --- a/app/src/main/res/values-b+hr/strings.xml +++ b/app/src/main/res/values-b+hr/strings.xml @@ -436,7 +436,7 @@ Jezik HLS playlista Automatski instaliraj dodatke - Zasluge + Zasluge Automatski instaliraj sve neinstalirane dodatke iz dodanih repozitorija. Preferirani video player Interni player diff --git a/app/src/main/res/values-b+hu/strings.xml b/app/src/main/res/values-b+hu/strings.xml index 8bd2ac7ac..bffc0a86a 100644 --- a/app/src/main/res/values-b+hu/strings.xml +++ b/app/src/main/res/values-b+hu/strings.xml @@ -469,7 +469,7 @@ Repó URL Bővítmény betöltve Bővítmény letöltve - Közreműködők + Közreműködők Betűrendben (Z-től az A-ig) Könyvtár kiválasztása Biztonságos módú fájlba ütköztünk! diff --git a/app/src/main/res/values-b+in/strings.xml b/app/src/main/res/values-b+in/strings.xml index d5bf2d4b0..830972586 100644 --- a/app/src/main/res/values-b+in/strings.xml +++ b/app/src/main/res/values-b+in/strings.xml @@ -467,7 +467,7 @@ Sesi Akhir Sinopsis Sesi akhir ganda - Sesi Kredit + Sesi Kredit Sesi Intro Terlalu banyak teks. Tidak dapat menyalin ke papan klip. Yakin ingin keluar? diff --git a/app/src/main/res/values-b+it/strings.xml b/app/src/main/res/values-b+it/strings.xml index 08a1572d6..c96299c72 100644 --- a/app/src/main/res/values-b+it/strings.xml +++ b/app/src/main/res/values-b+it/strings.xml @@ -447,7 +447,7 @@ Riassunto - Crediti + Crediti Cancella cronologia Cronologia diff --git a/app/src/main/res/values-b+iw/strings.xml b/app/src/main/res/values-b+iw/strings.xml index ef4cb9202..0b0479679 100644 --- a/app/src/main/res/values-b+iw/strings.xml +++ b/app/src/main/res/values-b+iw/strings.xml @@ -422,7 +422,7 @@ כל %s כבר הורד מחברים שפה - קרדיטים + קרדיטים מיין בחר ספרייה נראה שהספרייה שלכם ריקה :( diff --git a/app/src/main/res/values-b+ja/strings.xml b/app/src/main/res/values-b+ja/strings.xml index 0b66ca8b2..b489db37d 100644 --- a/app/src/main/res/values-b+ja/strings.xml +++ b/app/src/main/res/values-b+ja/strings.xml @@ -469,7 +469,7 @@ 無効: %d 優先ビデオプレーヤー %s をスキップ - クレジット + クレジット アプリのバッテリー使用はすでに無制限に設定されています 並べ替え 元に戻す diff --git a/app/src/main/res/values-b+ko/strings.xml b/app/src/main/res/values-b+ko/strings.xml index 256fc26e9..efe034258 100644 --- a/app/src/main/res/values-b+ko/strings.xml +++ b/app/src/main/res/values-b+ko/strings.xml @@ -443,7 +443,7 @@ 엔딩 혼합 엔딩 혼합 오프닝 - 크레딧 + 크레딧 소개 기록 삭제 기록 diff --git a/app/src/main/res/values-b+lv/strings.xml b/app/src/main/res/values-b+lv/strings.xml index 89003317a..101498b83 100644 --- a/app/src/main/res/values-b+lv/strings.xml +++ b/app/src/main/res/values-b+lv/strings.xml @@ -436,7 +436,7 @@ Kopsavilkums Jauktas beigas Jauktais sākums - Kredīts + Kredīts Notīrīt vēsturi Vēsture Rādīt izlaižamos uznirstošos logus atvēršanai/beigšanai diff --git a/app/src/main/res/values-b+mk/strings.xml b/app/src/main/res/values-b+mk/strings.xml index 6998c49db..4af4995ea 100644 --- a/app/src/main/res/values-b+mk/strings.xml +++ b/app/src/main/res/values-b+mk/strings.xml @@ -260,7 +260,7 @@ Подреди Внатрешен плеер Резолуција - Кредити + Кредити Пребарај %s… Приклучокот е избришан Статус diff --git a/app/src/main/res/values-b+ms/strings.xml b/app/src/main/res/values-b+ms/strings.xml index 83492a5ff..9ec0192cf 100644 --- a/app/src/main/res/values-b+ms/strings.xml +++ b/app/src/main/res/values-b+ms/strings.xml @@ -5,7 +5,7 @@ Sejarah Kosongkan sejarah Pengenalan - Kredit + Kredit Pembukaan bercampur Penamat Pembukaan diff --git a/app/src/main/res/values-b+my/strings.xml b/app/src/main/res/values-b+my/strings.xml index 4a7a50aa7..0938e4f98 100644 --- a/app/src/main/res/values-b+my/strings.xml +++ b/app/src/main/res/values-b+my/strings.xml @@ -336,7 +336,7 @@ အစမှပြန်စ ရောထားသောအဆုံးပိုင်း ရောထားသောအစပိုင်း - ခရက်ဒစ်များ + ခရက်ဒစ်များ အစ သေချာသည် သမားရိုးကျ diff --git a/app/src/main/res/values-b+nl/strings.xml b/app/src/main/res/values-b+nl/strings.xml index 30b8b2def..3cdea9d8f 100644 --- a/app/src/main/res/values-b+nl/strings.xml +++ b/app/src/main/res/values-b+nl/strings.xml @@ -514,7 +514,7 @@ Veilige mode aan Herstart Beschrijving - Waardering + Waardering Wis geschiedenis Ingeschreven Wis repository diff --git a/app/src/main/res/values-b+no/strings.xml b/app/src/main/res/values-b+no/strings.xml index 374b033c6..55b5303eb 100644 --- a/app/src/main/res/values-b+no/strings.xml +++ b/app/src/main/res/values-b+no/strings.xml @@ -381,7 +381,7 @@ Bruk dette hvis undertekster vises %d ms for sent Programtillegg innlastet Lydspor - Rulletekst + Rulletekst Introduksjon Lagringstilgang mangler. Prøv igjen. Vis trailere diff --git a/app/src/main/res/values-b+or/strings.xml b/app/src/main/res/values-b+or/strings.xml index 8c9379f5b..40a2915fd 100644 --- a/app/src/main/res/values-b+or/strings.xml +++ b/app/src/main/res/values-b+or/strings.xml @@ -61,7 +61,7 @@ ସବୁ ଭାଷା ମିଶ୍ରିତ ପ୍ରାନ୍ତ ମିଶ୍ରିତ ଆଦ୍ୟ - ଶ୍ରେୟ + ଶ୍ରେୟ ଉପକ୍ରମ ଏହି ଭାଷାଗୁଡ଼ିକରେ ଵିଡ଼ିଓ ଦେଖନ୍ତୁ ସଂସ୍କରଣ diff --git a/app/src/main/res/values-b+pl/strings.xml b/app/src/main/res/values-b+pl/strings.xml index c8126f2fe..0536e6807 100644 --- a/app/src/main/res/values-b+pl/strings.xml +++ b/app/src/main/res/values-b+pl/strings.xml @@ -452,7 +452,7 @@ Opening Ending Mixed opening - Napisy końcowe + Napisy końcowe Intro Mixed ending Pokaż wyskakujące okienka pomijania dla niektórych segmentów diff --git a/app/src/main/res/values-b+pt+BR/strings.xml b/app/src/main/res/values-b+pt+BR/strings.xml index 58d598708..72ecbf3d9 100644 --- a/app/src/main/res/values-b+pt+BR/strings.xml +++ b/app/src/main/res/values-b+pt+BR/strings.xml @@ -510,7 +510,7 @@ Versão Autores Instale a extensão primeiro - Créditos + Créditos Historico Limpar historico Tem Muito texto. Não é possível salvar no clipboard. diff --git a/app/src/main/res/values-b+pt/strings.xml b/app/src/main/res/values-b+pt/strings.xml index a1abfa338..88eccbeac 100644 --- a/app/src/main/res/values-b+pt/strings.xml +++ b/app/src/main/res/values-b+pt/strings.xml @@ -410,7 +410,7 @@ Sim Baixando atualização do app… Episódio %d lançado! - Créditos + Créditos Descrição Tamanho Parar diff --git a/app/src/main/res/values-b+qt/strings.xml b/app/src/main/res/values-b+qt/strings.xml index d60a4e32c..8a43e97d7 100644 --- a/app/src/main/res/values-b+qt/strings.xml +++ b/app/src/main/res/values-b+qt/strings.xml @@ -607,7 +607,7 @@ aaaagg aahh oooohh uuuuuk aaagg aaaahhh - ooooggg + ooooggg oh oooogg ooooggguuuugg aaaahhhug ooh aaaaggguuuuuk ooh aaaagggoog uk shows, aaaaggguugg aaagg uuuuggguug ug uug oh aaaahhhooh ah eeeeeekh OK, youl og aaaaaakg uh uuk aagg There, aaaagg uk oog uuuuhhh ooohh uug uuh oooohhh aaagg ug uuuuhhhooohh oooohh note, aagg aaaahhhaak oohh uug uugg CS3 aagg eeeek aahh uuuuhhh uh uugg oogg ooooggg ek uug aaaahhhaak oohh necessary, uugg oh uuhh uuuuhhhuh uuuuuukaaaahh ah oooohhhaagg uuuuuk oogg aaaagggh oooogggoog uh uuh aaaahh ug cancel, uuh uug aaaahh uugg uuuuggg ooogg uh aaaahhh uuuugggg uuuuggg (Old og New) uuuuggguuuhh (Z ak A) diff --git a/app/src/main/res/values-b+ro/strings.xml b/app/src/main/res/values-b+ro/strings.xml index dbd607666..bb49563ec 100644 --- a/app/src/main/res/values-b+ro/strings.xml +++ b/app/src/main/res/values-b+ro/strings.xml @@ -522,7 +522,7 @@ Afișează opțiunea de omitere a ferestrelor pop-up pentru început/sfârșit Toate limbile Deschidere mixat - Credite + Credite Limbă plugin plugin-uri diff --git a/app/src/main/res/values-b+ru/strings.xml b/app/src/main/res/values-b+ru/strings.xml index 9f6b53aa7..1a5db40a1 100644 --- a/app/src/main/res/values-b+ru/strings.xml +++ b/app/src/main/res/values-b+ru/strings.xml @@ -303,7 +303,7 @@ Приложение не найдено Все языки Вступление - Титры + Титры Отметить как просмотренное Показывать информацию про видеоплеер Предпочтительное качество видео (WiFi) diff --git a/app/src/main/res/values-b+so/strings.xml b/app/src/main/res/values-b+so/strings.xml index 09499af00..9e4e9f9f1 100644 --- a/app/src/main/res/values-b+so/strings.xml +++ b/app/src/main/res/values-b+so/strings.xml @@ -471,5 +471,5 @@ Dhamaad isku qasan Bilowga Bilow isku qasan - Qoraalka dhamaadka + Qoraalka dhamaadka diff --git a/app/src/main/res/values-b+sv/strings.xml b/app/src/main/res/values-b+sv/strings.xml index e388b67e1..695050336 100644 --- a/app/src/main/res/values-b+sv/strings.xml +++ b/app/src/main/res/values-b+sv/strings.xml @@ -549,7 +549,7 @@ Ladda ner listan över webbplatser du vill använda %s (Inaktiverad) Beskrivning - Eftertexter + Eftertexter Introduktion Favoriter Ange standard diff --git a/app/src/main/res/values-b+ta/strings.xml b/app/src/main/res/values-b+ta/strings.xml index 626554c18..5cdbeaa37 100644 --- a/app/src/main/res/values-b+ta/strings.xml +++ b/app/src/main/res/values-b+ta/strings.xml @@ -537,7 +537,7 @@ உள் வீரர் திறப்பு கலப்பு திறப்பு - வரவு + வரவு வரலாறு சரி கிளவுட்ச்ட்ரீமின் பயன்பாட்டுத் தகவலைத் திறக்க முடியவில்லை. diff --git a/app/src/main/res/values-b+tr/strings.xml b/app/src/main/res/values-b+tr/strings.xml index e84d5271e..47db0e97e 100644 --- a/app/src/main/res/values-b+tr/strings.xml +++ b/app/src/main/res/values-b+tr/strings.xml @@ -477,7 +477,7 @@ İzlenenlerden kaldır Karışık son Karışık başlangıç - Katkıda Bulunanlar + Katkıda Bulunanlar Giriş Eklenti İndirildi Eylemler diff --git a/app/src/main/res/values-b+uk/strings.xml b/app/src/main/res/values-b+uk/strings.xml index 2eb6e2451..74acb48ae 100644 --- a/app/src/main/res/values-b+uk/strings.xml +++ b/app/src/main/res/values-b+uk/strings.xml @@ -435,7 +435,7 @@ Коротке повторення Пропустити %s Змішаний ендінґ - Подяки + Подяки Опенінґ Вступ Очистити історію diff --git a/app/src/main/res/values-b+ur/strings.xml b/app/src/main/res/values-b+ur/strings.xml index 5f6d8aa14..acd6e4097 100644 --- a/app/src/main/res/values-b+ur/strings.xml +++ b/app/src/main/res/values-b+ur/strings.xml @@ -346,7 +346,7 @@ %d / 10 اٹھایا اگر سب ٹائٹلز %d ms بہت جلد دکھائے جائیں تو اسے استعمال کریں - کریڈٹس + کریڈٹس اضافی مرکزی ترتیب diff --git a/app/src/main/res/values-b+vi/strings.xml b/app/src/main/res/values-b+vi/strings.xml index a51a3551d..d0d6059aa 100644 --- a/app/src/main/res/values-b+vi/strings.xml +++ b/app/src/main/res/values-b+vi/strings.xml @@ -470,7 +470,7 @@ Điểm lại nội dung Kết thúc hỗn hợp Mở đầu hỗn hợp - Danh đề + Danh đề Giới thiệu Xoá lịch sử Hiện các popup bỏ qua cho mở đầu/kết thúc diff --git a/app/src/main/res/values-b+zh+TW/strings.xml b/app/src/main/res/values-b+zh+TW/strings.xml index 78ba57310..cb58e96f5 100644 --- a/app/src/main/res/values-b+zh+TW/strings.xml +++ b/app/src/main/res/values-b+zh+TW/strings.xml @@ -448,7 +448,7 @@ 前情回顧 混合片尾 混合片頭 - 致謝名單 + 致謝名單 介紹 清除歷史紀錄 歷史紀錄 diff --git a/app/src/main/res/values-b+zh/strings.xml b/app/src/main/res/values-b+zh/strings.xml index bc7c2ca0e..496afe81c 100644 --- a/app/src/main/res/values-b+zh/strings.xml +++ b/app/src/main/res/values-b+zh/strings.xml @@ -449,7 +449,7 @@ 前情回顾 混合片尾 混合片头 - 致谢名单 + 致谢名单 介绍 清除历史记录 历史记录 diff --git a/app/src/main/res/values-be/strings.xml b/app/src/main/res/values-be/strings.xml index ee2c24972..374de33d2 100644 --- a/app/src/main/res/values-be/strings.xml +++ b/app/src/main/res/values-be/strings.xml @@ -537,7 +537,7 @@ Зводка Змешанае заканчэнне Змешаны опенінг - Удзельнікі + Удзельнікі Застаўка Ачысціць гісторыю Гісторыя diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index d59065a64..e41c01fda 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -559,7 +559,7 @@ Recap Mixed ending Mixed opening - Credits + Credits Preview Intro Clear history From f7494f20e17a3731ea298588af62f4fc9e714f77 Mon Sep 17 00:00:00 2001 From: Luna712 <142361265+Luna712@users.noreply.github.com> Date: Sun, 19 Apr 2026 15:42:46 -0600 Subject: [PATCH 141/236] Support resuming fragmented MP4s (#2690) --- .../cloudstream3/ui/player/CS3IPlayer.kt | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/CS3IPlayer.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/CS3IPlayer.kt index 29a77883b..4323c98fd 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/CS3IPlayer.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/CS3IPlayer.kt @@ -1430,6 +1430,23 @@ class CS3IPlayer : IPlayer { event(PlayerAttachedEvent(exoPlayer)) exoPlayer?.prepare() + // For offline fragmented MP4s, FLAG_MERGE_FRAGMENTED_SIDX builds the SIDX seek map + // incrementally as data is buffered. The initial seek resolves to the nearest merged + // entry (~first fragment, 3 s). On STATE_READY, re-seek to the actual saved position. + // This may only be reproducible on large and fairly long fragmented MP4 files with + // multiple sidx boxes. + if (onlineSource == null && playbackPosition > (exoPlayer?.duration ?: 0L)) { + exoPlayer?.addListener(object : Player.Listener { + private var seekApplied = false + override fun onPlaybackStateChanged(playbackState: Int) { + if (seekApplied || playbackState != Player.STATE_READY) return + seekApplied = true + exoPlayer?.seekTo(currentWindow, playbackPosition) + exoPlayer?.removeListener(this) + } + }) + } + exoPlayer?.let { exo -> event(StatusEvent(CSPlayerLoading.IsBuffering, CSPlayerLoading.IsBuffering)) isPlaying = exo.isPlaying From 18ee71664ff6b7592e45e85c8ad6c758d64472f6 Mon Sep 17 00:00:00 2001 From: firelight <147925818+fire-light42@users.noreply.github.com> Date: Mon, 20 Apr 2026 23:24:37 +0000 Subject: [PATCH 142/236] Feat: Offline filler database (#2704) --- app/build.gradle.kts | 3 + .../ui/result/ResultViewModel2.kt | 18 +- .../cloudstream3/utils/FillerEpisodeCheck.kt | 236 +++++++++++------- gradle/libs.versions.toml | 2 + 4 files changed, 160 insertions(+), 99 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 0ea37a025..368445097 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -234,6 +234,9 @@ dependencies { // FFmpeg Decoding implementation(libs.bundles.nextlib) + // Anime-db for filler + implementation(libs.anime.db) + // PlayBack implementation(libs.colorpicker) // Subtitle Color Picker implementation(libs.newpipeextractor) // For Trailers 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 6eab987fc..cc48f6549 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 @@ -113,12 +113,14 @@ import com.lagradost.cloudstream3.utils.newExtractorLink import com.lagradost.cloudstream3.utils.txt import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.cancelChildren import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.isActive import kotlinx.coroutines.job import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import java.util.concurrent.TimeUnit /** This starts at 1 */ @@ -452,7 +454,7 @@ class ResultViewModel2 : ViewModel() { private var currentShowFillers: Boolean = false var currentRepo: APIRepository? = null private var currentId: Int? = null - private var fillers: Map = emptyMap() + private var fillers: HashSet = hashSetOf() private var generator: IGenerator? = null private var preferDubStatus: DubStatus? = null private var preferStartEpisode: Int? = null @@ -1806,11 +1808,11 @@ class ResultViewModel2 : ViewModel() { } - private suspend fun updateFillers(name: String) { + private suspend fun updateFillers(data : LoadResponse) { fillers = - ioWorkSafe { - FillerEpisodeCheck.getFillerEpisodes(name) - } ?: emptyMap() + withContext(Dispatchers.IO) { + safe { FillerEpisodeCheck.getFillerEpisodes(data) } + } ?: hashSetOf() } fun changeDubStatus(status: DubStatus) { @@ -2147,8 +2149,8 @@ class ResultViewModel2 : ViewModel() { ) { _episodes.postValue(Resource.Loading()) - if (updateFillers && loadResponse is AnimeLoadResponse) { - updateFillers(loadResponse.name) + if (updateFillers) { + updateFillers(loadResponse) } val allEpisodes = when (loadResponse) { @@ -2189,7 +2191,7 @@ class ResultViewModel2 : ViewModel() { index, i.score, i.description, - fillers.getOrDefault(episode, false), + fillers.contains(episode), loadResponse.type, mainId, totalIndex, diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/FillerEpisodeCheck.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/FillerEpisodeCheck.kt index 09d4683bc..8456094d1 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/FillerEpisodeCheck.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/FillerEpisodeCheck.kt @@ -1,112 +1,166 @@ package com.lagradost.cloudstream3.utils -import com.lagradost.cloudstream3.app +import androidx.annotation.WorkerThread +import com.fasterxml.jackson.annotation.JsonProperty +import com.lagradost.cloudstream3.LoadResponse +import com.lagradost.cloudstream3.LoadResponse.Companion.getAniListId +import com.lagradost.cloudstream3.LoadResponse.Companion.getImdbId +import com.lagradost.cloudstream3.LoadResponse.Companion.getKitsuId +import com.lagradost.cloudstream3.LoadResponse.Companion.getMalId +import com.lagradost.cloudstream3.LoadResponse.Companion.getTMDbId +import com.lagradost.cloudstream3.TvType +import com.lagradost.cloudstream3.ui.result.getId import com.lagradost.cloudstream3.utils.Coroutines.main -import org.jsoup.Jsoup import java.lang.Thread.sleep import java.util.* import kotlin.concurrent.thread +import com.lagradost.cloudstream3.utils.AppUtils.parseJson +import java.io.InputStream +import kotlin.let object FillerEpisodeCheck { - private const val MAIN_URL = "https://www.animefillerlist.com" - - var list: HashMap? = null - var cache: HashMap> = hashMapOf() - - private fun fixName(name: String): String { - return name.lowercase(Locale.ROOT)/*.replace(" ", "")*/.replace("-", " ") - .replace("[^a-zA-Z0-9 ]".toRegex(), "") - } - - private suspend fun getFillerList(): Boolean { - if (list != null) return true - try { - val result = app.get("$MAIN_URL/shows").text - val documented = Jsoup.parse(result) - val localHTMLList = documented.select("div#ShowList > div.Group > ul > li > a") - val localList = HashMap() - for (i in localHTMLList) { - val name = i.text() - - if (name.lowercase(Locale.ROOT).contains("manga only")) continue - - val href = i.attr("href") - if (name.isNullOrEmpty() || href.isNullOrEmpty()) { - continue - } - - val values = "(.*) \\((.*)\\)".toRegex().matchEntire(name)?.groups - if (values != null) { - for (index in 1 until values.size) { - val localName = values[index]?.value ?: continue - localList[fixName(localName)] = href - } - } else { - localList[fixName(name)] = href - } - } - if (localList.size > 0) { - list = localList - return true - } - } catch (e: Exception) { - e.printStackTrace() - } - return false - } - fun String?.toClassDir(): String { val q = this ?: "null" val z = (6..10).random().calc() return q + "cache" + z } - suspend fun getFillerEpisodes(query: String): HashMap? { - try { - cache[query]?.let { - return it - } - if (!getFillerList()) return null - val localList = list ?: return null + data class Show( + @JsonProperty("slug") + val slug: String, + @JsonProperty("title") + val title: String, + @JsonProperty("filler") + val filler: ArrayList, + @JsonProperty("mixedCanon") + val mixedCanon: ArrayList, + @JsonProperty("mangaCanon") + val mangaCanon: ArrayList, + @JsonProperty("animeCanon") + val animeCanon: ArrayList, + ) - // Strips these from the name - val blackList = listOf( - "TV Dubbed", - "(Dub)", - "Subbed", - "(TV)", - "(Uncensored)", - "(Censored)", - "(\\d+)" // year - ) - val blackListRegex = - Regex( - """ (${ - blackList.joinToString(separator = "|").replace("(", "\\(") - .replace(")", "\\)") - })""" - ) + data class MappingRoot( + @JsonProperty("type") + val type: String?, + @JsonProperty("anidb_id") + val anidbId: Long?, + @JsonProperty("anilist_id") + val anilistId: Long?, + @JsonProperty("animecountdown_id") + val animecountdownId: Long?, + @JsonProperty("animenewsnetwork_id") + val animenewsnetworkId: Long?, + @JsonProperty("anime-planet_id") + val animePlanetId: String?, + @JsonProperty("anisearch_id") + val anisearchId: Long?, + @JsonProperty("imdb_id") + val imdbId: String?, + @JsonProperty("kitsu_id") + val kitsuId: Long?, + @JsonProperty("livechart_id") + val livechartId: Long?, + @JsonProperty("mal_id") + val malId: Long?, + @JsonProperty("simkl_id") + val simklId: Long?, + @JsonProperty("themoviedb_id") + val themoviedbId: Long?, + @JsonProperty("tvdb_id") + val tvdbId: Long?, + @JsonProperty("season") + val season: Season?, + ) - val realQuery = - fixName(query.replace(blackListRegex, "")).replace("shippuuden", "shippuden") - if (!localList.containsKey(realQuery)) return null - val href = localList[realQuery]?.replace(MAIN_URL, "") ?: return null // JUST IN CASE - val result = app.get("$MAIN_URL$href").text - val documented = Jsoup.parse(result) - val hashMap = HashMap() - documented.select("table.EpisodeList > tbody > tr").forEach { - val type = it.selectFirst("td.Type > span")?.text() == "Filler" - val episodeNumber = it.selectFirst("td.Number")?.text()?.toIntOrNull() - if (episodeNumber != null) { - hashMap[episodeNumber] = type - } - } - cache[query] = hashMap - return hashMap - } catch (e: Exception) { - e.printStackTrace() + data class Season( + @JsonProperty("tvdb") + val tvdb: Long?, + @JsonProperty("tmdb") + val tmdb: Long?, + ) + + data class CombinedMedia( + @JsonProperty("mapping") + val mapping: MappingRoot?, + @JsonProperty("show") + val show: Show + ) + + data class Database( + val mal: HashMap = hashMapOf(), + val anilist: HashMap = hashMapOf(), + val kitsu: HashMap = hashMapOf(), + val tmdb: HashMap = hashMapOf(), + val imdb: HashMap = hashMapOf(), + val name: HashMap = hashMapOf(), + ) + + private var database: Database? = null + + private val strip = Regex("[ :\\-.!]") + + /** Makes names more uniform to make partial matches more still give a result */ + fun stripName(name: String): String = + name.replace(strip, "").lowercase() + + + @Synchronized + @Throws + @WorkerThread + fun loadJson(): Database { + database?.let { + return it + } + + /** The entire "database" is stored as a json file we can parse */ + val stream: InputStream = com.lagradost.AnimeDB.getDatabaseStream()!! + val text = stream.reader().readText() + + val allMedia = parseJson>(text) + val pending = Database() + for (media in allMedia) { + val lowercase = stripName(media.show.title) + pending.name[lowercase] = media + val map = media.mapping ?: continue + + map.imdbId?.let { id -> pending.imdb[id] = media } + map.malId?.let { id -> pending.mal[id] = media } + map.anilistId?.let { id -> pending.anilist[id] = media } + map.kitsuId?.let { id -> pending.kitsu[id] = media } + map.season?.tmdb?.let { id -> pending.tmdb[id] = media } + } + database = pending + return pending + } + + val loadCache: HashMap?> = hashMapOf() + + @Synchronized + @Throws + @WorkerThread + fun getFillerEpisodes(data: LoadResponse): HashSet? { + /** Only for anime */ + if (data.type != TvType.Anime) { return null } + /** Try to hit the cache for this entry, to avoid recreating the hashset */ + loadCache[data.getId()]?.let { cachedResponse -> + return cachedResponse + } + val db = loadJson() + + val media = + db.mal[data.getMalId()?.toLongOrNull()] + ?: db.anilist[data.getAniListId()?.toLongOrNull()] + ?: db.kitsu[data.getKitsuId()?.toLongOrNull()] + ?: db.imdb[data.getImdbId()] + ?: db.tmdb[data.getTMDbId()?.toLongOrNull()] + ?: db.name[stripName(data.name)] + + return media?.show?.filler?.toHashSet().also { response -> + loadCache[data.getId()] = response + } } private fun Int.calc(): Int { diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 19be8d6ad..f0b24c8e7 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -3,6 +3,7 @@ [versions] activityKtx = "1.13.0" androidGradlePlugin = "9.1.1" +animeDb = "1.0.2" appcompat = "1.7.1" biometric = "1.4.0-alpha06" buildkonfigGradlePlugin = "0.18.0" @@ -55,6 +56,7 @@ targetSdk = "36" [libraries] activity-ktx = { module = "androidx.activity:activity-ktx", version.ref = "activityKtx" } +anime-db = { module = "com.github.recloudstream:anime-db", version.ref = "animeDb" } appcompat = { module = "androidx.appcompat:appcompat", version.ref = "appcompat" } biometric = { module = "androidx.biometric:biometric", version.ref = "biometric" } coil = { module = "io.coil-kt.coil3:coil", version.ref = "coil" } From d4899536d3391b1228afdc0572310c23b7b296ea Mon Sep 17 00:00:00 2001 From: Bnyro Date: Wed, 22 Apr 2026 02:45:04 +0200 Subject: [PATCH 143/236] refactor(extractors): simplify and combine jwplayer extraction (#2398) --- .../cloudstream3/extractors/Bigwarp.kt | 51 ------ .../cloudstream3/extractors/Fastream.kt | 52 +++--- .../cloudstream3/extractors/Filegram.kt | 52 ++++++ .../cloudstream3/extractors/Filemoon.kt | 40 ++--- .../cloudstream3/extractors/Filesim.kt | 13 +- .../cloudstream3/extractors/GamoVideo.kt | 29 ++-- .../cloudstream3/extractors/Hxfile.kt | 68 ++------ .../cloudstream3/extractors/JWPlayer.kt | 73 ++++----- .../cloudstream3/extractors/Jeniusplay.kt | 38 +---- .../cloudstream3/extractors/LuluStream.kt | 21 +-- .../cloudstream3/extractors/Minoplres.kt | 38 ----- .../cloudstream3/extractors/MultiQuality.kt | 63 ------- .../cloudstream3/extractors/Pelisplus.kt | 101 ------------ .../extractors/StreamWishExtractor.kt | 15 +- .../cloudstream3/extractors/StreamoUpload.kt | 36 ++-- .../cloudstream3/extractors/Supervideo.kt | 45 ++--- .../cloudstream3/extractors/Up4Stream.kt | 27 ++- .../cloudstream3/extractors/VidHidePro.kt | 23 +-- .../cloudstream3/extractors/VidNest.kt | 45 ----- .../cloudstream3/extractors/Vidmoly.kt | 49 +----- .../cloudstream3/extractors/Vidstream.kt | 104 ------------ .../lagradost/cloudstream3/extractors/Vtbe.kt | 33 ++-- .../extractors/helper/JWPlayerHelper.kt | 155 ++++++++++++++++++ .../cloudstream3/utils/ExtractorApi.kt | 2 - 24 files changed, 379 insertions(+), 794 deletions(-) delete mode 100644 library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Bigwarp.kt create mode 100644 library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Filegram.kt delete mode 100644 library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Minoplres.kt delete mode 100644 library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/MultiQuality.kt delete mode 100644 library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Pelisplus.kt delete mode 100644 library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/VidNest.kt delete mode 100644 library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Vidstream.kt create mode 100644 library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/helper/JWPlayerHelper.kt diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Bigwarp.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Bigwarp.kt deleted file mode 100644 index 50a68c62f..000000000 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Bigwarp.kt +++ /dev/null @@ -1,51 +0,0 @@ -package com.lagradost.cloudstream3.extractors - -import com.lagradost.cloudstream3.SubtitleFile -import com.lagradost.cloudstream3.app -import com.lagradost.cloudstream3.utils.ExtractorApi -import com.lagradost.cloudstream3.utils.ExtractorLink -import com.lagradost.cloudstream3.utils.Qualities -import com.lagradost.cloudstream3.utils.newExtractorLink - -open class BigwarpIO : ExtractorApi() { - override var name = "Bigwarp" - override var mainUrl = "https://bigwarp.io" - override val requiresReferer = false - - private val sourceRegex = Regex("""file:\s*['"](.*?)['"],label:\s*['"](.*?)['"]""") - private val qualityRegex = Regex("""\d+x(\d+) .*""") - - override suspend fun getUrl( - url: String, - referer: String?, - subtitleCallback: (SubtitleFile) -> Unit, - callback: (ExtractorLink) -> Unit - ) { - val resp = app.get(url).text - - for (sourceMatch in sourceRegex.findAll(resp)) { - val label = sourceMatch.groupValues[2] - - callback.invoke( - newExtractorLink( - name, - "$name ${label.split(" ", limit = 2).getOrNull(1)}", - sourceMatch.groupValues[1], // streams are usually in mp4 format - ) { - this.referer = url - this.quality = - qualityRegex.find(label)?.groupValues?.getOrNull(1)?.toIntOrNull() - ?: Qualities.Unknown.value - } - ) - } - } -} - -class BgwpCC : BigwarpIO() { - override var mainUrl = "https://bgwp.cc" -} - -class BigwarpArt : BigwarpIO() { - override var mainUrl = "https://bigwarp.art" -} diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Fastream.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Fastream.kt index e8f8c49ac..94ddaf61e 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Fastream.kt +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Fastream.kt @@ -1,54 +1,44 @@ package com.lagradost.cloudstream3.extractors +import com.lagradost.cloudstream3.SubtitleFile import com.lagradost.cloudstream3.amap import com.lagradost.cloudstream3.app +import com.lagradost.cloudstream3.extractors.helper.JwPlayerHelper import com.lagradost.cloudstream3.utils.ExtractorApi import com.lagradost.cloudstream3.utils.ExtractorLink -import com.lagradost.cloudstream3.utils.M3u8Helper.Companion.generateM3u8 import com.lagradost.cloudstream3.utils.getAndUnpack -import org.jsoup.nodes.Document +import com.lagradost.cloudstream3.utils.getPacked -open class Fastream: ExtractorApi() { +open class Fastream : ExtractorApi() { override var mainUrl = "https://fastream.to" override var name = "Fastream" override val requiresReferer = false - suspend fun getstream( - response: Document, - sources: ArrayList): Boolean{ - response.select("script").amap { script -> - if (script.data().contains(Regex("eval\\(function\\(p,a,c,k,e,[rd]"))) { - val unpacked = getAndUnpack(script.data()) - //val m3u8regex = Regex("((https:|http:)\\/\\/.*\\.m3u8)") - val newm3u8link = unpacked.substringAfter("file:\"").substringBefore("\"") - //val m3u8link = m3u8regex.find(unpacked)?.value ?: return@forEach - generateM3u8( - name, - newm3u8link, - mainUrl - ).forEach { link -> - sources.add(link) - } - } - } - return true - } - override suspend fun getUrl(url: String, referer: String?): List { - val sources = ArrayList() + override suspend fun getUrl( + url: String, + referer: String?, + subtitleCallback: (SubtitleFile) -> Unit, + callback: (ExtractorLink) -> Unit + ) { val idregex = Regex("emb.html\\?(.*)=") - if (url.contains(Regex("(emb.html.*fastream)"))) { + val response = if (url.contains(Regex("(emb.html.*fastream)"))) { val id = idregex.find(url)?.destructured?.component1() ?: "" - val response = app.post("https://fastream.to/dl", allowRedirects = false, + app.post( + "$mainUrl/dl", allowRedirects = false, data = mapOf( "op" to "embed", "file_code" to id, "auto" to "1" ) ).document - getstream(response, sources) + } else { + app.get(url, referer = url).document + } + response.select("script").amap { script -> + if (getPacked(script.data()) != null) { + val unPacked = getAndUnpack(script.data()) + JwPlayerHelper.extractStreamLinks(unPacked, name, mainUrl, callback, subtitleCallback) + } } - val response = app.get(url, referer = url).document - getstream(response, sources) - return sources } } \ No newline at end of file diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Filegram.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Filegram.kt new file mode 100644 index 000000000..7756f7290 --- /dev/null +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Filegram.kt @@ -0,0 +1,52 @@ +package com.lagradost.cloudstream3.extractors + +import com.lagradost.cloudstream3.SubtitleFile +import com.lagradost.cloudstream3.USER_AGENT +import com.lagradost.cloudstream3.app +import com.lagradost.cloudstream3.extractors.helper.JwPlayerHelper +import com.lagradost.cloudstream3.utils.ExtractorApi +import com.lagradost.cloudstream3.utils.ExtractorLink +import com.lagradost.cloudstream3.utils.getAndUnpack +import org.jsoup.nodes.Element + +open class Filegram : ExtractorApi() { + override val name = "Filegram" + override val mainUrl = "https://filegram.to" + override val requiresReferer = true + + override suspend fun getUrl( + url: String, + referer: String?, + subtitleCallback: (SubtitleFile) -> Unit, + callback: (ExtractorLink) -> Unit + ) { + val header = mapOf( + "Accept" to "*/*", + "Accept-language" to "en-US,en;q=0.9", + "Origin" to mainUrl, + "Accept-Encoding" to "gzip, deflate, br, zstd", + "Connection" to "keep-alive", + "Sec-Fetch-Dest" to "empty", + "Sec-Fetch-Mode" to "cors", + "Sec-Fetch-Site" to "same-site", + "user-agent" to USER_AGENT, + ) + + val doc = app.get(getEmbedUrl(url), referer = referer).document + val unpackedJs = unpackJs(doc).toString() + + JwPlayerHelper.extractStreamLinks(unpackedJs, name, mainUrl, callback, subtitleCallback, headers = header) + } + + private fun unpackJs(script: Element): String? { + return script.select("script").find { it.data().contains("eval(function(p,a,c,k,e,d)") } + ?.data()?.let { getAndUnpack(it) } + } + + private fun getEmbedUrl(url: String): String { + return if (!url.contains("/embed-")) { + val videoId = url.substringAfter("$mainUrl/") + "$mainUrl/embed-$videoId" + } else url + } +} diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Filemoon.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Filemoon.kt index 6c10a92d9..ad4def1de 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Filemoon.kt +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Filemoon.kt @@ -3,6 +3,7 @@ package com.lagradost.cloudstream3.extractors import com.lagradost.api.Log import com.lagradost.cloudstream3.SubtitleFile import com.lagradost.cloudstream3.app +import com.lagradost.cloudstream3.extractors.helper.JwPlayerHelper import com.lagradost.cloudstream3.network.WebViewResolver import com.lagradost.cloudstream3.utils.ExtractorApi import com.lagradost.cloudstream3.utils.ExtractorLink @@ -54,18 +55,16 @@ open class FilemoonV2 : ExtractorApi() { ?.data().orEmpty() val unpackedScript = JsUnpacker(fallbackScriptData).unpack() - val videoUrl = unpackedScript?.let { - Regex("""sources:\[\{file:"(.*?)"""").find(it)?.groupValues?.get(1) - } + val linkFound = JwPlayerHelper.extractStreamLinks( + unpackedScript.orEmpty(), + name, + mainUrl, + callback, + subtitleCallback, + defaultHeaders + ) - if (!videoUrl.isNullOrEmpty()) { - M3u8Helper.generateM3u8( - name, - videoUrl, - mainUrl, - headers = defaultHeaders - ).forEach(callback) - } else { + if (!linkFound) { Log.d("FilemoonV2", "No iframe and no video URL found in script fallback.") } return @@ -81,18 +80,15 @@ open class FilemoonV2 : ExtractorApi() { val unpackedScript = JsUnpacker(iframeScriptData).unpack() - val videoUrl = unpackedScript?.let { - Regex("""sources:\[\{file:"(.*?)"""").find(it)?.groupValues?.get(1) - } + val linkFound = JwPlayerHelper.extractStreamLinks( + unpackedScript.orEmpty(), + name, + mainUrl, + callback, + subtitleCallback + ) - if (!videoUrl.isNullOrEmpty()) { - M3u8Helper.generateM3u8( - name, - videoUrl, - mainUrl, - headers = defaultHeaders - ).forEach(callback) - } else { + if (!linkFound) { // Last-resort fallback using WebView interception val resolver = WebViewResolver( interceptUrl = Regex("""(m3u8|master\.txt)"""), diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Filesim.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Filesim.kt index 8c0cbec32..51e127e3f 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Filesim.kt +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Filesim.kt @@ -4,6 +4,7 @@ import com.lagradost.cloudstream3.SubtitleFile import com.lagradost.cloudstream3.app import com.lagradost.cloudstream3.utils.* import com.lagradost.api.Log +import com.lagradost.cloudstream3.extractors.helper.JwPlayerHelper import com.lagradost.cloudstream3.network.WebViewResolver class Multimoviesshg : Filesim() { @@ -78,17 +79,9 @@ open class Filesim : ExtractorApi() { pageResponse.document.selectFirst("script:containsData(sources:)")?.data() } - val m3u8Url = scriptData?.let { - Regex("""file:\s*"(.*?m3u8.*?)"""").find(it)?.groupValues?.getOrNull(1) - } + val linkFound = JwPlayerHelper.extractStreamLinks(scriptData.orEmpty(), name, mainUrl, callback, subtitleCallback) - if (!m3u8Url.isNullOrEmpty()) { - M3u8Helper.generateM3u8( - name, - m3u8Url, - mainUrl - ).forEach(callback) - } else { + if (!linkFound) { // Fallback using WebViewResolver val resolver = WebViewResolver( interceptUrl = Regex("""(m3u8|master\.txt)"""), diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/GamoVideo.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/GamoVideo.kt index 7e00dbf95..85212e6bb 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/GamoVideo.kt +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/GamoVideo.kt @@ -1,7 +1,10 @@ package com.lagradost.cloudstream3.extractors +import com.lagradost.cloudstream3.SubtitleFile import com.lagradost.cloudstream3.app -import com.lagradost.cloudstream3.utils.* +import com.lagradost.cloudstream3.extractors.helper.JwPlayerHelper +import com.lagradost.cloudstream3.utils.ExtractorApi +import com.lagradost.cloudstream3.utils.ExtractorLink open class GamoVideo : ExtractorApi() { @@ -11,21 +14,13 @@ open class GamoVideo : ExtractorApi() { override suspend fun getUrl( url: String, - referer: String? - ): List? { - return app.get(url, referer = referer).document.select("script") - .firstOrNull { it.html().contains("sources:") }!!.html().substringAfter("file: \"") - .substringBefore("\",").let { - listOf( - newExtractorLink( - name, - name, - it, - ) { - this.referer = url - this.quality = Qualities.Unknown.value - } - ) - } + referer: String?, + subtitleCallback: (SubtitleFile) -> Unit, + callback: (ExtractorLink) -> Unit + ) { + app.get(url, referer = referer).document.select("script") + .firstOrNull { JwPlayerHelper.canParseJwScript(it.data()) }!!.let { + JwPlayerHelper.extractStreamLinks(it.data(), name, mainUrl, callback, subtitleCallback) + } } } diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Hxfile.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Hxfile.kt index 8a56783b1..8f8a0c0ce 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Hxfile.kt +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Hxfile.kt @@ -1,9 +1,9 @@ package com.lagradost.cloudstream3.extractors -import com.fasterxml.jackson.annotation.JsonProperty +import com.lagradost.cloudstream3.SubtitleFile import com.lagradost.cloudstream3.app +import com.lagradost.cloudstream3.extractors.helper.JwPlayerHelper import com.lagradost.cloudstream3.utils.* -import com.lagradost.cloudstream3.utils.AppUtils.tryParseJson class Neonime7n : Hxfile() { override val name = "Neonime7n" @@ -39,64 +39,22 @@ open class Hxfile : ExtractorApi() { override val requiresReferer = false open val redirect = true - override suspend fun getUrl(url: String, referer: String?): List? { - val sources = mutableListOf() + override suspend fun getUrl( + url: String, + referer: String?, + subtitleCallback: (SubtitleFile) -> Unit, + callback: (ExtractorLink) -> Unit + ) { val document = app.get(url, allowRedirects = redirect, referer = referer).document with(document) { this.select("script").map { script -> - if (script.data().contains("eval(function(p,a,c,k,e,d)")) { - val data = - getAndUnpack(script.data()).substringAfter("sources:[").substringBefore("]") - tryParseJson>("[$data]")?.map { - sources.add( - newExtractorLink( - name, - name, - it.file, - ) { - this.referer = mainUrl - this.quality = when { - url.contains("hxfile.co") -> getQualityFromName( - Regex("\\d\\.(.*?).mp4").find( - document.select("title").text() - )?.groupValues?.get(1).toString() - ) - else -> getQualityFromName(it.label) - } - } - ) - } - } else if (script.data().contains("\"sources\":[")) { - val data = script.data().substringAfter("\"sources\":[").substringBefore("]") - tryParseJson>("[$data]")?.map { - sources.add( - newExtractorLink( - name, - name, - it.file, - ) { - this.referer = mainUrl - this.quality = when { - it.label?.contains("HD") == true -> Qualities.P720.value - it.label?.contains("SD") == true -> Qualities.P480.value - else -> getQualityFromName(it.label) - } - } - ) - } - } - else { - null + if (getPacked(script.data()) != null) { + val data = getAndUnpack(script.data()) + JwPlayerHelper.extractStreamLinks(data, name, mainUrl, callback, subtitleCallback) + } else if (JwPlayerHelper.canParseJwScript(script.data())) { + JwPlayerHelper.extractStreamLinks(script.data(), name, mainUrl, callback, subtitleCallback) } } } - return sources } - - private data class ResponseSource( - @JsonProperty("file") val file: String, - @JsonProperty("type") val type: String?, - @JsonProperty("label") val label: String? - ) - } diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/JWPlayer.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/JWPlayer.kt index e744fdb39..324640355 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/JWPlayer.kt +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/JWPlayer.kt @@ -1,13 +1,10 @@ package com.lagradost.cloudstream3.extractors -import com.fasterxml.jackson.annotation.JsonProperty +import com.lagradost.cloudstream3.SubtitleFile import com.lagradost.cloudstream3.app -import com.lagradost.cloudstream3.utils.AppUtils.tryParseJson +import com.lagradost.cloudstream3.extractors.helper.JwPlayerHelper import com.lagradost.cloudstream3.utils.ExtractorApi import com.lagradost.cloudstream3.utils.ExtractorLink -import com.lagradost.cloudstream3.utils.Qualities -import com.lagradost.cloudstream3.utils.getQualityFromName -import com.lagradost.cloudstream3.utils.newExtractorLink class Meownime : JWPlayer() { override val name = "Meownime" @@ -34,50 +31,36 @@ class DesuOdvip : JWPlayer() { override val mainUrl = "https://desustream.me/odvip/" } +class VidNest : JWPlayer() { + override var name = "Vidnest" + override var mainUrl = "https://vidnest.io" +} + +open class BigwarpIO : JWPlayer() { + override var name = "Bigwarp" + override var mainUrl = "https://bigwarp.io" +} + +class BgwpCC : BigwarpIO() { + override var mainUrl = "https://bgwp.cc" +} + +class BigwarpArt : BigwarpIO() { + override var mainUrl = "https://bigwarp.art" +} + open class JWPlayer : ExtractorApi() { override val name = "JWPlayer" override val mainUrl = "https://www.jwplayer.com" override val requiresReferer = false - override suspend fun getUrl(url: String, referer: String?): List? { - val sources = mutableListOf() - with(app.get(url).document) { - val data = this.select("script").mapNotNull { script -> - if (script.data().contains("sources: [")) { - script.data().substringAfter("sources: [") - .substringBefore("],").replace("'", "\"") - } else if (script.data().contains("otakudesu('")) { - script.data().substringAfter("otakudesu('") - .substringBefore("');") - } else { - null - } - } - - tryParseJson>("$data")?.map { - sources.add( - newExtractorLink( - name, - name, - it.file, - ) { - this.referer = url - this.quality = getQualityFromName( - Regex("(\\d{3,4}p)").find(it.file)?.groupValues?.get( - 1 - ) - ) - } - ) - } - } - return sources + override suspend fun getUrl( + url: String, + referer: String?, + subtitleCallback: (SubtitleFile) -> Unit, + callback: (ExtractorLink) -> Unit + ) { + val script = app.get(url).document.selectFirst("script:containsData(sources:)") ?: return + JwPlayerHelper.extractStreamLinks(script.data(), name, mainUrl, callback, subtitleCallback) } - - private data class ResponseSource( - @JsonProperty("file") val file: String, - @JsonProperty("type") val type: String?, - @JsonProperty("label") val label: String? - ) - } \ No newline at end of file diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Jeniusplay.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Jeniusplay.kt index f64863a9f..896228b51 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Jeniusplay.kt +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Jeniusplay.kt @@ -3,9 +3,12 @@ package com.lagradost.cloudstream3.extractors import com.fasterxml.jackson.annotation.JsonProperty import com.lagradost.cloudstream3.SubtitleFile import com.lagradost.cloudstream3.app -import com.lagradost.cloudstream3.newSubtitleFile -import com.lagradost.cloudstream3.utils.* -import com.lagradost.cloudstream3.utils.AppUtils.tryParseJson +import com.lagradost.cloudstream3.extractors.helper.JwPlayerHelper +import com.lagradost.cloudstream3.utils.ExtractorApi +import com.lagradost.cloudstream3.utils.ExtractorLink +import com.lagradost.cloudstream3.utils.M3u8Helper +import com.lagradost.cloudstream3.utils.getAndUnpack +import com.lagradost.cloudstream3.utils.getPacked open class Jeniusplay : ExtractorApi() { override val name = "Jeniusplay" @@ -34,40 +37,17 @@ open class Jeniusplay : ExtractorApi() { url, ).forEach(callback) - document.select("script").map { script -> - if (script.data().contains("eval(function(p,a,c,k,e,d)")) { - val subData = - getAndUnpack(script.data()).substringAfter("\"tracks\":[").substringBefore("],") - tryParseJson>("[$subData]")?.map { subtitle -> - subtitleCallback.invoke( - newSubtitleFile( - getLanguage(subtitle.label ?: ""), - subtitle.file - ) - ) - } + if (getPacked(script.data()) != null) { + val unpacked = getAndUnpack(script.data()) + JwPlayerHelper.extractStreamLinks(unpacked, name, mainUrl, callback, subtitleCallback) } } } - private fun getLanguage(str: String): String { - return when { - str.contains("indonesia", true) || str - .contains("bahasa", true) -> "Indonesian" - else -> str - } - } - data class ResponseSource( @JsonProperty("hls") val hls: Boolean, @JsonProperty("videoSource") val videoSource: String, @JsonProperty("securedLink") val securedLink: String?, ) - - data class Tracks( - @JsonProperty("kind") val kind: String?, - @JsonProperty("file") val file: String, - @JsonProperty("label") val label: String?, - ) } \ No newline at end of file diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/LuluStream.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/LuluStream.kt index c7b658606..dec679594 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/LuluStream.kt +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/LuluStream.kt @@ -1,12 +1,10 @@ package com.lagradost.cloudstream3.extractors import com.lagradost.cloudstream3.SubtitleFile +import com.lagradost.cloudstream3.app +import com.lagradost.cloudstream3.extractors.helper.JwPlayerHelper import com.lagradost.cloudstream3.utils.ExtractorApi import com.lagradost.cloudstream3.utils.ExtractorLink -import com.lagradost.cloudstream3.app -import com.lagradost.cloudstream3.utils.INFER_TYPE -import com.lagradost.cloudstream3.utils.Qualities -import com.lagradost.cloudstream3.utils.newExtractorLink class Luluvdoo : LuluStream() { @@ -47,18 +45,7 @@ open class LuluStream : ExtractorApi() { ).document post.selectFirst("script:containsData(vplayer)")?.data() ?.let { script -> - Regex("file:\"(.*)\"").find(script)?.groupValues?.get(1)?.let { link -> - callback( - newExtractorLink( - name, - name, - link, - ) { - this.referer = mainUrl - this.quality = Qualities.P1080.value - } - ) - } + JwPlayerHelper.extractStreamLinks(script, name, mainUrl, callback, subtitleCallback) } } -} +} \ No newline at end of file diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Minoplres.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Minoplres.kt deleted file mode 100644 index 702501a1e..000000000 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Minoplres.kt +++ /dev/null @@ -1,38 +0,0 @@ -package com.lagradost.cloudstream3.extractors - -import com.fasterxml.jackson.annotation.JsonProperty -import com.lagradost.cloudstream3.app -import com.lagradost.cloudstream3.utils.AppUtils.tryParseJson -import com.lagradost.cloudstream3.utils.ExtractorApi -import com.lagradost.cloudstream3.utils.ExtractorLink -import com.lagradost.cloudstream3.utils.M3u8Helper - -open class Minoplres : ExtractorApi() { - - override val name = "Minoplres" // formerly SpeedoStream - override val requiresReferer = true - override val mainUrl = "https://minoplres.xyz" // formerly speedostream.bond - private val hostUrl = "https://minoplres.xyz" - - override suspend fun getUrl(url: String, referer: String?): List { - val sources = mutableListOf() - app.get(url, referer = referer).document.select("script").map { script -> - if (script.data().contains("jwplayer(\"vplayer\").setup(")) { - val data = script.data().substringAfter("sources: [") - .substringBefore("],").replace("file", "\"file\"").trim() - tryParseJson(data)?.let { - M3u8Helper.generateM3u8( - name, - it.file, - "$hostUrl/", - ).forEach { m3uData -> sources.add(m3uData) } - } - } - } - return sources - } - - private data class File( - @JsonProperty("file") val file: String, - ) -} diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/MultiQuality.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/MultiQuality.kt deleted file mode 100644 index 802d9ea3a..000000000 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/MultiQuality.kt +++ /dev/null @@ -1,63 +0,0 @@ -package com.lagradost.cloudstream3.extractors - -import com.lagradost.cloudstream3.app -import com.lagradost.cloudstream3.utils.ExtractorApi -import com.lagradost.cloudstream3.utils.ExtractorLink -import com.lagradost.cloudstream3.utils.ExtractorLinkType -import com.lagradost.cloudstream3.utils.Qualities -import com.lagradost.cloudstream3.utils.getQualityFromName -import com.lagradost.cloudstream3.utils.newExtractorLink -import java.net.URI - -open class MultiQuality : ExtractorApi() { - override var name = "MultiQuality" - override var mainUrl = "https://anihdplay.com" - private val sourceRegex = Regex("""file:\s*['"](.*?)['"],label:\s*['"](.*?)['"]""") - private val m3u8Regex = Regex(""".*?(\d*).m3u8""") - private val urlRegex = Regex("""(.*?)([^/]+$)""") - override val requiresReferer = false - - override fun getExtractorUrl(id: String): String { - return "$mainUrl/loadserver.php?id=$id" - } - - override suspend fun getUrl(url: String, referer: String?): List { - val extractedLinksList: MutableList = mutableListOf() - with(app.get(url)) { - sourceRegex.findAll(this.text).forEach { sourceMatch -> - val extractedUrl = sourceMatch.groupValues[1] - // Trusting this isn't mp4, may fuck up stuff - if (URI(extractedUrl).path.endsWith(".m3u8")) { - with(app.get(extractedUrl)) { - m3u8Regex.findAll(this.text).forEach { match -> - extractedLinksList.add( - newExtractorLink( - source = name, - name = name, - url = urlRegex.find(this.url)!!.groupValues[1] + match.groupValues[0], - type = ExtractorLinkType.M3U8 - ) { - this.referer = url - this.quality = getQualityFromName(match.groupValues[1]) - } - ) - } - - } - } else if (extractedUrl.endsWith(".mp4")) { - extractedLinksList.add( - newExtractorLink( - name, - "$name ${sourceMatch.groupValues[2]}", - extractedUrl, - ) { - this.referer = url.replace(" ", "%20") - this.quality = Qualities.Unknown.value - } - ) - } - } - return extractedLinksList - } - } -} diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Pelisplus.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Pelisplus.kt deleted file mode 100644 index e2588feb6..000000000 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Pelisplus.kt +++ /dev/null @@ -1,101 +0,0 @@ -package com.lagradost.cloudstream3.extractors - -import com.lagradost.cloudstream3.SubtitleFile -import com.lagradost.cloudstream3.amap -import com.lagradost.cloudstream3.app -import com.lagradost.cloudstream3.mvvm.safeAsync -import com.lagradost.cloudstream3.utils.ExtractorLink -import com.lagradost.cloudstream3.utils.INFER_TYPE -import com.lagradost.cloudstream3.utils.extractorApis -import com.lagradost.cloudstream3.utils.getQualityFromName -import com.lagradost.cloudstream3.utils.loadExtractor -import com.lagradost.cloudstream3.utils.newExtractorLink -import org.jsoup.Jsoup - -/** - * overrideMainUrl is necessary for for other vidstream clones like vidembed.cc - * If they diverge it'd be better to make them separate. - * */ -open class Pelisplus(val mainUrl: String) { - val name: String = "Vidstream" - - private fun getExtractorUrl(id: String): String { - return "$mainUrl/play?id=$id" - } - - private fun getDownloadUrl(id: String): String { - return "$mainUrl/download?id=$id" - } - - private val normalApis = arrayListOf(MultiQuality()) - - // https://gogo-stream.com/streaming.php?id=MTE3NDg5 - suspend fun getUrl( - id: String, - isCasting: Boolean = false, - subtitleCallback: (SubtitleFile) -> Unit, - callback: (ExtractorLink) -> Unit - ): Boolean { - try { - normalApis.amap { api -> - val url = api.getExtractorUrl(id) - api.getSafeUrl(url, subtitleCallback = subtitleCallback, callback = callback) - } - val extractorUrl = getExtractorUrl(id) - - /** Stolen from GogoanimeProvider.kt extractor */ - safeAsync { - val link = getDownloadUrl(id) - println("Generated vidstream download link: $link") - val page = app.get(link, referer = extractorUrl) - - val pageDoc = Jsoup.parse(page.text) - val qualityRegex = Regex("(\\d+)P") - - //a[download] - pageDoc.select(".dowload > a").amap { element -> - val href = element.attr("href") - val qual = if (element.text() - .contains("HDP") - ) "1080" else qualityRegex.find(element.text())?.destructured?.component1() - .toString() - - if (!loadExtractor(href, link, subtitleCallback, callback)) { - callback.invoke( - newExtractorLink( - this.name, - name = this.name, - href - ) { - this.referer = page.url - this.quality = getQualityFromName(qual) - } - ) - } - } - } - - with(app.get(extractorUrl)) { - val document = Jsoup.parse(this.text) - val primaryLinks = document.select("ul.list-server-items > li.linkserver") - //val extractedLinksList: MutableList = mutableListOf() - - // All vidstream links passed to extractors - primaryLinks.distinctBy { it.attr("data-video") }.forEach { element -> - val link = element.attr("data-video") - //val name = element.text() - - // Matches vidstream links with extractors - extractorApis.filter { !it.requiresReferer || !isCasting }.amap { api -> - if (link.startsWith(api.mainUrl)) { - api.getSafeUrl(link, extractorUrl, subtitleCallback, callback) - } - } - } - return true - } - } catch (e: Exception) { - return false - } - } -} diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/StreamWishExtractor.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/StreamWishExtractor.kt index db883d6af..c721db6b9 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/StreamWishExtractor.kt +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/StreamWishExtractor.kt @@ -4,6 +4,7 @@ import com.lagradost.api.Log import com.lagradost.cloudstream3.SubtitleFile import com.lagradost.cloudstream3.USER_AGENT import com.lagradost.cloudstream3.app +import com.lagradost.cloudstream3.extractors.helper.JwPlayerHelper import com.lagradost.cloudstream3.utils.ExtractorApi import com.lagradost.cloudstream3.utils.ExtractorLink import com.lagradost.cloudstream3.utils.M3u8Helper @@ -12,7 +13,6 @@ import com.lagradost.cloudstream3.utils.getPacked import com.lagradost.cloudstream3.network.WebViewResolver - class Mwish : StreamWishExtractor() { override val name = "Mwish" override val mainUrl = "https://mwish.pro" @@ -180,18 +180,9 @@ open class StreamWishExtractor : ExtractorApi() { else -> pageResponse.document.selectFirst("script:containsData(sources:)")?.data() } - val directStreamUrl = playerScriptData?.let { - Regex("""file:\s*"(.*?m3u8.*?)"""").find(it)?.groupValues?.getOrNull(1) - } + val linkFound = JwPlayerHelper.extractStreamLinks(playerScriptData.orEmpty(), name, mainUrl, callback, subtitleCallback, headers) - if (!directStreamUrl.isNullOrEmpty()) { - M3u8Helper.generateM3u8( - name, - directStreamUrl, - mainUrl, - headers = headers - ).forEach(callback) - } else { + if (!linkFound) { val webViewM3u8Resolver = WebViewResolver( interceptUrl = Regex("""txt|m3u8"""), additionalUrls = listOf(Regex("""txt|m3u8""")), diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/StreamoUpload.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/StreamoUpload.kt index 7fafe05be..b7f618e95 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/StreamoUpload.kt +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/StreamoUpload.kt @@ -1,42 +1,30 @@ package com.lagradost.cloudstream3.extractors -import com.fasterxml.jackson.annotation.JsonProperty +import com.lagradost.cloudstream3.SubtitleFile import com.lagradost.cloudstream3.app -import com.lagradost.cloudstream3.utils.AppUtils.tryParseJson +import com.lagradost.cloudstream3.extractors.helper.JwPlayerHelper import com.lagradost.cloudstream3.utils.ExtractorApi import com.lagradost.cloudstream3.utils.ExtractorLink import com.lagradost.cloudstream3.utils.getAndUnpack -import com.lagradost.cloudstream3.utils.Qualities -import com.lagradost.cloudstream3.utils.M3u8Helper +import com.lagradost.cloudstream3.utils.getPacked open class StreamoUpload : ExtractorApi() { override val name = "StreamoUpload" override val mainUrl = "https://streamoupload.xyz" override val requiresReferer = true - override suspend fun getUrl(url: String, referer: String?): List { - val sources = mutableListOf() + override suspend fun getUrl( + url: String, + referer: String?, + subtitleCallback: (SubtitleFile) -> Unit, + callback: (ExtractorLink) -> Unit + ) { val response = app.get(url, referer = referer) - val scriptElements = response.document.select("script").map { script -> - if (script.data().contains("eval(function(p,a,c,k,e,d)")) { + response.document.select("script").map { script -> + if (getPacked(script.data()) != null) { val data = getAndUnpack(script.data()) - .substringAfter("sources:[") - .substringBefore("],") - .replace("file", "\"file\"") - .trim() - tryParseJson(data)?.let { - M3u8Helper.generateM3u8( - name, - it.file, - "$mainUrl/", - ).forEach { m3uData -> sources.add(m3uData) } - } + JwPlayerHelper.extractStreamLinks(data, name, mainUrl, callback, subtitleCallback) } } - return sources } - - private data class File( - @JsonProperty("file") val file: String, - ) } diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Supervideo.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Supervideo.kt index e70cae6bd..5e47dd2de 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Supervideo.kt +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Supervideo.kt @@ -1,42 +1,27 @@ package com.lagradost.cloudstream3.extractors -import com.fasterxml.jackson.annotation.JsonProperty -import com.lagradost.cloudstream3.utils.ExtractorLink +import com.lagradost.cloudstream3.SubtitleFile import com.lagradost.cloudstream3.app -import com.lagradost.cloudstream3.utils.* -import com.lagradost.cloudstream3.utils.AppUtils.parseJson - -data class Files( - @JsonProperty("file") val id: String, - @JsonProperty("label") val label: String? = null, -) +import com.lagradost.cloudstream3.extractors.helper.JwPlayerHelper +import com.lagradost.cloudstream3.utils.ExtractorApi +import com.lagradost.cloudstream3.utils.ExtractorLink +import com.lagradost.cloudstream3.utils.JsUnpacker open class Supervideo : ExtractorApi() { override var name = "Supervideo" override var mainUrl = "https://supervideo.cc" override val requiresReferer = false - override suspend fun getUrl(url: String, referer: String?): List? { - val extractedLinksList: MutableList = mutableListOf() + + override suspend fun getUrl( + url: String, + referer: String?, + subtitleCallback: (SubtitleFile) -> Unit, + callback: (ExtractorLink) -> Unit + ) { val response = app.get(url).text val jstounpack = Regex("eval((.|\\n)*?)").find(response)?.groups?.get(1)?.value - val unpacjed = JsUnpacker(jstounpack).unpack() - val extractedUrl = - unpacjed?.let { Regex("""sources:((.|\n)*?)image""").find(it) }?.groups?.get(1)?.value.toString() - .replace("file", """"file"""").replace("label", """"label"""") - .substringBeforeLast(",") - val parsedlinks = parseJson>(extractedUrl) - parsedlinks.forEach { data -> - if (data.label.isNullOrBlank()) { // mp4 links (with labels) are slow. Use only m3u8 link. - M3u8Helper.generateM3u8( - name, - data.id, - url, - headers = mapOf("referer" to url) - ).forEach { link -> - extractedLinksList.add(link) - } - } - } - return extractedLinksList + val unpacked = JsUnpacker(jstounpack).unpack() + + JwPlayerHelper.extractStreamLinks(unpacked.orEmpty(), name, mainUrl, callback, subtitleCallback) } } \ No newline at end of file diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Up4Stream.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Up4Stream.kt index 91150992b..b72213e66 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Up4Stream.kt +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Up4Stream.kt @@ -1,13 +1,13 @@ package com.lagradost.cloudstream3.extractors import com.lagradost.api.Log +import com.lagradost.cloudstream3.SubtitleFile import com.lagradost.cloudstream3.app +import com.lagradost.cloudstream3.extractors.helper.JwPlayerHelper import com.lagradost.cloudstream3.utils.ExtractorApi import com.lagradost.cloudstream3.utils.ExtractorLink import com.lagradost.cloudstream3.utils.JsUnpacker -import com.lagradost.cloudstream3.utils.Qualities import com.lagradost.cloudstream3.utils.fixUrl -import com.lagradost.cloudstream3.utils.newExtractorLink import kotlinx.coroutines.delay class Up4FunTop : Up4Stream() { @@ -19,12 +19,17 @@ open class Up4Stream : ExtractorApi() { override var mainUrl = "https://up4stream.com" override val requiresReferer = true - override suspend fun getUrl(url: String, referer: String?): List? { + override suspend fun getUrl( + url: String, + referer: String?, + subtitleCallback: (SubtitleFile) -> Unit, + callback: (ExtractorLink) -> Unit + ) { val movieId = url.substringAfterLast("/").substringBefore(".html") // redirect from "wait 5 seconds" page to actual movie page val redirectResponse = app.get(url, cookies = mapOf("id" to movieId)) - val redirectForm = redirectResponse.document.selectFirst("form[method=POST]") ?: return null + val redirectForm = redirectResponse.document.selectFirst("form[method=POST]") ?: return val redirectUrl = fixUrl(redirectForm.attr("action")) val redirectParams = redirectForm.select("input[type=hidden]").associate { input -> input.attr("name") to input.attr("value") @@ -42,19 +47,7 @@ open class Up4Stream : ExtractorApi() { } JsUnpacker(extractedpack).unpack()?.let { unPacked -> - Regex("sources:\\[\\{file:\"(.*?)\"").find(unPacked)?.groupValues?.get(1)?.let { link -> - return listOf( - newExtractorLink( - this.name, - this.name, - link, - ) { - this.referer = referer.orEmpty() - this.quality = Qualities.Unknown.value - } - ) - } + JwPlayerHelper.extractStreamLinks(unPacked, name, mainUrl, callback, subtitleCallback) } - return null } } \ No newline at end of file diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/VidHidePro.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/VidHidePro.kt index 469efc5ec..849b2b6d9 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/VidHidePro.kt +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/VidHidePro.kt @@ -3,8 +3,11 @@ package com.lagradost.cloudstream3.extractors import com.lagradost.cloudstream3.SubtitleFile import com.lagradost.cloudstream3.USER_AGENT import com.lagradost.cloudstream3.app -import com.lagradost.cloudstream3.utils.* -import com.lagradost.cloudstream3.utils.M3u8Helper.Companion.generateM3u8 +import com.lagradost.cloudstream3.extractors.helper.JwPlayerHelper +import com.lagradost.cloudstream3.utils.ExtractorApi +import com.lagradost.cloudstream3.utils.ExtractorLink +import com.lagradost.cloudstream3.utils.getAndUnpack +import com.lagradost.cloudstream3.utils.getPacked class Ryderjet: VidHidePro() { override var mainUrl = "https://ryderjet.com" @@ -74,24 +77,12 @@ open class VidHidePro : ExtractorApi() { val response = app.get(getEmbedUrl(url), referer = referer) val script = if (!getPacked(response.text).isNullOrEmpty()) { - var result = getAndUnpack(response.text) - if(result.contains("var links")){ - result = result.substringAfter("var links") - } - result + getAndUnpack(response.text) } else { response.document.selectFirst("script:containsData(sources:)")?.data() } ?: return - // m3u8 urls could be prefixed by 'file:', 'hls2:' or 'hls4:', so we just match ':' - Regex(":\\s*\"(.*?m3u8.*?)\"").findAll(script).forEach { m3u8Match -> - generateM3u8( - name, - fixUrl(m3u8Match.groupValues[1]), - referer = "$mainUrl/", - headers = headers - ).forEach(callback) - } + JwPlayerHelper.extractStreamLinks(script, name, mainUrl, callback, subtitleCallback, headers) } private fun getEmbedUrl(url: String): String { diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/VidNest.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/VidNest.kt deleted file mode 100644 index f9d45ebb8..000000000 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/VidNest.kt +++ /dev/null @@ -1,45 +0,0 @@ -package com.lagradost.cloudstream3.extractors - -import com.lagradost.cloudstream3.app -import com.lagradost.cloudstream3.utils.ExtractorApi -import com.lagradost.cloudstream3.utils.ExtractorLink -import com.lagradost.cloudstream3.utils.M3u8Helper -import com.lagradost.cloudstream3.utils.newExtractorLink - -open class VidNest : ExtractorApi() { - override var name = "VidNest" - override var mainUrl = "https://vidnest.io" - override val requiresReferer = true - - private val sourceRegex = Regex("""sources:[\W\w]*?file:\s*?["'](.*?)["']""") - override suspend fun getUrl(url: String, referer: String?): List { - val extractedLinksList: MutableList = mutableListOf() - with(app.get(url, referer = referer)) { - sourceRegex.findAll(this.text).forEach { sourceMatch -> - val extractedUrl = sourceMatch.groupValues[1] - // Trusting this isn't mp4, may fuck up stuff - if (extractedUrl.contains(".m3u8")) { - M3u8Helper.generateM3u8( - name, - extractedUrl, - url, - headers = mapOf("referer" to this.url) - ).forEach { link -> - extractedLinksList.add(link) - } - } else if (extractedUrl.contains(".mp4")) { - extractedLinksList.add( - newExtractorLink( - source = name, - name = name, - url = extractedUrl, - ) { - this.referer = url.replace(" ", "%20") - } - ) - } - } - return extractedLinksList - } - } -} \ No newline at end of file diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Vidmoly.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Vidmoly.kt index bd259b175..11927c507 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Vidmoly.kt +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Vidmoly.kt @@ -1,13 +1,11 @@ package com.lagradost.cloudstream3.extractors -import com.fasterxml.jackson.annotation.JsonProperty import com.lagradost.cloudstream3.SubtitleFile import com.lagradost.cloudstream3.USER_AGENT import com.lagradost.cloudstream3.app -import com.lagradost.cloudstream3.newSubtitleFile -import com.lagradost.cloudstream3.utils.* -import com.lagradost.cloudstream3.utils.AppUtils.tryParseJson -import kotlinx.coroutines.delay +import com.lagradost.cloudstream3.extractors.helper.JwPlayerHelper +import com.lagradost.cloudstream3.utils.ExtractorApi +import com.lagradost.cloudstream3.utils.ExtractorLink class Vidmolyme : Vidmoly() { override val mainUrl = "https://vidmoly.me" @@ -26,20 +24,6 @@ open class Vidmoly : ExtractorApi() { override val mainUrl = "https://vidmoly.net" override val requiresReferer = true - private fun String.addMarks(str: String): String { - return this.replace(Regex("\"?$str\"?"), "\"$str\"") - } - - private data class Source( - @JsonProperty("file") val file: String? = null, - ) - - private data class SubSource( - @JsonProperty("file") val file: String? = null, - @JsonProperty("label") val label: String? = null, - @JsonProperty("kind") val kind: String? = null, - ) - override suspend fun getUrl( url: String, referer: String?, @@ -54,34 +38,13 @@ open class Vidmoly : ExtractorApi() { val newUrl = if (url.contains("/w/")) url.replaceFirst("/w/", "/embed-") + ".html" else url + val script = app.get(newUrl, headers = headers, referer = referer) .document.select("script") .firstOrNull { it.data().contains("sources:") } ?.data() + // Extracts and parses videoData - script?.substringAfter("sources: [") - ?.substringBefore("]") - ?.addMarks("file") - ?.replace("'","\"") - ?.let { videoData -> - tryParseJson(videoData)?.file?.let { m3uLink -> - M3u8Helper.generateM3u8(name, m3uLink, "$mainUrl/") - .forEach(callback) - } - } - // Extracts and parses captions - script?.substringAfter("tracks: [") - ?.substringBefore("]") - ?.addMarks("file")?.addMarks("label")?.addMarks("kind") - ?.replace("'","\"") - ?.let { subData -> - tryParseJson>("[$subData]") - ?.filter { it.kind == "captions" } - ?.forEach { - subtitleCallback( - newSubtitleFile(it.label.toString(), fixUrl(it.file.toString())) - ) - } - } + JwPlayerHelper.extractStreamLinks(script.orEmpty(), name, mainUrl, callback, subtitleCallback) } } diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Vidstream.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Vidstream.kt deleted file mode 100644 index ab228ee3c..000000000 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Vidstream.kt +++ /dev/null @@ -1,104 +0,0 @@ -package com.lagradost.cloudstream3.extractors - -import com.lagradost.cloudstream3.SubtitleFile -import com.lagradost.cloudstream3.amap -import com.lagradost.cloudstream3.app -import com.lagradost.cloudstream3.runAllAsync -import com.lagradost.cloudstream3.utils.ExtractorLink -import com.lagradost.cloudstream3.utils.INFER_TYPE -import com.lagradost.cloudstream3.utils.extractorApis -import com.lagradost.cloudstream3.utils.getQualityFromName -import com.lagradost.cloudstream3.utils.loadExtractor -import com.lagradost.cloudstream3.utils.newExtractorLink -import org.jsoup.Jsoup - -/** - * overrideMainUrl is necessary for for other vidstream clones like vidembed.cc - * If they diverge it'd be better to make them separate. - * */ -class Vidstream(val mainUrl: String) { - val name: String = "Vidstream" - - private fun getExtractorUrl(id: String): String { - return "$mainUrl/streaming.php?id=$id" - } - - private fun getDownloadUrl(id: String): String { - return "$mainUrl/download?id=$id" - } - - private val normalApis = arrayListOf(MultiQuality()) - - // https://gogo-stream.com/streaming.php?id=MTE3NDg5 - suspend fun getUrl( - id: String, - isCasting: Boolean = false, - subtitleCallback: (SubtitleFile) -> Unit, - callback: (ExtractorLink) -> Unit, - ): Boolean { - val extractorUrl = getExtractorUrl(id) - runAllAsync( - { - normalApis.amap { api -> - val url = api.getExtractorUrl(id) - api.getSafeUrl( - url, - callback = callback, - subtitleCallback = subtitleCallback - ) - } - }, { - /** Stolen from GogoanimeProvider.kt extractor */ - val link = getDownloadUrl(id) - println("Generated vidstream download link: $link") - val page = app.get(link, referer = extractorUrl) - - val pageDoc = Jsoup.parse(page.text) - val qualityRegex = Regex("(\\d+)P") - - //a[download] - pageDoc.select(".dowload > a").amap { element -> - val href = element.attr("href") - val qual = if (element.text() - .contains("HDP") - ) "1080" else qualityRegex.find(element.text())?.destructured?.component1() - .toString() - - if (!loadExtractor(href, link, subtitleCallback, callback)) { - callback.invoke( - newExtractorLink( - this.name, - name = this.name, - href, - type = INFER_TYPE - ) { - this.referer = page.url - this.quality = getQualityFromName(qual) - } - ) - } - } - }, { - with(app.get(extractorUrl)) { - val document = Jsoup.parse(this.text) - val primaryLinks = document.select("ul.list-server-items > li.linkserver") - //val extractedLinksList: MutableList = mutableListOf() - - // All vidstream links passed to extractors - primaryLinks.distinctBy { it.attr("data-video") }.forEach { element -> - val link = element.attr("data-video") - //val name = element.text() - - // Matches vidstream links with extractors - extractorApis.filter { !it.requiresReferer || !isCasting }.amap { api -> - if (link.startsWith(api.mainUrl)) { - api.getSafeUrl(link, extractorUrl, subtitleCallback, callback) - } - } - } - } - } - ) - return true - } -} \ No newline at end of file diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Vtbe.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Vtbe.kt index 37b8ecb23..2fdd7082a 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Vtbe.kt +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Vtbe.kt @@ -1,15 +1,11 @@ package com.lagradost.cloudstream3.extractors -import com.fasterxml.jackson.annotation.JsonProperty +import com.lagradost.cloudstream3.SubtitleFile import com.lagradost.cloudstream3.app -import com.lagradost.cloudstream3.utils.* -import com.lagradost.cloudstream3.utils.AppUtils.tryParseJson -import com.lagradost.cloudstream3.utils.JsUnpacker +import com.lagradost.cloudstream3.extractors.helper.JwPlayerHelper import com.lagradost.cloudstream3.utils.ExtractorApi import com.lagradost.cloudstream3.utils.ExtractorLink -import com.lagradost.cloudstream3.utils.Qualities -import com.lagradost.cloudstream3.utils.getQualityFromName -import java.net.URI +import com.lagradost.cloudstream3.utils.JsUnpacker open class Vtbe : ExtractorApi() { @@ -17,23 +13,16 @@ open class Vtbe : ExtractorApi() { override var mainUrl = "https://vtbe.to" override val requiresReferer = true - override suspend fun getUrl(url: String, referer: String?): List? { + override suspend fun getUrl( + url: String, + referer: String?, + subtitleCallback: (SubtitleFile) -> Unit, + callback: (ExtractorLink) -> Unit + ) { val response = app.get(url,referer=mainUrl).document - val extractedpack =response.selectFirst("script:containsData(function(p,a,c,k,e,d))")?.data().toString() + val extractedpack = response.selectFirst("script:containsData(function(p,a,c,k,e,d))")?.data().toString() JsUnpacker(extractedpack).unpack()?.let { unPacked -> - Regex("sources:\\[\\{file:\"(.*?)\"").find(unPacked)?.groupValues?.get(1)?.let { link -> - return listOf( - newExtractorLink( - this.name, - this.name, - link, - ) { - this.referer = referer ?: "" - this.quality = Qualities.Unknown.value - } - ) - } + JwPlayerHelper.extractStreamLinks(unPacked, name, mainUrl, callback, subtitleCallback) } - return null } } diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/helper/JWPlayerHelper.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/helper/JWPlayerHelper.kt new file mode 100644 index 000000000..43ceb2314 --- /dev/null +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/helper/JWPlayerHelper.kt @@ -0,0 +1,155 @@ +package com.lagradost.cloudstream3.extractors.helper + +import com.fasterxml.jackson.annotation.JsonProperty +import com.lagradost.api.Log +import com.lagradost.cloudstream3.Prerelease +import com.lagradost.cloudstream3.SubtitleFile +import com.lagradost.cloudstream3.newSubtitleFile +import com.lagradost.cloudstream3.utils.AppUtils.tryParseJson +import com.lagradost.cloudstream3.utils.ExtractorLink +import com.lagradost.cloudstream3.utils.M3u8Helper +import com.lagradost.cloudstream3.utils.newExtractorLink +import kotlin.collections.orEmpty + +@Prerelease +object JwPlayerHelper { + private val sourceRegex = Regex(""""?sources"?:\s*(\[.*?\])""") + private val tracksRegex = Regex(""""?tracks"?:\s*(\[.*?\])""") + private val m3u8Regex = Regex("""[:=]\s*\"([^\"\s]+(\.m3u8|master\.txt)[^\"\s]*)""") + + /** + * Get stream links the "sources" attribute inside a JWPlayer script, e.g. + * + * ```js + *