Merge remote-tracking branch 'origin/master'

This commit is contained in:
CranberrySoup 2023-09-15 13:13:48 +02:00
commit 45ecc903a2
31 changed files with 1496 additions and 578 deletions

View file

@ -58,8 +58,8 @@ android {
minSdk = 21
targetSdk = 29
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

View file

@ -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)

View file

@ -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<String, AniSearch> = hashMapOf()
/** backwards compatibility, use getTracker4 instead */
suspend fun getTracker(
titles: List<String>,
types: Set<TrackerType>?,
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<String>,
types: Set<TrackerType>?,
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<AniSearch>()?.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<String> {
//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<Media> = 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<Results>? = arrayListOf()
)
/**
* used for the getTracker() method
**/

View file

@ -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)

View file

@ -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<Account>()?.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,
)
}
}

View file

@ -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}")

View file

@ -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<T>(
@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<SimklCacheWrapper<Any>>(it)?.isFresh() == false
if (isOld) {
removeKey(it)
}
}
}
fun <T> 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 <reified T : Any> 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<String>(SIMKL_CACHE_KEY, path)?.let {
mapper.readValue<SimklCacheWrapper<T>>(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<EpisodeMetadata>?): List<MediaObject.Season.Episode> {
fun convertToEpisodes(list: List<EpisodeMetadata>?): List<MediaObject.Season.Episode>? {
return list?.map {
MediaObject.Season.Episode(it.episode)
} ?: emptyList()
}
}
fun convertToSeasons(list: List<EpisodeMetadata>?): List<MediaObject.Season> {
fun convertToSeasons(list: List<EpisodeMetadata>?): List<MediaObject.Season>? {
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<Season>? = null,
@JsonProperty("episodes") val episodes: List<Season.Episode>? = 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<MediaObject.Season>?, List<MediaObject.Season.Episode>?>? = null,
private var removeEpisodes: Pair<List<MediaObject.Season>?, List<MediaObject.Season.Episode>?>? = 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<EpisodeMetadata>?,
newEpisodes: Int?,
oldEpisodes: Int?,
) = apply {
if (allEpisodes == null || newEpisodes == null) return@apply
fun getEpisodes(rawEpisodes: List<EpisodeMetadata>) =
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<EpisodeMetadata>? {
if (simklId == null) return null
val cacheKey = "Episodes/$simklId"
val cache = SimklCache.getKey<Array<EpisodeMetadata>>(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<Array<EpisodeMetadata>>()?.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<Season>? = null,
@JsonProperty("episodes") episodes: List<Season.Episode>? = 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<Season>?,
@JsonProperty("episodes") episodes: List<Season.Episode>?,
) : MediaObject(title, year, ids, seasons = seasons, episodes = episodes)
@JsonInclude(JsonInclude.Include.NON_EMPTY)
data class StatusRequest(
@JsonProperty("movies") val movies: List<MediaObject>,
@ -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<EpisodeMetadata>? {
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<EpisodeMetadata>?,
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<MediaObject>(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<EpisodeMetadata>
): 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<EpisodeMetadata>? {
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<SyncAPI.SyncSearchResult>? {
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? {

View file

@ -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<Long, Long>) {
open fun playerPositionChanged(position: Long, duration : Long) {
throw NotImplementedError()
}
open fun playerDimensionsLoaded(widthHeight: Pair<Int, Int>) {
open fun playerDimensionsLoaded(width: Int, height : Int) {
throw NotImplementedError()
}
@ -132,8 +135,8 @@ abstract class AbstractPlayerFragment(
}
}
private fun updateIsPlaying(playing: Pair<CSPlayerLoading, CSPlayerLoading>) {
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<DefaultTimeBar>(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) {

View file

@ -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<String, String>,
)
override fun getDuration(): Long? = exoPlayer?.duration
@ -118,80 +141,24 @@ class CS3IPlayer : IPlayer {
* Boolean = if it's active
* */
private var playerSelectedSubtitleTracks = listOf<Pair<String, Boolean>>()
/** isPlaying */
private var updateIsPlaying: ((Pair<CSPlayerLoading, CSPlayerLoading>) -> 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<Int, Int>) -> Unit)? = null
/** used for playerPositionChanged */
private var requestedListeningPercentages: List<Int>? = null
/** Fired when seeking the player or on requestedListeningPercentages,
* used to make things appear on que
* position, duration */
private var playerPositionChanged: ((Pair<Long, Long>) -> 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<SubtitleData>) -> 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<CSPlayerLoading, CSPlayerLoading>) -> Unit)?,
requestAutoFocus: (() -> Unit)?,
playerError: ((Exception) -> Unit)?,
playerDimensionsLoaded: ((Pair<Int, Int>) -> Unit)?,
eventHandler: ((PlayerEvent) -> Unit),
requestedListeningPercentages: List<Int>?,
playerPositionChanged: ((Pair<Long, Long>) -> Unit)?,
nextEpisode: (() -> Unit)?,
prevEpisode: (() -> Unit)?,
subtitlesUpdates: (() -> Unit)?,
embeddedSubtitlesFetched: ((List<SubtitleData>) -> 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<Tracks.Group>.getTrack(id: String?): Pair<TrackGroup, Int>? {
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<Pair<Format, Int>> {
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<String, String>): 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<MediaItemSlice>,
@ -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<EpisodeSkip.SkipStamp> = emptyList()
@SuppressLint("UnsafeOptInUsageError")
override fun addTimeStamps(timeStamps: List<EpisodeSkip.SkipStamp>) {
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 {

View file

@ -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) {

View file

@ -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<Long, Long>) {
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<Int, Int>) {
setPlayerDimen(widthHeight)
override fun playerDimensionsLoaded(width: Int, height : Int) {
setPlayerDimen(width to height)
}
private fun unwrapBundle(savedInstanceState: Bundle?) {

View file

@ -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<SubtitleData>,
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<CSPlayerLoading, CSPlayerLoading>) -> 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<Int, Int>) -> Unit)? = null, // (with, height), for UI
requestedListeningPercentages: List<Int>? = null, // this is used to request when the player should report back view percentage
playerPositionChanged: ((Pair<Long, Long>) -> 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<SubtitleData>) -> 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<Int>? = 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()

View file

@ -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
)
}

View file

@ -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()

View file

@ -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<Long, Long>) {}
override fun playerPositionChanged(position: Long, duration : Long) {}
override fun nextMirror() {}
@ -99,8 +101,8 @@ open class ResultTrailerPlayer : ResultFragmentPhone(), IOnBackPressed {
}
}
override fun playerDimensionsLoaded(widthHeight: Pair<Int, Int>) {
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()
}

View file

@ -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
)

View file

@ -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<PlayListItem>,
override val referer: String,
override val quality: Int,
val isM3u8: Boolean = false,
override val headers: Map<String, String> = 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<String, String> = 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<String, String>
) : 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<String, String> = mapOf(),
/** Used for getExtractorVerifierJob() */
extractorData: String? = null,
kid : String,
key : String,
uuid : UUID = CLEARKEY_UUID,
kty : String = "oct",
keyRequestParameters : HashMap<String, String> = 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,

View file

@ -128,7 +128,7 @@ https://developer.android.com/design/ui/tv/samples/jet-fit
<FrameLayout
android:id="@+id/background_poster_holder"
android:layout_width="match_parent"
android:layout_height="200dp"
android:layout_height="150dp"
android:visibility="visible">
<ImageView
@ -147,8 +147,6 @@ https://developer.android.com/design/ui/tv/samples/jet-fit
android:src="@drawable/background_shadow">
</ImageView>
</FrameLayout>
@ -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. " />
<com.google.android.material.chip.ChipGroup
@ -537,18 +535,32 @@ https://developer.android.com/design/ui/tv/samples/jet-fit
</LinearLayout>
</androidx.core.widget.NestedScrollView>
<ImageView
<FrameLayout
android:visibility="gone"
tools:visibility="visible"
android:id="@+id/episodes_shadow"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_gravity="end"
android:clickable="false"
android:focusable="false"
android:focusableInTouchMode="false"
android:importantForAccessibility="no"
android:src="@drawable/episodes_shadow"
android:visibility="gone"
tools:visibility="visible" />
android:layout_height="match_parent">
<ImageView
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_gravity="end"
android:clickable="false"
android:focusable="false"
android:focusableInTouchMode="false"
android:importantForAccessibility="no"
android:src="@drawable/episodes_shadow"/>
<ImageView
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_gravity="end"
android:clickable="false"
android:focusable="false"
android:focusableInTouchMode="false"
android:importantForAccessibility="no"
android:src="@drawable/episodes_shadow"/>
</FrameLayout>
<LinearLayout
android:id="@+id/episode_holder_tv"

View file

@ -49,7 +49,8 @@
android:layout_marginTop="10dp"
android:text="@string/hls_playlist"
android:textColor="?attr/textColor"
android:textSize="16sp" />
android:textSize="16sp"
android:visibility="invisible" />
</LinearLayout>

View file

@ -200,4 +200,69 @@
<string name="use">استخدم</string>
<string name="unable_to_inflate">%sتعذر إنشاء واجهة المستخدم بشكل صحيح، وهذا خطأ كبير ويجب الإبلاغ عنه على الفور</string>
<string name="qualities">الصفات</string>
<string name="subs_edge_type">نوع الحافة</string>
<string name="home_play">العب</string>
<string name="error_loading_links_toast">حدث خطأ أثناء تحميل الروابط</string>
<string name="download_storage_text">التخزين الداخلي</string>
<string name="app_subbed_text">الترجمة</string>
<string name="popup_resume_download">استئناف تحميل</string>
<string name="home_info">معلومات</string>
<string name="popup_pause_download">وقفة التحميل</string>
<string name="sort_cancel">الغي</string>
<string name="sort_save">احفظ</string>
<string name="subtitles_settings">إعدادات الترجمة</string>
<string name="subs_text_color">لون الخط</string>
<string name="subs_outline_color">لون المخطط التفصيلي</string>
<string name="sort_close">اقفل</string>
<string name="sort_clear">امسح</string>
<string name="player_speed">سرعة اللاعب</string>
<string name="subs_background_color">لون الخلفية</string>
<string name="subs_window_color">لون النافذة</string>
<string name="subs_subtitle_elevation">ارتفاع الترجمة</string>
<string name="popup_delete_file">حذف ملف</string>
<string name="pref_disable_acra">تعطيل الإبلاغ التلقائي عن الأخطاء</string>
<string name="update_started">بدأ التحديث</string>
<string name="sort_copy">انسخ</string>
<string name="stream">بث</string>
<string name="popup_play_file">ملف اللعب</string>
<string name="home_more_info">مزيد من المعلومات</string>
<string name="filter_bookmarks">تصفية الإشارات المرجعية</string>
<string name="error_bookmarks_text">إشارات مرجعية</string>
<string name="action_remove_from_bookmarks">زيل</string>
<string name="action_add_to_bookmarks">ضبط حالة المشاهدة</string>
<string name="app_dubbed_text">مدبلجة</string>
<string name="home_expanded_hide">اخفي</string>
<string name="sort_apply">قدم</string>
<string name="torrent_plot">وصف</string>
<string name="picture_in_picture_des">يستمر التشغيل في مشغل مصغر فوق التطبيقات الأخرى</string>
<string name="delete_message" formatted="true">نهائيا %sسيؤدي هذا الى حذف
\nهل أنت متأكد؟</string>
<string name="subs_font">الخط</string>
<string name="subs_font_size">حجم الخط</string>
<string name="action_remove_watching">زيل</string>
<string name="vpn_torrent">هذا المزود عبارة عن تورنت، ويوصى باستخدام فيبيان</string>
<string name="provider_info_meta">لا يتم توفير البيانات الوصفية بواسطة الموقع، وسيفشل تحميل الفيديو إذا لم يكن موجودًا في الموقع.</string>
<string name="status_ongoing">جاري التنفيذ</string>
<string name="status_completed">مكتمل</string>
<string name="status">حالة</string>
<string name="subs_auto_select_language">التحديد التلقائي للغة</string>
<string name="player_size_settings">زر تغيير حجم المشغل</string>
<string name="continue_watching">مواصلة المشاهدة</string>
<string name="action_open_watching">مزيد من المعلومات</string>
<string name="search_provider_text_providers">البحث باستخدام مقدمي الخدمات</string>
<string name="search_provider_text_types">البحث باستخدام الأنواع</string>
<string name="benene_count_text">بنيني الى المطورين %d تم منح</string>
<string name="benene_count_text_none">لم يتم تقديم بنيني</string>
<string name="subs_download_languages">تحميل اللغات</string>
<string name="subs_subtitle_languages">لغة الترجمة</string>
<string name="subs_hold_to_reset_to_default">اضغط لإعادة التعيين إلى الوضع الافتراضي</string>
<string name="subs_import_text" formatted="true">%s قم باستيراد الخطوط بوضعها في</string>
<string name="vpn_might_be_needed">قد تكون هناك حاجة إلى فيبيان حتى يعمل هذا المزود بشكل صحيح</string>
<string name="normal_no_plot">لم يتم العثور على قطعة أرض</string>
<string name="torrent_no_plot">لم يتم العثور على وصف</string>
<string name="show_log_cat">🐈عرض لوجكات</string>
<string name="test_log">سجل</string>
<string name="picture_in_picture">صور في صور</string>
<string name="resume_time_left" formatted="true">%d
\nباقي</string>
</resources>

View file

@ -157,7 +157,7 @@
<string name="show_fillers_settings">Mostrar episódios de Filler em anime</string>
<string name="show_trailers_settings">Mostrar trailers</string>
<string name="kitsu_settings">Mostrar posters do Kitsu</string>
<string name="pref_filter_search_quality">Esconder qualidades de vídeo selecionadas nos resultados da Pesquisa</string>
<string name="pref_filter_search_quality">Esconder qualidades de vídeo selecionadas nos resultados da pesquisa</string>
<string name="automatic_plugin_updates">Atualizações de plugin automáticas</string>
<string name="updates_settings">Mostrar atualizações do app</string>
<string name="updates_settings_des">Automaticamente procurar por novas atualizações ao abrir</string>
@ -222,7 +222,7 @@
<string name="movies_singular">Filme</string>
<string name="tv_series_singular">Série</string>
<string name="cartoons_singular">Desenho Animado</string>
<string name="anime_singular">@string/anime</string>
<string name="anime_singular">Anime</string>
<string name="ova_singular">@string/ova</string>
<string name="torrent_singular">Torrent</string>
<string name="documentaries_singular">Documentário</string>
@ -265,14 +265,14 @@
<string name="video_buffer_disk_settings">Cache do vídeo em disco</string>
<string name="video_buffer_clear_settings">Limpar cache de vídeo e imagem</string>
<string name="video_ram_description">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</string>
<string name="video_disk_description">Pode causar problemas em sistemas com pouco espaço de armazenamento se definido muito alto, como em dispositivos Android TV</string>
<string name="video_disk_description">Causa problemas em sistemas com pouco espaço de armazenamento se definido muito alto, como em dispositivos Android TV.</string>
<string name="dns_pref">DNS sobre HTTPS</string>
<string name="dns_pref_summary">Útil para burlar bloqueios de provedores de internet</string>
<string name="add_site_pref">Clonar site</string>
<string name="remove_site_pref">Remover site</string>
<string name="add_site_summary">Adiciona um clone de um site existente, com uma URL diferente</string>
<string name="download_path_pref">Caminho para Download</string>
<string name="nginx_url_pref">Url do servidor Nginx</string>
<string name="nginx_url_pref">URL do servidor NGINX</string>
<string name="display_subbed_dubbed_settings">Mostrar Anime Dublado/Legendado</string>
<string name="resize_fit">Ajustar para a Tela</string>
<string name="resize_fill">Esticar</string>
@ -338,7 +338,7 @@
<string name="subtitles_shadow">Sombreado</string>
<string name="subtitles_raised">Em Relevo</string>
<string name="subtitle_offset">Sincronizar legendas</string>
<string name="subtitle_offset_hint">1000ms</string>
<string name="subtitle_offset_hint">1000 ms</string>
<string name="subtitle_offset_title">Atraso de legenda</string>
<string name="subtitle_offset_extra_hint_later_format">Use isto se as legendas forem mostradas %dms adiantadas</string>
<string name="subtitle_offset_extra_hint_before_format">Use isto se as legendas forem mostradas %dms atrasadas</string>
@ -382,9 +382,9 @@
<string name="resolution_and_title">Resolução e título</string>
<string name="title">Título</string>
<string name="resolution">Resolução</string>
<string name="error_invalid_id">Id invalida</string>
<string name="error_invalid_id">ID inválido</string>
<string name="error_invalid_data">Dado invalido</string>
<string name="error_invalid_url">URL invalido</string>
<string name="error_invalid_url">URL inválida</string>
<string name="error">Erro</string>
<string name="subtitles_remove_captions">Remover legendas ocultas(CC) das legendas</string>
<string name="subtitles_remove_bloat">Remover bloat das legendas</string>
@ -406,8 +406,8 @@
<string name="plugin_loaded">Plugin Carregado</string>
<string name="plugin_deleted">Plugin Apagado</string>
<string name="plugin_load_fail" formatted="true">Falha ao carregar %s</string>
<string name="batch_download_start_format" formatted="true">Iniciada a transferência %d %s</string>
<string name="batch_download_finish_format" formatted="true">Transferido %d %s com sucesso</string>
<string name="batch_download_start_format" formatted="true">Iniciada a transferência %d %s</string>
<string name="batch_download_finish_format" formatted="true">Transferido %d %s</string>
<string name="batch_download_nothing_to_download_format" formatted="true">Tudo %s já transferido</string>
<string name="batch_download">Transferência em batch</string>
<string name="plugin_singular">Plugin</string>
@ -444,7 +444,7 @@
<string name="browser">Navegador</string>
<string name="pref_category_backup">Copia de Segurança</string>
<string name="android_tv_interface_off_seek_settings_summary">A Barra de Progresso pode ser usada quando o player estiver oculto</string>
<string name="subscription_list_name">Inscrever</string>
<string name="subscription_list_name">Inscrito</string>
<string name="empty_library_logged_in_message">Essa lista está vazia. Tente mudar para outra.</string>
<string name="play_livestream_button">Reproduzir Livestream</string>
<string name="test_log">Log do Teste</string>
@ -493,10 +493,10 @@
\nNOTA: Se a soma for 10 ou mais, o Player pulará automaticamente o carregamento quando o link for carregado!</string>
<string name="safe_mode_file">Arquivo de modo de segurança encontrado!
\nNão carregar nenhuma extensão na inicialização até que o arquivo seja removido.</string>
<string name="subscription_new">Inscrevel em %d</string>
<string name="subscription_new">Inscrito em %s</string>
<string name="subscription_episode_released">Episódio %d Lançado</string>
<string name="set_default">Selecionar padrão</string>
<string name="subscription_deleted">Disinscrevel em %d</string>
<string name="subscription_deleted">Desinscrito de %s</string>
<string name="apk_installer_settings_des">Alguns aparelhos não possuem suporte para este pacote de instalação. Tente a opção legada se a atualização não instalar.</string>
<string name="mobile_data">Dados móveis</string>
<string name="profile_number">Perfil %d</string>
@ -550,4 +550,21 @@
<string name="audio_tracks">Faixas de áudio</string>
<string name="sort_updated_new">Adicionado em (novo para antigo)</string>
<string name="video_tracks">Faixas de video</string>
<string name="pref_category_subtitles">Legendas</string>
<string name="player_settings_play_in_browser">Navegador</string>
<string name="is_adult">18+</string>
<string name="pref_category_links">Links</string>
<string name="pref_category_player_features">Funcionalidades do Player</string>
<string name="apk_installer_settings">Instalador APK</string>
<string name="pref_category_looks">Aparência</string>
<string name="disable">Desativar</string>
<string name="use">Usar</string>
<string name="network_adress_example">Link da stream</string>
<string name="pref_category_gestures">Gestos</string>
<string name="plugin_downloaded">Plugin baixado</string>
<string name="jsdelivr_enabled">Não foi possível se conectar ao GitHub. Ativando proxy jsDelivr…</string>
<string name="pref_category_cache">Cache</string>
<string name="other_singular">Vídeo</string>
<string name="pref_category_android_tv">Android TV</string>
<string name="wifi">Wi-Fi</string>
</resources>

View file

@ -5,19 +5,19 @@
<string name="next_episode_format" formatted="true">Episode %d wird veröffentlicht in</string>
<string name="result_poster_img_des">Vorschaubild</string>
<string name="search_poster_img_des">Vorschaubild</string>
<string name="subs_hold_to_reset_to_default">Halten, um auf die Standardeinstellungen zurückzusetzen</string>
<string name="subs_hold_to_reset_to_default">Halten, um auf Standardeinstellungen zurückzusetzen</string>
<string name="restore_failed_format" formatted="true">Wiederherstellung der Daten aus der Datei %s fehlgeschlagen</string>
<string name="backup_success">Daten erfolgreich gesichert</string>
<string name="backup_failed_error_format">Fehler beim Sichern von %s</string>
<string name="no_chromecast_support_toast">Dieser Anbieter hat keine Chromecast-Unterstützung</string>
<string name="episode_action_chromecast_mirror">Chromecast-Mirror</string>
<string name="episode_action_play_in_app">In App wiedergeben</string>
<string name="skip_type_mixed_op">Vermischte Openings</string>
<string name="skip_type_mixed_op">Gemischte Openings</string>
<string name="skip_type_creddits">Abspann</string>
<string name="skip_type_intro">Intro</string>
<string name="clear_history">Verlauf löschen</string>
<string name="history">Verlauf</string>
<string name="enable_skip_op_from_database_des">Überspringen Knopf für Openings/Endings anzeigen</string>
<string name="enable_skip_op_from_database_des">Button zum Überspringen für Openings/Endings anzeigen</string>
<string name="clipboard_too_large">Zu viel Text. Kann nicht in der Zwischenablage gespeichert werden.</string>
<string name="episode_poster_img_des">Episodenvorschaubild</string>
<string name="home_main_poster_img_des">Medienvorschaubild</string>
@ -34,7 +34,7 @@
<string name="app_name">CloudStream</string>
<string name="play_with_app_name">Mit CloudStream abspielen</string>
<string name="title_home">Startseite</string>
<string name="title_search">Suchen</string>
<string name="title_search">Suche</string>
<string name="title_downloads">Downloads</string>
<string name="title_settings">Einstellungen</string>
<string name="search_hint">Suchen…</string>
@ -44,8 +44,8 @@
<string name="next_episode">Nächste Episode</string>
<string name="result_tags">Genres</string>
<string name="result_share">Teilen</string>
<string name="result_open_in_browser">In Browser öffnen</string>
<string name="skip_loading">Puffern überspringen</string>
<string name="result_open_in_browser">Im Browser öffnen</string>
<string name="skip_loading">Laden überspringen</string>
<string name="loading">Lädt…</string>
<string name="type_watching">Am schauen</string>
<string name="type_on_hold">Pausiert</string>
@ -79,7 +79,7 @@
<string name="popup_play_file">Datei abspielen</string>
<string name="popup_resume_download">Download fortsetzen</string>
<string name="popup_pause_download">Download pausieren</string>
<string name="pref_disable_acra">Automatische Fehlerberichterstattung deaktivieren</string>
<string name="pref_disable_acra">Automatische Fehlerberichtserstattung deaktivieren</string>
<string name="home_more_info">Mehr Infos</string>
<string name="home_expanded_hide">Verstecken</string>
<string name="home_play">Abspielen</string>
@ -106,8 +106,8 @@
<string name="subs_font_size">Schriftgröße</string>
<string name="search_provider_text_providers">Suche anhand Anbietern</string>
<string name="search_provider_text_types">Suche anhand Typen</string>
<string name="benene_count_text">%d Benenes an die Devs verteilt</string>
<string name="benene_count_text_none">Noch keine Benenes verteilt</string>
<string name="benene_count_text">%d Benenes an die Devs geschenkt</string>
<string name="benene_count_text_none">Noch keine Benenes verschenkt</string>
<string name="subs_auto_select_language">Sprache automatisch wählen</string>
<string name="subs_download_languages">Sprachen herunterladen</string>
<string name="subs_subtitle_languages">Untertitelsprache</string>
@ -117,8 +117,8 @@
<string name="action_open_watching">Mehr Infos</string>
<string name="action_open_play">@string/home_play</string>
<string name="vpn_might_be_needed">Damit dieser Anbieter korrekt funktioniert, ist möglicherweise ein VPN erforderlich</string>
<string name="vpn_torrent">Dieser Anbieter bietet Torrents an, ein VPN wird dringend empfohlen</string>
<string name="provider_info_meta">Metadaten werden nicht von der Website bereitgestellt, das Laden des Videos schlägt fehl, wenn sie auf der Website nicht vorhanden sind.</string>
<string name="vpn_torrent">Dieser Anbieter bietet Torrents an, ein VPN wird deswegen dringend empfohlen</string>
<string name="provider_info_meta">Metadaten werden nicht von der Website bereitgestellt, das Laden des Videos schlägt fehl, wenn sie nicht auf der Website vorhanden sind.</string>
<string name="torrent_plot">Beschreibung</string>
<string name="normal_no_plot">Keine Handlung gefunden</string>
<string name="torrent_no_plot">Keine Beschreibung gefunden</string>
@ -143,7 +143,7 @@
<string name="double_tap_to_pause_settings">Doppeltippen zum Pausieren</string>
<string name="double_tap_to_seek_amount_settings">Zeit für vor- und zurückspulen im Player (Sekunden)</string>
<string name="double_tap_to_seek_settings_des">Zweimal auf die rechte oder linke Seite tippen, um vor- oder zurückzuspulen</string>
<string name="double_tap_to_pause_settings_des">Doppelt in die Mitte tippen, um zu pausieren</string>
<string name="double_tap_to_pause_settings_des">Zweimal in die Mitte tippen, um zu pausieren</string>
<string name="use_system_brightness_settings">Systemhelligkeit verwenden</string>
<string name="use_system_brightness_settings_des">Systemhelligkeit anstelle eines dunklen Overlay im Player verwenden</string>
<string name="episode_sync_settings">Episodenfortschritt aktualisieren</string>
@ -163,7 +163,7 @@
<string name="show_fillers_settings">Füller-Episoden für Animes anzeigen</string>
<string name="show_trailers_settings">Trailer anzeigen</string>
<string name="kitsu_settings">Vorschaubilder von Kitsu anzeigen</string>
<string name="pref_filter_search_quality">Ausgewählte Videoqualität bei Suchergebnissen ausblenden</string>
<string name="pref_filter_search_quality">Ausgewählte Videoqualität in den Suchergebnissen ausblenden</string>
<string name="automatic_plugin_updates">Automatische Plugin-Updates</string>
<string name="updates_settings">App-Updates anzeigen</string>
<string name="updates_settings_des">Automatisches Suchen nach neuen Updates nach dem Start.</string>
@ -172,11 +172,11 @@
<string name="github">Github</string>
<string name="lightnovel">Light Novel App von denselben Entwicklern</string>
<string name="anim">Anime App von denselben Entwicklern</string>
<string name="discord">Discord beitreten</string>
<string name="benene">Eine Benene an die Devs verteilen</string>
<string name="benene_des">Verteilte Benenes</string>
<string name="discord">Trete dem Discord Server bei</string>
<string name="benene">Eine Benene an die Devs schenken</string>
<string name="benene_des">Geschenkte Benenes</string>
<string name="app_language">App-Sprache</string>
<string name="no_links_found_toast">Keine Verlinkung gefunden</string>
<string name="no_links_found_toast">Keine Links gefunden</string>
<string name="copy_link_toast">Link in die Zwischenablage kopiert</string>
<string name="play_episode_toast">Episode abspielen</string>
<string name="subs_default_reset_toast">Auf Standardwert zurücksetzen</string>
@ -240,7 +240,7 @@
<string name="remote_error">Remote-Fehler</string>
<string name="render_error">Renderfehler</string>
<string name="unexpected_error">Unerwarteter Playerfehler</string>
<string name="storage_error">Downloadfehler, Speicherberechtigungen prüfen</string>
<string name="storage_error">Downloadfehler, bitte überprüfen sie die Speicherberechtigungen</string>
<string name="episode_action_chromecast_episode">Chromecast-Episode</string>
<string name="episode_action_play_in_format">In %s wiedergeben</string>
<string name="episode_action_play_in_browser">In Browser wiedergeben</string>
@ -255,7 +255,7 @@
<string name="show_title">Titel</string>
<string name="poster_ui_settings">UI-Elemente auf Vorschaubild umschalten</string>
<string name="no_update_found">Kein Update gefunden</string>
<string name="check_for_update">Auf Update prüfen</string>
<string name="check_for_update">Auf Updates prüfen</string>
<string name="video_lock">Sperren</string>
<string name="video_aspect_ratio_resize">Skalieren</string>
<string name="video_source">Quelle</string>
@ -270,16 +270,16 @@
<string name="video_buffer_length_settings">Videopufferlänge</string>
<string name="video_buffer_disk_settings">Video-Cache in Speicher</string>
<string name="video_buffer_clear_settings">Video- und Bild-Cache leeren</string>
<string name="video_ram_description">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.</string>
<string name="video_disk_description">Kann auf Systemen mit geringem Speicherplatz, wie z. B. Android TV-Geräten, zu Problemen führen, wenn der Wert zu hoch eingestellt ist.</string>
<string name="video_ram_description">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.</string>
<string name="video_disk_description">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.</string>
<string name="dns_pref">DNS über HTTPS</string>
<string name="dns_pref_summary">Nützlich für die Umgehung von ISP-Sperren</string>
<string name="dns_pref_summary">Nützlich zur Umgehung von ISP-Sperren</string>
<string name="add_site_pref">Website klonen</string>
<string name="remove_site_pref">Website entfernen</string>
<string name="add_site_summary">Einen Klon einer bestehenden Website mit einer anderen URL hinzufügen</string>
<string name="download_path_pref">Downloadpfad</string>
<string name="nginx_url_pref">Nginx-Server-URL</string>
<string name="display_subbed_dubbed_settings">Dubbed/Subbed Anime anzeigen (Synchronisiert/Untertitelt)</string>
<string name="display_subbed_dubbed_settings">Dubbed/Subbed Anime anzeigen</string>
<string name="resize_fit">An Bildschirm anpassen</string>
<string name="resize_fill">Strecken</string>
<string name="resize_zoom">Vergrößern</string>
@ -308,7 +308,7 @@
<string name="example_ip">127.0.0.1</string>
<string name="example_site_name">MeineCooleSeite</string>
<string name="example_site_url">example.com</string>
<string name="example_lang_name">Sprachcode (en)</string>
<string name="example_lang_name">Sprachencode (en)</string>
<string name="login_format" formatted="true">%s %s</string>
<string name="account">Account</string>
<string name="logout">Ausloggen</string>
@ -317,13 +317,13 @@
<string name="add_account">Account hinzufügen</string>
<string name="create_account">Account erstellen</string>
<string name="add_sync">Synchronisation hinzufügen</string>
<string name="added_sync_format" formatted="true">Hinzugefügt %s</string>
<string name="added_sync_format" formatted="true">%s hinzugefügt</string>
<string name="upload_sync">Sync</string>
<string name="sync_score">Bewertung</string>
<string name="sync_score_format" formatted="true">%d / 10</string>
<string name="sync_total_episodes_none">/\?\?</string>
<string name="sync_total_episodes_some" formatted="true">/%d</string>
<string name="authenticated_user" formatted="true">Authentifiziert %s</string>
<string name="authenticated_user" formatted="true">%s authentifiziert</string>
<string name="authenticated_user_fail" formatted="true">Die Authentifizierung bei %s ist fehlgeschlagen</string>
<string name="none">Keine</string>
<string name="normal">Normal</string>
@ -335,10 +335,10 @@
<string name="subtitles_shadow">Schatten</string>
<string name="subtitles_raised">Erhöht</string>
<string name="subtitle_offset">Untertitel synchronisieren</string>
<string name="subtitle_offset_hint">1000ms</string>
<string name="subtitle_offset_hint">1000 ms</string>
<string name="subtitle_offset_title">Untertitelverzögerung</string>
<string name="subtitle_offset_extra_hint_later_format">Verwenden, wenn die Untertitel %dms zu früh angezeigt werden</string>
<string name="subtitle_offset_extra_hint_before_format">Verwenden, wenn die Untertitel %dms zu spät angezeigt werden</string>
<string name="subtitle_offset_extra_hint_later_format">Verwenden, wenn die Untertitel %d ms zu früh angezeigt werden</string>
<string name="subtitle_offset_extra_hint_before_format">Verwenden, wenn die Untertitel %d ms zu spät angezeigt werden</string>
<string name="subtitle_offset_extra_hint_none_format">Keine Untertitelverzögerung</string>
<string name="subtitles_example_text">Vogel Quax zwickt Johnys Pferd Bim</string>
<string name="recommended">Empfohlen</string>
@ -359,7 +359,7 @@
<string name="quality_hd">HD</string>
<string name="quality_ts">TS</string>
<string name="quality_tc">TC</string>
<string name="quality_blueray">BlueRay</string>
<string name="quality_blueray">Blue-ray</string>
<string name="quality_workprint">WP</string>
<string name="quality_dvd">DVD</string>
<string name="quality_4k">4K</string>
@ -408,7 +408,7 @@
<string name="plugin">Plugins</string>
<string name="delete_repository_plugins">Dadurch werden auch alle Repository-Plugins gelöscht</string>
<string name="delete_repository">Repository löschen</string>
<string name="setup_extensions_subtext">Lade eine Liste der Websiten herunter, welche du verwenden möchtest</string>
<string name="setup_extensions_subtext">Lade eine Liste der Websites herunter, welche du verwenden möchtest</string>
<string name="plugins_downloaded" formatted="true">Heruntergeladen: %d</string>
<string name="plugins_disabled" formatted="true">Deaktiviert: %d</string>
<string name="plugins_not_downloaded" formatted="true">Nicht heruntergeladen: %d</string>
@ -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.</string>
\nTrete unserem Discord Server bei oder suche online.</string>
<string name="view_public_repositories_button">Community-Repositories anzeigen</string>
<string name="view_public_repositories_button_short">Öffentliche Liste</string>
<string name="uppercase_all_subtitles">Alle Untertitel in Großbuchstaben</string>
@ -427,7 +427,7 @@
<string name="video_tracks">Videospuren</string>
<string name="apply_on_restart">Bei Neustart anwenden</string>
<string name="safe_mode_title">Abgesicherter Modus aktiviert</string>
<string name="safe_mode_description">Alle Erweiterungen wurden aufgrund eines Absturzes deaktiviert, damit Sie diejenige finden können, die Probleme verursacht.</string>
<string name="safe_mode_description">Alle Erweiterungen wurden aufgrund eines Absturzes deaktiviert, damit Sie diejenige finden können, welche Probleme verursacht.</string>
<string name="safe_mode_crash_info">Absturzinfo ansehen</string>
<string name="extension_rating" formatted="true">Bewertung: %s</string>
<string name="extension_description">Beschreibung</string>
@ -460,7 +460,7 @@
<string name="automatic_plugin_download_summary">Automatische Installation aller noch nicht installierten Plugins aus hinzugefügten Repositories.</string>
<string name="redo_setup_process">Einrichtungsvorgang wiederholen</string>
<string name="apk_installer_settings">APK-Installer</string>
<string name="apk_installer_settings_des">Einige Telefone unterstützen den neuen Package-Installer nicht. Benutze die Legacy-Option, wenn sich die Updates nicht installieren lassen.</string>
<string name="apk_installer_settings_des">Einige Smartphones unterstützen den neuen Package-Installer nicht. Benutze die Legacy-Option, wenn sich die Updates nicht installieren lassen.</string>
<string name="season_format">%s %d%s</string>
<string name="pref_category_links">Links</string>
<string name="pref_category_app_updates">App-Updates</string>
@ -482,7 +482,7 @@
<string name="no">Nein</string>
<string name="update_notification_downloading">App-Update wird heruntergeladen…</string>
<string name="update_notification_installing">App-Update wird installiert…</string>
<string name="update_notification_failed">Konnte die neue Version der App nicht installieren</string>
<string name="update_notification_failed">Die neue Version der App konnte nicht installieren werden</string>
<string name="apk_installer_legacy">Legacy</string>
<string name="apk_installer_package_installer">PackageInstaller</string>
<string name="update_started">Aktualisierung gestartet</string>
@ -493,18 +493,18 @@
<string name="browser">Browser</string>
<string name="sort_by">Sortieren nach</string>
<string name="sort">Sortieren</string>
<string name="sort_rating_desc">Bewertung (gut bis schlecht)</string>
<string name="sort_rating_asc">Bewertung (schlecht bis gut)</string>
<string name="sort_updated_new">Aktualisiert (neu bis alt)</string>
<string name="sort_updated_old">Aktualisiert (alt bis neu)</string>
<string name="sort_alphabetical_a">Alphabetisch (A bis Z)</string>
<string name="sort_alphabetical_z">Alphabetisch (Z bis A)</string>
<string name="sort_rating_desc">Bewertung (gut zu schlecht)</string>
<string name="sort_rating_asc">Bewertung (schlecht zu gut)</string>
<string name="sort_updated_new">Aktualisiert (neu zu alt)</string>
<string name="sort_updated_old">Aktualisiert (alt zu neu)</string>
<string name="sort_alphabetical_a">Alphabetisch (A zu Z)</string>
<string name="sort_alphabetical_z">Alphabetisch (Z zu A)</string>
<string name="select_library">Bibliothek auswählen</string>
<string name="open_with">Öffnen mit</string>
<string name="empty_library_no_accounts_message">Deine Bibliothek ist leer :(
\nMelde dich mit einem Bibliothekskonto an oder füge Titel zu deiner lokalen Bibliothek hinzu.</string>
<string name="empty_library_logged_in_message">Diese Liste ist leer. Versuche zu einer anderen Liste zu wechseln.</string>
<string name="safe_mode_file">Datei für abgesicherten Modus gefunden!
<string name="empty_library_logged_in_message">Diese Liste ist leer. Versuch zu einer anderen Liste zu wechseln.</string>
<string name="safe_mode_file">Datei für den abgesicherten Modus gefunden!
\nBeim Start werden keine Erweiterungen geladen, bis die Datei entfernt wird.</string>
<string name="android_tv_interface_off_seek_settings">Player ausgeblendet - Betrag zum vor- und zurückspulen</string>
<string name="android_tv_interface_on_seek_settings_summary">Der Betrag, welcher verwendet wird, wenn der Player eingeblendet ist</string>
@ -549,7 +549,7 @@
<string name="automatic_plugin_download_mode_title">Filtermodus für Plugin-Downloads auswählen</string>
<string name="already_voted">Es wurde bereits abgestimmt</string>
<string name="no_plugins_found_error">Keine Plugins im Repository gefunden</string>
<string name="no_repository_found_error">Repository nicht gefunden, überprüfe die URL und probiere eine VPN</string>
<string name="unable_to_inflate">Die Benutzeroberfläche konnte nicht korrekt erstellt werden. Dies ist ein schwerwiegender Fehler und sollte sofort gemeldet werden. %s</string>
<string name="no_repository_found_error">Repository nicht gefunden, überprüf die URL und versuch ein VPN</string>
<string name="unable_to_inflate">Die Benutzeroberfläche konnte nicht korrekt erstellt werden. Dies ist ein SCHWERWIEGENDER FEHLER und sollte sofort gemeldet werden. %s</string>
<string name="disable">Deaktivieren</string>
</resources>

View file

@ -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é!</string>
<string name="no_plugins_found_error">Aucun plugin trouvé dans ce dossier</string>
<string name="no_repository_found_error">Dossier non trouvé, vérifiez l\'url et essayé un VPN</string>
<string name="no_repository_found_error">Dépôt introuvable, vérifiez l\'URL et essayez avec un VPN</string>
<string name="mobile_data">Données mobiles</string>
<string name="set_default">Définir par défaut</string>
<string name="use">Utiliser</string>
@ -552,4 +552,6 @@
<string name="qualities">Qualités</string>
<string name="unable_to_inflate">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</string>
<string name="automatic_plugin_download_mode_title">Sélectionnez le mode pour filtrer le téléchargement des plugins</string>
<string name="profile_background_des">Fond de profil</string>
<string name="default_account">@string/default_subtitles</string>
</resources>

View file

@ -566,4 +566,15 @@
<string name="profile_background_des">Pozadina profila</string>
<string name="unable_to_inflate">Nije bilo moguće ispravno izraditi korisničko sučelje. Ovo je ZNAČAJNA GREŠKA i treba se odmah prijaviti %s</string>
<string name="automatic_plugin_download_mode_title">Odaberi modus za filtriranje preuzimanja dodataka</string>
<string name="disable">Onemogući</string>
<string name="default_account">@string/default_subtitles</string>
<string name="no_plugins_found_error">U repozitoriju nisu pronađeni dodaci</string>
<string name="no_repository_found_error">Repozitorij nije pronađen, provjerite URL i pokušajte koristiti VPN</string>
<string name="quality_profile_help">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!</string>
</resources>

View file

@ -219,7 +219,7 @@
<string name="status_ongoing">Folyamatban levő</string>
<string name="year">Év</string>
<string name="site">Webhely</string>
<string name="synopsis">Szinopszis</string>
<string name="synopsis">Összegzés</string>
<string name="no_subtitles">Nincsenek feliratok</string>
<string name="remote_error">Távoli hiba</string>
<string name="render_error">Render hiba</string>
@ -237,7 +237,7 @@
<string name="swipe_to_change_settings_des">Csúsztassa felfelé vagy lefelé a bal vagy jobb oldalon a fényerő vagy a hangerő megváltoztatásához</string>
<string name="backup_settings">Biztonsági mentés</string>
<string name="benene_count_text_none">0 Banán a fejlesztőknek</string>
<string name="swipe_to_seek_settings">Húzás a kereséshez</string>
<string name="swipe_to_seek_settings">Húzd el, hogy beless</string>
<string name="autoplay_next_settings">Következő epizód automatikus lejátszása</string>
<string name="autoplay_next_settings_des">Következő epizód lejátszása amikor az aktuális epizód véget ér</string>
<string name="double_tap_to_seek_settings">Dupla koppintás a kereséshez</string>
@ -510,4 +510,5 @@
<string name="tv_layout">TV elrendezés</string>
<string name="automatic">Automatikus</string>
<string name="android_tv_interface_on_seek_settings_summary">Az átugrás mértéke, amikor a lejátszó látható</string>
<string name="automatic_plugin_download_mode_title">Válassza ki a módot a pluginek letöltésének szűréséhez</string>
</resources>

View file

@ -153,4 +153,9 @@
<string name="updates_settings">ଆପ୍ ଅଦ୍ୟତନ ଦେଖାଇବା</string>
<string name="update_started">ଅଦ୍ୟତନ ଆରମ୍ଭ ହୋଇଛି</string>
<string name="search_hint">ସନ୍ଧାନ କରିବା…</string>
<string name="skip_type_recap">ସଂକ୍ଷିପ୍ତବୃତ୍ତି</string>
<string name="play_movie_button">ଚଳଚ୍ଚିତ୍ର ଚଲାଅ</string>
<string name="search_hint_site" formatted="true">%s ସନ୍ଧାନ କରିବା…</string>
<string name="next_episode">ପରବର୍ତ୍ତୀ ଅଧ୍ୟାୟ</string>
<string name="no_data">କୌଣସି ତଥ୍ୟ ନାହିଁ</string>
</resources>

View file

@ -570,4 +570,5 @@
<string name="unable_to_inflate">UI nu a putut fi creată corect, acesta este un BUG MAJOR și trebuie raportat imediat %s</string>
<string name="automatic_plugin_download_mode_title">Selectați modul de filtrare a descărcării plugin-urilor</string>
<string name="default_account">@string/default_subtitles</string>
<string name="already_voted">Ați votat deja</string>
</resources>

View file

@ -6,12 +6,12 @@
<string name="title_home">முகப்பு</string>
<string name="title_search">தேடு</string>
<string name="title_downloads">பதிவிறக்கம்</string>
<string name="no_data">கவல் எதுவும் இல்லை</string>
<string name="no_data">ரவு இல்லை</string>
<string name="episode_more_options_des">மேலும் விருப்பங்கள்</string>
<string name="next_episode">அடுத்த எபிசோட</string>
<string name="next_episode">அடுத்த அத்தியாயம</string>
<string name="result_tags">வகைகள்</string>
<string name="result_share">பகிர்</string>
<string name="result_open_in_browser">Browser இல் திற</string>
<string name="result_open_in_browser">உலாவியில் திற</string>
<string name="skip_loading">ஏற்றுவதைத் தவிர்</string>
<string name="type_watching">பார்த்து கொண்டிருப்பது</string>
<string name="type_on_hold">நிறுத்தி வைக்கப்பட்டுள்ளது</string>
@ -21,9 +21,9 @@
<string name="play_torrent_button">ஸ்ட்ரீம் டோரண்ட்</string>
<string name="pick_subtitle">வசன வரிகள்</string>
<string name="go_back">பின் செல்</string>
<string name="play_episode">எபிசோடை இயக்கு</string>
<string name="play_episode">அத்தியாயத்தை இயக்கு</string>
<string name="download">எபிசோட் பதிவிற்கான அனுமதி கொடுக்கவும்</string>
<string name="downloaded">பதிவிறக்கம் செய்யப்பட்டது</string>
<string name="downloaded">பதிவிறக்கப்பட்டது</string>
<string name="downloading">பதிவிறக்குகிறது</string>
<string name="download_paused">பதிவிறக்கம் இடைநிறுத்தப்பட்டது</string>
<string name="download_started">பதிவிறக்கம் தொடங்கியது</string>
@ -67,10 +67,10 @@
<string name="loading">ஏற்றுகிறது…</string>
<string name="type_dropped">கைவிடப்பட்டது</string>
<string name="download_done">பதிவிறக்கம் முடிந்தது</string>
<string name="reload_error">இணைப்பை மீண்டும் முயற்சிக்கவும்…</string>
<string name="reload_error">இணைப்பை மீண்டும் முயவும்…</string>
<string name="play_movie_button">திரைப்படத்தை இயக்கு</string>
<string name="play_livestream_button">லைவ்ஸ்ட்ரீம் இயக்கு</string>
<string name="play_trailer_button">டிரெய்லரை இயக்கவும்</string>
<string name="play_trailer_button">டிரெய்லரை இயக்க</string>
<string name="pick_source">மூலம்</string>
<string name="error_loading_links_toast">இணைப்புகளை ஏற்றுவதில் பிழை</string>
<string name="home_play">இயக்கு</string>
@ -107,4 +107,14 @@
<string name="double_tap_to_pause_settings">இடைநிறுத்துவதற்கு இருமுறை தட்டவும்</string>
<string name="chromecast_subtitles_settings_des">Chromecast வசன அமைப்புகள்</string>
<string name="use_system_brightness_settings_des">இருண்ட மேலடுக்குக்குப் பதிலாக ஆப் பிளேயரில் சிஸ்டம் பிரகாசத்தைப் பயன்படுத்தவும்</string>
<string name="next_episode_format" formatted="true">அத்தியாயம் %d-இன் வெளியீட்டு நேரம்</string>
<string name="next_episode_time_hour_format" formatted="true">%dம %dநி</string>
<string name="next_episode_time_min_format" formatted="true">%dநி</string>
<string name="home_next_random_img_des">அடுத்து ஏதாவது</string>
<string name="browser">உலாவி</string>
<string name="duration_format" formatted="true">%d நிமி</string>
<string name="play_with_app_name">CloudStream-உடன் இயக்கு</string>
<string name="new_update_format" formatted="true">புதிய புதுப்பிப்பு உள்ளது
\n%s-&gt;%s</string>
<string name="filler" formatted="true">நிரப்பி</string>
</resources>

View file

@ -18,7 +18,7 @@
<string name="preview_background_img_des">Попередній перегляд фону</string>
<string name="player_speed_text_format" formatted="true">Швидкість (%.2fx)</string>
<string name="new_update_format" formatted="true">Знайдено нове оновлення!
\n%s -&gt; %s</string>
\n%s &gt; %s</string>
<string name="title_search">Пошук</string>
<string name="title_downloads">Завантаження</string>
<string name="duration_format" formatted="true">%d хв</string>
@ -37,7 +37,7 @@
<string name="type_dropped">Покинуто</string>
<string name="play_movie_button">Переглянути фільм</string>
<string name="play_trailer_button">Переглянути трейлер</string>
<string name="play_torrent_button">Трансляція через торрент</string>
<string name="play_torrent_button">Трансляція через торент</string>
<string name="reload_error">Повторити підключення…</string>
<string name="go_back">Назад</string>
<string name="play_episode">Переглянути епізод</string>
@ -75,7 +75,7 @@
<string name="continue_watching">Продовжити перегляд</string>
<string name="action_remove_watching">Вилучити</string>
<string name="action_open_watching">Детальніше</string>
<string name="vpn_torrent">Цей постачальник є торрентом, рекомендується VPN</string>
<string name="vpn_torrent">Цей постачальник є торентом, рекомендується використовувати VPN</string>
<string name="torrent_plot">Опис</string>
<string name="normal_no_plot">Сюжет не знайдено</string>
<string name="torrent_no_plot">Опис не знайдено</string>
@ -86,9 +86,9 @@
<string name="chromecast_subtitles_settings">Субтитри Chromecast</string>
<string name="chromecast_subtitles_settings_des">Налаштування субтитрів Chromecast</string>
<string name="eigengraumode_settings">Режим Eigengravy</string>
<string name="swipe_to_change_settings">Проведіть пальцем, щоб змінити налаштування</string>
<string name="swipe_to_change_settings">Проведіть, щоб змінити налаштування</string>
<string name="swipe_to_change_settings_des">Проведіть вгору або вниз з лівого або правого боку, щоб змінити яскравість чи гучність</string>
<string name="autoplay_next_settings_des">Відтворювати наступний епізод після закінчення поточного</string>
<string name="autoplay_next_settings_des">Відтворює наступний епізод після закінчення поточного</string>
<string name="title_home">Головна</string>
<string name="app_name">CloudStream</string>
<string name="filler" formatted="true">Філер</string>
@ -130,7 +130,7 @@
<string name="picture_in_picture">Картинка в картинці</string>
<string name="player_subtitles_settings_des">Налаштування субтитрів плеєра</string>
<string name="eigengraumode_settings_des">Додає опцію керування швидкістю в плеєрі</string>
<string name="swipe_to_seek_settings">Проведіть пальцем, щоб перемотати</string>
<string name="swipe_to_seek_settings">Проведіть, щоб перемотати</string>
<string name="double_tap_to_seek_settings">Двічі торкніться, щоб перемотати</string>
<string name="double_tap_to_pause_settings">Двічі торкніться для паузи</string>
<string name="double_tap_to_seek_amount_settings">Крок перемотки (секунди)</string>
@ -224,7 +224,7 @@
<string name="double_tap_to_seek_settings_des">Двічі торкніться праворуч або ліворуч, щоб перемотати відео вперед або назад</string>
<string name="use_system_brightness_settings_des">Використовуйте системну яскравість у плеєрі замість темної накладки</string>
<string name="restore_success">Завантажено файл резервної копії</string>
<string name="torrent">Торренти</string>
<string name="torrent">Торенти</string>
<string name="episode_sync_settings_des">Автоматична синхронізація прогресу поточного епізоду</string>
<string name="backup_failed">Відсутні дозволи на зберігання. Будь ласка, спробуйте ще раз.</string>
<string name="kitsu_settings">Показувати постери від Kitsu</string>
@ -256,7 +256,7 @@
<string name="nsfw">NSFW</string>
<string name="movies_singular">Фільм</string>
<string name="ova_singular">OVA</string>
<string name="torrent_singular">Торрент</string>
<string name="torrent_singular">Торент</string>
<string name="show_hd">Мітка якості</string>
<string name="nsfw_singular">NSFW</string>
<string name="episode_action_play_in_browser">Переглянути в браузері</string>
@ -294,9 +294,9 @@
<string name="primary_color_settings">Основний колір</string>
<string name="app_theme_settings">Тема застосунку</string>
<string name="bottom_title_settings">Розташування назви постера</string>
<string name="bottom_title_settings_des">Розмістіть назву під постером</string>
<string name="bottom_title_settings_des">Розмістити назву під постером</string>
<string name="example_password">Пароль123</string>
<string name="example_username">Моє круте ім\'я</string>
<string name="example_username">Моє круте імя</string>
<string name="example_email">hello@world.com</string>
<string name="example_site_name">Мій крутий сайт</string>
<string name="example_lang_name">Код мови (uk)</string>
@ -348,9 +348,9 @@
<string name="limit_title_rez">Роздільна здатність відеоплеєра</string>
<string name="video_buffer_length_settings">Довжина буфера відео</string>
<string name="video_buffer_clear_settings">Очистити кеш відео та зображень</string>
<string name="video_ram_description">Спричиняє збої, якщо встановлено занадто високе значення на пристроях із малим об\'ємом пам\'яті, наприклад Android TV.</string>
<string name="video_ram_description">Спричиняє збої, якщо встановлено занадто високе значення на пристроях із малим об’ємом пам’яті, наприклад Android TV.</string>
<string name="dns_pref_summary">Корисно для обходу блокувань провайдера</string>
<string name="video_disk_description">Спричиняє збої, якщо встановлено занадто високе значення на пристроях із малим об\'ємом вільної пам\'яті, наприклад Android TV.</string>
<string name="video_disk_description">Спричиняє збої, якщо встановлено занадто високе значення на пристроях із малим об’ємом вільної пам’яті, наприклад Android TV.</string>
<string name="dns_pref">DNS через HTTPS</string>
<string name="download_path_pref">Шлях завантаження</string>
<string name="add_site_summary">Додайте клон існуючого сайту, з іншою URL-адресою</string>
@ -374,10 +374,10 @@
<string name="sync_score">Оцінений</string>
<string name="player_load_subtitles">Завантажити з файлу</string>
<string name="max">Макс.</string>
<string name="subtitles_example_text">Щастям б\'єш жук їх глицю в фон й ґедзь пріч</string>
<string name="subtitles_example_text">Щастям бєш жук їх глицю в фон й ґедзь пріч</string>
<string name="subtitle_offset_hint">1000 мс</string>
<string name="subtitle_offset_extra_hint_later_format">Використовуйте цей параметр, якщо субтитри з\'являються на %d мс занадто рано</string>
<string name="subtitle_offset_extra_hint_before_format">Використовуйте це, якщо субтитри з\'являються із запізненням на %d мс</string>
<string name="subtitle_offset_extra_hint_later_format">Використовуйте цей параметр, якщо субтитри зявляються на %d мс занадто рано</string>
<string name="subtitle_offset_extra_hint_before_format">Використовуйте це, якщо субтитри зявляються із запізненням на %d мс</string>
<string name="player_loaded_subtitles" formatted="true">Завантажено %s</string>
<string name="actor_supporting">Підтримка</string>
<string name="actor_background">Фон</string>
@ -507,8 +507,8 @@
<string name="safe_mode_file">Файл безпечного режиму знайдено!
\nРозширеня не завантажуються під час запуску, доки файл не буде видалено.</string>
<string name="pref_category_android_tv">Android TV</string>
<string name="android_tv_interface_off_seek_settings">Плеєр сховано - обсяг перемотки</string>
<string name="android_tv_interface_on_seek_settings">Плеєр показано - обсяг перемотки</string>
<string name="android_tv_interface_off_seek_settings">Плеєр сховано обсяг перемотки</string>
<string name="android_tv_interface_on_seek_settings">Плеєр показано обсяг перемотки</string>
<string name="android_tv_interface_on_seek_settings_summary">Обсяг перемотки, який використовується, коли плеєр видимий</string>
<string name="android_tv_interface_off_seek_settings_summary">Обсяг перемотки, який використовується, коли плеєр прихований</string>
<string name="test_failed">Тест провалено</string>
@ -532,7 +532,7 @@
<string name="set_default">Встановити за замовчуванням</string>
<string name="profiles">Профілі</string>
<string name="help">Допомога</string>
<string name="quality_profile_help">Тут ви можете змінити порядок джерел. Якщо відео має вищий пріоритет, воно з\'явиться вище у списку джерел. Сума пріоритету джерела та пріоритету якості є пріоритетом відео.
<string name="quality_profile_help">Тут ви можете змінити порядок джерел. Якщо відео має вищий пріоритет, воно зявиться вище у списку джерел. Сума пріоритету джерела та пріоритету якості є пріоритетом відео.
\n
\nДжерело A: 3
\nЯкість B: 7

View file

@ -0,0 +1 @@
- ପରିବର୍ତ୍ତନ ପୋଥି ଯୋଡ଼ାଗଲା!

View file

@ -1 +1 @@
- Додано журнал змін!
Додано журнал змін!