diff --git a/app/build.gradle.kts b/app/build.gradle.kts index e31de078..66ba16c6 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -58,8 +58,8 @@ android { minSdk = 21 targetSdk = 33 - versionCode = 59 - versionName = "4.1.8" + versionCode = 60 + versionName = "4.1.9" resValue("string", "app_version", "${defaultConfig.versionName}${versionNameSuffix ?: ""}") resValue("string", "commit_hash", "git rev-parse --short HEAD".execute() ?: "") @@ -250,9 +250,9 @@ dependencies { // used for subtitle decoding https://github.com/albfernandez/juniversalchardet implementation("com.github.albfernandez:juniversalchardet:2.4.0") - // newpipe yt taken from https://github.com/TeamNewPipe/NewPipe/blob/dev/app/build.gradle#L204 + // newpipe yt taken from https://github.com/TeamNewPipe/NewPipeExtractor/commits/dev // this should be updated frequently to avoid trailer fu*kery - implementation("com.github.TeamNewPipe:NewPipeExtractor:1f08d28") + implementation("com.github.teamnewpipe:NewPipeExtractor:1f08d28") coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:1.1.6") // Library/extensions searching with Levenshtein distance diff --git a/app/src/main/java/com/lagradost/cloudstream3/CommonActivity.kt b/app/src/main/java/com/lagradost/cloudstream3/CommonActivity.kt index 0bcd4152..a7d899b6 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/CommonActivity.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/CommonActivity.kt @@ -7,9 +7,14 @@ import android.content.Context import android.content.pm.PackageManager import android.content.res.Resources import android.os.Build +import android.util.DisplayMetrics import android.util.Log -import android.view.* +import android.view.Gravity +import android.view.KeyEvent +import android.view.LayoutInflater +import android.view.View import android.view.View.NO_ID +import android.view.ViewGroup import android.widget.TextView import android.widget.Toast import androidx.activity.ComponentActivity @@ -40,7 +45,9 @@ import com.lagradost.cloudstream3.utils.UIHelper.shouldShowPIPMode import com.lagradost.cloudstream3.utils.UIHelper.toPx import org.schabi.newpipe.extractor.NewPipe import java.lang.ref.WeakReference -import java.util.* +import java.util.Locale +import kotlin.math.max +import kotlin.math.min enum class FocusDirection { Start, @@ -63,6 +70,19 @@ object CommonActivity { return (this as MainActivity?)?.mSessionManager?.currentCastSession } + val displayMetrics: DisplayMetrics = Resources.getSystem().displayMetrics + + // screenWidth and screenHeight does always + // refer to the screen while in landscape mode + val screenWidth: Int + get() { + return max(displayMetrics.widthPixels, displayMetrics.heightPixels) + } + val screenHeight: Int + get() { + return min(displayMetrics.widthPixels, displayMetrics.heightPixels) + } + var canEnterPipMode: Boolean = false var canShowPipMode: Boolean = false @@ -328,6 +348,14 @@ object CommonActivity { currentLook = currentLook.parent as? View ?: break }*/ + private fun View.hasContent() : Boolean { + return isShown && when(this) { + //is RecyclerView -> this.childCount > 0 + is ViewGroup -> this.childCount > 0 + else -> true + } + } + /** skips the initial stage of searching for an id using the view, see getNextFocus for specification */ fun continueGetNextFocus( root: Any?, @@ -348,16 +376,17 @@ object CommonActivity { } ?: return null next = localLook(view, nextId) ?: next + val shown = next.hasContent() // if cant focus but visible then break and let android decide // the exception if is the view is a parent and has children that wants focus val hasChildrenThatWantsFocus = (next as? ViewGroup)?.let { parent -> parent.descendantFocusability == ViewGroup.FOCUS_AFTER_DESCENDANTS && parent.childCount > 0 } ?: false - if (!next.isFocusable && next.isShown && !hasChildrenThatWantsFocus) return null + if (!next.isFocusable && shown && !hasChildrenThatWantsFocus) return null // if not shown then continue because we will "skip" over views to get to a replacement - if (!next.isShown) { + if (!shown) { // we don't want a while true loop, so we let android decide if we find a recursive view if (next == view) return null return getNextFocus(root, next, direction, depth + 1) diff --git a/app/src/main/java/com/lagradost/cloudstream3/MainAPI.kt b/app/src/main/java/com/lagradost/cloudstream3/MainAPI.kt index 80332445..5b674c4c 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/MainAPI.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/MainAPI.kt @@ -22,8 +22,10 @@ import com.lagradost.cloudstream3.utils.* import com.lagradost.cloudstream3.utils.AppUtils.toJson import com.lagradost.cloudstream3.utils.Coroutines.mainWork import com.lagradost.cloudstream3.utils.Coroutines.threadSafeListOf +import com.lagradost.nicehttp.RequestBodyTypes import okhttp3.Interceptor -import org.mozilla.javascript.Scriptable +import okhttp3.MediaType.Companion.toMediaTypeOrNull +import okhttp3.RequestBody.Companion.toRequestBody import java.text.SimpleDateFormat import java.util.* import kotlin.math.absoluteValue @@ -177,10 +179,17 @@ object APIHolder { private var trackerCache: HashMap = hashMapOf() + /** backwards compatibility, use getTracker4 instead */ + suspend fun getTracker( + titles: List, + types: Set?, + year: Int?, + ): Tracker? = getTracker(titles, types, year, false) + /** * Get anime tracker information based on title, year and type. * Both titles are attempted to be matched with both Romaji and English title. - * Uses the consumet api. + * Uses the anilist api. * * @param titles uses first index to search, but if you have multiple titles and want extra guarantee to match you can also have that * @param types Optional parameter to narrow down the scope to Movies, TV, etc. See TrackerType.getTypes() @@ -189,7 +198,8 @@ object APIHolder { suspend fun getTracker( titles: List, types: Set?, - year: Int? + year: Int?, + lessAccurate: Boolean ): Tracker? { return try { require(titles.isNotEmpty()) { "titles must no be empty when calling getTracker" } @@ -197,30 +207,70 @@ object APIHolder { val mainTitle = titles[0] val search = trackerCache[mainTitle] - ?: app.get("https://api.consumet.org/meta/anilist/$mainTitle") - .parsedSafe()?.also { - trackerCache[mainTitle] = it - } ?: return null + ?: searchAnilist(mainTitle)?.also { + trackerCache[mainTitle] = it + } ?: return null - val res = search.results?.find { media -> - val matchingYears = year == null || media.releaseDate == year + val res = search.data?.page?.media?.find { media -> + val matchingYears = year == null || media.seasonYear == year val matchingTitles = media.title?.let { title -> titles.any { userTitle -> title.isMatchingTitles(userTitle) } } ?: false - val matchingTypes = types?.any { it.name.equals(media.type, true) } == true - matchingTitles && matchingTypes && matchingYears + val matchingTypes = types?.any { it.name.equals(media.format, true) } == true + if(lessAccurate) matchingTitles || matchingTypes && matchingYears else matchingTitles && matchingTypes && matchingYears } ?: return null - Tracker(res.malId, res.aniId, res.image, res.cover) + Tracker(res.idMal, res.id.toString(), res.coverImage?.extraLarge ?: res.coverImage?.large, res.bannerImage) } catch (t: Throwable) { logError(t) null } } + private suspend fun searchAnilist( + title: String?, + ): AniSearch? { + val query = """ + query ( + ${'$'}page: Int = 1 + ${'$'}search: String + ${'$'}sort: [MediaSort] = [POPULARITY_DESC, SCORE_DESC] + ${'$'}type: MediaType + ) { + Page(page: ${'$'}page, perPage: 20) { + media( + search: ${'$'}search + sort: ${'$'}sort + type: ${'$'}type + ) { + id + idMal + title { romaji english } + coverImage { extraLarge large } + bannerImage + seasonYear + format + } + } + } + """.trimIndent().trim() + + val data = mapOf( + "query" to query, + "variables" to mapOf( + "search" to title, + "sort" to "SEARCH_MATCH", + "type" to "ANIME", + ) + ).toJson().toRequestBody(RequestBodyTypes.JSON.toMediaTypeOrNull()) + + return app.post("https://graphql.anilist.co", requestBody = data) + .parsedSafe() + } + fun Context.getApiSettings(): HashSet { //val settingsManager = PreferenceManager.getDefaultSharedPreferences(this) @@ -1730,30 +1780,42 @@ data class Tracker( val cover: String? = null, ) -data class Title( - @JsonProperty("romaji") val romaji: String? = null, - @JsonProperty("english") val english: String? = null, +data class AniSearch( + @JsonProperty("data") var data: Data? = Data() ) { - fun isMatchingTitles(title: String?): Boolean { - if (title == null) return false - return english.equals(title, true) || romaji.equals(title, true) + data class Data( + @JsonProperty("Page") var page: Page? = Page() + ) { + data class Page( + @JsonProperty("media") var media: ArrayList = arrayListOf() + ) { + data class Media( + @JsonProperty("title") var title: Title? = null, + @JsonProperty("id") var id: Int? = null, + @JsonProperty("idMal") var idMal: Int? = null, + @JsonProperty("seasonYear") var seasonYear: Int? = null, + @JsonProperty("format") var format: String? = null, + @JsonProperty("coverImage") var coverImage: CoverImage? = null, + @JsonProperty("bannerImage") var bannerImage: String? = null, + ) { + data class CoverImage( + @JsonProperty("extraLarge") var extraLarge: String? = null, + @JsonProperty("large") var large: String? = null, + ) + data class Title( + @JsonProperty("romaji") var romaji: String? = null, + @JsonProperty("english") var english: String? = null, + ) { + fun isMatchingTitles(title: String?): Boolean { + if (title == null) return false + return english.equals(title, true) || romaji.equals(title, true) + } + } + } + } } } -data class Results( - @JsonProperty("id") val aniId: String? = null, - @JsonProperty("malId") val malId: Int? = null, - @JsonProperty("title") val title: Title? = null, - @JsonProperty("releaseDate") val releaseDate: Int? = null, - @JsonProperty("type") val type: String? = null, - @JsonProperty("image") val image: String? = null, - @JsonProperty("cover") val cover: String? = null, -) - -data class AniSearch( - @JsonProperty("results") val results: ArrayList? = arrayListOf() -) - /** * used for the getTracker() method **/ diff --git a/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt b/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt index fbad4fce..a07ae2c2 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt @@ -6,6 +6,7 @@ import android.content.Context import android.content.Intent import android.content.res.ColorStateList import android.content.res.Configuration +import android.graphics.Rect import android.net.Uri import android.os.Build import android.os.Bundle @@ -52,6 +53,7 @@ import com.google.android.material.bottomnavigation.BottomNavigationView import com.google.android.material.bottomsheet.BottomSheetDialog import com.google.android.material.navigationrail.NavigationRailView import com.google.android.material.snackbar.Snackbar +import com.google.common.collect.Comparators.min import com.jaredrummler.android.colorpicker.ColorPickerDialogListener import com.lagradost.cloudstream3.APIHolder.allProviders import com.lagradost.cloudstream3.APIHolder.apis @@ -64,13 +66,13 @@ import com.lagradost.cloudstream3.CommonActivity.loadThemes import com.lagradost.cloudstream3.CommonActivity.onColorSelectedEvent import com.lagradost.cloudstream3.CommonActivity.onDialogDismissedEvent import com.lagradost.cloudstream3.CommonActivity.onUserLeaveHint +import com.lagradost.cloudstream3.CommonActivity.screenHeight import com.lagradost.cloudstream3.CommonActivity.showToast import com.lagradost.cloudstream3.CommonActivity.updateLocale import com.lagradost.cloudstream3.databinding.ActivityMainBinding import com.lagradost.cloudstream3.databinding.ActivityMainTvBinding import com.lagradost.cloudstream3.databinding.BottomResultviewPreviewBinding import com.lagradost.cloudstream3.mvvm.Resource -import com.lagradost.cloudstream3.mvvm.debugAssert import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.mvvm.normalSafeApiCall import com.lagradost.cloudstream3.mvvm.observeNullable @@ -832,6 +834,13 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener { focusOutline.get()?.isVisible = false } } + /*private val scrollListener = object : RecyclerView.OnScrollListener() { + override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { + super.onScrolled(recyclerView, dx, dy) + current = current.copy(x = current.x + dx, y = current.y + dy) + setTargetPosition(current) + } + }*/ private fun setTargetPosition(target: FocusTarget) { focusOutline.get()?.apply { @@ -874,7 +883,10 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener { if (!exactlyTheSame) { lastView?.removeOnLayoutChangeListener(layoutListener) lastView?.removeOnAttachStateChangeListener(attachListener) - (lastView?.parent as? RecyclerView)?.removeOnLayoutChangeListener(layoutListener) + (lastView?.parent as? RecyclerView)?.apply { + removeOnLayoutChangeListener(layoutListener) + //removeOnScrollListener(scrollListener) + } } val wasGone = focusOutline.isGone @@ -952,7 +964,10 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener { focusOutline.isVisible = false } if (!exactlyTheSame) { - (newFocus.parent as? RecyclerView)?.addOnLayoutChangeListener(layoutListener) + (newFocus.parent as? RecyclerView)?.apply { + addOnLayoutChangeListener(layoutListener) + //addOnScrollListener(scrollListener) + } newFocus.addOnLayoutChangeListener(layoutListener) newFocus.addOnAttachStateChangeListener(attachListener) } @@ -970,8 +985,9 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener { ) // if they are the same within then snap, aka scrolling - val deltaMin = 50.toPx - if (start.width == end.width && start.height == end.height && (start.x - end.x).absoluteValue < deltaMin && (start.y - end.y).absoluteValue < deltaMin) { + val deltaMinX = min(end.width / 2, 60.toPx) + val deltaMinY = min(end.height / 2, 60.toPx) + if (start.width == end.width && start.height == end.height && (start.x - end.x).absoluteValue < deltaMinX && (start.y - end.y).absoluteValue < deltaMinY) { animator?.cancel() last = start current = end @@ -1000,7 +1016,7 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener { // animate between a and b animator = ValueAnimator.ofFloat(0.0f, 1.0f).apply { startDelay = 0 - duration = 100 + duration = 200 addUpdateListener { animation -> val animatedValue = animation.animatedValue as Float val target = FocusTarget.lerp(last, current, minOf(animatedValue, 1.0f)) @@ -1095,7 +1111,29 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener { TvFocus.focusOutline = WeakReference(newLocalBinding.focusOutline) newLocalBinding.root.viewTreeObserver.addOnGlobalFocusChangeListener { _, newFocus -> // println("refocus $oldFocus -> $newFocus") + try { + val r = Rect(0,0,0,0) + newFocus.getDrawingRect(r) + val x = r.centerX() + val y = r.centerY() + val dx = 0 //screenWidth / 2 + val dy = screenHeight / 2 + val r2 = Rect(x-dx,y-dy,x+dx,y+dy) + newFocus.requestRectangleOnScreen(r2, false) + // TvFocus.current =TvFocus.current.copy(y=y.toFloat()) + } catch (_ : Throwable) { } TvFocus.updateFocusView(newFocus) + /*var focus = newFocus + + while(focus != null) { + if(focus is ScrollingView && focus.canScrollVertically()) { + focus.scrollBy() + } + when(focus.parent) { + is View -> focus = newFocus + else -> break + } + }*/ } newLocalBinding.root.viewTreeObserver.addOnScrollChangedListener { TvFocus.updateFocusView(TvFocus.lastFocus.get(), same = true) diff --git a/app/src/main/java/com/lagradost/cloudstream3/extractors/Gofile.kt b/app/src/main/java/com/lagradost/cloudstream3/extractors/Gofile.kt index d76b0e11..eaf9c65f 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/extractors/Gofile.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/extractors/Gofile.kt @@ -19,7 +19,7 @@ open class Gofile : ExtractorApi() { subtitleCallback: (SubtitleFile) -> Unit, callback: (ExtractorLink) -> Unit ) { - val id = Regex("/(?:\\?c=|d/)([\\da-zA-Z]+)").find(url)?.groupValues?.get(1) + val id = Regex("/(?:\\?c=|d/)([\\da-zA-Z-]+)").find(url)?.groupValues?.get(1) val token = app.get("$mainApi/createAccount").parsedSafe()?.data?.get("token") val websiteToken = app.get("$mainUrl/dist/js/alljs.js").text.let { Regex("websiteToken\\s*=\\s*\"([^\"]+)").find(it)?.groupValues?.get(1) @@ -59,4 +59,4 @@ open class Gofile : ExtractorApi() { @JsonProperty("data") val data: Data? = null, ) -} \ No newline at end of file +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/OpenSubtitlesApi.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/OpenSubtitlesApi.kt index 3e372c2d..4030649d 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/OpenSubtitlesApi.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/OpenSubtitlesApi.kt @@ -2,12 +2,13 @@ package com.lagradost.cloudstream3.syncproviders.providers import android.util.Log import com.fasterxml.jackson.annotation.JsonProperty -import com.google.common.collect.BiMap -import com.google.common.collect.HashBiMap -import com.lagradost.cloudstream3.* import com.lagradost.cloudstream3.AcraApplication.Companion.getKey import com.lagradost.cloudstream3.AcraApplication.Companion.removeKey import com.lagradost.cloudstream3.AcraApplication.Companion.setKey +import com.lagradost.cloudstream3.ErrorLoadingException +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.TvType +import com.lagradost.cloudstream3.app import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.subtitles.AbstractSubApi import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities @@ -15,8 +16,8 @@ import com.lagradost.cloudstream3.syncproviders.AuthAPI import com.lagradost.cloudstream3.syncproviders.InAppAuthAPI import com.lagradost.cloudstream3.syncproviders.InAppAuthAPIManager import com.lagradost.cloudstream3.utils.AppUtils -import java.net.URLEncoder -import java.nio.charset.StandardCharsets +import okhttp3.Interceptor +import okhttp3.Response class OpenSubtitlesApi(index: Int) : InAppAuthAPIManager(index), AbstractSubApi { override val idPrefix = "opensubtitles" @@ -36,6 +37,23 @@ class OpenSubtitlesApi(index: Int) : InAppAuthAPIManager(index), AbstractSubApi var currentSession: SubtitleOAuthEntity? = null } + private val headerInterceptor = OpenSubtitleInterceptor() + + /** Automatically adds required api headers */ + private class OpenSubtitleInterceptor : Interceptor { + /** Required user agent! */ + private val userAgent = "Cloudstream3 v0.1" + override fun intercept(chain: Interceptor.Chain): Response { + return chain.proceed( + chain.request().newBuilder() + .removeHeader("user-agent") + .addHeader("user-agent", userAgent) + .addHeader("Api-Key", apiKey) + .build() + ) + } + } + private fun canDoRequest(): Boolean { return unixTimeMs > currentCoolDown } @@ -98,13 +116,13 @@ class OpenSubtitlesApi(index: Int) : InAppAuthAPIManager(index), AbstractSubApi val response = app.post( url = "$host/login", headers = mapOf( - "Api-Key" to apiKey, - "Content-Type" to "application/json" + "Content-Type" to "application/json", ), data = mapOf( "username" to username, "password" to password - ) + ), + interceptor = headerInterceptor ) //Log.i(TAG, "Responsecode = ${response.code}") //Log.i(TAG, "Result => ${response.text}") @@ -149,11 +167,13 @@ class OpenSubtitlesApi(index: Int) : InAppAuthAPIManager(index), AbstractSubApi // "pt" to "pt-PT", // "pt" to "pt-BR" ) - private fun fixLanguage(language: String?) : String? { + + private fun fixLanguage(language: String?): String? { return languageExceptions[language] ?: language } + // O(n) but good enough, BiMap did not want to work properly - private fun fixLanguageReverse(language: String?) : String? { + private fun fixLanguageReverse(language: String?): String? { return languageExceptions.entries.firstOrNull { it.value == language }?.key ?: language } @@ -183,9 +203,9 @@ class OpenSubtitlesApi(index: Int) : InAppAuthAPIManager(index), AbstractSubApi val req = app.get( url = searchQueryUrl, headers = mapOf( - Pair("Api-Key", apiKey), Pair("Content-Type", "application/json") - ) + ), + interceptor = headerInterceptor ) Log.i(TAG, "Search Req => ${req.text}") if (!req.isSuccessful) { @@ -207,7 +227,7 @@ class OpenSubtitlesApi(index: Int) : InAppAuthAPIManager(index), AbstractSubApi //Use any valid name/title in hierarchy val name = filename ?: featureDetails?.movieName ?: featureDetails?.title ?: featureDetails?.parentTitle ?: attr.release ?: query.query - val lang = fixLanguageReverse(attr.language)?: "" + val lang = fixLanguageReverse(attr.language) ?: "" val resEpNum = featureDetails?.episodeNumber ?: query.epNumber val resSeasonNum = featureDetails?.seasonNumber ?: query.seasonNumber val year = featureDetails?.year ?: query.year @@ -251,13 +271,13 @@ class OpenSubtitlesApi(index: Int) : InAppAuthAPIManager(index), AbstractSubApi "Authorization", "Bearer ${currentSession?.access_token ?: throw ErrorLoadingException("No access token active in current session")}" ), - Pair("Api-Key", apiKey), Pair("Content-Type", "application/json"), Pair("Accept", "*/*") ), data = mapOf( Pair("file_id", data.data) - ) + ), + interceptor = headerInterceptor ) Log.i(TAG, "Request result => (${req.code}) ${req.text}") //Log.i(TAG, "Request headers => ${req.headers}") diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/SimklApi.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/SimklApi.kt index b4a9d789..cd1df562 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/SimklApi.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/SimklApi.kt @@ -5,7 +5,9 @@ import androidx.core.net.toUri import androidx.fragment.app.FragmentActivity import com.fasterxml.jackson.annotation.JsonInclude import com.fasterxml.jackson.annotation.JsonProperty +import com.lagradost.cloudstream3.AcraApplication import com.lagradost.cloudstream3.AcraApplication.Companion.getKey +import com.lagradost.cloudstream3.AcraApplication.Companion.getKeys import com.lagradost.cloudstream3.AcraApplication.Companion.openBrowser import com.lagradost.cloudstream3.AcraApplication.Companion.removeKey import com.lagradost.cloudstream3.AcraApplication.Companion.setKey @@ -13,6 +15,7 @@ import com.lagradost.cloudstream3.BuildConfig import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.TvType import com.lagradost.cloudstream3.app +import com.lagradost.cloudstream3.mapper import com.lagradost.cloudstream3.mvvm.debugAssert import com.lagradost.cloudstream3.mvvm.debugPrint import com.lagradost.cloudstream3.mvvm.logError @@ -33,6 +36,9 @@ import java.text.SimpleDateFormat import java.time.Instant import java.util.Date import java.util.TimeZone +import kotlin.time.Duration +import kotlin.time.DurationUnit +import kotlin.time.toDuration class SimklApi(index: Int) : AccountManager(index), SyncAPI { override var name = "Simkl" @@ -59,6 +65,80 @@ class SimklApi(index: Int) : AccountManager(index), SyncAPI { */ private var lastScoreTime = -1L + private object SimklCache { + private const val SIMKL_CACHE_KEY = "SIMKL_API_CACHE" + + enum class CacheTimes(val value: String) { + OneMonth("30d"), + ThirtyMinutes("30m") + } + + private class SimklCacheWrapper( + @JsonProperty("obj") val obj: T?, + @JsonProperty("validUntil") val validUntil: Long, + @JsonProperty("cacheTime") val cacheTime: Long = unixTime, + ) { + /** Returns true if cache is newer than cacheDays */ + fun isFresh(): Boolean { + return validUntil > unixTime + } + + fun remainingTime(): Duration { + val unixTime = unixTime + return if (validUntil > unixTime) { + (validUntil - unixTime).toDuration(DurationUnit.SECONDS) + } else { + Duration.ZERO + } + } + } + + fun cleanOldCache() { + getKeys(SIMKL_CACHE_KEY)?.forEach { + val isOld = AcraApplication.getKey>(it)?.isFresh() == false + if (isOld) { + removeKey(it) + } + } + } + + fun setKey(path: String, value: T, cacheTime: Duration) { + debugPrint { "Set cache: $SIMKL_CACHE_KEY/$path for ${cacheTime.inWholeDays} days or ${cacheTime.inWholeSeconds} seconds." } + setKey( + SIMKL_CACHE_KEY, + path, + // Storing as plain sting is required to make generics work. + SimklCacheWrapper(value, unixTime + cacheTime.inWholeSeconds).toJson() + ) + } + + /** + * Gets cached object, if object is not fresh returns null and removes it from cache + */ + inline fun getKey(path: String): T? { + // Required for generic otherwise "LinkedHashMap cannot be cast to MediaObject" + val type = mapper.typeFactory.constructParametricType( + SimklCacheWrapper::class.java, + T::class.java + ) + val cache = getKey(SIMKL_CACHE_KEY, path)?.let { + mapper.readValue>(it, type) + } + + return if (cache?.isFresh() == true) { + debugPrint { + "Cache hit at: $SIMKL_CACHE_KEY/$path. " + + "Remains fresh for ${cache.remainingTime().inWholeDays} days or ${cache.remainingTime().inWholeSeconds} seconds." + } + cache.obj + } else { + debugPrint { "Cache miss at: $SIMKL_CACHE_KEY/$path" } + removeKey(SIMKL_CACHE_KEY, path) + null + } + } + } + companion object { private const val clientId: String = BuildConfig.SIMKL_CLIENT_ID private const val clientSecret: String = BuildConfig.SIMKL_CLIENT_SECRET @@ -210,18 +290,18 @@ class SimklApi(index: Int) : AccountManager(index), SyncAPI { @JsonProperty("img") val img: String? ) { companion object { - fun convertToEpisodes(list: List?): List { + fun convertToEpisodes(list: List?): List? { return list?.map { MediaObject.Season.Episode(it.episode) - } ?: emptyList() + } } - fun convertToSeasons(list: List?): List { + fun convertToSeasons(list: List?): List? { return list?.filter { it.season != null }?.groupBy { it.season - }?.map { (season, episodes) -> - MediaObject.Season(season!!, convertToEpisodes(episodes)) - } ?: emptyList() + }?.mapNotNull { (season, episodes) -> + convertToEpisodes(episodes)?.let { MediaObject.Season(season!!, it) } + }?.ifEmpty { null } } } } @@ -235,11 +315,17 @@ class SimklApi(index: Int) : AccountManager(index), SyncAPI { @JsonProperty("title") val title: String?, @JsonProperty("year") val year: Int?, @JsonProperty("ids") val ids: Ids?, + @JsonProperty("total_episodes") val total_episodes: Int? = null, + @JsonProperty("status") val status: String? = null, @JsonProperty("poster") val poster: String? = null, @JsonProperty("type") val type: String? = null, @JsonProperty("seasons") val seasons: List? = null, @JsonProperty("episodes") val episodes: List? = null ) { + fun hasEnded(): Boolean { + return status == "released" || status == "ended" + } + @JsonInclude(JsonInclude.Include.NON_EMPTY) data class Season( @JsonProperty("number") val number: Int, @@ -281,6 +367,194 @@ class SimklApi(index: Int) : AccountManager(index), SyncAPI { } } + class SimklScoreBuilder private constructor() { + data class Builder( + private var url: String? = null, + private var interceptor: Interceptor? = null, + private var ids: MediaObject.Ids? = null, + private var score: Int? = null, + private var status: Int? = null, + private var addEpisodes: Pair?, List?>? = null, + private var removeEpisodes: Pair?, List?>? = null, + ) { + fun interceptor(interceptor: Interceptor) = apply { this.interceptor = interceptor } + fun apiUrl(url: String) = apply { this.url = url } + fun ids(ids: MediaObject.Ids) = apply { this.ids = ids } + fun score(score: Int?, oldScore: Int?) = apply { + if (score != oldScore) { + this.score = score + } + } + + fun status(newStatus: Int?, oldStatus: Int?) = apply { + // Only set status if its new + if (newStatus != oldStatus) { + this.status = newStatus + } else { + this.status = null + } + } + + fun episodes( + allEpisodes: List?, + newEpisodes: Int?, + oldEpisodes: Int?, + ) = apply { + if (allEpisodes == null || newEpisodes == null) return@apply + + fun getEpisodes(rawEpisodes: List) = + if (rawEpisodes.any { it.season != null }) { + EpisodeMetadata.convertToSeasons(rawEpisodes) to null + } else { + null to EpisodeMetadata.convertToEpisodes(rawEpisodes) + } + + // Do not add episodes if there is no change + if (newEpisodes > (oldEpisodes ?: 0)) { + this.addEpisodes = getEpisodes(allEpisodes.take(newEpisodes)) + } + if ((oldEpisodes ?: 0) > newEpisodes) { + this.removeEpisodes = getEpisodes(allEpisodes.drop(newEpisodes)) + } + } + + suspend fun execute(): Boolean { + val time = getDateTime(unixTime) + + return if (this.status == SimklListStatusType.None.value) { + app.post( + "$url/sync/history/remove", + json = StatusRequest( + shows = listOf(HistoryMediaObject(ids = ids)), + movies = emptyList() + ), + interceptor = interceptor + ).isSuccessful + } else { + val episodeRemovalResponse = removeEpisodes?.let { (seasons, episodes) -> + app.post( + "${this.url}/sync/history/remove", + json = StatusRequest( + shows = listOf( + HistoryMediaObject( + ids = ids, + seasons = seasons, + episodes = episodes + ) + ), + movies = emptyList() + ), + interceptor = interceptor + ).isSuccessful + } ?: true + + val historyResponse = + // Only post if there are episodes or score to upload + if (addEpisodes != null || score != null) { + app.post( + "${this.url}/sync/history", + json = StatusRequest( + shows = listOf( + HistoryMediaObject( + null, + null, + ids, + addEpisodes?.first, + addEpisodes?.second, + score, + score?.let { time }, + ) + ), movies = emptyList() + ), + interceptor = interceptor + ).isSuccessful + } else { + true + } + + val statusResponse = status?.let { setStatus -> + val newStatus = + SimklListStatusType.values() + .firstOrNull { it.value == setStatus }?.originalName + ?: SimklListStatusType.Watching.originalName!! + + app.post( + "${this.url}/sync/add-to-list", + json = StatusRequest( + shows = listOf( + StatusMediaObject( + null, + null, + ids, + newStatus, + ) + ), movies = emptyList() + ), + interceptor = interceptor + ).isSuccessful + } ?: true + + statusResponse && episodeRemovalResponse && historyResponse + } + } + } + } + + suspend fun getEpisodes( + simklId: Int?, + type: String?, + episodes: Int?, + hasEnded: Boolean? + ): Array? { + if (simklId == null) return null + + val cacheKey = "Episodes/$simklId" + val cache = SimklCache.getKey>(cacheKey) + + // Return cached result if its higher or equal the amount of episodes. + if (cache != null && cache.size >= (episodes ?: 0)) { + return cache + } + + // There is always one season in Anime -> no request necessary + if (type == "anime" && episodes != null) { + return episodes.takeIf { it > 0 }?.let { + (1..it).map { episode -> + EpisodeMetadata( + null, null, null, episode, null + ) + }.toTypedArray() + } + } + val url = when (type) { + "anime" -> "https://api.simkl.com/anime/episodes/$simklId" + "tv" -> "https://api.simkl.com/tv/episodes/$simklId" + "movie" -> return null + else -> return null + } + + debugPrint { "Requesting episodes from $url" } + return app.get(url, params = mapOf("client_id" to clientId)) + .parsedSafe>()?.also { + val cacheTime = + if (hasEnded == true) SimklCache.CacheTimes.OneMonth.value else SimklCache.CacheTimes.ThirtyMinutes.value + + // 1 Month cache + SimklCache.setKey(cacheKey, it, Duration.parse(cacheTime)) + } + } + + @JsonInclude(JsonInclude.Include.NON_EMPTY) + class HistoryMediaObject( + @JsonProperty("title") title: String? = null, + @JsonProperty("year") year: Int? = null, + @JsonProperty("ids") ids: Ids? = null, + @JsonProperty("seasons") seasons: List? = null, + @JsonProperty("episodes") episodes: List? = null, + @JsonProperty("rating") val rating: Int? = null, + @JsonProperty("rated_at") val rated_at: String? = null, + ) : MediaObject(title, year, ids, seasons = seasons, episodes = episodes) + @JsonInclude(JsonInclude.Include.NON_EMPTY) class RatingMediaObject( @JsonProperty("title") title: String?, @@ -299,15 +573,6 @@ class SimklApi(index: Int) : AccountManager(index), SyncAPI { @JsonProperty("watched_at") val watched_at: String? = getDateTime(unixTime) ) : MediaObject(title, year, ids) - @JsonInclude(JsonInclude.Include.NON_EMPTY) - class HistoryMediaObject( - @JsonProperty("title") title: String?, - @JsonProperty("year") year: Int?, - @JsonProperty("ids") ids: Ids?, - @JsonProperty("seasons") seasons: List?, - @JsonProperty("episodes") episodes: List?, - ) : MediaObject(title, year, ids, seasons = seasons, episodes = episodes) - @JsonInclude(JsonInclude.Include.NON_EMPTY) data class StatusRequest( @JsonProperty("movies") val movies: List, @@ -404,13 +669,13 @@ class SimklApi(index: Int) : AccountManager(index), SyncAPI { } data class ShowMetadata( - override val last_watched_at: String?, - override val status: String, - override val user_rating: Int?, - override val last_watched: String?, - override val watched_episodes_count: Int?, - override val total_episodes_count: Int?, - val show: Show + @JsonProperty("last_watched_at") override val last_watched_at: String?, + @JsonProperty("status") override val status: String, + @JsonProperty("user_rating") override val user_rating: Int?, + @JsonProperty("last_watched") override val last_watched: String?, + @JsonProperty("watched_episodes_count") override val watched_episodes_count: Int?, + @JsonProperty("total_episodes_count") override val total_episodes_count: Int?, + @JsonProperty("show") val show: Show ) : Metadata { override fun getIds(): Show.Ids { return this.show.ids @@ -435,23 +700,23 @@ class SimklApi(index: Int) : AccountManager(index), SyncAPI { } data class Show( - val title: String, - val poster: String?, - val year: Int?, - val ids: Ids, + @JsonProperty("title") val title: String, + @JsonProperty("poster") val poster: String?, + @JsonProperty("year") val year: Int?, + @JsonProperty("ids") val ids: Ids, ) { data class Ids( - val simkl: Int, - val slug: String?, - val imdb: String?, - val zap2it: String?, - val tmdb: String?, - val offen: String?, - val tvdb: String?, - val mal: String?, - val anidb: String?, - val anilist: String?, - val traktslug: String? + @JsonProperty("simkl") val simkl: Int, + @JsonProperty("slug") val slug: String?, + @JsonProperty("imdb") val imdb: String?, + @JsonProperty("zap2it") val zap2it: String?, + @JsonProperty("tmdb") val tmdb: String?, + @JsonProperty("offen") val offen: String?, + @JsonProperty("tvdb") val tvdb: String?, + @JsonProperty("mal") val mal: String?, + @JsonProperty("anidb") val anidb: String?, + @JsonProperty("anilist") val anilist: String?, + @JsonProperty("traktslug") val traktslug: String? ) { fun matchesId(database: SyncServices, id: String): Boolean { return when (database) { @@ -491,20 +756,58 @@ class SimklApi(index: Int) : AccountManager(index), SyncAPI { } } + /** + * Useful to get episodes on demand to prevent unnecessary requests. + */ + class SimklEpisodeConstructor( + private val simklId: Int?, + private val type: String?, + private val totalEpisodeCount: Int?, + private val hasEnded: Boolean? + ) { + suspend fun getEpisodes(): Array? { + return getEpisodes(simklId, type, totalEpisodeCount, hasEnded) + } + } + class SimklSyncStatus( override var status: Int, override var score: Int?, + val oldScore: Int?, override var watchedEpisodes: Int?, - val episodes: Array?, + val episodeConstructor: SimklEpisodeConstructor, override var isFavorite: Boolean? = null, override var maxEpisodes: Int? = null, /** Save seen episodes separately to know the change from old to new. * Required to remove seen episodes if count decreases */ val oldEpisodes: Int, + val oldStatus: String? ) : SyncAPI.AbstractSyncStatus() override suspend fun getStatus(id: String): SyncAPI.AbstractSyncStatus? { val realIds = readIdFromString(id) + + // Key which assumes all ids are the same each time :/ + // This could be some sort of reference system to make multiple IDs + // point to the same key. + val idKey = + realIds.toList().map { "${it.first.originalName}=${it.second}" }.sorted().joinToString() + + val cachedObject = SimklCache.getKey(idKey) + val searchResult: MediaObject = cachedObject + ?: (searchByIds(realIds)?.firstOrNull()?.also { result -> + val cacheTime = + if (result.hasEnded()) SimklCache.CacheTimes.OneMonth.value else SimklCache.CacheTimes.ThirtyMinutes.value + SimklCache.setKey(idKey, result, Duration.parse(cacheTime)) + }) ?: return null + + val episodeConstructor = SimklEpisodeConstructor( + searchResult.ids?.simkl, + searchResult.type, + searchResult.total_episodes, + searchResult.hasEnded() + ) + val foundItem = getSyncListSmart()?.let { list -> listOf(list.shows, list.anime, list.movies).flatten().firstOrNull { show -> realIds.any { (database, id) -> @@ -513,172 +816,63 @@ class SimklApi(index: Int) : AccountManager(index), SyncAPI { } } - // Search to get episodes - val searchResult = searchByIds(realIds)?.firstOrNull() - val episodes = getEpisodes(searchResult?.ids?.simkl, searchResult?.type) - if (foundItem != null) { return SimklSyncStatus( status = foundItem.status?.let { SimklListStatusType.fromString(it)?.value } ?: return null, score = foundItem.user_rating, watchedEpisodes = foundItem.watched_episodes_count, - maxEpisodes = foundItem.total_episodes_count, - episodes = episodes, + maxEpisodes = searchResult.total_episodes, + episodeConstructor = episodeConstructor, oldEpisodes = foundItem.watched_episodes_count ?: 0, + oldScore = foundItem.user_rating, + oldStatus = foundItem.status ) } else { - return if (searchResult != null) { - SimklSyncStatus( - status = SimklListStatusType.None.value, - score = 0, - watchedEpisodes = 0, - maxEpisodes = if (searchResult.type == "movie") 0 else null, - episodes = episodes, - oldEpisodes = 0, - ) - } else { - null - } + return SimklSyncStatus( + status = SimklListStatusType.None.value, + score = 0, + watchedEpisodes = 0, + maxEpisodes = if (searchResult.type == "movie") 0 else searchResult.total_episodes, + episodeConstructor = episodeConstructor, + oldEpisodes = 0, + oldStatus = null, + oldScore = null + ) } } override suspend fun score(id: String, status: SyncAPI.AbstractSyncStatus): Boolean { val parsedId = readIdFromString(id) lastScoreTime = unixTime - - if (status.status == SimklListStatusType.None.value) { - return app.post( - "$mainUrl/sync/history/remove", - json = StatusRequest( - shows = listOf( - HistoryMediaObject( - null, - null, - MediaObject.Ids.fromMap(parsedId), - emptyList(), - emptyList() - ) - ), - movies = emptyList() - ), - interceptor = interceptor - ).isSuccessful - } - - val realScore = status.score - val ratingResponseSuccess = if (realScore != null) { - // Remove rating if score is 0 - val ratingsSuffix = if (realScore == 0) "/remove" else "" - debugPrint { "Rate ${this.name} item: rating=$realScore" } - app.post( - "$mainUrl/sync/ratings$ratingsSuffix", - json = StatusRequest( - // Not possible to know if TV or Movie - shows = listOf( - RatingMediaObject( - null, - null, - MediaObject.Ids.fromMap(parsedId), - realScore - ) - ), - movies = emptyList() - ), - interceptor = interceptor - ).isSuccessful - } else { - true - } - val simklStatus = status as? SimklSyncStatus + + val builder = SimklScoreBuilder.Builder() + .apiUrl(this.mainUrl) + .score(status.score, simklStatus?.oldScore) + .status(status.status, (status as? SimklSyncStatus)?.oldStatus?.let { oldStatus -> + SimklListStatusType.values().firstOrNull { + it.originalName == oldStatus + }?.value + }) + .interceptor(interceptor) + .ids(MediaObject.Ids.fromMap(parsedId)) + + + // Get episodes only when required + val episodes = simklStatus?.episodeConstructor?.getEpisodes() + // All episodes if marked as completed val watchedEpisodes = if (status.status == SimklListStatusType.Completed.value) { - simklStatus?.episodes?.size + episodes?.size } else { status.watchedEpisodes } - // Only post episodes if available episodes and the status is correct - val episodeResponseSuccess = - if (simklStatus != null && watchedEpisodes != null && !simklStatus.episodes.isNullOrEmpty() && listOf( - SimklListStatusType.Paused.value, - SimklListStatusType.Dropped.value, - SimklListStatusType.Watching.value, - SimklListStatusType.Completed.value, - SimklListStatusType.ReWatching.value - ).contains(status.status) - ) { - suspend fun postEpisodes( - url: String, - rawEpisodes: List - ): Boolean { - val (seasons, episodes) = if (rawEpisodes.any { it.season != null }) { - EpisodeMetadata.convertToSeasons(rawEpisodes) to null - } else { - null to EpisodeMetadata.convertToEpisodes(rawEpisodes) - } - debugPrint { "Synced history using $url: seasons=${seasons?.toList()}, episodes=${episodes?.toList()}" } - return app.post( - url, - json = StatusRequest( - shows = listOf( - HistoryMediaObject( - null, - null, - MediaObject.Ids.fromMap(parsedId), - seasons, - episodes - ) - ), - movies = emptyList() - ), - interceptor = interceptor - ).isSuccessful - } + builder.episodes(episodes?.toList(), watchedEpisodes, simklStatus?.oldEpisodes) - // If episodes decrease: remove all episodes beyond watched episodes. - val removeResponse = if (simklStatus.oldEpisodes > watchedEpisodes) { - val removeEpisodes = simklStatus.episodes - .drop(watchedEpisodes) - postEpisodes("$mainUrl/sync/history/remove", removeEpisodes) - } else { - true - } - val cutEpisodes = simklStatus.episodes.take(watchedEpisodes) - val addResponse = postEpisodes("$mainUrl/sync/history/", cutEpisodes) - - removeResponse && addResponse - } else true - - val newStatus = - SimklListStatusType.values().firstOrNull { it.value == status.status }?.originalName - ?: SimklListStatusType.Watching.originalName - - val statusResponseSuccess = if (newStatus != null) { - debugPrint { "Add to ${this.name} list: status=$newStatus" } - app.post( - "$mainUrl/sync/add-to-list", - json = StatusRequest( - shows = listOf( - StatusMediaObject( - null, - null, - MediaObject.Ids.fromMap(parsedId), - newStatus - ) - ), - movies = emptyList() - ), - interceptor = interceptor - ).isSuccessful - } else { - true - } - - debugPrint { "All scoring complete: rating=$ratingResponseSuccess, status=$statusResponseSuccess, episode=$episodeResponseSuccess" } requireLibraryRefresh = true - return ratingResponseSuccess && statusResponseSuccess && episodeResponseSuccess + return builder.execute() } @@ -694,17 +888,6 @@ class SimklApi(index: Int) : AccountManager(index), SyncAPI { ).parsedSafe() } - suspend fun getEpisodes(simklId: Int?, type: String?): Array? { - if (simklId == null) return null - val url = when (type) { - "anime" -> "https://api.simkl.com/anime/episodes/$simklId" - "tv" -> "https://api.simkl.com/tv/episodes/$simklId" - "movie" -> return null - else -> return null - } - return app.get(url, params = mapOf("client_id" to clientId)).parsedSafe() - } - override suspend fun search(name: String): List? { return app.get( "$mainUrl/search/", params = mapOf("client_id" to clientId, "q" to name) @@ -737,16 +920,17 @@ class SimklApi(index: Int) : AccountManager(index), SyncAPI { return null } - private suspend fun getSyncListSince(since: Long?): AllItemsResponse { + private suspend fun getSyncListSince(since: Long?): AllItemsResponse? { val params = getDateTime(since)?.let { mapOf("date_from" to it) } ?: emptyMap() + // Can return null on no change. return app.get( "$mainUrl/sync/all-items/", params = params, interceptor = interceptor - ).parsed() + ).parsedSafe() } private suspend fun getActivities(): ActivitiesResponse? { diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/AbstractPlayerFragment.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/AbstractPlayerFragment.kt index 53ee5e12..8388e58f 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/AbstractPlayerFragment.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/AbstractPlayerFragment.kt @@ -7,6 +7,7 @@ import android.graphics.drawable.AnimatedVectorDrawable import android.media.metrics.PlaybackErrorEvent import android.os.Build import android.os.Bundle +import android.util.Log import android.view.LayoutInflater import android.view.View import android.view.ViewGroup @@ -25,8 +26,10 @@ import androidx.media3.common.PlaybackException import androidx.media3.exoplayer.ExoPlayer import androidx.media3.session.MediaSession import androidx.media3.ui.AspectRatioFrameLayout +import androidx.media3.ui.DefaultTimeBar import androidx.media3.ui.PlayerView import androidx.media3.ui.SubtitleView +import androidx.media3.ui.TimeBar import com.lagradost.cloudstream3.AcraApplication.Companion.getKey import com.lagradost.cloudstream3.AcraApplication.Companion.setKey import com.lagradost.cloudstream3.CommonActivity.canEnterPipMode @@ -92,11 +95,11 @@ abstract class AbstractPlayerFragment( throw NotImplementedError() } - open fun playerPositionChanged(posDur: Pair) { + open fun playerPositionChanged(position: Long, duration : Long) { throw NotImplementedError() } - open fun playerDimensionsLoaded(widthHeight: Pair) { + open fun playerDimensionsLoaded(width: Int, height : Int) { throw NotImplementedError() } @@ -132,8 +135,8 @@ abstract class AbstractPlayerFragment( } } - private fun updateIsPlaying(playing: Pair) { - val (wasPlaying, isPlaying) = playing + private fun updateIsPlaying(wasPlaying : CSPlayerLoading, + isPlaying : CSPlayerLoading) { val isPlayingRightNow = CSPlayerLoading.IsPlaying == isPlaying val isPausedRightNow = CSPlayerLoading.IsPaused == isPlaying @@ -206,7 +209,7 @@ abstract class AbstractPlayerFragment( CSPlayerEvent.values()[intent.getIntExtra( EXTRA_CONTROL_TYPE, 0 - )] + )], source = PlayerEventSource.UI ) } } @@ -216,7 +219,7 @@ abstract class AbstractPlayerFragment( val isPlaying = player.getIsPlaying() val isPlayingValue = if (isPlaying) CSPlayerLoading.IsPlaying else CSPlayerLoading.IsPaused - updateIsPlaying(Pair(isPlayingValue, isPlayingValue)) + updateIsPlaying(isPlayingValue, isPlayingValue) } else { // Restore the full-screen UI. piphide?.isVisible = true @@ -249,7 +252,7 @@ abstract class AbstractPlayerFragment( } } - open fun playerError(exception: Exception) { + open fun playerError(exception: Throwable) { fun showToast(message: String, gotoNext: Boolean = false) { if (gotoNext && hasNextMirror()) { showToast( @@ -326,6 +329,7 @@ abstract class AbstractPlayerFragment( } } + @SuppressLint("UnsafeOptInUsageError") private fun playerUpdated(player: Any?) { if (player is ExoPlayer) { context?.let { ctx -> @@ -366,33 +370,87 @@ abstract class AbstractPlayerFragment( // } //} + /** This receives the events from the player, if you want to append functionality you do it here, + * do note that this only receives events for UI changes, + * and returning early WONT stop it from changing in eg the player time or pause status */ + open fun mainCallback(event : PlayerEvent) { + Log.i(TAG, "Handle event: $event") + when(event) { + is ResizedEvent -> { + playerDimensionsLoaded(event.width, event.height) + } + is PlayerAttachedEvent -> { + playerUpdated(event.player) + } + is SubtitlesUpdatedEvent -> { + subtitlesChanged() + } + is TimestampSkippedEvent -> { + onTimestampSkipped(event.timestamp) + } + is TimestampInvokedEvent -> { + onTimestamp(event.timestamp) + } + is TracksChangedEvent -> { + onTracksInfoChanged() + } + is EmbeddedSubtitlesFetchedEvent -> { + embeddedSubtitlesFetched(event.tracks) + } + is ErrorEvent -> { + playerError(event.error) + } + is RequestAudioFocusEvent -> { + requestAudioFocus() + } + is EpisodeSeekEvent -> { + when(event.offset) { + -1 -> prevEpisode() + 1 -> nextEpisode() + else -> {} + } + } + is StatusEvent -> { + updateIsPlaying(wasPlaying = event.wasPlaying, isPlaying = event.isPlaying) + } + is PositionEvent -> { + playerPositionChanged(position = event.toMs, duration = event.durationMs) + } + is VideoEndedEvent -> { + context?.let { ctx -> + // Only play next episode if autoplay is on (default) + if (PreferenceManager.getDefaultSharedPreferences(ctx) + ?.getBoolean( + ctx.getString(R.string.autoplay_next_key), + true + ) == true + ) { + player.handleEvent( + CSPlayerEvent.NextEpisode, + source = PlayerEventSource.Player + ) + } + } + } + is PauseEvent -> Unit + is PlayEvent -> Unit + } + } - @SuppressLint("SetTextI18n") + @SuppressLint("SetTextI18n", "UnsafeOptInUsageError") override fun onViewCreated(view: View, savedInstanceState: Bundle?) { resizeMode = getKey(RESIZE_MODE_KEY) ?: 0 resize(resizeMode, false) player.releaseCallbacks() player.initCallbacks( - playerUpdated = ::playerUpdated, - updateIsPlaying = ::updateIsPlaying, - playerError = ::playerError, - requestAutoFocus = ::requestAudioFocus, - nextEpisode = ::nextEpisode, - prevEpisode = ::prevEpisode, - playerPositionChanged = ::playerPositionChanged, - playerDimensionsLoaded = ::playerDimensionsLoaded, + eventHandler = ::mainCallback, requestedListeningPercentages = listOf( SKIP_OP_VIDEO_PERCENTAGE, PRELOAD_NEXT_EPISODE_PERCENTAGE, NEXT_WATCH_EPISODE_PERCENTAGE, UPDATE_SYNC_PROGRESS_PERCENTAGE, ), - subtitlesUpdates = ::subtitlesChanged, - embeddedSubtitlesFetched = ::embeddedSubtitlesFetched, - onTracksInfoChanged = ::onTracksInfoChanged, - onTimestampInvoked = ::onTimestamp, - onTimestampSkipped = ::onTimestampSkipped ) if (player is CS3IPlayer) { @@ -400,6 +458,19 @@ abstract class AbstractPlayerFragment( subStyle = SubtitlesFragment.getCurrentSavedStyle() player.initSubtitles(subView, subtitleHolder, subStyle) + /** this might seam a bit fucky and that is because it is, the seek event is captured twice, once by the player + * and once by the UI even if it should only be registered once by the UI */ + playerView?.findViewById(R.id.exo_progress)?.addListener(object : TimeBar.OnScrubListener { + override fun onScrubStart(timeBar: TimeBar, position: Long) = Unit + override fun onScrubMove(timeBar: TimeBar, position: Long) = Unit + override fun onScrubStop(timeBar: TimeBar, position: Long, canceled: Boolean) { + if (canceled) return + val playerDuration = player.getDuration() ?: return + val playerPosition = player.getPosition() ?: return + mainCallback(PositionEvent(source = PlayerEventSource.UI, durationMs = playerDuration, fromMs = playerPosition, toMs = position)) + } + }) + SubtitlesFragment.applyStyleEvent += ::onSubStyleChanged try { @@ -446,6 +517,7 @@ abstract class AbstractPlayerFragment( canEnterPipMode = false mMediaSession?.release() mMediaSession = null + playerView?.player = null SubtitlesFragment.applyStyleEvent -= ::onSubStyleChanged keepScreenOn(false) @@ -461,6 +533,7 @@ abstract class AbstractPlayerFragment( resize(PlayerResize.values()[resize], showToast) } + @SuppressLint("UnsafeOptInUsageError") fun resize(resize: PlayerResize, showToast: Boolean) { setKey(RESIZE_MODE_KEY, resize.ordinal) val type = when (resize) { 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 fd1da5ca..331cfb73 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 @@ -1,5 +1,6 @@ package com.lagradost.cloudstream3.ui.player +import android.annotation.SuppressLint import android.content.Context import android.net.Uri import android.os.Handler @@ -31,6 +32,10 @@ import androidx.media3.exoplayer.DefaultRenderersFactory import androidx.media3.exoplayer.DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON import androidx.media3.exoplayer.ExoPlayer import androidx.media3.exoplayer.SeekParameters +import androidx.media3.exoplayer.dash.DashMediaSource +import androidx.media3.exoplayer.drm.DefaultDrmSessionManager +import androidx.media3.exoplayer.drm.FrameworkMediaDrm +import androidx.media3.exoplayer.drm.LocalMediaDrmCallback import androidx.media3.exoplayer.source.ClippingMediaSource import androidx.media3.exoplayer.source.ConcatenatingMediaSource import androidx.media3.exoplayer.source.DefaultMediaSourceFactory @@ -46,10 +51,12 @@ import com.lagradost.cloudstream3.AcraApplication.Companion.getKey import com.lagradost.cloudstream3.AcraApplication.Companion.setKey import com.lagradost.cloudstream3.USER_AGENT import com.lagradost.cloudstream3.app +import com.lagradost.cloudstream3.mvvm.debugAssert import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.mvvm.normalSafeApiCall import com.lagradost.cloudstream3.ui.subtitles.SaveCaptionStyle import com.lagradost.cloudstream3.utils.AppUtils.isUsingMobileData +import com.lagradost.cloudstream3.utils.DrmExtractorLink import com.lagradost.cloudstream3.utils.EpisodeSkip import com.lagradost.cloudstream3.utils.ExtractorLink import com.lagradost.cloudstream3.utils.ExtractorLinkPlayList @@ -58,6 +65,7 @@ import com.lagradost.cloudstream3.utils.ExtractorUri import com.lagradost.cloudstream3.utils.SubtitleHelper.fromTwoLettersToLanguage import java.io.File import java.lang.IllegalArgumentException +import java.util.UUID import javax.net.ssl.HttpsURLConnection import javax.net.ssl.SSLContext import javax.net.ssl.SSLSession @@ -78,6 +86,12 @@ const val toleranceAfterUs = 300_000L class CS3IPlayer : IPlayer { private var isPlaying = false private var exoPlayer: ExoPlayer? = null + set(value) { + // If the old value is not null then the player has not been properly released. + debugAssert({ field != null && value != null }, { "Previous player instance should be released!" }) + field = value + } + var cacheSize = 0L var simpleCacheSize = 0L var videoBufferMs = 0L @@ -104,7 +118,16 @@ class CS3IPlayer : IPlayer { * */ data class MediaItemSlice( val mediaItem: MediaItem, - val durationUs: Long + val durationUs: Long, + val drm: DrmMetadata? = null + ) + + data class DrmMetadata( + val kid: String, + val key: String, + val uuid: UUID, + val kty: String, + val keyRequestParameters: HashMap, ) override fun getDuration(): Long? = exoPlayer?.duration @@ -118,80 +141,24 @@ class CS3IPlayer : IPlayer { * Boolean = if it's active * */ private var playerSelectedSubtitleTracks = listOf>() - - /** isPlaying */ - private var updateIsPlaying: ((Pair) -> Unit)? = null - private var requestAutoFocus: (() -> Unit)? = null - private var playerError: ((Exception) -> Unit)? = null - private var subtitlesUpdates: (() -> Unit)? = null - - /** width x height */ - private var playerDimensionsLoaded: ((Pair) -> Unit)? = null - - /** used for playerPositionChanged */ private var requestedListeningPercentages: List? = null - /** Fired when seeking the player or on requestedListeningPercentages, - * used to make things appear on que - * position, duration */ - private var playerPositionChanged: ((Pair) -> Unit)? = null + private var eventHandler: ((PlayerEvent) -> Unit)? = null - private var nextEpisode: (() -> Unit)? = null - private var prevEpisode: (() -> Unit)? = null - - private var playerUpdated: ((Any?) -> Unit)? = null - private var embeddedSubtitlesFetched: ((List) -> Unit)? = null - private var onTracksInfoChanged: (() -> Unit)? = null - private var onTimestampInvoked: ((EpisodeSkip.SkipStamp?) -> Unit)? = null - private var onTimestampSkipped: ((EpisodeSkip.SkipStamp) -> Unit)? = null + fun event(event: PlayerEvent) { + eventHandler?.invoke(event) + } override fun releaseCallbacks() { - playerUpdated = null - updateIsPlaying = null - requestAutoFocus = null - playerError = null - playerDimensionsLoaded = null - requestedListeningPercentages = null - playerPositionChanged = null - nextEpisode = null - prevEpisode = null - subtitlesUpdates = null - onTracksInfoChanged = null - onTimestampInvoked = null - requestSubtitleUpdate = null - onTimestampSkipped = null + eventHandler = null } override fun initCallbacks( - playerUpdated: (Any?) -> Unit, - updateIsPlaying: ((Pair) -> Unit)?, - requestAutoFocus: (() -> Unit)?, - playerError: ((Exception) -> Unit)?, - playerDimensionsLoaded: ((Pair) -> Unit)?, + eventHandler: ((PlayerEvent) -> Unit), requestedListeningPercentages: List?, - playerPositionChanged: ((Pair) -> Unit)?, - nextEpisode: (() -> Unit)?, - prevEpisode: (() -> Unit)?, - subtitlesUpdates: (() -> Unit)?, - embeddedSubtitlesFetched: ((List) -> Unit)?, - onTracksInfoChanged: (() -> Unit)?, - onTimestampInvoked: ((EpisodeSkip.SkipStamp?) -> Unit)?, - onTimestampSkipped: ((EpisodeSkip.SkipStamp) -> Unit)?, ) { - this.playerUpdated = playerUpdated - this.updateIsPlaying = updateIsPlaying - this.requestAutoFocus = requestAutoFocus - this.playerError = playerError - this.playerDimensionsLoaded = playerDimensionsLoaded this.requestedListeningPercentages = requestedListeningPercentages - this.playerPositionChanged = playerPositionChanged - this.nextEpisode = nextEpisode - this.prevEpisode = prevEpisode - this.subtitlesUpdates = subtitlesUpdates - this.embeddedSubtitlesFetched = embeddedSubtitlesFetched - this.onTracksInfoChanged = onTracksInfoChanged - this.onTimestampInvoked = onTimestampInvoked - this.onTimestampSkipped = onTimestampSkipped + this.eventHandler = eventHandler } // I know, this is not a perfect solution, however it works for fixing subs @@ -200,7 +167,7 @@ class CS3IPlayer : IPlayer { try { Handler(it).post { try { - seekTime(1L) + seekTime(1L, source = PlayerEventSource.Player) } catch (e: Exception) { logError(e) } @@ -254,8 +221,9 @@ class CS3IPlayer : IPlayer { subtitleHelper.setAllSubtitles(subtitles) } - var currentSubtitles: SubtitleData? = null + private var currentSubtitles: SubtitleData? = null + @SuppressLint("UnsafeOptInUsageError") private fun List.getTrack(id: String?): Pair? { if (id == null) return null // This beast of an expression does: @@ -340,6 +308,7 @@ class CS3IPlayer : IPlayer { }.flatten() } + @SuppressLint("UnsafeOptInUsageError") private fun Tracks.Group.getFormats(): List> { return (0 until this.mediaTrackGroup.length).mapNotNull { i -> if (this.isSupported) @@ -368,6 +337,7 @@ class CS3IPlayer : IPlayer { ) } + @SuppressLint("UnsafeOptInUsageError") override fun getVideoTracks(): CurrentTracks { val allTracks = exoPlayer?.currentTracks?.groups ?: emptyList() val videoTracks = allTracks.filter { it.type == TRACK_TYPE_VIDEO } @@ -387,6 +357,7 @@ class CS3IPlayer : IPlayer { /** * @return True if the player should be reloaded * */ + @SuppressLint("UnsafeOptInUsageError") override fun setPreferredSubtitles(subtitle: SubtitleData?): Boolean { Log.i(TAG, "setPreferredSubtitles init $subtitle") currentSubtitles = subtitle @@ -465,6 +436,7 @@ class CS3IPlayer : IPlayer { } } + @SuppressLint("UnsafeOptInUsageError") override fun getAspectRatio(): Rational? { return exoPlayer?.videoFormat?.let { format -> Rational(format.width, format.height) @@ -475,6 +447,7 @@ class CS3IPlayer : IPlayer { subtitleHelper.setSubStyle(style) } + @SuppressLint("UnsafeOptInUsageError") override fun saveData() { Log.i(TAG, "saveData") updatedTime() @@ -504,14 +477,14 @@ class CS3IPlayer : IPlayer { Log.i(TAG, "onStop") saveData() - exoPlayer?.pause() + handleEvent(CSPlayerEvent.Pause, PlayerEventSource.Player) //releasePlayer() } override fun onPause() { Log.i(TAG, "onPause") saveData() - exoPlayer?.pause() + handleEvent(CSPlayerEvent.Pause, PlayerEventSource.Player) //releasePlayer() } @@ -548,6 +521,7 @@ class CS3IPlayer : IPlayer { var requestSubtitleUpdate: (() -> Unit)? = null + @SuppressLint("UnsafeOptInUsageError") private fun createOnlineSource(headers: Map): HttpDataSource.Factory { val source = OkHttpDataSource.Factory(app.baseClient).setUserAgent(USER_AGENT) return source.apply { @@ -555,6 +529,7 @@ class CS3IPlayer : IPlayer { } } + @SuppressLint("UnsafeOptInUsageError") private fun createOnlineSource(link: ExtractorLink): HttpDataSource.Factory { val provider = getApiFromNameNull(link.source) val interceptor = provider?.getVideoInterceptor(link) @@ -587,6 +562,7 @@ class CS3IPlayer : IPlayer { } } + @SuppressLint("UnsafeOptInUsageError") private fun Context.createOfflineSource(): DataSource.Factory { return DefaultDataSourceFactory(this, USER_AGENT) } @@ -632,6 +608,7 @@ class CS3IPlayer : IPlayer { return Pair(subSources, activeSubtitles) }*/ + @SuppressLint("UnsafeOptInUsageError") private fun getCache(context: Context, cacheSize: Long): SimpleCache? { return try { val databaseProvider = StandaloneDatabaseProvider(context) @@ -663,6 +640,7 @@ class CS3IPlayer : IPlayer { return getMediaItemBuilder(mimeType).setUri(url).build() } + @SuppressLint("UnsafeOptInUsageError") private fun getTrackSelector(context: Context, maxVideoHeight: Int?): TrackSelector { val trackSelector = DefaultTrackSelector(context) trackSelector.parameters = trackSelector.buildUponParameters() @@ -676,6 +654,7 @@ class CS3IPlayer : IPlayer { var currentTextRenderer: CustomTextRenderer? = null + @SuppressLint("UnsafeOptInUsageError") private fun buildExoPlayer( context: Context, mediaItemSlices: List, @@ -710,13 +689,13 @@ class CS3IPlayer : IPlayer { metadataRendererOutput ).map { if (it is TextRenderer) { - currentTextRenderer = CustomTextRenderer( + val currentTextRenderer = CustomTextRenderer( subtitleOffset, textRendererOutput, eventHandler.looper, CustomSubtitleDecoderFactory() - ) - currentTextRenderer!! + ).also { this.currentTextRenderer = it } + currentTextRenderer } else it }.toTypedArray() } @@ -760,15 +739,33 @@ class CS3IPlayer : IPlayer { // If there is only one item then treat it as normal, if multiple: concatenate the items. val videoMediaSource = if (mediaItemSlices.size == 1) { - factory.createMediaSource(mediaItemSlices.first().mediaItem) + val item = mediaItemSlices.first() + + item.drm?.let { drm -> + val drmCallback = + LocalMediaDrmCallback("{\"keys\":[{\"kty\":\"${drm.kty}\",\"k\":\"${drm.key}\",\"kid\":\"${drm.kid}\"}],\"type\":\"temporary\"}".toByteArray()) + val manager = DefaultDrmSessionManager.Builder() + .setPlayClearSamplesWithoutKeys(true) + .setMultiSession(false) + .setKeyRequestParameters(drm.keyRequestParameters) + .setUuidAndExoMediaDrmProvider(drm.uuid, FrameworkMediaDrm.DEFAULT_PROVIDER) + .build(drmCallback) + val manifestDataSourceFactory = DefaultHttpDataSource.Factory() + + DashMediaSource.Factory(manifestDataSourceFactory) + .setDrmSessionManagerProvider { manager } + .createMediaSource(item.mediaItem) + } ?: run { + factory.createMediaSource(item.mediaItem) + } } else { val source = ConcatenatingMediaSource() - mediaItemSlices.map { + mediaItemSlices.map { item -> source.addMediaSource( // The duration MUST be known for it to work properly, see https://github.com/google/ExoPlayer/issues/4727 ClippingMediaSource( - factory.createMediaSource(it.mediaItem), - it.durationUs + factory.createMediaSource(item.mediaItem), + item.durationUs ) ) } @@ -801,43 +798,55 @@ class CS3IPlayer : IPlayer { return null } - fun updatedTime(writePosition: Long? = null) { + fun updatedTime( + writePosition: Long? = null, + source: PlayerEventSource = PlayerEventSource.Player + ) { val position = writePosition ?: exoPlayer?.currentPosition getCurrentTimestamp(position)?.let { timestamp -> - onTimestampInvoked?.invoke(timestamp) + event(TimestampInvokedEvent(timestamp, source)) } val duration = exoPlayer?.contentDuration if (duration != null && position != null) { - playerPositionChanged?.invoke(Pair(position, duration)) + event( + PositionEvent( + source, + fromMs = exoPlayer?.currentPosition ?: 0, + position, + duration + ) + ) } } - override fun seekTime(time: Long) { - exoPlayer?.seekTime(time) + override fun seekTime(time: Long, source: PlayerEventSource) { + exoPlayer?.seekTime(time, source) } - override fun seekTo(time: Long) { - updatedTime(time) + override fun seekTo(time: Long, source: PlayerEventSource) { + updatedTime(time, source) exoPlayer?.seekTo(time) } - private fun ExoPlayer.seekTime(time: Long) { - updatedTime(currentPosition + time) + private fun ExoPlayer.seekTime(time: Long, source: PlayerEventSource) { + updatedTime(currentPosition + time, source) seekTo(currentPosition + time) } - override fun handleEvent(event: CSPlayerEvent) { + override fun handleEvent(event: CSPlayerEvent, source: PlayerEventSource) { Log.i(TAG, "handleEvent ${event.name}") try { exoPlayer?.apply { when (event) { CSPlayerEvent.Play -> { + event(PlayEvent(source)) play() } CSPlayerEvent.Pause -> { + event(PauseEvent(source)) pause() } @@ -854,32 +863,32 @@ class CS3IPlayer : IPlayer { CSPlayerEvent.PlayPauseToggle -> { if (isPlaying) { - pause() + handleEvent(CSPlayerEvent.Pause, source) } else { - play() + handleEvent(CSPlayerEvent.Play, source) } } - CSPlayerEvent.SeekForward -> seekTime(seekActionTime) - CSPlayerEvent.SeekBack -> seekTime(-seekActionTime) - CSPlayerEvent.NextEpisode -> nextEpisode?.invoke() - CSPlayerEvent.PrevEpisode -> prevEpisode?.invoke() + CSPlayerEvent.SeekForward -> seekTime(seekActionTime, source) + CSPlayerEvent.SeekBack -> seekTime(-seekActionTime, source) + CSPlayerEvent.NextEpisode -> event(EpisodeSeekEvent(offset = 1, source = source)) + CSPlayerEvent.PrevEpisode -> event(EpisodeSeekEvent(offset = -1, source = source)) CSPlayerEvent.SkipCurrentChapter -> { //val dur = this@CS3IPlayer.getDuration() ?: return@apply getCurrentTimestamp()?.let { lastTimeStamp -> if (lastTimeStamp.skipToNextEpisode) { - handleEvent(CSPlayerEvent.NextEpisode) + handleEvent(CSPlayerEvent.NextEpisode, source) } else { seekTo(lastTimeStamp.endMs + 1L) } - onTimestampSkipped?.invoke(lastTimeStamp) + event(TimestampSkippedEvent(timestamp = lastTimeStamp, source = source)) } } } } - } catch (e: Exception) { - Log.e(TAG, "handleEvent error", e) - playerError?.invoke(e) + } catch (t: Throwable) { + Log.e(TAG, "handleEvent error", t) + event(ErrorEvent(t)) } } @@ -918,18 +927,14 @@ class CS3IPlayer : IPlayer { requestSubtitleUpdate = ::reloadSubs - playerUpdated?.invoke(exoPlayer) + event(PlayerAttachedEvent(exoPlayer)) exoPlayer?.prepare() exoPlayer?.let { exo -> - updateIsPlaying?.invoke( - Pair( - CSPlayerLoading.IsBuffering, - CSPlayerLoading.IsBuffering - ) - ) + event(StatusEvent(CSPlayerLoading.IsBuffering, CSPlayerLoading.IsBuffering)) isPlaying = exo.isPlaying } + exoPlayer?.addListener(object : Player.Listener { override fun onTracksChanged(tracks: Tracks) { normalSafeApiCall { @@ -963,18 +968,19 @@ class CS3IPlayer : IPlayer { ) } - embeddedSubtitlesFetched?.invoke(exoPlayerReportedTracks) - onTracksInfoChanged?.invoke() - subtitlesUpdates?.invoke() + event(EmbeddedSubtitlesFetchedEvent(tracks = exoPlayerReportedTracks)) + event(TracksChangedEvent()) + event(SubtitlesUpdatedEvent()) } } + @SuppressLint("UnsafeOptInUsageError") override fun onPlayerStateChanged(playWhenReady: Boolean, playbackState: Int) { exoPlayer?.let { exo -> - updateIsPlaying?.invoke( - Pair( - if (isPlaying) CSPlayerLoading.IsPlaying else CSPlayerLoading.IsPaused, - if (playbackState == Player.STATE_BUFFERING) CSPlayerLoading.IsBuffering else if (exo.isPlaying) CSPlayerLoading.IsPlaying else CSPlayerLoading.IsPaused + event( + StatusEvent( + wasPlaying = if (isPlaying) CSPlayerLoading.IsPlaying else CSPlayerLoading.IsPaused, + isPlaying = if (playbackState == Player.STATE_BUFFERING) CSPlayerLoading.IsBuffering else if (exo.isPlaying) CSPlayerLoading.IsPlaying else CSPlayerLoading.IsPaused ) ) isPlaying = exo.isPlaying @@ -996,23 +1002,15 @@ class CS3IPlayer : IPlayer { } Player.STATE_ENDED -> { - // Only play next episode if autoplay is on (default) - if (PreferenceManager.getDefaultSharedPreferences(context) - ?.getBoolean( - context.getString(com.lagradost.cloudstream3.R.string.autoplay_next_key), - true - ) == true - ) { - handleEvent(CSPlayerEvent.NextEpisode) - } + event(VideoEndedEvent()) } Player.STATE_BUFFERING -> { - updatedTime() + updatedTime(source = PlayerEventSource.Player) } Player.STATE_IDLE -> { - // IDLE + } else -> Unit @@ -1037,7 +1035,7 @@ class CS3IPlayer : IPlayer { } else -> { - playerError?.invoke(error) + event(ErrorEvent(error)) } } @@ -1051,7 +1049,7 @@ class CS3IPlayer : IPlayer { override fun onIsPlayingChanged(isPlaying: Boolean) { super.onIsPlayingChanged(isPlaying) if (isPlaying) { - requestAutoFocus?.invoke() + event(RequestAudioFocusEvent()) onRenderFirst() } } @@ -1071,12 +1069,15 @@ class CS3IPlayer : IPlayer { true ) == true ) { - handleEvent(CSPlayerEvent.NextEpisode) + handleEvent( + CSPlayerEvent.NextEpisode, + source = PlayerEventSource.Player + ) } } Player.STATE_BUFFERING -> { - updatedTime() + updatedTime(source = PlayerEventSource.Player) } Player.STATE_IDLE -> { @@ -1089,27 +1090,29 @@ class CS3IPlayer : IPlayer { override fun onVideoSizeChanged(videoSize: VideoSize) { super.onVideoSizeChanged(videoSize) - playerDimensionsLoaded?.invoke(Pair(videoSize.width, videoSize.height)) + event(ResizedEvent(height = videoSize.height, width = videoSize.width)) } override fun onRenderedFirstFrame() { super.onRenderedFirstFrame() onRenderFirst() - updatedTime() + updatedTime(source = PlayerEventSource.Player) } }) - } catch (e: Exception) { - Log.e(TAG, "loadExo error", e) - playerError?.invoke(e) + } catch (t: Throwable) { + Log.e(TAG, "loadExo error", t) + event(ErrorEvent(t)) } } private var lastTimeStamps: List = emptyList() + + @SuppressLint("UnsafeOptInUsageError") override fun addTimeStamps(timeStamps: List) { lastTimeStamps = timeStamps timeStamps.forEach { timestamp -> exoPlayer?.createMessage { _, _ -> - updatedTime() + updatedTime(source = PlayerEventSource.Player) //if (payload is EpisodeSkip.SkipStamp) // this should always be true // onTimestampInvoked?.invoke(payload) } @@ -1119,9 +1122,10 @@ class CS3IPlayer : IPlayer { ?.setDeleteAfterDelivery(false) ?.send() } - updatedTime() + updatedTime(source = PlayerEventSource.Player) } + @SuppressLint("UnsafeOptInUsageError") fun onRenderFirst() { if (hasUsedFirstRender) { // this insures that we only call this once per player load return @@ -1139,7 +1143,7 @@ class CS3IPlayer : IPlayer { if (invalid) { releasePlayer(saveTime = false) - playerError?.invoke(InvalidFileException("Too short playback")) + event(ErrorEvent(InvalidFileException("Too short playback"))) return } @@ -1148,7 +1152,7 @@ class CS3IPlayer : IPlayer { val width = format?.width val height = format?.height if (height != null && width != null) { - playerDimensionsLoaded?.invoke(Pair(width, height)) + event(ResizedEvent(width = width, height = height)) updatedTime() exoPlayer?.apply { requestedListeningPercentages?.forEach { percentage -> @@ -1182,12 +1186,13 @@ class CS3IPlayer : IPlayer { subtitleHelper.setActiveSubtitles(activeSubtitles.toSet()) loadExo(context, listOf(MediaItemSlice(mediaItem, Long.MIN_VALUE)), subSources) - } catch (e: Exception) { - Log.e(TAG, "loadOfflinePlayer error", e) - playerError?.invoke(e) + } catch (t: Throwable) { + Log.e(TAG, "loadOfflinePlayer error", t) + event(ErrorEvent(t)) } } + @SuppressLint("UnsafeOptInUsageError") private fun getSubSources( onlineSourceFactory: HttpDataSource.Factory?, offlineSourceFactory: DataSource.Factory?, @@ -1243,6 +1248,7 @@ class CS3IPlayer : IPlayer { return exoPlayer != null } + @SuppressLint("UnsafeOptInUsageError") private fun loadOnlinePlayer(context: Context, link: ExtractorLink) { Log.i(TAG, "loadOnlinePlayer $link") try { @@ -1259,7 +1265,7 @@ class CS3IPlayer : IPlayer { HttpsURLConnection.setDefaultSSLSocketFactory(sslContext.socketFactory) } - val mime = when(link.type) { + val mime = when (link.type) { ExtractorLinkType.M3U8 -> MimeTypes.APPLICATION_M3U8 ExtractorLinkType.DASH -> MimeTypes.APPLICATION_MPD ExtractorLinkType.VIDEO -> MimeTypes.VIDEO_MP4 @@ -1267,12 +1273,29 @@ class CS3IPlayer : IPlayer { ExtractorLinkType.MAGNET -> throw IllegalArgumentException("No magnet support") } - val mediaItems = if (link is ExtractorLinkPlayList) { - link.playlist.map { + + val mediaItems = when (link) { + is ExtractorLinkPlayList -> link.playlist.map { MediaItemSlice(getMediaItem(mime, it.url), it.durationUs) } - } else { - listOf( + + is DrmExtractorLink -> { + listOf( + // Single sliced list with unset length + MediaItemSlice( + getMediaItem(mime, link.url), Long.MIN_VALUE, + drm = DrmMetadata( + kid = link.kid, + key = link.key, + uuid = link.uuid, + kty = link.kty, + keyRequestParameters = link.keyRequestParameters + ) + ) + ) + } + + else -> listOf( // Single sliced list with unset length MediaItemSlice(getMediaItem(mime, link.url), Long.MIN_VALUE) ) @@ -1298,16 +1321,16 @@ class CS3IPlayer : IPlayer { } loadExo(context, mediaItems, subSources, cacheFactory) - } catch (e: Exception) { - Log.e(TAG, "loadOnlinePlayer error", e) - playerError?.invoke(e) + } catch (t: Throwable) { + Log.e(TAG, "loadOnlinePlayer error", t) + event(ErrorEvent(t)) } } override fun reloadPlayer(context: Context) { Log.i(TAG, "reloadPlayer") - exoPlayer?.release() + releasePlayer(false) currentLink?.let { loadOnlinePlayer(context, it) } ?: currentDownloadedFile?.let { 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 0f3c189d..e698191d 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 @@ -38,6 +38,8 @@ import com.lagradost.cloudstream3.AcraApplication.Companion.getKey import com.lagradost.cloudstream3.AcraApplication.Companion.setKey import com.lagradost.cloudstream3.CommonActivity.keyEventListener import com.lagradost.cloudstream3.CommonActivity.playerEventListener +import com.lagradost.cloudstream3.CommonActivity.screenHeight +import com.lagradost.cloudstream3.CommonActivity.screenWidth import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.databinding.PlayerCustomLayoutBinding import com.lagradost.cloudstream3.databinding.SubtitleOffsetBinding @@ -126,19 +128,6 @@ open class FullScreenPlayer : AbstractPlayerFragment() { protected var useTrueSystemBrightness = true private val fullscreenNotch = true //TODO SETTING - protected val displayMetrics: DisplayMetrics = Resources.getSystem().displayMetrics - - // screenWidth and screenHeight does always - // refer to the screen while in landscape mode - protected val screenWidth: Int - get() { - return max(displayMetrics.widthPixels, displayMetrics.heightPixels) - } - protected val screenHeight: Int - get() { - return min(displayMetrics.widthPixels, displayMetrics.heightPixels) - } - private var statusBarHeight: Int? = null private var navigationBarHeight: Int? = null @@ -874,7 +863,7 @@ open class FullScreenPlayer : AbstractPlayerFragment() { currentTouch )?.let { seekTo -> if (abs(seekTo - startTime) > MINIMUM_SEEK_TIME) { - player.seekTo(seekTo) + player.seekTo(seekTo, PlayerEventSource.UI) } } } @@ -909,7 +898,7 @@ open class FullScreenPlayer : AbstractPlayerFragment() { } else -> { - player.handleEvent(CSPlayerEvent.PlayPauseToggle) + player.handleEvent(CSPlayerEvent.PlayPauseToggle, PlayerEventSource.UI) } } } else if (doubleTapEnabled && isFullScreenPlayer) { 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 2b9304b6..b2542ffa 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 @@ -551,7 +551,7 @@ class GeneratorPlayer : FullScreenPlayer() { //println("CURRENT SELECTED :$currentSelectedSubtitles of $currentSubs") context?.let { ctx -> val isPlaying = player.getIsPlaying() - player.handleEvent(CSPlayerEvent.Pause) + player.handleEvent(CSPlayerEvent.Pause, PlayerEventSource.UI) val currentSubtitles = sortSubs(currentSubs) val sourceDialog = Dialog(ctx, R.style.AlertDialogCustomBlack) @@ -883,7 +883,7 @@ class GeneratorPlayer : FullScreenPlayer() { } - override fun playerError(exception: Exception) { + override fun playerError(exception: Throwable) { Log.i(TAG, "playerError = $currentSelectedLink") super.playerError(exception) } @@ -945,14 +945,13 @@ class GeneratorPlayer : FullScreenPlayer() { var maxEpisodeSet: Int? = null var hasRequestedStamps: Boolean = false - override fun playerPositionChanged(posDur: Pair) { + override fun playerPositionChanged(position: Long, duration : Long) { // Don't save livestream data if ((currentMeta as? ResultEpisode)?.tvType?.isLiveStream() == true) return // Don't save NSFW data if ((currentMeta as? ResultEpisode)?.tvType == TvType.NSFW) return - val (position, duration) = posDur if (duration <= 0L) return // idk how you achieved this, but div by zero crash if (!hasRequestedStamps) { hasRequestedStamps = true @@ -1209,8 +1208,8 @@ class GeneratorPlayer : FullScreenPlayer() { } } - override fun playerDimensionsLoaded(widthHeight: Pair) { - setPlayerDimen(widthHeight) + override fun playerDimensionsLoaded(width: Int, height : Int) { + setPlayerDimen(width to height) } private fun unwrapBundle(savedInstanceState: Bundle?) { 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 3038cb8d..ec006234 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 @@ -45,9 +45,120 @@ enum class CSPlayerLoading { IsPaused, IsPlaying, IsBuffering, - //IsDone, } +enum class PlayerEventSource { + /** This event was invoked from the user pressing some button or selecting something */ + UI, + + /** This event was invoked automatically */ + Player, + + /** This event was invoked from a external sync tool like WatchTogether */ + Sync, +} + +abstract class PlayerEvent { + abstract val source: PlayerEventSource +} + +/** this is used to update UI based of the current time, + * using requestedListeningPercentages as well as saving time */ +data class PositionEvent( + override val source: PlayerEventSource, + val fromMs: Long, + val toMs: Long, + /** duration of the entire video */ + val durationMs: Long, +) : PlayerEvent() { + /** how many ms (+-) we have skipped */ + val seekMs : Long get() = toMs - fromMs +} + +/** player error when rendering or misc, used to display toast or log */ +data class ErrorEvent( + val error: Throwable, + override val source: PlayerEventSource = PlayerEventSource.Player, +) : PlayerEvent() + +/** Event when timestamps appear, null when it should disappear */ +data class TimestampInvokedEvent( + val timestamp: EpisodeSkip.SkipStamp, + override val source: PlayerEventSource = PlayerEventSource.Player, +) : PlayerEvent() + +/** Event for when a chapter is skipped, aka when event is handled (or for future use when skip automatically ads/sponsor) */ +data class TimestampSkippedEvent( + val timestamp: EpisodeSkip.SkipStamp, + override val source: PlayerEventSource = PlayerEventSource.Player, +) : PlayerEvent() + +/** this is used by the player to load the next or prev episode */ +data class EpisodeSeekEvent( + /** -1 = prev, 1 = next, will never be 0, atm the user cant seek more than +-1 */ + val offset: Int, + override val source: PlayerEventSource = PlayerEventSource.Player, +) : PlayerEvent() { + init { + assert(offset != 0) + } +} + +/** Event when the video is resized aka changed resolution or mirror */ +data class ResizedEvent( + val height: Int, + val width: Int, + override val source: PlayerEventSource = PlayerEventSource.Player, +) : PlayerEvent() + +/** Event when the player status update, along with the previous status (for animation)*/ +data class StatusEvent( + val wasPlaying: CSPlayerLoading, + val isPlaying: CSPlayerLoading, + override val source: PlayerEventSource = PlayerEventSource.Player +) : PlayerEvent() + +/** Event when tracks are changed, used for UI changes */ +data class TracksChangedEvent( + override val source: PlayerEventSource = PlayerEventSource.Player +) : PlayerEvent() + +/** Event from player to give all embedded subtitles */ +data class EmbeddedSubtitlesFetchedEvent( + val tracks: List, + override val source: PlayerEventSource = PlayerEventSource.Player +) : PlayerEvent() + +/** on attach player to view */ +data class PlayerAttachedEvent( + val player: Any?, + override val source: PlayerEventSource = PlayerEventSource.Player +) : PlayerEvent() + +/** Event from player to inform that subtitles have updated in some way */ +data class SubtitlesUpdatedEvent( + override val source: PlayerEventSource = PlayerEventSource.Player +) : PlayerEvent() + +/** current player starts, asking for all other programs to shut the fuck up */ +data class RequestAudioFocusEvent( + override val source: PlayerEventSource = PlayerEventSource.Player +) : PlayerEvent() + +/** Pause event, separate from StatusEvent */ +data class PauseEvent( + override val source: PlayerEventSource = PlayerEventSource.Player +) : PlayerEvent() + +/** Play event, separate from StatusEvent */ +data class PlayEvent( + override val source: PlayerEventSource = PlayerEventSource.Player +) : PlayerEvent() + +/** Event when the player video has ended, up to the settings on what to do when that happens */ +data class VideoEndedEvent( + override val source: PlayerEventSource = PlayerEventSource.Player +) : PlayerEvent() interface Track { /** @@ -108,27 +219,16 @@ interface IPlayer { fun getDuration(): Long? fun getPosition(): Long? - fun seekTime(time: Long) - fun seekTo(time: Long) + fun seekTime(time: Long, source: PlayerEventSource = PlayerEventSource.UI) + fun seekTo(time: Long, source: PlayerEventSource = PlayerEventSource.UI) fun getSubtitleOffset(): Long // in ms fun setSubtitleOffset(offset: Long) // in ms fun initCallbacks( - playerUpdated: (Any?) -> Unit, // attach player to view - updateIsPlaying: ((Pair) -> Unit)? = null, // (wasPlaying, isPlaying) - requestAutoFocus: (() -> Unit)? = null, // current player starts, asking for all other programs to shut the fuck up - playerError: ((Exception) -> Unit)? = null, // player error when rendering or misc, used to display toast or log - playerDimensionsLoaded: ((Pair) -> Unit)? = null, // (with, height), for UI - requestedListeningPercentages: List? = null, // this is used to request when the player should report back view percentage - playerPositionChanged: ((Pair) -> Unit)? = null,// (position, duration) this is used to update UI based of the current time - nextEpisode: (() -> Unit)? = null, // this is used by the player to load the next episode - prevEpisode: (() -> Unit)? = null, // this is used by the player to load the previous episode - subtitlesUpdates: (() -> Unit)? = null, // callback from player to inform that subtitles have updated in some way - embeddedSubtitlesFetched: ((List) -> Unit)? = null, // callback from player to give all embedded subtitles - onTracksInfoChanged: (() -> Unit)? = null, // Callback when tracks are changed, used for UI changes - onTimestampInvoked: ((EpisodeSkip.SkipStamp?) -> Unit)? = null, // Callback when timestamps appear, null when it should disappear - onTimestampSkipped: ((EpisodeSkip.SkipStamp) -> Unit)? = null, // callback for when a chapter is skipped, aka when event is handled (or for future use when skip automatically ads/sponsor) + eventHandler: ((PlayerEvent) -> Unit), + /** this is used to request when the player should report back view percentage */ + requestedListeningPercentages: List? = null, ) fun releaseCallbacks() @@ -155,7 +255,7 @@ interface IPlayer { fun setPreferredSubtitles(subtitle: SubtitleData?): Boolean // returns true if the player requires a reload, null for nothing fun getCurrentPreferredSubtitle(): SubtitleData? - fun handleEvent(event: CSPlayerEvent) + fun handleEvent(event: CSPlayerEvent, source: PlayerEventSource = PlayerEventSource.UI) fun onStop() fun onPause() diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/LinkGenerator.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/LinkGenerator.kt index ba2cdb40..ca2d9c81 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/LinkGenerator.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/LinkGenerator.kt @@ -67,9 +67,8 @@ class LinkGenerator( link.name ?: link.url, unshortenLinkSafe(link.url), // unshorten because it might be a raw link referer ?: "", - Qualities.Unknown.value, isM3u8 ?: normalSafeApiCall { - URI(link.url).path?.substringAfterLast(".")?.contains("m3u") - } ?: false + Qualities.Unknown.value, + type = INFER_TYPE, ) to null ) } diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultFragmentPhone.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultFragmentPhone.kt index a932a57c..ef2ed0df 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultFragmentPhone.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultFragmentPhone.kt @@ -130,8 +130,8 @@ open class ResultFragmentPhone : FullScreenPlayer() { return currentTrailerIndex + 1 < currentTrailers.size } - override fun playerError(exception: Exception) { - if (player.getIsPlaying()) { // because we dont want random toasts in player + override fun playerError(exception: Throwable) { + if (player.getIsPlaying()) { // because we don't want random toasts in player super.playerError(exception) } else { nextMirror() diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultTrailerPlayer.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultTrailerPlayer.kt index 91e97dfc..c30e70e5 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultTrailerPlayer.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultTrailerPlayer.kt @@ -3,15 +3,17 @@ package com.lagradost.cloudstream3.ui.result import android.animation.ValueAnimator import android.content.Context import android.content.res.Configuration -import android.graphics.Rect import android.os.Bundle import android.view.View import android.view.ViewGroup import android.widget.FrameLayout import androidx.core.view.isGone import androidx.core.view.isVisible +import com.lagradost.cloudstream3.CommonActivity.screenHeight +import com.lagradost.cloudstream3.CommonActivity.screenWidth import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.ui.player.CSPlayerEvent +import com.lagradost.cloudstream3.ui.player.PlayerEventSource import com.lagradost.cloudstream3.ui.player.SubtitleData import com.lagradost.cloudstream3.utils.IOnBackPressed @@ -32,7 +34,7 @@ open class ResultTrailerPlayer : ResultFragmentPhone(), IOnBackPressed { override fun prevEpisode() {} - override fun playerPositionChanged(posDur: Pair) {} + override fun playerPositionChanged(position: Long, duration : Long) {} override fun nextMirror() {} @@ -99,8 +101,8 @@ open class ResultTrailerPlayer : ResultFragmentPhone(), IOnBackPressed { } } - override fun playerDimensionsLoaded(widthHeight: Pair) { - playerWidthHeight = widthHeight + override fun playerDimensionsLoaded(width: Int, height : Int) { + playerWidthHeight = width to height fixPlayerSize() } @@ -164,7 +166,7 @@ open class ResultTrailerPlayer : ResultFragmentPhone(), IOnBackPressed { playerBinding?.playerIntroPlay?.setOnClickListener { playerBinding?.playerIntroPlay?.isGone = true - player.handleEvent(CSPlayerEvent.Play) + player.handleEvent(CSPlayerEvent.Play, PlayerEventSource.UI) updateUIVisibility() fixPlayerSize() } 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 b2c57137..b398b54e 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 @@ -1538,7 +1538,11 @@ class ResultViewModel2 : ViewModel() { this.name, this.japName ).filter { it.length > 2 } - .distinct(), // the reason why we filter is due to not wanting smth like " " or "?" + .distinct().map { + // this actually would be nice if we improved a bit as 3rd season == season 3 == III ect + // right now it just removes the dubbed status + it.lowercase().replace(Regex("""\(?[ds]ub(bed)?\)?(\s|$)""") , "").trim() + }, TrackerType.getTypes(this.type), this.year ) diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/ExtractorApi.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/ExtractorApi.kt index 2a539f0d..5edff7a1 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/ExtractorApi.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/ExtractorApi.kt @@ -1,14 +1,204 @@ package com.lagradost.cloudstream3.utils import android.net.Uri -import com.lagradost.cloudstream3.* +import com.lagradost.cloudstream3.SubtitleFile +import com.lagradost.cloudstream3.TvType +import com.lagradost.cloudstream3.USER_AGENT +import com.lagradost.cloudstream3.app +import com.lagradost.cloudstream3.extractors.AStreamHub +import com.lagradost.cloudstream3.extractors.Acefile +import com.lagradost.cloudstream3.extractors.Ahvsh +import com.lagradost.cloudstream3.extractors.Aico +import com.lagradost.cloudstream3.extractors.AsianLoad +import com.lagradost.cloudstream3.extractors.Bestx +import com.lagradost.cloudstream3.extractors.Blogger +import com.lagradost.cloudstream3.extractors.BullStream +import com.lagradost.cloudstream3.extractors.ByteShare +import com.lagradost.cloudstream3.extractors.Cda +import com.lagradost.cloudstream3.extractors.Cdnplayer +import com.lagradost.cloudstream3.extractors.Chillx +import com.lagradost.cloudstream3.extractors.CineGrabber +import com.lagradost.cloudstream3.extractors.Cinestart +import com.lagradost.cloudstream3.extractors.DBfilm +import com.lagradost.cloudstream3.extractors.Dailymotion +import com.lagradost.cloudstream3.extractors.DatabaseGdrive +import com.lagradost.cloudstream3.extractors.DatabaseGdrive2 +import com.lagradost.cloudstream3.extractors.DesuArcg +import com.lagradost.cloudstream3.extractors.DesuDrive +import com.lagradost.cloudstream3.extractors.DesuOdchan +import com.lagradost.cloudstream3.extractors.DesuOdvip +import com.lagradost.cloudstream3.extractors.Dokicloud +import com.lagradost.cloudstream3.extractors.DoodCxExtractor +import com.lagradost.cloudstream3.extractors.DoodLaExtractor +import com.lagradost.cloudstream3.extractors.DoodPmExtractor +import com.lagradost.cloudstream3.extractors.DoodShExtractor +import com.lagradost.cloudstream3.extractors.DoodSoExtractor +import com.lagradost.cloudstream3.extractors.DoodToExtractor +import com.lagradost.cloudstream3.extractors.DoodWatchExtractor +import com.lagradost.cloudstream3.extractors.DoodWfExtractor +import com.lagradost.cloudstream3.extractors.DoodWsExtractor +import com.lagradost.cloudstream3.extractors.DoodYtExtractor +import com.lagradost.cloudstream3.extractors.Dooood +import com.lagradost.cloudstream3.extractors.Embedgram +import com.lagradost.cloudstream3.extractors.Evoload +import com.lagradost.cloudstream3.extractors.Evoload1 +import com.lagradost.cloudstream3.extractors.FEmbed +import com.lagradost.cloudstream3.extractors.FEnet +import com.lagradost.cloudstream3.extractors.Fastream +import com.lagradost.cloudstream3.extractors.FeHD +import com.lagradost.cloudstream3.extractors.Fembed9hd +import com.lagradost.cloudstream3.extractors.FileMoon +import com.lagradost.cloudstream3.extractors.FileMoonIn +import com.lagradost.cloudstream3.extractors.FileMoonSx +import com.lagradost.cloudstream3.extractors.Filesim +import com.lagradost.cloudstream3.extractors.Fplayer +import com.lagradost.cloudstream3.extractors.GMPlayer +import com.lagradost.cloudstream3.extractors.Gdriveplayer +import com.lagradost.cloudstream3.extractors.Gdriveplayerapi +import com.lagradost.cloudstream3.extractors.Gdriveplayerapp +import com.lagradost.cloudstream3.extractors.Gdriveplayerbiz +import com.lagradost.cloudstream3.extractors.Gdriveplayerco +import com.lagradost.cloudstream3.extractors.Gdriveplayerfun +import com.lagradost.cloudstream3.extractors.Gdriveplayerio +import com.lagradost.cloudstream3.extractors.Gdriveplayerme +import com.lagradost.cloudstream3.extractors.Gdriveplayerorg +import com.lagradost.cloudstream3.extractors.Gdriveplayerus +import com.lagradost.cloudstream3.extractors.Gofile +import com.lagradost.cloudstream3.extractors.GuardareStream +import com.lagradost.cloudstream3.extractors.Guccihide +import com.lagradost.cloudstream3.extractors.Hxfile +import com.lagradost.cloudstream3.extractors.JWPlayer +import com.lagradost.cloudstream3.extractors.Jawcloud +import com.lagradost.cloudstream3.extractors.Jeniusplay +import com.lagradost.cloudstream3.extractors.Keephealth +import com.lagradost.cloudstream3.extractors.KotakAnimeid +import com.lagradost.cloudstream3.extractors.Kotakajair +import com.lagradost.cloudstream3.extractors.Krakenfiles +import com.lagradost.cloudstream3.extractors.LayarKaca +import com.lagradost.cloudstream3.extractors.Linkbox +import com.lagradost.cloudstream3.extractors.Luxubu +import com.lagradost.cloudstream3.extractors.Lvturbo +import com.lagradost.cloudstream3.extractors.Maxstream +import com.lagradost.cloudstream3.extractors.Mcloud +import com.lagradost.cloudstream3.extractors.Megacloud +import com.lagradost.cloudstream3.extractors.Meownime +import com.lagradost.cloudstream3.extractors.MixDrop +import com.lagradost.cloudstream3.extractors.MixDropBz +import com.lagradost.cloudstream3.extractors.MixDropCh +import com.lagradost.cloudstream3.extractors.MixDropTo +import com.lagradost.cloudstream3.extractors.Movhide +import com.lagradost.cloudstream3.extractors.Moviehab +import com.lagradost.cloudstream3.extractors.MoviehabNet +import com.lagradost.cloudstream3.extractors.Moviesapi +import com.lagradost.cloudstream3.extractors.Moviesm4u +import com.lagradost.cloudstream3.extractors.Mp4Upload +import com.lagradost.cloudstream3.extractors.Mvidoo +import com.lagradost.cloudstream3.extractors.MwvnVizcloudInfo +import com.lagradost.cloudstream3.extractors.Neonime7n +import com.lagradost.cloudstream3.extractors.Neonime8n +import com.lagradost.cloudstream3.extractors.OkRu +import com.lagradost.cloudstream3.extractors.OkRuHttps +import com.lagradost.cloudstream3.extractors.Okrulink +import com.lagradost.cloudstream3.extractors.Pixeldrain +import com.lagradost.cloudstream3.extractors.PlayLtXyz +import com.lagradost.cloudstream3.extractors.PlayerVoxzer +import com.lagradost.cloudstream3.extractors.Rabbitstream +import com.lagradost.cloudstream3.extractors.Rasacintaku +import com.lagradost.cloudstream3.extractors.SBfull +import com.lagradost.cloudstream3.extractors.Sbasian +import com.lagradost.cloudstream3.extractors.Sbface +import com.lagradost.cloudstream3.extractors.Sbflix +import com.lagradost.cloudstream3.extractors.Sblona +import com.lagradost.cloudstream3.extractors.Sblongvu +import com.lagradost.cloudstream3.extractors.Sbnet +import com.lagradost.cloudstream3.extractors.Sbrapid +import com.lagradost.cloudstream3.extractors.Sbsonic +import com.lagradost.cloudstream3.extractors.Sbspeed +import com.lagradost.cloudstream3.extractors.Sbthe +import com.lagradost.cloudstream3.extractors.Sendvid +import com.lagradost.cloudstream3.extractors.ShaveTape +import com.lagradost.cloudstream3.extractors.Solidfiles +import com.lagradost.cloudstream3.extractors.SpeedoStream +import com.lagradost.cloudstream3.extractors.SpeedoStream1 +import com.lagradost.cloudstream3.extractors.SpeedoStream2 +import com.lagradost.cloudstream3.extractors.Ssbstream +import com.lagradost.cloudstream3.extractors.StreamM4u +import com.lagradost.cloudstream3.extractors.StreamSB +import com.lagradost.cloudstream3.extractors.StreamSB1 +import com.lagradost.cloudstream3.extractors.StreamSB10 +import com.lagradost.cloudstream3.extractors.StreamSB11 +import com.lagradost.cloudstream3.extractors.StreamSB2 +import com.lagradost.cloudstream3.extractors.StreamSB3 +import com.lagradost.cloudstream3.extractors.StreamSB4 +import com.lagradost.cloudstream3.extractors.StreamSB5 +import com.lagradost.cloudstream3.extractors.StreamSB6 +import com.lagradost.cloudstream3.extractors.StreamSB7 +import com.lagradost.cloudstream3.extractors.StreamSB8 +import com.lagradost.cloudstream3.extractors.StreamSB9 +import com.lagradost.cloudstream3.extractors.StreamTape +import com.lagradost.cloudstream3.extractors.StreamTapeNet +import com.lagradost.cloudstream3.extractors.StreamhideCom +import com.lagradost.cloudstream3.extractors.StreamhideTo +import com.lagradost.cloudstream3.extractors.Streamhub2 +import com.lagradost.cloudstream3.extractors.Streamlare +import com.lagradost.cloudstream3.extractors.StreamoUpload +import com.lagradost.cloudstream3.extractors.Streamplay +import com.lagradost.cloudstream3.extractors.Streamsss +import com.lagradost.cloudstream3.extractors.Supervideo +import com.lagradost.cloudstream3.extractors.Tantifilm +import com.lagradost.cloudstream3.extractors.Tomatomatela +import com.lagradost.cloudstream3.extractors.TomatomatelalClub +import com.lagradost.cloudstream3.extractors.Tubeless +import com.lagradost.cloudstream3.extractors.Upstream +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.Userload +import com.lagradost.cloudstream3.extractors.Userscloud +import com.lagradost.cloudstream3.extractors.Uservideo +import com.lagradost.cloudstream3.extractors.Vanfem +import com.lagradost.cloudstream3.extractors.Vicloud +import com.lagradost.cloudstream3.extractors.VidSrcExtractor +import com.lagradost.cloudstream3.extractors.VidSrcExtractor2 +import com.lagradost.cloudstream3.extractors.VideoVard +import com.lagradost.cloudstream3.extractors.VideovardSX +import com.lagradost.cloudstream3.extractors.Vidgomunime +import com.lagradost.cloudstream3.extractors.Vidgomunimesb +import com.lagradost.cloudstream3.extractors.Vidmoly +import com.lagradost.cloudstream3.extractors.Vidmolyme +import com.lagradost.cloudstream3.extractors.Vido +import com.lagradost.cloudstream3.extractors.Vidstreamz +import com.lagradost.cloudstream3.extractors.Vizcloud +import com.lagradost.cloudstream3.extractors.Vizcloud2 +import com.lagradost.cloudstream3.extractors.VizcloudCloud +import com.lagradost.cloudstream3.extractors.VizcloudDigital +import com.lagradost.cloudstream3.extractors.VizcloudInfo +import com.lagradost.cloudstream3.extractors.VizcloudLive +import com.lagradost.cloudstream3.extractors.VizcloudOnline +import com.lagradost.cloudstream3.extractors.VizcloudSite +import com.lagradost.cloudstream3.extractors.VizcloudXyz +import com.lagradost.cloudstream3.extractors.Voe +import com.lagradost.cloudstream3.extractors.Watchx +import com.lagradost.cloudstream3.extractors.WcoStream +import com.lagradost.cloudstream3.extractors.Wibufile +import com.lagradost.cloudstream3.extractors.XStreamCdn +import com.lagradost.cloudstream3.extractors.YourUpload +import com.lagradost.cloudstream3.extractors.YoutubeExtractor +import com.lagradost.cloudstream3.extractors.YoutubeMobileExtractor +import com.lagradost.cloudstream3.extractors.YoutubeNoCookieExtractor +import com.lagradost.cloudstream3.extractors.YoutubeShortLinkExtractor +import com.lagradost.cloudstream3.extractors.Yufiles +import com.lagradost.cloudstream3.extractors.Zorofile +import com.lagradost.cloudstream3.extractors.Zplayer +import com.lagradost.cloudstream3.extractors.ZplayerV2 +import com.lagradost.cloudstream3.extractors.Ztreamhub import com.lagradost.cloudstream3.mvvm.logError -import com.lagradost.cloudstream3.extractors.* import com.lagradost.cloudstream3.mvvm.normalSafeApiCall import kotlinx.coroutines.delay import org.jsoup.Jsoup import java.net.URL -import kotlin.collections.MutableList +import java.util.UUID /** * For use in the ConcatenatingMediaSource. @@ -37,7 +227,6 @@ data class ExtractorLinkPlayList( val playlist: List, override val referer: String, override val quality: Int, - val isM3u8: Boolean = false, override val headers: Map = mapOf(), /** Used for getExtractorVerifierJob() */ override val extractorData: String? = null, @@ -99,6 +288,84 @@ private fun inferTypeFromUrl(url: String): ExtractorLinkType { } } val INFER_TYPE : ExtractorLinkType? = null + +/** + * UUID for the ClearKey DRM scheme. + * + * + * ClearKey is supported on Android devices running Android 5.0 (API Level 21) and up. + */ +val CLEARKEY_UUID = UUID(-0x1d8e62a7567a4c37L, 0x781AB030AF78D30EL) + +/** + * UUID for the Widevine DRM scheme. + * + * + * Widevine is supported on Android devices running Android 4.3 (API Level 18) and up. + */ +val WIDEVINE_UUID = UUID(-0x121074568629b532L, -0x5c37d8232ae2de13L) + +/** + * UUID for the PlayReady DRM scheme. + * + * + * PlayReady is supported on all AndroidTV devices. Note that most other Android devices do not + * provide PlayReady support. + */ +val PLAYREADY_UUID = UUID(-0x65fb0f8667bfbd7aL, -0x546d19a41f77a06bL) + +open class DrmExtractorLink private constructor( + override val source: String, + override val name: String, + override val url: String, + override val referer: String, + override val quality: Int, + override val headers: Map = mapOf(), + /** Used for getExtractorVerifierJob() */ + override val extractorData: String? = null, + override val type: ExtractorLinkType, + open val kid : String, + open val key : String, + open val uuid : UUID, + open val kty : String, + + open val keyRequestParameters : HashMap +) : ExtractorLink( + source, name, url, referer, quality, type, headers, extractorData +) { + constructor( + source: String, + name: String, + url: String, + referer: String, + quality: Int, + /** the type of the media, use INFER_TYPE if you want to auto infer the type from the url */ + type: ExtractorLinkType?, + headers: Map = mapOf(), + /** Used for getExtractorVerifierJob() */ + extractorData: String? = null, + kid : String, + key : String, + uuid : UUID = CLEARKEY_UUID, + kty : String = "oct", + keyRequestParameters : HashMap = hashMapOf(), + ) : this( + source = source, + name = name, + url = url, + referer = referer, + quality = quality, + headers = headers, + extractorData = extractorData, + type = type ?: inferTypeFromUrl(url), + kid = kid, + key = key, + uuid = uuid, + keyRequestParameters = keyRequestParameters, + kty = kty, + ) +} + open class ExtractorLink constructor( open val source: String, open val name: String, @@ -110,6 +377,9 @@ open class ExtractorLink constructor( open val extractorData: String? = null, open val type: ExtractorLinkType, ) : VideoDownloadManager.IDownloadableMinimum { + val isM3u8 : Boolean get() = type == ExtractorLinkType.M3U8 + val isDash : Boolean get() = type == ExtractorLinkType.DASH + constructor( source: String, name: String, diff --git a/app/src/main/res/layout/fragment_result_tv.xml b/app/src/main/res/layout/fragment_result_tv.xml index 1fde999c..4d236d78 100644 --- a/app/src/main/res/layout/fragment_result_tv.xml +++ b/app/src/main/res/layout/fragment_result_tv.xml @@ -128,7 +128,7 @@ https://developer.android.com/design/ui/tv/samples/jet-fit - - @@ -411,7 +409,7 @@ https://developer.android.com/design/ui/tv/samples/jet-fit android:padding="5dp" android:requiresFadingEdge="vertical" android:textColor="?attr/textColor" - android:textSize="12sp" + android:textSize="16sp" tools:text="Ryan Quicksave Romano is an eccentric adventurer with a strange power: he can create a save-point in time and redo his life whenever he dies. Arriving in New Rome, the glitzy capital of sin of a rebuilding Europe, he finds the city torn between mega-corporations, sponsored heroes, superpowered criminals, and true monsters. It's a time of chaos, where potions can grant the power to rule the world and dangers lurk everywhere. " /> - + android:layout_height="match_parent"> + + + + + android:textSize="16sp" + android:visibility="invisible" /> diff --git a/app/src/main/res/values-ars/strings.xml b/app/src/main/res/values-ars/strings.xml index ea8aa05c..a1042b7e 100644 --- a/app/src/main/res/values-ars/strings.xml +++ b/app/src/main/res/values-ars/strings.xml @@ -200,4 +200,69 @@ استخدم %sتعذر إنشاء واجهة المستخدم بشكل صحيح، وهذا خطأ كبير ويجب الإبلاغ عنه على الفور الصفات + نوع الحافة + العب + حدث خطأ أثناء تحميل الروابط + التخزين الداخلي + الترجمة + استئناف تحميل + معلومات + وقفة التحميل + الغي + احفظ + إعدادات الترجمة + لون الخط + لون المخطط التفصيلي + اقفل + امسح + سرعة اللاعب + لون الخلفية + لون النافذة + ارتفاع الترجمة + حذف ملف + تعطيل الإبلاغ التلقائي عن الأخطاء + بدأ التحديث + انسخ + بث + ملف اللعب + مزيد من المعلومات + تصفية الإشارات المرجعية + إشارات مرجعية + زيل + ضبط حالة المشاهدة + مدبلجة + اخفي + قدم + وصف + يستمر التشغيل في مشغل مصغر فوق التطبيقات الأخرى + نهائيا %sسيؤدي هذا الى حذف +\nهل أنت متأكد؟ + الخط + حجم الخط + زيل + هذا المزود عبارة عن تورنت، ويوصى باستخدام فيبيان + لا يتم توفير البيانات الوصفية بواسطة الموقع، وسيفشل تحميل الفيديو إذا لم يكن موجودًا في الموقع. + جاري التنفيذ + مكتمل + حالة + التحديد التلقائي للغة + زر تغيير حجم المشغل + مواصلة المشاهدة + مزيد من المعلومات + البحث باستخدام مقدمي الخدمات + البحث باستخدام الأنواع + بنيني الى المطورين %d تم منح + لم يتم تقديم بنيني + تحميل اللغات + لغة الترجمة + اضغط لإعادة التعيين إلى الوضع الافتراضي + %s قم باستيراد الخطوط بوضعها في + قد تكون هناك حاجة إلى فيبيان حتى يعمل هذا المزود بشكل صحيح + لم يتم العثور على قطعة أرض + لم يتم العثور على وصف + 🐈عرض لوجكات + سجل + صور في صور + %d +\nباقي diff --git a/app/src/main/res/values-bp/strings.xml b/app/src/main/res/values-bp/strings.xml index b70eec12..016fbe43 100644 --- a/app/src/main/res/values-bp/strings.xml +++ b/app/src/main/res/values-bp/strings.xml @@ -157,7 +157,7 @@ Mostrar episódios de Filler em anime Mostrar trailers Mostrar posters do Kitsu - Esconder qualidades de vídeo selecionadas nos resultados da Pesquisa + Esconder qualidades de vídeo selecionadas nos resultados da pesquisa Atualizações de plugin automáticas Mostrar atualizações do app Automaticamente procurar por novas atualizações ao abrir @@ -222,7 +222,7 @@ Filme Série Desenho Animado - @string/anime + Anime @string/ova Torrent Documentário @@ -265,14 +265,14 @@ Cache do vídeo em disco Limpar cache de vídeo e imagem Causará travamentos aleatórios se definido muito alto. Não mude caso tiver pouca memória RAM, como um Android TV ou um telefone antigo - Pode causar problemas em sistemas com pouco espaço de armazenamento se definido muito alto, como em dispositivos Android TV + Causa problemas em sistemas com pouco espaço de armazenamento se definido muito alto, como em dispositivos Android TV. DNS sobre HTTPS Útil para burlar bloqueios de provedores de internet Clonar site Remover site Adiciona um clone de um site existente, com uma URL diferente Caminho para Download - Url do servidor Nginx + URL do servidor NGINX Mostrar Anime Dublado/Legendado Ajustar para a Tela Esticar @@ -338,7 +338,7 @@ Sombreado Em Relevo Sincronizar legendas - 1000ms + 1000 ms Atraso de legenda Use isto se as legendas forem mostradas %dms adiantadas Use isto se as legendas forem mostradas %dms atrasadas @@ -382,9 +382,9 @@ Resolução e título Título Resolução - Id invalida + ID inválido Dado invalido - URL invalido + URL inválida Erro Remover legendas ocultas(CC) das legendas Remover bloat das legendas @@ -406,8 +406,8 @@ Plugin Carregado Plugin Apagado Falha ao carregar %s - Iniciada a transferência %d %s - Transferido %d %s com sucesso + Iniciada a transferência %d %s… + Transferido %d %s Tudo %s já transferido Transferência em batch Plugin @@ -444,7 +444,7 @@ Navegador Copia de Segurança A Barra de Progresso pode ser usada quando o player estiver oculto - Inscrever + Inscrito Essa lista está vazia. Tente mudar para outra. Reproduzir Livestream Log do Teste @@ -493,10 +493,10 @@ \nNOTA: Se a soma for 10 ou mais, o Player pulará automaticamente o carregamento quando o link for carregado! Arquivo de modo de segurança encontrado! \nNão carregar nenhuma extensão na inicialização até que o arquivo seja removido. - Inscrevel em %d + Inscrito em %s Episódio %d Lançado Selecionar padrão - Disinscrevel em %d + Desinscrito de %s Alguns aparelhos não possuem suporte para este pacote de instalação. Tente a opção legada se a atualização não instalar. Dados móveis Perfil %d @@ -550,4 +550,21 @@ Faixas de áudio Adicionado em (novo para antigo) Faixas de video + Legendas + Navegador + 18+ + Links + Funcionalidades do Player + Instalador APK + Aparência + Desativar + Usar + Link da stream + Gestos + Plugin baixado + Não foi possível se conectar ao GitHub. Ativando proxy jsDelivr… + Cache + Vídeo + Android TV + Wi-Fi diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 6739465a..3efc4072 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -5,19 +5,19 @@ Episode %d wird veröffentlicht in Vorschaubild Vorschaubild - Halten, um auf die Standardeinstellungen zurückzusetzen + Halten, um auf Standardeinstellungen zurückzusetzen Wiederherstellung der Daten aus der Datei %s fehlgeschlagen Daten erfolgreich gesichert Fehler beim Sichern von %s Dieser Anbieter hat keine Chromecast-Unterstützung Chromecast-Mirror In App wiedergeben - Vermischte Openings + Gemischte Openings Abspann Intro Verlauf löschen Verlauf - Überspringen Knopf für Openings/Endings anzeigen + Button zum Überspringen für Openings/Endings anzeigen Zu viel Text. Kann nicht in der Zwischenablage gespeichert werden. Episodenvorschaubild Medienvorschaubild @@ -34,7 +34,7 @@ CloudStream Mit CloudStream abspielen Startseite - Suchen + Suche Downloads Einstellungen Suchen… @@ -44,8 +44,8 @@ Nächste Episode Genres Teilen - In Browser öffnen - Puffern überspringen + Im Browser öffnen + Laden überspringen Lädt… Am schauen Pausiert @@ -79,7 +79,7 @@ Datei abspielen Download fortsetzen Download pausieren - Automatische Fehlerberichterstattung deaktivieren + Automatische Fehlerberichtserstattung deaktivieren Mehr Infos Verstecken Abspielen @@ -106,8 +106,8 @@ Schriftgröße Suche anhand Anbietern Suche anhand Typen - %d Benenes an die Devs verteilt - Noch keine Benenes verteilt + %d Benenes an die Devs geschenkt + Noch keine Benenes verschenkt Sprache automatisch wählen Sprachen herunterladen Untertitelsprache @@ -117,8 +117,8 @@ Mehr Infos @string/home_play Damit dieser Anbieter korrekt funktioniert, ist möglicherweise ein VPN erforderlich - Dieser Anbieter bietet Torrents an, ein VPN wird dringend empfohlen - Metadaten werden nicht von der Website bereitgestellt, das Laden des Videos schlägt fehl, wenn sie auf der Website nicht vorhanden sind. + Dieser Anbieter bietet Torrents an, ein VPN wird deswegen dringend empfohlen + Metadaten werden nicht von der Website bereitgestellt, das Laden des Videos schlägt fehl, wenn sie nicht auf der Website vorhanden sind. Beschreibung Keine Handlung gefunden Keine Beschreibung gefunden @@ -143,7 +143,7 @@ Doppeltippen zum Pausieren Zeit für vor- und zurückspulen im Player (Sekunden) Zweimal auf die rechte oder linke Seite tippen, um vor- oder zurückzuspulen - Doppelt in die Mitte tippen, um zu pausieren + Zweimal in die Mitte tippen, um zu pausieren Systemhelligkeit verwenden Systemhelligkeit anstelle eines dunklen Overlay im Player verwenden Episodenfortschritt aktualisieren @@ -163,7 +163,7 @@ Füller-Episoden für Animes anzeigen Trailer anzeigen Vorschaubilder von Kitsu anzeigen - Ausgewählte Videoqualität bei Suchergebnissen ausblenden + Ausgewählte Videoqualität in den Suchergebnissen ausblenden Automatische Plugin-Updates App-Updates anzeigen Automatisches Suchen nach neuen Updates nach dem Start. @@ -172,11 +172,11 @@ Github Light Novel App von denselben Entwicklern Anime App von denselben Entwicklern - Discord beitreten - Eine Benene an die Devs verteilen - Verteilte Benenes + Trete dem Discord Server bei + Eine Benene an die Devs schenken + Geschenkte Benenes App-Sprache - Keine Verlinkung gefunden + Keine Links gefunden Link in die Zwischenablage kopiert Episode abspielen Auf Standardwert zurücksetzen @@ -240,7 +240,7 @@ Remote-Fehler Renderfehler Unerwarteter Playerfehler - Downloadfehler, Speicherberechtigungen prüfen + Downloadfehler, bitte überprüfen sie die Speicherberechtigungen Chromecast-Episode In %s wiedergeben In Browser wiedergeben @@ -255,7 +255,7 @@ Titel UI-Elemente auf Vorschaubild umschalten Kein Update gefunden - Auf Update prüfen + Auf Updates prüfen Sperren Skalieren Quelle @@ -270,16 +270,16 @@ Videopufferlänge Video-Cache in Speicher Video- und Bild-Cache leeren - Verursacht Abstürze, wenn zu hoch eingestellt. Nicht ändern, wenn wenig Arbeitsspeicher verfügbar ist, wie z.B. ein Android TV oder ein altes Telefon. - Kann auf Systemen mit geringem Speicherplatz, wie z. B. Android TV-Geräten, zu Problemen führen, wenn der Wert zu hoch eingestellt ist. + Verursacht Abstürze, wenn zu hoch eingestellt. Nicht ändern, wenn wenig Arbeitsspeicher verfügbar ist, wie z.B. auf einem Android TV oder auf einem alten Smartphone. + Kann auf Systemen mit geringem Speicherplatz, wie z. B. auf Android TV-Geräten, zu Problemen führen, wenn der Wert zu hoch eingestellt ist. DNS über HTTPS - Nützlich für die Umgehung von ISP-Sperren + Nützlich zur Umgehung von ISP-Sperren Website klonen Website entfernen Einen Klon einer bestehenden Website mit einer anderen URL hinzufügen Downloadpfad Nginx-Server-URL - Dubbed/Subbed Anime anzeigen (Synchronisiert/Untertitelt) + Dubbed/Subbed Anime anzeigen An Bildschirm anpassen Strecken Vergrößern @@ -308,7 +308,7 @@ 127.0.0.1 MeineCooleSeite example.com - Sprachcode (en) + Sprachencode (en) %s %s Account Ausloggen @@ -317,13 +317,13 @@ Account hinzufügen Account erstellen Synchronisation hinzufügen - Hinzugefügt %s + %s hinzugefügt Sync Bewertung %d / 10 /\?\? /%d - Authentifiziert %s + %s authentifiziert Die Authentifizierung bei %s ist fehlgeschlagen Keine Normal @@ -335,10 +335,10 @@ Schatten Erhöht Untertitel synchronisieren - 1000ms + 1000 ms Untertitelverzögerung - Verwenden, wenn die Untertitel %dms zu früh angezeigt werden - Verwenden, wenn die Untertitel %dms zu spät angezeigt werden + Verwenden, wenn die Untertitel %d ms zu früh angezeigt werden + Verwenden, wenn die Untertitel %d ms zu spät angezeigt werden Keine Untertitelverzögerung Vogel Quax zwickt Johnys Pferd Bim Empfohlen @@ -359,7 +359,7 @@ HD TS TC - BlueRay + Blue-ray WP DVD 4K @@ -408,7 +408,7 @@ Plugins Dadurch werden auch alle Repository-Plugins gelöscht Repository löschen - Lade eine Liste der Websiten herunter, welche du verwenden möchtest + Lade eine Liste der Websites herunter, welche du verwenden möchtest Heruntergeladen: %d Deaktiviert: %d Nicht heruntergeladen: %d @@ -416,7 +416,7 @@ \n \nAufgrund eines hirnlosen DMCA-Takedowns durch Sky UK Limited 🤮 können wir die Repository-Site nicht in der App verlinken. \n -\nTrete unserem Discord bei oder suche online. +\nTrete unserem Discord Server bei oder suche online. Community-Repositories anzeigen Öffentliche Liste Alle Untertitel in Großbuchstaben @@ -427,7 +427,7 @@ Videospuren Bei Neustart anwenden Abgesicherter Modus aktiviert - Alle Erweiterungen wurden aufgrund eines Absturzes deaktiviert, damit Sie diejenige finden können, die Probleme verursacht. + Alle Erweiterungen wurden aufgrund eines Absturzes deaktiviert, damit Sie diejenige finden können, welche Probleme verursacht. Absturzinfo ansehen Bewertung: %s Beschreibung @@ -460,7 +460,7 @@ Automatische Installation aller noch nicht installierten Plugins aus hinzugefügten Repositories. Einrichtungsvorgang wiederholen APK-Installer - Einige Telefone unterstützen den neuen Package-Installer nicht. Benutze die Legacy-Option, wenn sich die Updates nicht installieren lassen. + Einige Smartphones unterstützen den neuen Package-Installer nicht. Benutze die Legacy-Option, wenn sich die Updates nicht installieren lassen. %s %d%s Links App-Updates @@ -482,7 +482,7 @@ Nein App-Update wird heruntergeladen… App-Update wird installiert… - Konnte die neue Version der App nicht installieren + Die neue Version der App konnte nicht installieren werden Legacy PackageInstaller Aktualisierung gestartet @@ -493,18 +493,18 @@ Browser Sortieren nach Sortieren - Bewertung (gut bis schlecht) - Bewertung (schlecht bis gut) - Aktualisiert (neu bis alt) - Aktualisiert (alt bis neu) - Alphabetisch (A bis Z) - Alphabetisch (Z bis A) + Bewertung (gut zu schlecht) + Bewertung (schlecht zu gut) + Aktualisiert (neu zu alt) + Aktualisiert (alt zu neu) + Alphabetisch (A zu Z) + Alphabetisch (Z zu A) Bibliothek auswählen Öffnen mit Deine Bibliothek ist leer :( \nMelde dich mit einem Bibliothekskonto an oder füge Titel zu deiner lokalen Bibliothek hinzu. - Diese Liste ist leer. Versuche zu einer anderen Liste zu wechseln. - Datei für abgesicherten Modus gefunden! + Diese Liste ist leer. Versuch zu einer anderen Liste zu wechseln. + Datei für den abgesicherten Modus gefunden! \nBeim Start werden keine Erweiterungen geladen, bis die Datei entfernt wird. Player ausgeblendet - Betrag zum vor- und zurückspulen Der Betrag, welcher verwendet wird, wenn der Player eingeblendet ist @@ -549,7 +549,7 @@ Filtermodus für Plugin-Downloads auswählen Es wurde bereits abgestimmt Keine Plugins im Repository gefunden - Repository nicht gefunden, überprüfe die URL und probiere eine VPN - Die Benutzeroberfläche konnte nicht korrekt erstellt werden. Dies ist ein schwerwiegender Fehler und sollte sofort gemeldet werden. %s + Repository nicht gefunden, überprüf die URL und versuch ein VPN + Die Benutzeroberfläche konnte nicht korrekt erstellt werden. Dies ist ein SCHWERWIEGENDER FEHLER und sollte sofort gemeldet werden. %s Deaktivieren diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 208e6140..63d03a6b 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -540,7 +540,7 @@ \n \nREMARQUE : Si la somme est de 10 ou plus, le joueur sautera automatiquement le chargement lorsque ce lien est chargé ! Aucun plugin trouvé dans ce dossier - Dossier non trouvé, vérifiez l\'url et essayé un VPN + Dépôt introuvable, vérifiez l\'URL et essayez avec un VPN Données mobiles Définir par défaut Utiliser @@ -552,4 +552,6 @@ Qualités L\'interface utilisateur n\'a pas pu être créée correctement. Il s\'agit d\'un bogue majeur qui doit être signalé immédiatement %s Sélectionnez le mode pour filtrer le téléchargement des plugins + Fond de profil + @string/default_subtitles diff --git a/app/src/main/res/values-hr/strings.xml b/app/src/main/res/values-hr/strings.xml index 35df36ac..477ab92b 100644 --- a/app/src/main/res/values-hr/strings.xml +++ b/app/src/main/res/values-hr/strings.xml @@ -566,4 +566,15 @@ Pozadina profila Nije bilo moguće ispravno izraditi korisničko sučelje. Ovo je ZNAČAJNA GREŠKA i treba se odmah prijaviti %s Odaberi modus za filtriranje preuzimanja dodataka + Onemogući + @string/default_subtitles + U repozitoriju nisu pronađeni dodaci + Repozitorij nije pronađen, provjerite URL i pokušajte koristiti VPN + Ovdje možete promijeniti način na koji su izvori poredani. Ako video ima viši prioritet, pojavit će se više u odabiru izvora. Zbroj prioriteta izvora i prioriteta kvalitete je video prioritet. +\n +\nIzvor A: 3 +\nKvaliteta B: 7 +\nImat će kombinirani prioritet videozapisa od 10. +\n +\nNAPOMENA: Ako je zbroj 10 ili više, video player će automatski preskočiti učitavanje kada se ta poveznica učita! diff --git a/app/src/main/res/values-hu/strings.xml b/app/src/main/res/values-hu/strings.xml index 05a7f0a7..677beaf8 100644 --- a/app/src/main/res/values-hu/strings.xml +++ b/app/src/main/res/values-hu/strings.xml @@ -219,7 +219,7 @@ Folyamatban levő Év Webhely - Szinopszis + Összegzés Nincsenek feliratok Távoli hiba Render hiba @@ -237,7 +237,7 @@ Csúsztassa felfelé vagy lefelé a bal vagy jobb oldalon a fényerő vagy a hangerő megváltoztatásához Biztonsági mentés 0 Banán a fejlesztőknek - Húzás a kereséshez + Húzd el, hogy beless Következő epizód automatikus lejátszása Következő epizód lejátszása amikor az aktuális epizód véget ér Dupla koppintás a kereséshez @@ -510,4 +510,5 @@ TV elrendezés Automatikus Az átugrás mértéke, amikor a lejátszó látható + Válassza ki a módot a pluginek letöltésének szűréséhez diff --git a/app/src/main/res/values-or/strings.xml b/app/src/main/res/values-or/strings.xml index 9b9385c2..177f7ea1 100644 --- a/app/src/main/res/values-or/strings.xml +++ b/app/src/main/res/values-or/strings.xml @@ -153,4 +153,9 @@ ଆପ୍ ଅଦ୍ୟତନ ଦେଖାଇବା ଅଦ୍ୟତନ ଆରମ୍ଭ ହୋଇଛି ସନ୍ଧାନ କରିବା… + ସଂକ୍ଷିପ୍ତବୃତ୍ତି + ଚଳଚ୍ଚିତ୍ର ଚଲାଅ + %s ସନ୍ଧାନ କରିବା… + ପରବର୍ତ୍ତୀ ଅଧ୍ୟାୟ + କୌଣସି ତଥ୍ୟ ନାହିଁ diff --git a/app/src/main/res/values-ro/strings.xml b/app/src/main/res/values-ro/strings.xml index 1f288d2a..b6971c37 100644 --- a/app/src/main/res/values-ro/strings.xml +++ b/app/src/main/res/values-ro/strings.xml @@ -570,4 +570,5 @@ UI nu a putut fi creată corect, acesta este un BUG MAJOR și trebuie raportat imediat %s Selectați modul de filtrare a descărcării plugin-urilor @string/default_subtitles + Ați votat deja diff --git a/app/src/main/res/values-ta/strings.xml b/app/src/main/res/values-ta/strings.xml index affb04bf..3f4134e5 100644 --- a/app/src/main/res/values-ta/strings.xml +++ b/app/src/main/res/values-ta/strings.xml @@ -6,12 +6,12 @@ முகப்பு தேடு பதிவிறக்கம் - தகவல் எதுவும் இல்லை + தரவு இல்லை மேலும் விருப்பங்கள் - அடுத்த எபிசோட் + அடுத்த அத்தியாயம் வகைகள் பகிர் - Browser இல் திற + உலாவியில் திற ஏற்றுவதைத் தவிர் பார்த்து கொண்டிருப்பது நிறுத்தி வைக்கப்பட்டுள்ளது @@ -21,9 +21,9 @@ ஸ்ட்ரீம் டோரண்ட் வசன வரிகள் பின் செல் - எபிசோடை இயக்கு + அத்தியாயத்தை இயக்கு எபிசோட் பதிவிற்கான அனுமதி கொடுக்கவும் - பதிவிறக்கம் செய்யப்பட்டது + பதிவிறக்கப்பட்டது பதிவிறக்குகிறது பதிவிறக்கம் இடைநிறுத்தப்பட்டது பதிவிறக்கம் தொடங்கியது @@ -67,10 +67,10 @@ ஏற்றுகிறது… கைவிடப்பட்டது பதிவிறக்கம் முடிந்தது - இணைப்பை மீண்டும் முயற்சிக்கவும்… + இணைப்பை மீண்டும் முயலவும்… திரைப்படத்தை இயக்கு லைவ்ஸ்ட்ரீம் இயக்கு - டிரெய்லரை இயக்கவும் + டிரெய்லரை இயக்கு மூலம் இணைப்புகளை ஏற்றுவதில் பிழை இயக்கு @@ -107,4 +107,14 @@ இடைநிறுத்துவதற்கு இருமுறை தட்டவும் Chromecast வசன அமைப்புகள் இருண்ட மேலடுக்குக்குப் பதிலாக ஆப் பிளேயரில் சிஸ்டம் பிரகாசத்தைப் பயன்படுத்தவும் + அத்தியாயம் %d-இன் வெளியீட்டு நேரம் + %dம %dநி + %dநி + அடுத்து ஏதாவது + உலாவி + %d நிமி + CloudStream-உடன் இயக்கு + புதிய புதுப்பிப்பு உள்ளது +\n%s->%s + நிரப்பி diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index 4866ecd4..f9dccfc4 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -18,7 +18,7 @@ Попередній перегляд фону Швидкість (%.2fx) Знайдено нове оновлення! -\n%s -> %s +\n%s –> %s Пошук Завантаження %d хв @@ -37,7 +37,7 @@ Покинуто Переглянути фільм Переглянути трейлер - Трансляція через торрент + Трансляція через торент Повторити підключення… Назад Переглянути епізод @@ -75,7 +75,7 @@ Продовжити перегляд Вилучити Детальніше - Цей постачальник є торрентом, рекомендується VPN + Цей постачальник є торентом, рекомендується використовувати VPN Опис Сюжет не знайдено Опис не знайдено @@ -86,9 +86,9 @@ Субтитри Chromecast Налаштування субтитрів Chromecast Режим Eigengravy - Проведіть пальцем, щоб змінити налаштування + Проведіть, щоб змінити налаштування Проведіть вгору або вниз з лівого або правого боку, щоб змінити яскравість чи гучність - Відтворювати наступний епізод після закінчення поточного + Відтворює наступний епізод після закінчення поточного Головна CloudStream Філер @@ -130,7 +130,7 @@ Картинка в картинці Налаштування субтитрів плеєра Додає опцію керування швидкістю в плеєрі - Проведіть пальцем, щоб перемотати + Проведіть, щоб перемотати Двічі торкніться, щоб перемотати Двічі торкніться для паузи Крок перемотки (секунди) @@ -224,7 +224,7 @@ Двічі торкніться праворуч або ліворуч, щоб перемотати відео вперед або назад Використовуйте системну яскравість у плеєрі замість темної накладки Завантажено файл резервної копії - Торренти + Торенти Автоматична синхронізація прогресу поточного епізоду Відсутні дозволи на зберігання. Будь ласка, спробуйте ще раз. Показувати постери від Kitsu @@ -256,7 +256,7 @@ NSFW Фільм OVA - Торрент + Торент Мітка якості NSFW Переглянути в браузері @@ -294,9 +294,9 @@ Основний колір Тема застосунку Розташування назви постера - Розмістіть назву під постером + Розмістити назву під постером Пароль123 - Моє круте ім\'я + Моє круте ім’я hello@world.com Мій крутий сайт Код мови (uk) @@ -348,9 +348,9 @@ Роздільна здатність відеоплеєра Довжина буфера відео Очистити кеш відео та зображень - Спричиняє збої, якщо встановлено занадто високе значення на пристроях із малим об\'ємом пам\'яті, наприклад Android TV. + Спричиняє збої, якщо встановлено занадто високе значення на пристроях із малим об’ємом пам’яті, наприклад Android TV. Корисно для обходу блокувань провайдера - Спричиняє збої, якщо встановлено занадто високе значення на пристроях із малим об\'ємом вільної пам\'яті, наприклад Android TV. + Спричиняє збої, якщо встановлено занадто високе значення на пристроях із малим об’ємом вільної пам’яті, наприклад Android TV. DNS через HTTPS Шлях завантаження Додайте клон існуючого сайту, з іншою URL-адресою @@ -374,10 +374,10 @@ Оцінений Завантажити з файлу Макс. - Щастям б\'єш жук їх глицю в фон й ґедзь пріч + Щастям б’єш жук їх глицю в фон й ґедзь пріч 1000 мс - Використовуйте цей параметр, якщо субтитри з\'являються на %d мс занадто рано - Використовуйте це, якщо субтитри з\'являються із запізненням на %d мс + Використовуйте цей параметр, якщо субтитри з’являються на %d мс занадто рано + Використовуйте це, якщо субтитри з’являються із запізненням на %d мс Завантажено %s Підтримка Фон @@ -507,8 +507,8 @@ Файл безпечного режиму знайдено! \nРозширеня не завантажуються під час запуску, доки файл не буде видалено. Android TV - Плеєр сховано - обсяг перемотки - Плеєр показано - обсяг перемотки + Плеєр сховано – обсяг перемотки + Плеєр показано – обсяг перемотки Обсяг перемотки, який використовується, коли плеєр видимий Обсяг перемотки, який використовується, коли плеєр прихований Тест провалено @@ -532,7 +532,7 @@ Встановити за замовчуванням Профілі Допомога - Тут ви можете змінити порядок джерел. Якщо відео має вищий пріоритет, воно з\'явиться вище у списку джерел. Сума пріоритету джерела та пріоритету якості є пріоритетом відео. + Тут ви можете змінити порядок джерел. Якщо відео має вищий пріоритет, воно з’явиться вище у списку джерел. Сума пріоритету джерела та пріоритету якості є пріоритетом відео. \n \nДжерело A: 3 \nЯкість B: 7 diff --git a/fastlane/metadata/android/or/changelogs/2.txt b/fastlane/metadata/android/or/changelogs/2.txt new file mode 100644 index 00000000..e8b23e5f --- /dev/null +++ b/fastlane/metadata/android/or/changelogs/2.txt @@ -0,0 +1 @@ +- ପରିବର୍ତ୍ତନ ପୋଥି ଯୋଡ଼ାଗଲା! diff --git a/fastlane/metadata/android/uk/changelogs/2.txt b/fastlane/metadata/android/uk/changelogs/2.txt index 2c8d9f7e..97e84fa8 100644 --- a/fastlane/metadata/android/uk/changelogs/2.txt +++ b/fastlane/metadata/android/uk/changelogs/2.txt @@ -1 +1 @@ -- Додано журнал змін! +– Додано журнал змін!