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