From 58f45c7bda7e592a3547bf2c1ce0ab632d88170b Mon Sep 17 00:00:00 2001
From: Luna712 <142361265+Luna712@users.noreply.github.com>
Date: Wed, 29 Apr 2026 16:46:12 -0600
Subject: [PATCH 01/82] Remove unnecessary ?: 0 (#2734)
---
.../com/lagradost/cloudstream3/ui/player/PlayerGestureHelper.kt | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerGestureHelper.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerGestureHelper.kt
index 4d2b9237e..1c7086d12 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerGestureHelper.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerGestureHelper.kt
@@ -1055,7 +1055,7 @@ class PlayerGestureHelper(private val playerView: PlayerView) {
return validHeight && validWidth
}
- return rawY > (context.getStatusBarHeight() ?: 0) && rawX < screenWidthWithOrientation
+ return rawY > context.getStatusBarHeight() && rawX < screenWidthWithOrientation
}
private fun handleGesture(view: View, event: MotionEvent): Boolean {
From 8523a4bd9060b2f3c42732b8b7fb71c52306646b Mon Sep 17 00:00:00 2001
From: Luna712 <142361265+Luna712@users.noreply.github.com>
Date: Thu, 30 Apr 2026 09:22:32 -0600
Subject: [PATCH 02/82] Fix `DownloadedPlayerActivity` not loading new files
when activity is already running (#2738)
---
.../ui/player/DownloadedPlayerActivity.kt | 72 ++++++++++++-------
1 file changed, 45 insertions(+), 27 deletions(-)
diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/DownloadedPlayerActivity.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/DownloadedPlayerActivity.kt
index a3a9b7125..7a42cea93 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/DownloadedPlayerActivity.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/DownloadedPlayerActivity.kt
@@ -14,7 +14,9 @@ import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.attachBackPres
import com.lagradost.cloudstream3.utils.UIHelper.enableEdgeToEdgeCompat
class DownloadedPlayerActivity : AppCompatActivity() {
- private val dTAG = "DownloadedPlayerAct"
+ companion object {
+ const val TAG = "DownloadedPlayerActivity"
+ }
override fun dispatchKeyEvent(event: KeyEvent): Boolean =
CommonActivity.dispatchKeyEvent(this, event) ?: super.dispatchKeyEvent(event)
@@ -27,53 +29,69 @@ class DownloadedPlayerActivity : AppCompatActivity() {
CommonActivity.onUserLeaveHint(this)
}
+ override fun onNewIntent(intent: Intent) {
+ super.onNewIntent(intent)
+ // Ignore same intent so the player doesnt totally
+ // reload if you are playing the same thing.
+ if (isSameIntent(intent)) return
+ setIntent(intent)
+ Log.i(TAG, "onNewIntent")
+ handleIntent(intent)
+ }
+
+ private fun isSameIntent(newIntent: Intent): Boolean {
+ val old = intent ?: return false
+ // Compare URIs first
+ val oldUri = old.data ?: old.clipData?.getItemAt(0)?.uri
+ val newUri = newIntent.data ?: newIntent.clipData?.getItemAt(0)?.uri
+ if (oldUri != null && oldUri == newUri) return true
+ // Fall back to comparing EXTRA_TEXT links
+ val oldText = safe { old.getStringExtra(Intent.EXTRA_TEXT) }
+ val newText = safe { newIntent.getStringExtra(Intent.EXTRA_TEXT) }
+ return oldText != null && oldText == newText
+ }
+
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
CommonActivity.loadThemes(this)
CommonActivity.init(this)
enableEdgeToEdgeCompat()
setContentView(R.layout.empty_layout)
- Log.i(dTAG, "onCreate")
+ Log.i(TAG, "onCreate")
+ handleIntent(intent)
+ attachBackPressedCallback("DownloadedPlayerActivity") { finish() }
+ }
+
+ private fun handleIntent(intent: Intent) {
val data = intent.data
-
if (OfflinePlaybackHelper.playIntent(activity = this, intent = intent)) {
return
}
- if (intent?.action == Intent.ACTION_SEND || intent?.action == Intent.ACTION_OPEN_DOCUMENT || intent?.action == Intent.ACTION_VIEW) {
- val extraText = safe { // I dont trust android
- intent.getStringExtra(Intent.EXTRA_TEXT)
- }
+ if (
+ intent.action == Intent.ACTION_SEND ||
+ intent.action == Intent.ACTION_OPEN_DOCUMENT ||
+ intent.action == Intent.ACTION_VIEW
+ ) {
+ val extraText = safe { intent.getStringExtra(Intent.EXTRA_TEXT) }
val cd = intent.clipData
val item = if (cd != null && cd.itemCount > 0) cd.getItemAt(0) else null
val url = item?.text?.toString()
-
- // idk what I am doing, just hope any of these work
- if (item?.uri != null)
- playUri(this, item.uri)
- else if (url != null)
- playLink(this, url)
- else if (data != null)
- playUri(this, data)
- else if (extraText != null)
- playLink(this, extraText)
- else {
- finish()
- return
+ when {
+ item?.uri != null -> playUri(this, item.uri)
+ url != null -> playLink(this, url)
+ data != null -> playUri(this, data)
+ extraText != null -> playLink(this, extraText)
+ else -> { finish(); return }
}
} else if (data?.scheme == "content") {
playUri(this, data)
- } else {
- finish()
- return
- }
-
- attachBackPressedCallback("DownloadedPlayerActivity") { finish() }
+ } else finish()
}
override fun onResume() {
super.onResume()
CommonActivity.setActivityInstance(this)
}
-}
\ No newline at end of file
+}
From 4cc76ee6c5446b5fed5d15f581b753aaf33ee02e Mon Sep 17 00:00:00 2001
From: firelight <147925818+fire-light42@users.noreply.github.com>
Date: Thu, 30 Apr 2026 15:23:08 +0000
Subject: [PATCH 03/82] Fix(TV): Color on "Skip Chapter" button (#2731)
---
.../cloudstream3/ui/player/GeneratorPlayer.kt | 13 ++-
.../cloudstream3/ui/player/PlayerView.kt | 8 +-
.../res/layout/player_custom_layout_tv.xml | 104 +++++++++---------
3 files changed, 65 insertions(+), 60 deletions(-)
diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/GeneratorPlayer.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/GeneratorPlayer.kt
index d8796177d..311230235 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/GeneratorPlayer.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/GeneratorPlayer.kt
@@ -1996,6 +1996,12 @@ class GeneratorPlayer : FullScreenPlayer() {
skipAnimator?.cancel()
isVisible = true
+ /** Focus instantly to make the focus color appear instantly */
+ if (show && !isShowing) {
+ // Automatically request focus if the menu is not opened
+ playerBinding?.skipChapterButton?.requestFocus()
+ }
+
// just in case
val lay = layoutParams
lay.width = from
@@ -2004,12 +2010,7 @@ class GeneratorPlayer : FullScreenPlayer() {
from, to
).apply {
addListener(onEnd = {
- if (show) {
- if (!isShowing) {
- // Automatically request focus if the menu is not opened
- playerBinding?.skipChapterButton?.requestFocus()
- }
- } else {
+ if (!show) {
playerBinding?.skipChapterButton?.isVisible = false
if (!isShowing) {
// Automatically return focus to play pause
diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerView.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerView.kt
index d8e7e579d..d410cd129 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerView.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerView.kt
@@ -287,7 +287,13 @@ class PlayerView @JvmOverloads constructor(
val previewFrameLayout: FrameLayout? =
exoPlayerView?.findViewById(R.id.previewFrameLayout)
- if (progressBar != null && previewImageView != null && previewFrameLayout != null) {
+ /** Hide the previewFrameLayout on TV to make the skip op button not float,
+ * as previewFrameLayout is normally invisible */
+ if(isLayout(TV)) {
+ previewFrameLayout?.isVisible = false
+ }
+
+ if (progressBar != null && previewImageView != null && previewFrameLayout != null && !isLayout(TV)) {
var resume = false
progressBar.addOnScrubListener(object : PreviewBar.OnScrubListener {
override fun onScrubStart(previewBar: PreviewBar?) {
diff --git a/app/src/main/res/layout/player_custom_layout_tv.xml b/app/src/main/res/layout/player_custom_layout_tv.xml
index 2c536c825..3a3076943 100644
--- a/app/src/main/res/layout/player_custom_layout_tv.xml
+++ b/app/src/main/res/layout/player_custom_layout_tv.xml
@@ -13,16 +13,16 @@
android:layout_width="680dp"
android:layout_height="match_parent"
android:background="@drawable/bg_player_metadata_scrim_netflix"
+ app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
- app:layout_constraintTop_toTopOf="parent"
- app:layout_constraintBottom_toBottomOf="parent">
+ app:layout_constraintTop_toTopOf="parent">
@@ -39,23 +39,23 @@
android:adjustViewBounds="true"
android:scaleType="fitStart"
android:visibility="gone"
- tools:visibility="visible"/>
+ tools:visibility="visible" />
+ android:textColor="@android:color/white"
+ android:textSize="30sp"
+ android:textStyle="bold"
+ tools:text="Zootopia 2" />
@@ -64,10 +64,10 @@
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
+ android:maxLines="2"
android:textColor="#B3FFFFFF"
android:textSize="14sp"
- android:maxLines="2"
- tools:text="Animation • Action • Adventure • 2025 • ⭐ 7.6"/>
+ tools:text="Animation • Action • Adventure • 2025 • ⭐ 7.6" />
+ android:textColor="#E6FFFFFF"
+ android:textSize="16sp"
+ tools:text="Brave rabbit cop Judy Hopps..." />
+
+ android:layout_height="match_parent"
+ android:src="@drawable/video_outline"
+ android:visibility="gone" />
+ tools:progress="30" />
+ tools:progress="0" />
@@ -360,28 +356,30 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="end">
+
+
+ tools:visibility="visible" />
+
+ app:layout_constraintEnd_toEndOf="parent" />
@@ -1141,34 +1139,34 @@
+ android:padding="5dp"
+ android:visibility="gone">
+ android:layout_height="wrap_content"
+ android:layout_marginEnd="0dp"
+ android:padding="10dp"
+ android:text="@string/episodes"
+ android:textSize="15sp" />
+ android:descendantFocusability="afterDescendants"
+ android:nextFocusLeft="@id/player_episodes_button"
+ android:requiresFadingEdge="vertical"
+ app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
+ tools:listitem="@layout/player_episodes">
From 2400e6ab454835d758f55d5941d7d0e9391399ab Mon Sep 17 00:00:00 2001
From: Osten <11805592+LagradOst@users.noreply.github.com>
Date: Thu, 30 Apr 2026 17:23:49 +0200
Subject: [PATCH 04/82] fixed observe, aka #2567 (#2736)
---
.../lagradost/cloudstream3/mvvm/Lifecycle.kt | 62 +++++++++++++++++--
.../cloudstream3/ui/home/HomeFragment.kt | 1 -
.../ui/home/HomeParentItemAdapterPreview.kt | 10 ++-
.../ui/settings/testing/TestFragment.kt | 2 +-
4 files changed, 62 insertions(+), 13 deletions(-)
diff --git a/app/src/main/java/com/lagradost/cloudstream3/mvvm/Lifecycle.kt b/app/src/main/java/com/lagradost/cloudstream3/mvvm/Lifecycle.kt
index 3df5197cd..482ec05fc 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/mvvm/Lifecycle.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/mvvm/Lifecycle.kt
@@ -1,16 +1,68 @@
package com.lagradost.cloudstream3.mvvm
+import android.view.View
+import androidx.activity.ComponentActivity
+import androidx.core.view.doOnAttach
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.LiveData
+import androidx.lifecycle.findViewTreeLifecycleOwner
+import androidx.viewbinding.ViewBinding
+import com.lagradost.cloudstream3.ui.BaseFragment
/** NOTE: Only one observer at a time per value */
-fun LifecycleOwner.observe(liveData: LiveData, action: (t: T) -> Unit) {
- liveData.removeObservers(this)
- liveData.observe(this) { it?.let { t -> action(t) } }
+fun ComponentActivity.observe(liveData: LiveData, action: (T) -> Unit) {
+ observeNullable(liveData) { t -> t?.run(action) }
}
/** NOTE: Only one observer at a time per value */
-fun LifecycleOwner.observeNullable(liveData: LiveData, action: (t: T) -> Unit) {
+fun ComponentActivity.observeNullable(liveData: LiveData, action: (T?) -> Unit) {
liveData.removeObservers(this)
- liveData.observe(this) { action(it) }
+ liveData.observe(this, action)
}
+
+/** NOTE: Only one observer at a time per value */
+fun BaseFragment.observe(liveData: LiveData, action: (T) -> Unit) {
+ observeNullable(liveData) { t -> t?.run(action) }
+}
+
+/**
+ * Attaches an observable to the root binding, instead of the fragment. This is more efficient as
+ * it will not call observe if the view is in the background.
+ *
+ * NOTE: Only one observer at a time per value
+ * */
+fun BaseFragment.observeNullable(
+ liveData: LiveData, action: (T?) -> Unit
+) {
+ val root = this.binding?.root
+ if (root == null) {
+ liveData.removeObservers(this)
+ liveData.observe(this, action)
+ } else {
+ root.doOnAttach { view ->
+ // On attach should make findViewTreeLifecycleOwner non-null, but use "this" just in case
+ val owner: LifecycleOwner = view.findViewTreeLifecycleOwner() ?: this@observeNullable
+ liveData.removeObservers(owner)
+ liveData.observe(owner, action)
+ }
+ }
+}
+
+/** NOTE: Only one observer at a time per value */
+fun View.observe(liveData: LiveData, action: (T) -> Unit) {
+ observeNullable(liveData) { t -> t?.run(action) }
+}
+
+/** NOTE: Only one observer at a time per value */
+fun View.observeNullable(liveData: LiveData, action: (T?) -> Unit) {
+ doOnAttach { view ->
+ // On attach should make findViewTreeLifecycleOwner non-null
+ val owner: LifecycleOwner? = view.findViewTreeLifecycleOwner()
+ if(owner == null) {
+ debugException { "Expected non-null findViewTreeLifecycleOwner" }
+ return@doOnAttach
+ }
+ liveData.removeObservers(owner)
+ liveData.observe(owner, action)
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeFragment.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeFragment.kt
index 375b2313f..b68ef5962 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeFragment.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeFragment.kt
@@ -651,7 +651,6 @@ class HomeFragment : BaseFragment(
}
homeMasterAdapter = HomeParentItemAdapterPreview(
- fragment = this@HomeFragment,
homeViewModel, accountViewModel
)
homeMasterRecycler.setRecycledViewPool(ParentItemAdapter.sharedPool)
diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeParentItemAdapterPreview.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeParentItemAdapterPreview.kt
index a292c2da2..959806e56 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeParentItemAdapterPreview.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeParentItemAdapterPreview.kt
@@ -63,7 +63,6 @@ import androidx.core.graphics.toColorInt
import com.lagradost.cloudstream3.ui.setRecycledViewPool
class HomeParentItemAdapterPreview(
- val fragment: LifecycleOwner,
private val viewModel: HomeViewModel,
private val accountViewModel: AccountViewModel
) : ParentItemAdapter(
@@ -105,7 +104,7 @@ class HomeParentItemAdapterPreview(
)
}
- return HeaderViewHolder(binding, viewModel, accountViewModel, fragment)
+ return HeaderViewHolder(binding, viewModel, accountViewModel)
}
override fun onBindHeader(holder: ViewHolderState) {
@@ -132,7 +131,6 @@ class HomeParentItemAdapterPreview(
val binding: ViewBinding,
val viewModel: HomeViewModel,
accountViewModel: AccountViewModel,
- fragment: LifecycleOwner,
) :
ViewHolderState(binding) {
@@ -544,7 +542,7 @@ class HomeParentItemAdapterPreview(
headProfilePicCard?.isGone = isLayout(TV or EMULATOR)
alternateHeadProfilePicCard?.isGone = isLayout(TV or EMULATOR)
- fragment.observe(viewModel.currentAccount) { currentAccount ->
+ (headProfilePic ?: alternateHeadProfilePic)?.observe(viewModel.currentAccount) { currentAccount ->
headProfilePic?.loadImage(currentAccount?.image)
alternateHeadProfilePic?.loadImage(currentAccount?.image)
}
@@ -775,7 +773,7 @@ class HomeParentItemAdapterPreview(
fun onViewAttachedToWindow() {
previewViewpager.registerOnPageChangeCallback(previewCallback)
- binding.root.findViewTreeLifecycleOwner()?.apply {
+ previewViewpager.apply {
observe(viewModel.preview) {
updatePreview(it)
}
@@ -800,7 +798,7 @@ class HomeParentItemAdapterPreview(
}
toggleListHolder?.isGone = visible.isEmpty()
}
- } ?: debugException { "Expected findViewTreeLifecycleOwner" }
+ }
}
}
}
diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/testing/TestFragment.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/testing/TestFragment.kt
index 37677c1d8..4ec005a09 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/testing/TestFragment.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/testing/TestFragment.kt
@@ -40,7 +40,7 @@ class TestFragment : BaseFragment(
providerTest.setProgress(passed, failed, total)
}
- observeNullable(testViewModel.providerResults) {
+ observe(testViewModel.providerResults) {
safe {
val newItems = it.sortedBy { api -> api.first.name }
(providerTestRecyclerView.adapter as? TestResultAdapter)?.submitList(
From 104ab2679050d4f0f407dd37653d758709a0b944 Mon Sep 17 00:00:00 2001
From: Luna712 <142361265+Luna712@users.noreply.github.com>
Date: Fri, 1 May 2026 10:53:30 -0600
Subject: [PATCH 05/82] Use ioWorkSafe for updateFillers for simplification
(#2743)
---
.../lagradost/cloudstream3/ui/result/ResultViewModel2.kt | 9 +++------
1 file changed, 3 insertions(+), 6 deletions(-)
diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultViewModel2.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultViewModel2.kt
index e3ff6c83b..cf563df8e 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultViewModel2.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultViewModel2.kt
@@ -113,14 +113,12 @@ import com.lagradost.cloudstream3.utils.newExtractorLink
import com.lagradost.cloudstream3.utils.txt
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancelChildren
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.isActive
import kotlinx.coroutines.job
import kotlinx.coroutines.launch
-import kotlinx.coroutines.withContext
import java.util.concurrent.TimeUnit
/** This starts at 1 */
@@ -1810,10 +1808,9 @@ class ResultViewModel2 : ViewModel() {
private suspend fun updateFillers(data : LoadResponse) {
- fillers =
- withContext(Dispatchers.IO) {
- safe { FillerEpisodeCheck.getFillerEpisodes(data) }
- } ?: hashSetOf()
+ fillers = ioWorkSafe {
+ FillerEpisodeCheck.getFillerEpisodes(data)
+ } ?: hashSetOf()
}
fun changeDubStatus(status: DubStatus) {
From e64136db8a3a3b755331318ffac53dca58fedf19 Mon Sep 17 00:00:00 2001
From: Luna712 <142361265+Luna712@users.noreply.github.com>
Date: Sat, 2 May 2026 12:15:34 -0600
Subject: [PATCH 06/82] Use runOnMainThread for simplicity (#2749)
---
.../cloudstream3/network/WebViewResolver.android.kt | 6 ++----
1 file changed, 2 insertions(+), 4 deletions(-)
diff --git a/library/src/androidMain/kotlin/com/lagradost/cloudstream3/network/WebViewResolver.android.kt b/library/src/androidMain/kotlin/com/lagradost/cloudstream3/network/WebViewResolver.android.kt
index 60a4d0453..975572d05 100644
--- a/library/src/androidMain/kotlin/com/lagradost/cloudstream3/network/WebViewResolver.android.kt
+++ b/library/src/androidMain/kotlin/com/lagradost/cloudstream3/network/WebViewResolver.android.kt
@@ -3,8 +3,6 @@ package com.lagradost.cloudstream3.network
import android.annotation.SuppressLint
import android.content.Context
import android.net.http.SslError
-import android.os.Handler
-import android.os.Looper
import android.webkit.*
import com.lagradost.api.Log
import com.lagradost.api.getContext
@@ -14,6 +12,7 @@ import com.lagradost.cloudstream3.mvvm.logError
import com.lagradost.cloudstream3.mvvm.safe
import com.lagradost.cloudstream3.utils.Coroutines.main
import com.lagradost.cloudstream3.utils.Coroutines.mainWork
+import com.lagradost.cloudstream3.utils.Coroutines.runOnMainThread
import com.lagradost.cloudstream3.utils.Coroutines.threadSafeListOf
import com.lagradost.nicehttp.requestCreator
import kotlinx.coroutines.delay
@@ -150,8 +149,7 @@ actual class WebViewResolver actual constructor(
Log.i(TAG, "Loading WebView URL: $webViewUrl")
if (script != null) {
- val handler = Handler(Looper.getMainLooper())
- handler.post {
+ runOnMainThread {
view.evaluateJavascript(script)
{ scriptCallback?.invoke(it) }
}
From e36e9e8d24712b9dc32dc8cc77b599602806e783 Mon Sep 17 00:00:00 2001
From: Luna712 <142361265+Luna712@users.noreply.github.com>
Date: Sun, 3 May 2026 10:06:09 -0600
Subject: [PATCH 07/82] Use `Looper.getMainLooper().isCurrentThread` for
simplicity (#2753)
---
.../cloudstream3/ui/download/button/PieFetchButton.kt | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/download/button/PieFetchButton.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/download/button/PieFetchButton.kt
index a414dedf5..f6f8a5ff8 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/ui/download/button/PieFetchButton.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/ui/download/button/PieFetchButton.kt
@@ -304,8 +304,8 @@ open class PieFetchButton(context: Context, attributeSet: AttributeSet) :
override fun setStatus(status: DownloadStatusTell?) {
currentStatus = status
- // Runs on the main thread, but also instant if it already is
- if (Looper.myLooper() == Looper.getMainLooper()) {
+ // Runs on the main thread, but also instant if it already is.
+ if (Looper.getMainLooper().isCurrentThread) {
try {
setStatusInternal(status)
} catch (t: Throwable) {
From c82fec086230b1d37c917d5a183b5aa4e2cfe82e Mon Sep 17 00:00:00 2001
From: firelight <147925818+fire-light42@users.noreply.github.com>
Date: Sun, 3 May 2026 20:46:09 +0000
Subject: [PATCH 08/82] Refactor: Move all key logic into the player, and added
toggleEpisodesOverlay keybind (#2745)
---
.../lagradost/cloudstream3/CommonActivity.kt | 82 ------
.../ui/player/FullScreenPlayer.kt | 277 +++++++++---------
.../cloudstream3/ui/player/IPlayer.kt | 21 --
.../cloudstream3/ui/player/PlayerView.kt | 2 -
4 files changed, 146 insertions(+), 236 deletions(-)
diff --git a/app/src/main/java/com/lagradost/cloudstream3/CommonActivity.kt b/app/src/main/java/com/lagradost/cloudstream3/CommonActivity.kt
index dddcd4892..ed0aaf9b7 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/CommonActivity.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/CommonActivity.kt
@@ -41,7 +41,6 @@ import com.lagradost.cloudstream3.mvvm.logError
import com.lagradost.cloudstream3.syncproviders.AccountManager
import com.lagradost.cloudstream3.ui.home.HomeChildItemAdapter
import com.lagradost.cloudstream3.ui.home.ParentItemAdapter
-import com.lagradost.cloudstream3.ui.player.PlayerEventType
import com.lagradost.cloudstream3.ui.player.PlayerPipHelper.isPIPPossible
import com.lagradost.cloudstream3.ui.player.Torrent
import com.lagradost.cloudstream3.ui.result.ActorAdaptor
@@ -117,7 +116,6 @@ object CommonActivity {
val onColorSelectedEvent = Event>()
val onDialogDismissedEvent = Event()
- var playerEventListener: ((PlayerEventType) -> Unit)? = null
var keyEventListener: ((Pair) -> Boolean)? = null
var appliedTheme: Int = 0
var appliedColor: Int = 0
@@ -534,87 +532,7 @@ object CommonActivity {
fun onKeyDown(act: Activity?, keyCode: Int, event: KeyEvent?): Boolean? {
-
- // 149 keycode_numpad 5
- val playerEvent = when (keyCode) {
- KeyEvent.KEYCODE_FORWARD, KeyEvent.KEYCODE_D, KeyEvent.KEYCODE_MEDIA_SKIP_FORWARD, KeyEvent.KEYCODE_MEDIA_FAST_FORWARD -> {
- PlayerEventType.SeekForward
- }
-
- KeyEvent.KEYCODE_A, KeyEvent.KEYCODE_MEDIA_SKIP_BACKWARD, KeyEvent.KEYCODE_MEDIA_REWIND -> {
- PlayerEventType.SeekBack
- }
-
- KeyEvent.KEYCODE_MEDIA_NEXT, KeyEvent.KEYCODE_BUTTON_R1, KeyEvent.KEYCODE_N, KeyEvent.KEYCODE_NUMPAD_2, KeyEvent.KEYCODE_CHANNEL_UP -> {
- PlayerEventType.NextEpisode
- }
-
- KeyEvent.KEYCODE_MEDIA_PREVIOUS, KeyEvent.KEYCODE_BUTTON_L1, KeyEvent.KEYCODE_B, KeyEvent.KEYCODE_NUMPAD_1, KeyEvent.KEYCODE_CHANNEL_DOWN -> {
- PlayerEventType.PrevEpisode
- }
-
- KeyEvent.KEYCODE_MEDIA_PAUSE -> {
- PlayerEventType.Pause
- }
-
- KeyEvent.KEYCODE_MEDIA_PLAY, KeyEvent.KEYCODE_BUTTON_START -> {
- PlayerEventType.Play
- }
-
- KeyEvent.KEYCODE_L, KeyEvent.KEYCODE_NUMPAD_7, KeyEvent.KEYCODE_7 -> {
- PlayerEventType.Lock
- }
-
- KeyEvent.KEYCODE_H, KeyEvent.KEYCODE_MENU -> {
- PlayerEventType.ToggleHide
- }
-
- KeyEvent.KEYCODE_M, KeyEvent.KEYCODE_VOLUME_MUTE -> {
- PlayerEventType.ToggleMute
- }
-
- KeyEvent.KEYCODE_S, KeyEvent.KEYCODE_NUMPAD_9, KeyEvent.KEYCODE_9 -> {
- PlayerEventType.ShowMirrors
- }
- // OpenSubtitles shortcut
- KeyEvent.KEYCODE_O, KeyEvent.KEYCODE_NUMPAD_8, KeyEvent.KEYCODE_8 -> {
- PlayerEventType.SearchSubtitlesOnline
- }
-
- KeyEvent.KEYCODE_E, KeyEvent.KEYCODE_NUMPAD_3, KeyEvent.KEYCODE_3 -> {
- PlayerEventType.ShowSpeed
- }
-
- KeyEvent.KEYCODE_R, KeyEvent.KEYCODE_NUMPAD_0, KeyEvent.KEYCODE_0 -> {
- PlayerEventType.Resize
- }
-
- KeyEvent.KEYCODE_C, KeyEvent.KEYCODE_NUMPAD_4, KeyEvent.KEYCODE_4 -> {
- PlayerEventType.SkipOp
- }
-
- KeyEvent.KEYCODE_V, KeyEvent.KEYCODE_NUMPAD_5, KeyEvent.KEYCODE_5 -> {
- PlayerEventType.SkipCurrentChapter
- }
-
- KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE, KeyEvent.KEYCODE_P, KeyEvent.KEYCODE_SPACE, KeyEvent.KEYCODE_NUMPAD_ENTER, KeyEvent.KEYCODE_ENTER -> { // space is not captured due to navigation
- PlayerEventType.PlayPauseToggle
- }
-
- else -> return null
- }
- val listener = playerEventListener
- if (listener != null) {
- listener.invoke(playerEvent)
- return true
- }
return null
-
- //when (keyCode) {
- // KeyEvent.KEYCODE_DPAD_CENTER -> {
- // println("DPAD PRESSED")
- // }
- //}
}
/** overrides focus and custom key events */
diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/FullScreenPlayer.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/FullScreenPlayer.kt
index 2a46bad31..9e13a00a6 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/FullScreenPlayer.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/FullScreenPlayer.kt
@@ -39,7 +39,6 @@ import androidx.preference.PreferenceManager
import androidx.recyclerview.widget.SimpleItemAnimator
import com.google.android.material.button.MaterialButton
import com.lagradost.cloudstream3.CommonActivity.keyEventListener
-import com.lagradost.cloudstream3.CommonActivity.playerEventListener
import com.lagradost.cloudstream3.LoadResponse
import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.databinding.FragmentPlayerBinding
@@ -435,7 +434,8 @@ open class FullScreenPlayer : AbstractPlayerFragment(
// Restore when lock is disabled.
restoreOrientationWithSensor(this)
} else {
- this.requestedOrientation = playerHostView?.dynamicOrientation() ?: return@apply
+ this.requestedOrientation =
+ playerHostView?.dynamicOrientation() ?: return@apply
}
}
}
@@ -443,14 +443,14 @@ open class FullScreenPlayer : AbstractPlayerFragment(
}
private fun setupKeyEventListener() {
- keyEventListener = { eventNav ->
- val (event, hasNavigated) = eventNav
+ keyEventListener = { (event, hasNavigated) ->
when {
event == null -> false
event.action == KeyEvent.ACTION_DOWN &&
- (event.keyCode == KeyEvent.KEYCODE_VOLUME_UP ||
- event.keyCode == KeyEvent.KEYCODE_VOLUME_DOWN) ->
+ (event.keyCode == KeyEvent.KEYCODE_VOLUME_UP ||
+ event.keyCode == KeyEvent.KEYCODE_VOLUME_DOWN) ->
playerHostView?.handleVolumeKey(event.keyCode) ?: false
+
player.isActive() -> handleKeyEvent(event, hasNavigated)
else -> false
}
@@ -880,6 +880,138 @@ open class FullScreenPlayer : AbstractPlayerFragment(
playerHostView?.requestUpdateBrightnessOverlayOnNextLayout()
}
+ private fun handleKeyDownEvent(keyCode: Int): Boolean? {
+ // adb shell input keyevent [INT]
+ when (keyCode) {
+ KeyEvent.KEYCODE_FORWARD, KeyEvent.KEYCODE_D, KeyEvent.KEYCODE_MEDIA_SKIP_FORWARD, KeyEvent.KEYCODE_MEDIA_FAST_FORWARD -> {
+ player.handleEvent(CSPlayerEvent.SeekForward)
+ }
+
+ KeyEvent.KEYCODE_A, KeyEvent.KEYCODE_MEDIA_SKIP_BACKWARD, KeyEvent.KEYCODE_MEDIA_REWIND -> {
+ player.handleEvent(CSPlayerEvent.SeekBack)
+ }
+
+ KeyEvent.KEYCODE_MEDIA_NEXT, KeyEvent.KEYCODE_BUTTON_R1, KeyEvent.KEYCODE_N, KeyEvent.KEYCODE_NUMPAD_2, KeyEvent.KEYCODE_CHANNEL_UP -> {
+ player.handleEvent(CSPlayerEvent.NextEpisode)
+ }
+
+ KeyEvent.KEYCODE_MEDIA_PREVIOUS, KeyEvent.KEYCODE_BUTTON_L1, KeyEvent.KEYCODE_B, KeyEvent.KEYCODE_NUMPAD_1, KeyEvent.KEYCODE_CHANNEL_DOWN -> {
+ player.handleEvent(CSPlayerEvent.PrevEpisode)
+ }
+
+ KeyEvent.KEYCODE_MEDIA_PAUSE -> {
+ player.handleEvent(CSPlayerEvent.Pause)
+ }
+
+ KeyEvent.KEYCODE_MEDIA_PLAY, KeyEvent.KEYCODE_BUTTON_START -> {
+ player.handleEvent(CSPlayerEvent.Play)
+ }
+
+ KeyEvent.KEYCODE_L, KeyEvent.KEYCODE_NUMPAD_7, KeyEvent.KEYCODE_7 -> {
+ toggleLock()
+ }
+
+ KeyEvent.KEYCODE_H -> {
+ onClickChange()
+ }
+
+ KeyEvent.KEYCODE_M, KeyEvent.KEYCODE_VOLUME_MUTE -> {
+ player.handleEvent(CSPlayerEvent.ToggleMute)
+ }
+
+ KeyEvent.KEYCODE_S, KeyEvent.KEYCODE_NUMPAD_9, KeyEvent.KEYCODE_9 -> {
+ showMirrorsDialogue()
+ }
+ // OpenSubtitles shortcut
+ KeyEvent.KEYCODE_O, KeyEvent.KEYCODE_NUMPAD_8, KeyEvent.KEYCODE_8 -> {
+ val context = context
+ if (subsProvidersIsActive && context != null) {
+ openOnlineSubPicker(context, null) {}
+ }
+ }
+
+ KeyEvent.KEYCODE_E, KeyEvent.KEYCODE_NUMPAD_3, KeyEvent.KEYCODE_3 -> {
+ showSpeedDialog()
+ }
+
+ KeyEvent.KEYCODE_R, KeyEvent.KEYCODE_NUMPAD_0, KeyEvent.KEYCODE_0 -> {
+ nextResize()
+ }
+
+ KeyEvent.KEYCODE_C, KeyEvent.KEYCODE_NUMPAD_4, KeyEvent.KEYCODE_4 -> {
+ skipOp()
+ }
+
+ KeyEvent.KEYCODE_V, KeyEvent.KEYCODE_NUMPAD_5, KeyEvent.KEYCODE_5 -> {
+ player.handleEvent(CSPlayerEvent.SkipCurrentChapter)
+ }
+
+ KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE, KeyEvent.KEYCODE_P, KeyEvent.KEYCODE_SPACE, KeyEvent.KEYCODE_NUMPAD_ENTER, KeyEvent.KEYCODE_ENTER -> { // space is not captured due to navigation
+ player.handleEvent(CSPlayerEvent.PlayPauseToggle)
+ }
+
+ KeyEvent.KEYCODE_DPAD_CENTER -> {
+ if (isShowing) {
+ return null
+ }
+ // If UI is not shown make click instantly skip to next chapter even if locked
+ if (timestampShowState) {
+ player.handleEvent(CSPlayerEvent.SkipCurrentChapter)
+ } else if (!isLocked) {
+ player.handleEvent(CSPlayerEvent.PlayPauseToggle)
+ }
+ onClickChange()
+ }
+
+ KeyEvent.KEYCODE_DPAD_DOWN,
+ KeyEvent.KEYCODE_DPAD_UP -> {
+ if (isShowing || isShowingEpisodeOverlay) {
+ return null
+ }
+ onClickChange()
+ }
+
+ KeyEvent.KEYCODE_DPAD_LEFT -> {
+ if (!isShowing && !isLocked && !isShowingEpisodeOverlay) {
+ player.seekTime(-androidTVInterfaceOffSeekTime)
+ return true
+ } else if (playerBinding?.playerPausePlay?.isFocused == true) {
+ player.seekTime(-androidTVInterfaceOnSeekTime)
+ return true
+ } else {
+ return null
+ }
+ }
+
+ KeyEvent.KEYCODE_DPAD_RIGHT -> {
+ if (!isShowing && !isLocked && !isShowingEpisodeOverlay) {
+ player.seekTime(androidTVInterfaceOffSeekTime)
+ } else if (playerBinding?.playerPausePlay?.isFocused == true) {
+ player.seekTime(androidTVInterfaceOnSeekTime)
+ } else {
+ return null
+ }
+ }
+
+ KeyEvent.KEYCODE_VOLUME_DOWN,
+ KeyEvent.KEYCODE_VOLUME_UP -> {
+ // Handled entirely by PlayerView.handleVolumeKey (checks PHONE/EMULATOR).
+ if (playerHostView?.handleVolumeKey(keyCode) != true) {
+ return null
+ }
+ }
+
+ KeyEvent.KEYCODE_MENU,
+ KeyEvent.KEYCODE_SETTINGS -> {
+ if (isLocked || !isThereEpisodes()) {
+ return null
+ }
+ toggleEpisodesOverlay(true)
+ }
+ }
+ return true
+ }
+
private fun handleKeyEvent(event: KeyEvent, hasNavigated: Boolean): Boolean {
if (hasNavigated) {
autoHide()
@@ -888,53 +1020,9 @@ open class FullScreenPlayer : AbstractPlayerFragment(
val keyCode = event.keyCode
if (event.action == KeyEvent.ACTION_DOWN) {
- when (keyCode) {
- KeyEvent.KEYCODE_DPAD_CENTER -> {
- if (!isShowing) {
- // If UI is not shown make click instantly skip to next chapter even if locked
- if (timestampShowState) {
- player.handleEvent(CSPlayerEvent.SkipCurrentChapter)
- } else if (!isLocked) {
- player.handleEvent(CSPlayerEvent.PlayPauseToggle)
- }
- onClickChange()
- return true
- }
- }
-
- KeyEvent.KEYCODE_DPAD_DOWN,
- KeyEvent.KEYCODE_DPAD_UP -> {
- if (!isShowing && !isShowingEpisodeOverlay) {
- onClickChange()
- return true
- }
- }
-
- KeyEvent.KEYCODE_DPAD_LEFT -> {
- if (!isShowing && !isLocked && !isShowingEpisodeOverlay) {
- player.seekTime(-androidTVInterfaceOffSeekTime)
- return true
- } else if (playerBinding?.playerPausePlay?.isFocused == true) {
- player.seekTime(-androidTVInterfaceOnSeekTime)
- return true
- }
- }
-
- KeyEvent.KEYCODE_DPAD_RIGHT -> {
- if (!isShowing && !isLocked && !isShowingEpisodeOverlay) {
- player.seekTime(androidTVInterfaceOffSeekTime)
- return true
- } else if (playerBinding?.playerPausePlay?.isFocused == true) {
- player.seekTime(androidTVInterfaceOnSeekTime)
- return true
- }
- }
-
- KeyEvent.KEYCODE_VOLUME_DOWN,
- KeyEvent.KEYCODE_VOLUME_UP -> {
- // Handled entirely by PlayerView.handleVolumeKey (checks PHONE/EMULATOR).
- if (playerHostView?.handleVolumeKey(keyCode) == true) return true
- }
+ val value = handleKeyDownEvent(keyCode)
+ if (value != null) {
+ return value
}
}
@@ -1000,7 +1088,8 @@ open class FullScreenPlayer : AbstractPlayerFragment(
override fun onBindingCreated(binding: FragmentPlayerBinding, savedInstanceState: Bundle?) {
// Set up playerBinding before super initializes the player
// (brightness overlay is now injected by PlayerView.initialize())
- playerBinding = PlayerCustomLayoutBinding.bind(binding.root.findViewById(R.id.player_holder))
+ playerBinding =
+ PlayerCustomLayoutBinding.bind(binding.root.findViewById(R.id.player_holder))
super.onBindingCreated(binding, savedInstanceState)
@@ -1018,81 +1107,6 @@ open class FullScreenPlayer : AbstractPlayerFragment(
subtitleDelay = it
}
- // handle tv controls
- playerEventListener = { eventType ->
- when (eventType) {
- PlayerEventType.Lock -> {
- toggleLock()
- }
-
- PlayerEventType.NextEpisode -> {
- player.handleEvent(CSPlayerEvent.NextEpisode)
- }
-
- PlayerEventType.Pause -> {
- player.handleEvent(CSPlayerEvent.Pause)
- }
-
- PlayerEventType.PlayPauseToggle -> {
- player.handleEvent(CSPlayerEvent.PlayPauseToggle)
- }
-
- PlayerEventType.Play -> {
- player.handleEvent(CSPlayerEvent.Play)
- }
-
- PlayerEventType.SkipCurrentChapter -> {
- player.handleEvent(CSPlayerEvent.SkipCurrentChapter)
- }
-
- PlayerEventType.Resize -> {
- nextResize()
- }
-
- PlayerEventType.PrevEpisode -> {
- player.handleEvent(CSPlayerEvent.PrevEpisode)
- }
-
- PlayerEventType.SeekForward -> {
- player.handleEvent(CSPlayerEvent.SeekForward)
- }
-
- PlayerEventType.ShowSpeed -> {
- showSpeedDialog()
- }
-
- PlayerEventType.SeekBack -> {
- player.handleEvent(CSPlayerEvent.SeekBack)
- }
-
- PlayerEventType.Restart -> {
- player.handleEvent(CSPlayerEvent.Restart)
- }
-
- PlayerEventType.ToggleMute -> {
- player.handleEvent(CSPlayerEvent.ToggleMute)
- }
-
- PlayerEventType.ToggleHide -> {
- onClickChange()
- }
-
- PlayerEventType.ShowMirrors -> {
- showMirrorsDialogue()
- }
-
- PlayerEventType.SearchSubtitlesOnline -> {
- if (subsProvidersIsActive) {
- openOnlineSubPicker(view.context, null) {}
- }
- }
-
- PlayerEventType.SkipOp -> {
- skipOp()
- }
- }
- }
-
// handle tv controls directly based on player state
setupKeyEventListener()
@@ -1137,8 +1151,9 @@ open class FullScreenPlayer : AbstractPlayerFragment(
else QualityDataHelper.QualityProfileType.WiFi
currentQualityProfile =
- profiles.firstOrNull { it.types.contains(type) }?.id ?: profiles.firstOrNull()?.id
- ?: currentQualityProfile
+ profiles.firstOrNull { it.types.contains(type) }?.id
+ ?: profiles.firstOrNull()?.id
+ ?: currentQualityProfile
}
playerBinding?.apply {
playerSpeedBtt.isVisible = playBackSpeedEnabled
diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/IPlayer.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/IPlayer.kt
index 43ec756ed..7bc5f46a0 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/IPlayer.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/IPlayer.kt
@@ -7,27 +7,6 @@ import com.lagradost.cloudstream3.ui.subtitles.SaveCaptionStyle
import com.lagradost.cloudstream3.utils.ExtractorLink
import com.lagradost.cloudstream3.utils.videoskip.VideoSkipStamp
-enum class PlayerEventType(val value: Int) {
- Pause(0),
- Play(1),
- SeekForward(2),
- SeekBack(3),
-
- SkipCurrentChapter(4),
- NextEpisode(5),
- PrevEpisode(6),
- PlayPauseToggle(7),
- ToggleMute(8),
- Lock(9),
- ToggleHide(10),
- ShowSpeed(11),
- ShowMirrors(12),
- Resize(13),
- SearchSubtitlesOnline(14),
- SkipOp(15),
- Restart(16),
-}
-
enum class CSPlayerEvent(val value: Int) {
Pause(0),
Play(1),
diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerView.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerView.kt
index d410cd129..e9822a25a 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerView.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerView.kt
@@ -44,7 +44,6 @@ import androidx.vectordrawable.graphics.drawable.AnimatedVectorDrawableCompat
import com.github.rubensousa.previewseekbar.PreviewBar
import com.github.rubensousa.previewseekbar.media3.PreviewTimeBar
import com.lagradost.cloudstream3.CommonActivity.isInPIPMode
-import com.lagradost.cloudstream3.CommonActivity.playerEventListener
import com.lagradost.cloudstream3.CommonActivity.screenWidth
import com.lagradost.cloudstream3.CommonActivity.showToast
import com.lagradost.cloudstream3.ErrorLoadingException
@@ -466,7 +465,6 @@ class PlayerView @JvmOverloads constructor(
player.releaseCallbacks()
player = CS3IPlayer()
- playerEventListener = null
// keyEventListener is deregistered in onPause so that the incoming player's
// onResume can register its own listener without racing against release().
From 7476d24db38153ce95650389815a916366333fce Mon Sep 17 00:00:00 2001
From: Osten <11805592+LagradOst@users.noreply.github.com>
Date: Mon, 4 May 2026 14:40:13 +0200
Subject: [PATCH 09/82] Added exhaustive keyCode check, fixed #2757
---
.../com/lagradost/cloudstream3/ui/player/FullScreenPlayer.kt | 1 +
1 file changed, 1 insertion(+)
diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/FullScreenPlayer.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/FullScreenPlayer.kt
index 9e13a00a6..801f7e155 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/FullScreenPlayer.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/FullScreenPlayer.kt
@@ -1008,6 +1008,7 @@ open class FullScreenPlayer : AbstractPlayerFragment(
}
toggleEpisodesOverlay(true)
}
+ else -> return null // Avoid capturing all input
}
return true
}
From 4e24aa5db1b1de07fb695f4282cba9f14025910c Mon Sep 17 00:00:00 2001
From: Luna712 <142361265+Luna712@users.noreply.github.com>
Date: Mon, 4 May 2026 10:43:01 -0600
Subject: [PATCH 10/82] Minor fixes to player (#2756)
---
.../cloudstream3/ui/player/FullScreenPlayer.kt | 16 +++-------------
.../cloudstream3/ui/player/PlayerView.kt | 3 ++-
.../ui/result/ResultTrailerPlayer.kt | 18 +++++++++++++++---
3 files changed, 20 insertions(+), 17 deletions(-)
diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/FullScreenPlayer.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/FullScreenPlayer.kt
index 801f7e155..4141aef51 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/FullScreenPlayer.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/FullScreenPlayer.kt
@@ -763,12 +763,11 @@ open class FullScreenPlayer : AbstractPlayerFragment(
}
}
playerBinding?.apply {
-
playerLockHolder.isGone = isGone
playerVideoBar.isGone = isGone
- playerPausePlay.isGone = isGone
- // player_buffering?.isGone = isGone
+ playerPausePlayHolderHolder.isGone =
+ isGone || currentPlayerStatus == CSPlayerLoading.IsBuffering
playerTopHolder.isGone = isGone
val showPlayerEpisodes = !isGone && isThereEpisodes()
playerEpisodesButtonRoot.isVisible = showPlayerEpisodes
@@ -778,9 +777,9 @@ open class FullScreenPlayer : AbstractPlayerFragment(
playerEpisodeFiller.isGone = isGone
playerCenterMenu.isGone = isGone
playerLock.isGone = !isShowing
- // player_media_route_button?.isClickable = !isGone
playerGoBackHolder.isGone = isGone
playerSourcesBtt.isGone = isGone
+ shadowOverlay.isGone = isGone
playerSkipEpisode.isClickable = !isGone
}
}
@@ -1195,15 +1194,6 @@ open class FullScreenPlayer : AbstractPlayerFragment(
}
}
- playerPausePlay.setOnClickListener {
- autoHide()
- if (currentPlayerStatus == CSPlayerLoading.IsEnded && isLayout(PHONE)) {
- player.handleEvent(CSPlayerEvent.Restart)
- } else {
- player.handleEvent(CSPlayerEvent.PlayPauseToggle)
- }
- }
-
skipChapterButton.setOnClickListener {
player.handleEvent(CSPlayerEvent.SkipCurrentChapter)
}
diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerView.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerView.kt
index e9822a25a..d421fa93b 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerView.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerView.kt
@@ -374,7 +374,8 @@ class PlayerView @JvmOverloads constructor(
exoFfwdText?.text = context.getString(R.string.ffw_text_regular_format).format(seekSecs)
playerPausePlay?.setOnClickListener {
- if (currentPlayerStatus == CSPlayerLoading.IsEnded) {
+ scheduleAutoHide()
+ if (currentPlayerStatus == CSPlayerLoading.IsEnded && isLayout(PHONE)) {
player.handleEvent(CSPlayerEvent.Restart, PlayerEventSource.UI)
} else {
player.handleEvent(CSPlayerEvent.PlayPauseToggle, PlayerEventSource.UI)
diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultTrailerPlayer.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultTrailerPlayer.kt
index c2054825c..3b1471e6a 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultTrailerPlayer.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultTrailerPlayer.kt
@@ -38,9 +38,8 @@ class ResultTrailerPlayer : ResultFragmentPhone() {
// Single-tap on empty player area: toggle controls.
override fun onSingleTap() {
- if (!introVisible) {
- if (isShowing) uiReset() else showControls()
- }
+ if (introVisible) return
+ if (isShowing) uiReset() else showControls()
}
private fun showControls() {
@@ -58,6 +57,19 @@ class ResultTrailerPlayer : ResultFragmentPhone() {
override fun onHidePlayerUI() = uiReset()
+ // When the hold-speedup gesture fires, hide controls so the video is unobstructed.
+ // The speedup button show/hide and speed change are handled by PlayerView.
+ override fun onHoldSpeedUp(show: Boolean) {
+ if (show && isShowing) uiReset()
+ }
+
+ override fun onGestureEnd(hadSwipe: Boolean, wasUiShowing: Boolean) {
+ if (!player.getIsPlaying() && hadSwipe && wasUiShowing && !isShowing) {
+ isShowing = true
+ showControls()
+ } else playerHostView?.scheduleAutoHide()
+ }
+
override fun nextEpisode() {}
override fun prevEpisode() {}
override fun playerPositionChanged(position: Long, duration: Long) {}
From 948a2c1725db1284f0d4b8abd6c7a48469f9db3d Mon Sep 17 00:00:00 2001
From: Luna712 <142361265+Luna712@users.noreply.github.com>
Date: Mon, 4 May 2026 13:57:27 -0600
Subject: [PATCH 11/82] Always go to next source in player and add thread
safety (#2733)
---
app/build.gradle.kts | 1 +
.../ui/download/button/BaseFetchButton.kt | 6 +--
.../cloudstream3/ui/player/CS3IPlayer.kt | 17 +++---
.../cloudstream3/ui/player/IPlayer.kt | 9 ++--
.../cloudstream3/ui/player/PlayerView.kt | 52 +++++++------------
.../settings/extensions/ExtensionsFragment.kt | 13 +++--
gradle/libs.versions.toml | 2 +
library/build.gradle.kts | 1 +
.../cloudstream3/utils/Coroutines.android.kt | 7 ++-
.../cloudstream3/utils/Coroutines.kt | 29 +++++++----
.../cloudstream3/utils/Coroutines.jvm.kt | 8 ++-
11 files changed, 75 insertions(+), 70 deletions(-)
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 2466cc73d..32f000509 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -213,6 +213,7 @@ dependencies {
// Android Core & Lifecycle
implementation(libs.core.ktx)
implementation(libs.activity.ktx)
+ implementation(libs.annotation)
implementation(libs.appcompat)
implementation(libs.fragment.ktx)
implementation(libs.bundles.lifecycle)
diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/download/button/BaseFetchButton.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/download/button/BaseFetchButton.kt
index 82c4dcc3b..382a770cd 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/ui/download/button/BaseFetchButton.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/ui/download/button/BaseFetchButton.kt
@@ -76,10 +76,10 @@ abstract class BaseFetchButton(context: Context, attributeSet: AttributeSet) :
currentMetaData.id = id
if (!doSetProgress) return
+ val appContext = context.applicationContext
ioSafe {
- val savedData = VideoDownloadManager.getDownloadFileInfo(context, id)
-
+ val savedData = VideoDownloadManager.getDownloadFileInfo(appContext, id)
mainWork {
if (savedData != null) {
val downloadedBytes = savedData.fileLength
@@ -216,4 +216,4 @@ abstract class BaseFetchButton(context: Context, attributeSet: AttributeSet) :
* Get a clean slate again, might be useful in recyclerview?
* */
abstract fun resetView()
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/CS3IPlayer.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/CS3IPlayer.kt
index 1bd9ef87d..aa44b9235 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/CS3IPlayer.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/CS3IPlayer.kt
@@ -12,6 +12,7 @@ import android.os.Looper
import android.util.Log
import android.util.Rational
import android.widget.FrameLayout
+import androidx.annotation.AnyThread
import androidx.annotation.MainThread
import androidx.annotation.OptIn
import androidx.appcompat.app.AlertDialog
@@ -206,16 +207,14 @@ class CS3IPlayer : IPlayer {
private var requestedListeningPercentages: List? = null
private var eventHandler: ((PlayerEvent) -> Unit)? = null
- private val mainHandler = Handler(Looper.getMainLooper())
+ @AnyThread
fun event(event: PlayerEvent) {
- // Ensure that all work is done on the main looper, aka main thread
- if (Looper.myLooper() == mainHandler.looper) {
+ // Ensure that all work is done on the main thread.
+ if (Looper.getMainLooper().isCurrentThread) {
+ eventHandler?.invoke(event)
+ } else runOnMainThread {
eventHandler?.invoke(event)
- } else {
- mainHandler.post {
- eventHandler?.invoke(event)
- }
}
}
@@ -235,8 +234,9 @@ class CS3IPlayer : IPlayer {
}
}
+ @AnyThread
override fun initCallbacks(
- eventHandler: ((PlayerEvent) -> Unit),
+ @MainThread eventHandler: ((PlayerEvent) -> Unit),
requestedListeningPercentages: List?,
) {
this.requestedListeningPercentages = requestedListeningPercentages
@@ -1770,7 +1770,6 @@ class CS3IPlayer : IPlayer {
return exoPlayer != null
}
-
@MainThread
private fun loadTorrent(context: Context, link: ExtractorLink) {
ioSafe {
diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/IPlayer.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/IPlayer.kt
index 7bc5f46a0..034237266 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/IPlayer.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/IPlayer.kt
@@ -3,6 +3,8 @@ package com.lagradost.cloudstream3.ui.player
import android.content.Context
import android.graphics.Bitmap
import android.util.Rational
+import androidx.annotation.AnyThread
+import androidx.annotation.MainThread
import com.lagradost.cloudstream3.ui.subtitles.SaveCaptionStyle
import com.lagradost.cloudstream3.utils.ExtractorLink
import com.lagradost.cloudstream3.utils.videoskip.VideoSkipStamp
@@ -199,8 +201,6 @@ data class CurrentTracks(
val allTextTracks: List,
)
-class InvalidFileException(msg: String) : Exception(msg)
-
//http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4
const val ACTION_MEDIA_CONTROL = "media_control"
const val EXTRA_CONTROL_TYPE = "control_type"
@@ -222,8 +222,9 @@ interface IPlayer {
fun getSubtitleOffset(): Long // in ms
fun setSubtitleOffset(offset: Long) // in ms
+ @AnyThread
fun initCallbacks(
- eventHandler: ((PlayerEvent) -> Unit),
+ @MainThread eventHandler: ((PlayerEvent) -> Unit),
/** this is used to request when the player should report back view percentage */
requestedListeningPercentages: List? = null,
)
@@ -290,4 +291,4 @@ interface IPlayer {
/** Get the current subtitle cues, for use with syncing */
fun getSubtitleCues(): List
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerView.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerView.kt
index d421fa93b..0e6f1a367 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerView.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerView.kt
@@ -25,6 +25,7 @@ import android.widget.ProgressBar
import android.widget.RelativeLayout
import android.widget.TextView
import android.widget.Toast
+import androidx.annotation.MainThread
import androidx.annotation.OptIn
import androidx.core.view.isGone
import androidx.core.view.isInvisible
@@ -619,9 +620,10 @@ class PlayerView @JvmOverloads constructor(
/** Error handling */
+ @MainThread
fun playerError(exception: Throwable) {
- fun showErrorToast(message: String, gotoNext: Boolean = false) {
- if (gotoNext && callbacks?.hasNextMirror() == true) {
+ fun showErrorToast(message: String) {
+ if (callbacks?.hasNextMirror() == true) {
showToast(message, Toast.LENGTH_SHORT)
callbacks?.nextMirror()
} else {
@@ -643,7 +645,7 @@ class PlayerView @JvmOverloads constructor(
PlaybackException.ERROR_CODE_IO_READ_POSITION_OUT_OF_RANGE,
PlaybackException.ERROR_CODE_IO_UNSPECIFIED,
PlaybackException.ERROR_CODE_PARSING_CONTAINER_UNSUPPORTED ->
- showErrorToast("${context.getString(R.string.source_error)}\n$errorName ($code)\n$msg", gotoNext = true)
+ showErrorToast("${context.getString(R.string.source_error)}\n$errorName ($code)\n$msg")
PlaybackException.ERROR_CODE_REMOTE_ERROR,
PlaybackException.ERROR_CODE_IO_BAD_HTTP_STATUS,
@@ -651,7 +653,7 @@ class PlayerView @JvmOverloads constructor(
PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_FAILED,
PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_TIMEOUT,
PlaybackException.ERROR_CODE_IO_INVALID_HTTP_CONTENT_TYPE ->
- showErrorToast("${context.getString(R.string.remote_error)}\n$errorName ($code)\n$msg", gotoNext = true)
+ showErrorToast("${context.getString(R.string.remote_error)}\n$errorName ($code)\n$msg")
PlaybackErrorEvent.ERROR_AUDIO_TRACK_INIT_FAILED,
PlaybackErrorEvent.ERROR_AUDIO_TRACK_OTHER,
@@ -659,43 +661,31 @@ class PlayerView @JvmOverloads constructor(
PlaybackException.ERROR_CODE_AUDIO_TRACK_WRITE_FAILED,
PlaybackException.ERROR_CODE_DECODER_INIT_FAILED,
PlaybackException.ERROR_CODE_DECODER_QUERY_FAILED ->
- showErrorToast("${context.getString(R.string.render_error)}\n$errorName ($code)\n$msg", gotoNext = true)
+ showErrorToast("${context.getString(R.string.render_error)}\n$errorName ($code)\n$msg")
PlaybackException.ERROR_CODE_DECODING_FORMAT_UNSUPPORTED,
PlaybackException.ERROR_CODE_DECODING_FORMAT_EXCEEDS_CAPABILITIES ->
- showErrorToast("${context.getString(R.string.unsupported_error)}\n$errorName ($code)\n$msg", gotoNext = true)
+ showErrorToast("${context.getString(R.string.unsupported_error)}\n$errorName ($code)\n$msg")
PlaybackException.ERROR_CODE_PARSING_CONTAINER_MALFORMED,
PlaybackException.ERROR_CODE_PARSING_MANIFEST_MALFORMED ->
- showErrorToast("${context.getString(R.string.encoding_error)}\n$errorName ($code)\n$msg", gotoNext = true)
+ showErrorToast("${context.getString(R.string.encoding_error)}\n$errorName ($code)\n$msg")
else ->
- showErrorToast("${context.getString(R.string.unexpected_error)}\n$errorName ($code)\n$msg", gotoNext = false)
+ showErrorToast("${context.getString(R.string.unexpected_error)}\n$errorName ($code)\n$msg")
}
}
- is InvalidFileException ->
- showErrorToast("${context.getString(R.string.source_error)}\n${exception.message}", gotoNext = true)
-
- is SocketTimeoutException -> {
- /**
- * Ensures this is run on the UI thread to prevent issues
- * caused by SocketTimeoutException in torrents. Running
- * on another thread can break player interactions or
- * prevent switching to the next source.
- */
- (context as? Activity)?.runOnUiThread {
- showErrorToast("${context.getString(R.string.remote_error)}\n${exception.message}", gotoNext = true)
- }
- }
+ is SocketTimeoutException ->
+ showErrorToast("${context.getString(R.string.remote_error)}\n${exception.message}")
is ErrorLoadingException ->
- exception.message?.let { showErrorToast(it, gotoNext = true) }
- ?: showErrorToast(exception.toString(), gotoNext = true)
+ exception.message?.let { showErrorToast(it) }
+ ?: showErrorToast(exception.toString())
else ->
- exception.message?.let { showErrorToast(it, gotoNext = false) }
- ?: showErrorToast(exception.toString(), gotoNext = false)
+ exception.message?.let { showErrorToast(it) }
+ ?: showErrorToast(exception.toString())
}
}
@@ -734,8 +724,7 @@ class PlayerView @JvmOverloads constructor(
if (isLayout(TV or EMULATOR)) return ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE
return if (autoPlayerRotateEnabled && isVerticalOrientation)
ActivityInfo.SCREEN_ORIENTATION_SENSOR_PORTRAIT
- else
- ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE
+ else ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE
}
/** Event dispatch */
@@ -746,6 +735,7 @@ class PlayerView @JvmOverloads constructor(
* and returning early WON'T stop it from changing in e.g. the player time
* or pause status.
*/
+ @MainThread
fun mainCallback(event: PlayerEvent) {
// We don't want to spam DownloadEvent.
if (event !is DownloadEvent) Log.i(TAG, "Handle event: $event")
@@ -766,11 +756,7 @@ class PlayerView @JvmOverloads constructor(
is TimestampInvokedEvent -> callbacks?.onTimestamp(event.timestamp)
is TracksChangedEvent -> callbacks?.onTracksInfoChanged()
is EmbeddedSubtitlesFetchedEvent -> callbacks?.embeddedSubtitlesFetched(event.tracks)
- is ErrorEvent -> {
- val cb = callbacks
- if (cb != null) cb.playerError(event.error)
- else playerError(event.error)
- }
+ is ErrorEvent -> callbacks?.playerError(event.error) ?: playerError(event.error)
is RequestAudioFocusEvent -> requestAudioFocus()
is EpisodeSeekEvent -> when (event.offset) {
-1 -> callbacks?.prevEpisode()
diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/ExtensionsFragment.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/ExtensionsFragment.kt
index bc85cc478..af0d3dfe7 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/ExtensionsFragment.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/ExtensionsFragment.kt
@@ -119,13 +119,14 @@ class ExtensionsFragment : BaseFragment(
}, { repo ->
// Prompt user before deleting repo
main {
- val builder = AlertDialog.Builder(context ?: binding.root.context)
+ val uiContext = context ?: binding.root.context
+ val builder = AlertDialog.Builder(uiContext)
val dialogClickListener =
DialogInterface.OnClickListener { _, which ->
when (which) {
DialogInterface.BUTTON_POSITIVE -> {
ioSafe {
- RepositoryManager.removeRepository(binding.root.context, repo)
+ RepositoryManager.removeRepository(uiContext.applicationContext, repo)
extensionViewModel.loadStats()
extensionViewModel.loadRepositories()
}
@@ -136,9 +137,7 @@ class ExtensionsFragment : BaseFragment(
}
builder.setTitle(R.string.delete_repository)
- .setMessage(
- context?.getString(R.string.delete_repository_plugins)
- )
+ .setMessage(uiContext.getString(R.string.delete_repository_plugins))
.setPositiveButton(R.string.delete, dialogClickListener)
.setNegativeButton(R.string.cancel, dialogClickListener)
.show().setDefaultFocus()
@@ -210,9 +209,9 @@ class ExtensionsFragment : BaseFragment(
binding.applyBtt.setOnClickListener secondListener@{
val name = binding.repoNameInput.text?.toString()
+ val urlInput = binding.repoUrlInput.text?.toString()
ioSafe {
- val url = binding.repoUrlInput.text?.toString()
- ?.let { it1 -> RepositoryManager.parseRepoUrl(it1) }
+ val url = urlInput?.let { it1 -> RepositoryManager.parseRepoUrl(it1) }
if (url.isNullOrBlank()) {
main {
showToast(R.string.error_invalid_data, Toast.LENGTH_SHORT)
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index f0b24c8e7..dc65cc4ee 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -4,6 +4,7 @@
activityKtx = "1.13.0"
androidGradlePlugin = "9.1.1"
animeDb = "1.0.2"
+annotation = "1.10.0"
appcompat = "1.7.1"
biometric = "1.4.0-alpha06"
buildkonfigGradlePlugin = "0.18.0"
@@ -57,6 +58,7 @@ targetSdk = "36"
[libraries]
activity-ktx = { module = "androidx.activity:activity-ktx", version.ref = "activityKtx" }
anime-db = { module = "com.github.recloudstream:anime-db", version.ref = "animeDb" }
+annotation = { module = "androidx.annotation:annotation", version.ref = "annotation" }
appcompat = { module = "androidx.appcompat:appcompat", version.ref = "appcompat" }
biometric = { module = "androidx.biometric:biometric", version.ref = "biometric" }
coil = { module = "io.coil-kt.coil3:coil", version.ref = "coil" }
diff --git a/library/build.gradle.kts b/library/build.gradle.kts
index 14ef644f0..073e49e64 100644
--- a/library/build.gradle.kts
+++ b/library/build.gradle.kts
@@ -53,6 +53,7 @@ kotlin {
}
commonMain.dependencies {
+ implementation(libs.annotation) // Annotations
implementation(libs.nicehttp) // HTTP Lib
implementation(libs.jackson.module.kotlin) // JSON Parser
implementation(libs.kotlinx.coroutines.core)
diff --git a/library/src/androidMain/kotlin/com/lagradost/cloudstream3/utils/Coroutines.android.kt b/library/src/androidMain/kotlin/com/lagradost/cloudstream3/utils/Coroutines.android.kt
index 48a709eb4..048e7fc02 100644
--- a/library/src/androidMain/kotlin/com/lagradost/cloudstream3/utils/Coroutines.android.kt
+++ b/library/src/androidMain/kotlin/com/lagradost/cloudstream3/utils/Coroutines.android.kt
@@ -2,10 +2,13 @@ package com.lagradost.cloudstream3.utils
import android.os.Handler
import android.os.Looper
+import androidx.annotation.AnyThread
+import androidx.annotation.MainThread
-actual fun runOnMainThreadNative(work: () -> Unit) {
+@AnyThread
+actual fun runOnMainThreadNative(@MainThread work: () -> Unit) {
val mainHandler = Handler(Looper.getMainLooper())
mainHandler.post {
work()
}
-}
\ No newline at end of file
+}
diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/Coroutines.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/Coroutines.kt
index f87ddc6ab..c525a1f36 100644
--- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/Coroutines.kt
+++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/Coroutines.kt
@@ -1,28 +1,34 @@
package com.lagradost.cloudstream3.utils
+import androidx.annotation.AnyThread
+import androidx.annotation.MainThread
+import androidx.annotation.WorkerThread
import com.lagradost.cloudstream3.mvvm.launchSafe
import com.lagradost.cloudstream3.mvvm.logError
import kotlinx.coroutines.*
import java.util.Collections.synchronizedList
-expect fun runOnMainThreadNative(work: (() -> Unit))
+@AnyThread
+expect fun runOnMainThreadNative(@MainThread work: (() -> Unit))
object Coroutines {
- fun T.main(work: suspend ((T) -> Unit)): Job {
+ @AnyThread
+ fun T.main(@MainThread work: suspend ((T) -> Unit)): Job {
val value = this
return CoroutineScope(Dispatchers.Main).launchSafe {
work(value)
}
}
- fun T.ioSafe(work: suspend (CoroutineScope.(T) -> Unit)): Job {
+ @AnyThread
+ fun T.ioSafe(@WorkerThread work: suspend (CoroutineScope.(T) -> Unit)): Job {
val value = this
-
return CoroutineScope(Dispatchers.IO).launchSafe {
work(value)
}
}
- suspend fun V.ioWorkSafe(work: suspend (CoroutineScope.(V) -> T)): T? {
+ @AnyThread
+ suspend fun V.ioWorkSafe(@WorkerThread work: suspend (CoroutineScope.(V) -> T)): T? {
val value = this
return withContext(Dispatchers.IO) {
try {
@@ -34,21 +40,24 @@ object Coroutines {
}
}
- suspend fun V.ioWork(work: suspend (CoroutineScope.(V) -> T)): T {
+ @AnyThread
+ suspend fun V.ioWork(@WorkerThread work: suspend (CoroutineScope.(V) -> T)): T {
val value = this
return withContext(Dispatchers.IO) {
work(value)
}
}
- suspend fun V.mainWork(work: suspend (CoroutineScope.(V) -> T)): T {
+ @AnyThread
+ suspend fun V.mainWork(@MainThread work: suspend (CoroutineScope.(V) -> T)): T {
val value = this
return withContext(Dispatchers.Main) {
work(value)
}
}
- fun runOnMainThread(work: (() -> Unit)) {
+ @AnyThread
+ fun runOnMainThread(@MainThread work: (() -> Unit)) {
runOnMainThreadNative(work)
}
@@ -56,8 +65,8 @@ object Coroutines {
* Safe to add and remove how you want
* If you want to iterate over the list then you need to do:
* synchronized(allProviders) { code here }
- **/
+ */
fun threadSafeListOf(vararg items: T): MutableList {
return synchronizedList(items.toMutableList())
}
-}
\ No newline at end of file
+}
diff --git a/library/src/jvmMain/kotlin/com/lagradost/cloudstream3/utils/Coroutines.jvm.kt b/library/src/jvmMain/kotlin/com/lagradost/cloudstream3/utils/Coroutines.jvm.kt
index 0a9667cbc..8fc9a8b0f 100644
--- a/library/src/jvmMain/kotlin/com/lagradost/cloudstream3/utils/Coroutines.jvm.kt
+++ b/library/src/jvmMain/kotlin/com/lagradost/cloudstream3/utils/Coroutines.jvm.kt
@@ -1,5 +1,9 @@
package com.lagradost.cloudstream3.utils
-actual fun runOnMainThreadNative(work: () -> Unit) {
+import androidx.annotation.AnyThread
+import androidx.annotation.MainThread
+
+@AnyThread
+actual fun runOnMainThreadNative(@MainThread work: () -> Unit) {
work.invoke()
-}
\ No newline at end of file
+}
From a45f1d9ab1edc165941d01996cda92954f99852e Mon Sep 17 00:00:00 2001
From: firelight <147925818+fire-light42@users.noreply.github.com>
Date: Wed, 6 May 2026 00:29:47 +0000
Subject: [PATCH 12/82] Refactor: Player, Generator and ViewModel (#2764)
---
app/build.gradle.kts | 1 +
.../lagradost/cloudstream3/MainActivity.kt | 17 +-
.../actions/temp/PlayMirrorAction.kt | 6 +-
.../cloudstream3/ui/ControllerActivity.kt | 1 +
.../ui/download/DownloadButtonSetup.kt | 3 +-
.../ui/download/DownloadFragment.kt | 5 +-
.../ui/player/DownloadFileGenerator.kt | 9 +-
.../ui/player/ExtractorLinkGenerator.kt | 2 +-
.../cloudstream3/ui/player/GeneratorPlayer.kt | 295 +++++++--------
.../cloudstream3/ui/player/IGenerator.kt | 71 +---
.../cloudstream3/ui/player/LinkGenerator.kt | 9 +-
.../ui/player/OfflinePlaybackHelper.kt | 12 +-
.../ui/player/PlayerGeneratorViewModel.kt | 342 ++++++++++++------
.../ui/player/RepoLinkGenerator.kt | 43 +--
.../ui/result/ResultFragmentTv.kt | 10 +-
.../ui/result/ResultViewModel2.kt | 100 +++--
.../utils/downloader/DownloadManager.kt | 2 +
gradle/libs.versions.toml | 2 +
18 files changed, 540 insertions(+), 390 deletions(-)
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 32f000509..ae5301929 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -218,6 +218,7 @@ dependencies {
implementation(libs.fragment.ktx)
implementation(libs.bundles.lifecycle)
implementation(libs.bundles.navigation)
+ implementation(libs.kotlinx.collections.immutable)
// Design & UI
implementation(libs.preference.ktx)
diff --git a/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt b/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt
index 4a2a103c5..8a98bd297 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt
@@ -362,7 +362,8 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa
LinkGenerator(
listOf(BasicLink(url, name)),
extract = true,
- )
+ id = url.hashCode()
+ ), 0
)
)
} else if (safeURI(str)?.scheme == APP_STRING_RESUME_WATCHING) {
@@ -559,9 +560,11 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa
navView.isVisible = isNavVisible && !isLandscape()
navHostFragment.apply {
val marginPx = resources.getDimensionPixelSize(R.dimen.nav_rail_view_width)
- layoutParams = (navHostFragment.layoutParams as ViewGroup.MarginLayoutParams).apply {
- marginStart = if (isNavVisible && isLandscape() && isLayout(TV or EMULATOR)) marginPx else 0
- }
+ layoutParams =
+ (navHostFragment.layoutParams as ViewGroup.MarginLayoutParams).apply {
+ marginStart =
+ if (isNavVisible && isLandscape() && isLayout(TV or EMULATOR)) marginPx else 0
+ }
}
/**
@@ -570,7 +573,11 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa
* highlight the wrong one in UI.
*/
when (destination.id) {
- in listOf(R.id.navigation_downloads, R.id.navigation_download_child, R.id.navigation_download_queue) -> {
+ in listOf(
+ R.id.navigation_downloads,
+ R.id.navigation_download_child,
+ R.id.navigation_download_queue
+ ) -> {
navRailView.menu.findItem(R.id.navigation_downloads).isChecked = true
navView.menu.findItem(R.id.navigation_downloads).isChecked = true
}
diff --git a/app/src/main/java/com/lagradost/cloudstream3/actions/temp/PlayMirrorAction.kt b/app/src/main/java/com/lagradost/cloudstream3/actions/temp/PlayMirrorAction.kt
index d69619b45..56512377b 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/actions/temp/PlayMirrorAction.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/actions/temp/PlayMirrorAction.kt
@@ -35,9 +35,11 @@ class PlayMirrorAction : VideoClickAction() {
) {
//Implemented a generator to handle the single
val activity = context as? Activity ?: return
+ val link = index?.let { result.links[it] }
val generatorMirror = object : VideoGenerator(listOf(video)) {
override val hasCache: Boolean = false
override val canSkipLoading: Boolean = false
+ override fun getId(index: Int): Int = video.id
override suspend fun generateLinks(
clearCache: Boolean,
@@ -47,7 +49,7 @@ class PlayMirrorAction : VideoClickAction() {
offset: Int,
isCasting: Boolean
): Boolean {
- index?.let { callback(result.links[it] to null) }
+ index?.let { callback(link to null) }
result.subs.forEach { subtitle -> subtitleCallback(subtitle) }
return true
}
@@ -56,7 +58,7 @@ class PlayMirrorAction : VideoClickAction() {
activity.navigate(
R.id.global_to_navigation_player,
GeneratorPlayer.newInstance(
- generatorMirror, result.syncData
+ generatorMirror, 0, result.syncData
)
)
}
diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/ControllerActivity.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/ControllerActivity.kt
index ed273a3ce..f91d40f28 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/ui/ControllerActivity.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/ui/ControllerActivity.kt
@@ -334,6 +334,7 @@ class SelectSourceController(val view: ImageView, val activity: ControllerActivi
}, subtitleCallback = {
currentSubs.add(it)
},
+ offset = 0,
isCasting = true
)
}
diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadButtonSetup.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadButtonSetup.kt
index 884eebd62..dae70ebd7 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadButtonSetup.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadButtonSetup.kt
@@ -162,7 +162,8 @@ object DownloadButtonSetup {
}
act.navigate(
R.id.global_to_navigation_player, GeneratorPlayer.newInstance(
- DownloadFileGenerator(items).apply { goto(items.indexOfFirst { it.id == click.data.id }) }
+ DownloadFileGenerator(items),
+ items.indexOfFirst { it.id == click.data.id }
)
)
}
diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadFragment.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadFragment.kt
index be9f768a8..abc432ef9 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadFragment.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadFragment.kt
@@ -148,7 +148,7 @@ class DownloadFragment : BaseFragment(
val size = cards.currentDownloads.size + cards.queue.size
val context = binding.root.context
val baseText = context.getString(R.string.download_queue)
- binding.downloadQueueText.text = if (size > 0) {
+ binding.downloadQueueText.text = if (size > 0) {
"$baseText (${cards.currentDownloads.size}/$size)"
} else {
baseText
@@ -349,7 +349,8 @@ class DownloadFragment : BaseFragment(
listOf(BasicLink(url)),
extract = true,
refererUrl = referer,
- )
+ id = url.hashCode()
+ ), 0
)
)
dialog.dismissSafe(activity)
diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/DownloadFileGenerator.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/DownloadFileGenerator.kt
index eb1bcd00d..35f8dcfd8 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/DownloadFileGenerator.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/DownloadFileGenerator.kt
@@ -14,12 +14,13 @@ import com.lagradost.cloudstream3.utils.downloader.DownloadFileManagement.getFol
import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager.getDownloadFileInfo
class DownloadFileGenerator(
- episodes: List,
- currentIndex: Int = 0
-) : VideoGenerator(episodes, currentIndex) {
+ episodes: List
+) : VideoGenerator(episodes) {
override val hasCache = false
override val canSkipLoading = false
+ override fun getId(index: Int): Int? = this.videos.getOrNull(index)?.id
+
override suspend fun generateLinks(
clearCache: Boolean,
sourceTypes: Set,
@@ -28,7 +29,7 @@ class DownloadFileGenerator(
offset: Int,
isCasting: Boolean
): Boolean {
- val meta = getCurrent(offset) ?: return false
+ val meta = videos.getOrNull(offset) ?: return false
if (meta.uri == Uri.EMPTY) {
// We do this here so that we only load it when
diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/ExtractorLinkGenerator.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/ExtractorLinkGenerator.kt
index a52a3c646..85db33fc0 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/ExtractorLinkGenerator.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/ExtractorLinkGenerator.kt
@@ -6,7 +6,7 @@ import com.lagradost.cloudstream3.utils.ExtractorLinkType
class ExtractorLinkGenerator(
private val links: List,
private val subtitles: List,
-) : NoVideoGenerator() {
+) : NoVideoGenerator(null) {
override suspend fun generateLinks(
clearCache: Boolean,
sourceTypes: Set,
diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/GeneratorPlayer.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/GeneratorPlayer.kt
index 311230235..9ee85a941 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/GeneratorPlayer.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/GeneratorPlayer.kt
@@ -131,6 +131,9 @@ import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import java.io.Serializable
import java.util.Calendar
+import java.util.UUID
+import java.util.concurrent.ConcurrentHashMap
+import java.util.concurrent.atomic.AtomicBoolean
@OptIn(UnstableApi::class)
class GeneratorPlayer : FullScreenPlayer() {
@@ -139,11 +142,14 @@ class GeneratorPlayer : FullScreenPlayer() {
const val CHANNEL_ID = 7340
const val STOP_ACTION = "stopcs3"
- private var lastUsedGenerator: IGenerator? = null
- fun newInstance(generator: IGenerator, syncData: HashMap? = null): Bundle {
+ private val generators = ConcurrentHashMap>()
+ fun newInstance(generator: VideoGenerator<*>, index : Int, syncData: HashMap? = null): Bundle {
Log.i(TAG, "newInstance = $syncData")
- lastUsedGenerator = generator
+ val uuid = UUID.randomUUID().toString()
+ generators[uuid] = generator
return Bundle().apply {
+ putString("uuid", uuid)
+ putInt("index", index)
if (syncData != null) putSerializable("syncData", syncData)
}
}
@@ -162,25 +168,21 @@ class GeneratorPlayer : FullScreenPlayer() {
private lateinit var viewModel: PlayerGeneratorViewModel //by activityViewModels()
private lateinit var sync: SyncViewModel
- private var currentLinks: Set> = setOf()
- private var currentSubs: Set = setOf()
private var currentSelectedLink: Pair? = null
private var currentSelectedSubtitles: SubtitleData? = null
- private var currentMeta: Any? = null
- private var nextMeta: Any? = null
- private var isActive: Boolean = false
+ private val currentMeta: Any? get() = viewModel.state.generatorState?.meta
+ private val nextMeta: Any? get() = viewModel.state.generatorState?.nextMeta
+
+ private var isPlayerActive: AtomicBoolean = AtomicBoolean(false)
private var isNextEpisode: Boolean = false // this is used to reset the watch time
private var preferredAutoSelectSubtitles: String? = null // null means do nothing, "" means none
-
- private var allMeta: List? = null
- private fun startLoading() {
- player.release()
- currentSelectedSubtitles = null
- isActive = false
- binding?.overlayLoadingSkipButton?.isVisible = false
- binding?.playerLoadingOverlay?.isVisible = true
+ private val allMeta: List? get() = viewModel.state.generatorState?.allMeta?.filterIsInstance()?.map { episode ->
+ // Refresh all the episodes watch duration
+ getViewPos(episode.id)?.let { data ->
+ episode.copy(position = data.position, duration = data.duration)
+ } ?: episode
}
private fun setSubtitles(subtitle: SubtitleData?, userInitiated: Boolean): Boolean {
@@ -213,7 +215,7 @@ class GeneratorPlayer : FullScreenPlayer() {
playerBinding?.playerTracksBtt?.isVisible =
tracks.allVideoTracks.size > 1 || tracks.allAudioTracks.size > 1
// Only set the preferred language if it is available.
- // Otherwise it may give some users audio track init failed!
+ // Otherwise, it may give some users audio track init failed!
if (tracks.allAudioTracks.any { it.language == preferredAudioTrackLanguage }) {
player.setPreferredAudioTrack(preferredAudioTrackLanguage)
}
@@ -232,7 +234,7 @@ class GeneratorPlayer : FullScreenPlayer() {
}
private fun getPos(): Long {
- val durPos = getViewPos(viewModel.getId()) ?: return 0L
+ val durPos = getViewPos(viewModel.state.generatorState?.id) ?: return 0L
if (durPos.duration == 0L) return 0L
if (durPos.position * 100L / durPos.duration > 95L) {
return 0L
@@ -383,9 +385,7 @@ class GeneratorPlayer : FullScreenPlayer() {
override fun onCustomAction(player: Player, action: String, intent: Intent) {
when (action) {
STOP_ACTION -> {
- playerHostView?.exitFullscreen()
- this@GeneratorPlayer.player.release()
- activity?.popCurrentPage()
+ exitPlayer()
}
}
}
@@ -485,9 +485,9 @@ class GeneratorPlayer : FullScreenPlayer() {
}
}
- private fun loadLink(link: Pair?, sameEpisode: Boolean) {
+ private fun loadLink(link: VideoLink?, sameEpisode: Boolean) {
if (link == null) return
-
+ isPlayerActive.set(true)
// manage UI
binding?.playerLoadingOverlay?.isVisible = false
val isTorrent =
@@ -503,16 +503,7 @@ class GeneratorPlayer : FullScreenPlayer() {
uiReset()
currentSelectedLink = link
- currentMeta = viewModel.getMeta()
- nextMeta = viewModel.getNextMeta()
- allMeta = viewModel.getAllMeta()?.filterIsInstance()?.map { episode ->
- // Refresh all the episodes watch duration
- getViewPos(episode.id)?.let { data ->
- episode.copy(position = data.position, duration = data.duration)
- } ?: episode
- }
// setEpisodes(viewModel.getAllMeta() ?: emptyList())
- isActive = true
setPlayerDimen(null)
setTitle()
if (!sameEpisode)
@@ -522,6 +513,7 @@ class GeneratorPlayer : FullScreenPlayer() {
// load player
context?.let { ctx ->
val (url, uri) = link
+ val subtitles = viewModel.state.subtitles
player.loadPlayer(
ctx,
sameEpisode,
@@ -530,9 +522,9 @@ class GeneratorPlayer : FullScreenPlayer() {
startPosition = if (sameEpisode) null else {
if (isNextEpisode) 0L else getPos()
},
- currentSubs,
+ subtitles,
(if (sameEpisode) currentSelectedSubtitles else null) ?: getAutoSelectSubtitle(
- currentSubs, settings = true, downloads = true
+ subtitles, settings = true, downloads = true
),
preview = true
)
@@ -545,13 +537,6 @@ class GeneratorPlayer : FullScreenPlayer() {
}
}
- private fun sortLinks(qualityProfile: Int): List> {
- return currentLinks.sortedBy {
- // negative because we want to sort highest quality first
- -getLinkPriority(qualityProfile, it.first)
- }
- }
-
data class TempMetaData(
var episode: Int? = null,
var season: Int? = null,
@@ -877,20 +862,18 @@ class GeneratorPlayer : FullScreenPlayer() {
vararg subtitleData: SubtitleData
) {
if (subtitleData.isEmpty()) return
- val selectedSubtitle = subtitleData.first()
val ctx = context ?: return
-
- val subs = currentSubs + subtitleData
+ val selectedSubtitle = subtitleData.first()
+ viewModel.addSubtitles(subtitleData.toSet())
// this is used instead of observe(viewModel._currentSubs), because observe is too slow
- player.setActiveSubtitles(subs)
+ player.setActiveSubtitles(viewModel.state.subtitles)
// Save current time as to not reset player to 00:00
player.saveData()
player.reloadPlayer(ctx)
setSubtitles(selectedSubtitle, false)
- viewModel.addSubtitles(subtitleData.toSet())
selectSourceDialog?.dismissSafe()
selectSourceDialog = null
@@ -989,7 +972,7 @@ class GeneratorPlayer : FullScreenPlayer() {
}
// checks for both a race condition and if any of the subs generated is new
- if (this.isActive && !currentSubs.containsAll(subtitles) && !hasSelectASubtitle) {
+ if (this.isActive && !viewModel.state.subtitles.containsAll(subtitles) && !hasSelectASubtitle) {
hasSelectASubtitle = true
runOnMainThread {
addAndSelectSubtitles(*subtitles.toTypedArray())
@@ -1012,7 +995,7 @@ class GeneratorPlayer : FullScreenPlayer() {
context?.let { ctx ->
val isPlaying = player.getIsPlaying()
player.handleEvent(CSPlayerEvent.Pause, PlayerEventSource.UI)
- val currentSubtitles = sortSubs(currentSubs)
+ val currentSubtitles = sortSubs(viewModel.state.subtitles)
val sourceDialog = Dialog(ctx, R.style.DialogFullscreenPlayer)
val binding =
@@ -1054,7 +1037,7 @@ class GeneratorPlayer : FullScreenPlayer() {
}
if (subsProvidersIsActive) {
- val currentLoadResponse = viewModel.getLoadResponse()
+ val currentLoadResponse = viewModel.state.generatorState?.response
val loadFromOpenSubsFooter: TextView = layoutInflater.inflate(
R.layout.sort_bottom_footer_add_choice, null
@@ -1112,7 +1095,7 @@ class GeneratorPlayer : FullScreenPlayer() {
var sortedUrls = emptyList>()
fun refreshLinks(qualityProfile: Int) {
- sortedUrls = sortLinks(qualityProfile)
+ sortedUrls = viewModel.state.sortLinks(qualityProfile)
if (sortedUrls.isEmpty()) {
sourceDialog.findViewById(R.id.sort_sources_holder)?.isGone =
true
@@ -1277,16 +1260,28 @@ class GeneratorPlayer : FullScreenPlayer() {
binding.profilesClickSettings.setOnClickListener {
val activity = activity ?: return@setOnClickListener
- QualityProfileDialog(
+ val dialog = QualityProfileDialog(
activity,
R.style.DialogFullscreenPlayer,
- currentLinks.mapNotNull { it.first?.let { extractorLink -> LinkSource(extractorLink) } },
+ viewModel.state.links.mapNotNull {
+ it.first?.let { extractorLink ->
+ LinkSource(
+ extractorLink
+ )
+ }
+ },
currentQualityProfile
) { profile ->
currentQualityProfile = profile.id
setProfileName(profile.id)
- refreshLinks(profile.id)
- }.show()
+ }
+
+ dialog.setOnDismissListener {
+ viewModel.state.clearSortedLinksCache()
+ refreshLinks(currentQualityProfile)
+ }
+
+ dialog.show()
}
binding.subtitlesEncodingFormat.apply {
@@ -1430,11 +1425,12 @@ class GeneratorPlayer : FullScreenPlayer() {
}
var audioIndexStart = currentAudioTracks.indexOfFirst { track ->
- track.id == tracks.currentAudioTrack?.id &&
- track.formatIndex == tracks.currentAudioTrack?.formatIndex
+ track.id == tracks.currentAudioTrack?.id &&
+ track.formatIndex == tracks.currentAudioTrack?.formatIndex
}.coerceAtLeast(0)
- val audioArrayAdapter = ArrayAdapter(ctx, R.layout.sort_bottom_single_choice)
+ val audioArrayAdapter =
+ ArrayAdapter(ctx, R.layout.sort_bottom_single_choice)
audioArrayAdapter.addAll(
currentAudioTracks.mapIndexed { _, track ->
@@ -1442,7 +1438,9 @@ class GeneratorPlayer : FullScreenPlayer() {
val language = (
track.language?.trim()?.let { raw ->
fromTagToLanguageName(raw)
- ?: fromTagToLanguageName(raw.replace('_','-').substringBefore('-').lowercase())
+ ?: fromTagToLanguageName(
+ raw.replace('_', '-').substringBefore('-').lowercase()
+ )
?: raw
}
?: track.label
@@ -1464,7 +1462,8 @@ class GeneratorPlayer : FullScreenPlayer() {
}
listOfNotNull(
- language.takeIf { it.isNotBlank() }?.replaceFirstChar { it.uppercaseChar() },
+ language.takeIf { it.isNotBlank() }
+ ?.replaceFirstChar { it.uppercaseChar() },
channels.takeIf { it.isNotBlank() },
codec.takeIf { it.isNotBlank() }?.uppercase()
).joinToString(" • ")
@@ -1492,7 +1491,7 @@ class GeneratorPlayer : FullScreenPlayer() {
binding.applyBtt.setOnClickListener {
val currentTrack = currentAudioTracks.getOrNull(audioIndexStart)
player.setPreferredAudioTrack(
- currentTrack?.language,
+ currentTrack?.language,
currentTrack?.id,
currentTrack?.formatIndex,
)
@@ -1541,13 +1540,20 @@ class GeneratorPlayer : FullScreenPlayer() {
}
private fun startPlayer() {
- if (isActive) return // we don't want double load when you skip loading
+ // We don't want double load when you skip loading
+ if(isPlayerActive.get()) {
+ return
+ }
- val links = sortLinks(currentQualityProfile)
+ val links = viewModel.state.sortLinks(currentQualityProfile)
if (links.isEmpty()) {
noLinksFound()
return
}
+ // Atomic operation to prevent double loading
+ if (!isPlayerActive.compareAndSet(false, true)) {
+ return
+ }
loadLink(links.first(), false)
showPlayerMetadata()
}
@@ -1560,7 +1566,7 @@ class GeneratorPlayer : FullScreenPlayer() {
val metaView = overlay.findViewById(R.id.player_movie_meta)
val descView = overlay.findViewById(R.id.player_movie_overview)
- val load = viewModel.getLoadResponse() ?: return
+ val load = viewModel.state.generatorState?.response ?: return
val episode = currentMeta as? ResultEpisode
titleView.text = load.name
@@ -1602,7 +1608,7 @@ class GeneratorPlayer : FullScreenPlayer() {
override fun nextEpisode() {
if (viewModel.hasNextEpisode() == true) {
isNextEpisode = true
- player.release()
+ releasePlayer()
viewModel.loadLinksNext()
}
}
@@ -1610,18 +1616,18 @@ class GeneratorPlayer : FullScreenPlayer() {
override fun prevEpisode() {
if (viewModel.hasPrevEpisode() == true) {
isNextEpisode = true
- player.release()
+ releasePlayer()
viewModel.loadLinksPrev()
}
}
override fun hasNextMirror(): Boolean {
- val links = sortLinks(currentQualityProfile)
+ val links = viewModel.state.sortLinks(currentQualityProfile)
return links.isNotEmpty() && links.indexOf(currentSelectedLink) + 1 < links.size
}
override fun nextMirror() {
- val links = sortLinks(currentQualityProfile)
+ val links = viewModel.state.sortLinks(currentQualityProfile)
if (links.isEmpty()) {
noLinksFound()
return
@@ -1668,7 +1674,7 @@ class GeneratorPlayer : FullScreenPlayer() {
val percentage = position * 100L / duration
DataStoreHelper.setViewPosAndResume(
- viewModel.getId(),
+ viewModel.state.generatorState?.id,
position,
duration,
currentMeta,
@@ -1720,14 +1726,18 @@ class GeneratorPlayer : FullScreenPlayer() {
): SubtitleData? {
val langCode = preferredAutoSelectSubtitles ?: return null
if (downloads) {
- return sortSubs(subtitles).firstOrNull { it.origin == SubtitleOrigin.DOWNLOADED_FILE && it.matchesLanguageCode(langCode) }
+ return sortSubs(subtitles).firstOrNull {
+ it.origin == SubtitleOrigin.DOWNLOADED_FILE && it.matchesLanguageCode(
+ langCode
+ )
+ }
}
if (!settings) return null
return sortSubs(subtitles).firstOrNull { it.matchesLanguageCode(langCode) }
}
-
+
private fun autoSelectFromSettings(): Boolean {
// auto select subtitle based on settings
val langCode = preferredAutoSelectSubtitles
@@ -1744,7 +1754,7 @@ class GeneratorPlayer : FullScreenPlayer() {
}
} else if (!langCode.isNullOrEmpty()) {
getAutoSelectSubtitle(
- currentSubs, settings = true, downloads = false
+ viewModel.state.subtitles, settings = true, downloads = false
)?.let { sub ->
if (setSubtitles(sub, false)) {
player.saveData()
@@ -1758,20 +1768,20 @@ class GeneratorPlayer : FullScreenPlayer() {
return false
}
- private fun autoSelectFromDownloads(): Boolean {
- if (player.getCurrentPreferredSubtitle() == null) {
- getAutoSelectSubtitle(currentSubs, settings = false, downloads = true)?.let { sub ->
- context?.let { ctx ->
- if (setSubtitles(sub, false)) {
- player.saveData()
- player.reloadPlayer(ctx)
- player.handleEvent(CSPlayerEvent.Play)
- return true
- }
- }
- }
+ private fun autoSelectFromDownloads() {
+ if (player.getCurrentPreferredSubtitle() != null) {
+ return
}
- return false
+ val sub =
+ getAutoSelectSubtitle(viewModel.state.subtitles, settings = false, downloads = true)
+ ?: return
+ val ctx = context ?: return
+ if (!setSubtitles(sub, false)) {
+ return
+ }
+ player.saveData()
+ player.reloadPlayer(ctx)
+ player.handleEvent(CSPlayerEvent.Play)
}
private fun autoSelectSubtitles() {
@@ -1855,7 +1865,7 @@ class GeneratorPlayer : FullScreenPlayer() {
playerBinding?.playerEpisodeFillerHolder?.isVisible = isFiller ?: false
playerBinding?.playerVideoTitle?.text = playerVideoTitle
- playerBinding?.offlinePin?.isVisible = lastUsedGenerator is DownloadFileGenerator
+ playerBinding?.offlinePin?.isVisible = viewModel.generator is DownloadFileGenerator
}
fun setPlayerDimen(widthHeight: Pair?) {
@@ -2049,8 +2059,9 @@ class GeneratorPlayer : FullScreenPlayer() {
}
override fun isThereEpisodes(): Boolean {
- val meta = allMeta
- return !meta.isNullOrEmpty() && meta.size > 1
+ // Checks if there is a second episode of type ResultEpisode
+ // => There exists more than 1 episode, and they are all ResultEpisode
+ return viewModel.state.generatorState?.allMeta?.getOrNull(1) as? ResultEpisode != null
}
override fun showEpisodesOverlay() {
@@ -2062,7 +2073,7 @@ class GeneratorPlayer : FullScreenPlayer() {
{ episodeClick ->
if (episodeClick.action == ACTION_CLICK_DEFAULT) {
isNextEpisode = false
- player.release()
+ releasePlayer()
playerEpisodeOverlay.isGone = true
episodeClick.position?.let { viewModel.loadThisEpisode(it) }
}
@@ -2081,7 +2092,7 @@ class GeneratorPlayer : FullScreenPlayer() {
(playerEpisodeList.adapter as? EpisodeAdapter)?.submitList(episodes)
// Scroll to current episode
- viewModel.getCurrentIndex()?.let { index ->
+ viewModel.state.generatorState?.index?.let { index ->
playerEpisodeList.scrollToPosition(index)
// Ensure focus on tv
if (isLayout(TV)) {
@@ -2125,32 +2136,51 @@ class GeneratorPlayer : FullScreenPlayer() {
}
}
+ @MainThread
+ fun releasePlayer() {
+ player.release()
+ currentSelectedSubtitles = null
+ isPlayerActive.set(false)
+ binding?.overlayLoadingSkipButton?.isVisible = false
+ binding?.playerLoadingOverlay?.isVisible = true
+ uiReset()
+ }
+
+ fun exitPlayer() {
+ playerHostView?.exitFullscreen()
+ player.release()
+ activity?.popCurrentPage()
+ }
+
override fun onBindingCreated(binding: FragmentPlayerBinding, savedInstanceState: Bundle?) {
viewModel = ViewModelProvider(this)[PlayerGeneratorViewModel::class.java]
sync = ViewModelProvider(this)[SyncViewModel::class.java]
- viewModel.attachGenerator(lastUsedGenerator)
+
+ val uuid = savedInstanceState?.getString("uuid") ?: arguments?.getString("uuid")
+ val index = savedInstanceState?.getInt("index") ?: arguments?.getInt("index")
+
+ viewModel.attachGenerator(generators[uuid], index)
+
unwrapBundle(savedInstanceState)
unwrapBundle(arguments)
super.onBindingCreated(binding, savedInstanceState)
-
- var langFilterList = listOf()
- var filterSubByLang = false
-
context?.let { ctx ->
val settingsManager = PreferenceManager.getDefaultSharedPreferences(ctx)
showName = settingsManager.getBoolean(ctx.getString(R.string.show_name_key), true)
- showResolution = settingsManager.getBoolean(ctx.getString(R.string.show_resolution_key), true)
- showMediaInfo = settingsManager.getBoolean(ctx.getString(R.string.show_media_info_key), false)
+ showResolution =
+ settingsManager.getBoolean(ctx.getString(R.string.show_resolution_key), true)
+ showMediaInfo =
+ settingsManager.getBoolean(ctx.getString(R.string.show_media_info_key), false)
limitTitle = settingsManager.getInt(ctx.getString(R.string.prefer_title_limit_key), 0)
updateForcedEncoding(ctx)
- filterSubByLang =
+ viewModel.filterSubByLang =
settingsManager.getBoolean(getString(R.string.filter_sub_lang_key), false)
- if (filterSubByLang) {
+ if (viewModel.filterSubByLang) {
val langFromPrefMedia = settingsManager.getStringSet(
this.getString(R.string.provider_lang_key), mutableSetOf("en")
)
- langFilterList = langFromPrefMedia?.mapNotNull {
+ viewModel.langFilterList = langFromPrefMedia?.mapNotNull {
fromTagToEnglishLanguageName(it)?.lowercase() ?: return@mapNotNull null
} ?: listOf()
}
@@ -2168,13 +2198,14 @@ class GeneratorPlayer : FullScreenPlayer() {
}
binding.overlayLoadingSkipButton.setOnClickListener {
- startPlayer()
+ // Mark as "success" early
+ viewModel.modifyState {
+ copy(loading = Resource.Success(true))
+ }
}
binding.playerLoadingGoBack.setOnClickListener {
- playerHostView?.exitFullscreen()
- player.release()
- activity?.popCurrentPage()
+ exitPlayer()
}
playerBinding?.downloadHeader?.setOnClickListener {
@@ -2191,10 +2222,21 @@ class GeneratorPlayer : FullScreenPlayer() {
player.addTimeStamps(stamps)
}
- observe(viewModel.loadingLinks) {
- when (it) {
+ observe(viewModel.currentSubtitles) { subtitles ->
+ player.setActiveSubtitles(subtitles)
+
+ // If the file is downloaded then do not select auto select the subtitles
+ // Downloaded subtitles cannot be selected immediately after loading since
+ // player.getCurrentPreferredSubtitle() cannot fetch data from non-loaded subtitles
+ // Resulting in unselecting the downloaded subtitle
+ if (subtitles.lastOrNull()?.origin != SubtitleOrigin.DOWNLOADED_FILE) {
+ autoSelectSubtitles()
+ }
+ }
+ observe(viewModel.loadingLinks) { loading ->
+ when (loading) {
is Resource.Loading -> {
- startLoading()
+ releasePlayer()
}
is Resource.Success -> {
@@ -2206,30 +2248,28 @@ class GeneratorPlayer : FullScreenPlayer() {
}
is Resource.Failure -> {
- showToast(it.errorString, Toast.LENGTH_LONG)
+ showToast(loading.errorString, Toast.LENGTH_LONG)
startPlayer()
}
}
}
- observe(viewModel.currentLinks) {
- currentLinks = it
- val turnVisible = it.isNotEmpty() && lastUsedGenerator?.canSkipLoading == true
+ observe(viewModel.currentLinks) { links ->
+ val turnVisible = links.isNotEmpty() && viewModel.generator?.canSkipLoading == true
val wasGone = binding.overlayLoadingSkipButton.isGone
binding.overlayLoadingSkipButton.apply {
isVisible = turnVisible
- val value = viewModel.currentLinks.value
- if (value.isNullOrEmpty()) {
+ if (links.isEmpty()) {
setText(R.string.skip_loading)
} else {
@SuppressLint("SetTextI18n")
- text = "${context.getString(R.string.skip_loading)} (${value.size})"
+ text = "${context.getString(R.string.skip_loading)} (${links.size})"
}
}
safe {
- if (currentLinks.any { link ->
+ if (viewModel.state.links.any { link ->
getLinkPriority(currentQualityProfile, link.first) >=
QualityDataHelper.AUTO_SKIP_PRIORITY
}
@@ -2242,34 +2282,7 @@ class GeneratorPlayer : FullScreenPlayer() {
binding.overlayLoadingSkipButton.requestFocus()
}
}
-
- observe(viewModel.currentSubs) { set ->
- val setOfSub = mutableSetOf()
- if (langFilterList.isNotEmpty() && filterSubByLang) {
- Log.i("subfilter", "Filtering subtitle")
- langFilterList.forEach { lang ->
- Log.i("subfilter", "Lang: $lang")
- setOfSub += set.filter {
- it.originalName.contains(lang, ignoreCase = true) ||
- it.origin != SubtitleOrigin.URL
- }
- }
- currentSubs = setOfSub
- } else {
- currentSubs = set
- }
- player.setActiveSubtitles(set)
-
- // If the file is downloaded then do not select auto select the subtitles
- // Downloaded subtitles cannot be selected immediately after loading since
- // player.getCurrentPreferredSubtitle() cannot fetch data from non-loaded subtitles
- // Resulting in unselecting the downloaded subtitle
- if (set.lastOrNull()?.origin != SubtitleOrigin.DOWNLOADED_FILE) {
- autoSelectSubtitles()
- }
- }
}
-
}
@Suppress("DEPRECATION")
diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/IGenerator.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/IGenerator.kt
index 0a34feee3..3ab46ce21 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/IGenerator.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/IGenerator.kt
@@ -1,10 +1,7 @@
package com.lagradost.cloudstream3.ui.player
-import com.lagradost.cloudstream3.ui.result.ResultEpisode
import com.lagradost.cloudstream3.utils.ExtractorLink
import com.lagradost.cloudstream3.utils.ExtractorLinkType
-import kotlin.math.max
-import kotlin.math.min
val LOADTYPE_INAPP = setOf(
ExtractorLinkType.VIDEO,
@@ -28,71 +25,27 @@ val LOADTYPE_CHROMECAST = setOf(
val LOADTYPE_ALL = ExtractorLinkType.entries.toSet()
-abstract class NoVideoGenerator : VideoGenerator(emptyList(), 0) {
+abstract class NoVideoGenerator(val id : Int?) : VideoGenerator(emptyList()) {
override val hasCache = false
override val canSkipLoading = false
+ override fun getId(index: Int): Int? = id
}
-abstract class VideoGenerator(val videos: List, var videoIndex: Int = 0) :
- IGenerator {
+abstract class VideoGenerator(val videos: List) {
+ abstract val hasCache: Boolean
+ abstract val canSkipLoading: Boolean
+ abstract fun getId(index : Int) : Int?
- override fun hasNext(): Boolean = videoIndex < videos.lastIndex
- override fun hasPrev(): Boolean = videoIndex > 0
- override fun getAll(): List? = videos
- override fun getCurrent(offset: Int): T? = videos.getOrNull(videoIndex + offset)
- override fun next() {
- if (hasNext()) {
- videoIndex += 1
- }
- }
+ fun hasNext(videoIndex : Int): Boolean = videoIndex < videos.lastIndex
+ fun hasPrev(videoIndex : Int): Boolean = videoIndex > 0
- override fun prev() {
- if (hasPrev()) {
- videoIndex -= 1
- }
- }
-
- override fun goto(index: Int) {
- videoIndex = min(videos.lastIndex, max(0, index))
- }
-
- override fun getCurrentId(): Int? {
- return when (val current = getCurrent()) {
- is ResultEpisode -> {
- current.id
- }
-
- is ExtractorUri -> {
- current.id
- }
-
- else -> null
- }
- }
-}
-
-// TODO deprecate/remove IGenerator in favor of a more ergonomic and correct implementation
-interface IGenerator {
- val hasCache: Boolean
- val canSkipLoading: Boolean
-
- fun hasNext(): Boolean
- fun hasPrev(): Boolean
- fun next()
- fun prev()
- fun goto(index: Int)
-
- fun getCurrentId(): Int? // this is used to save data or read data about this id
- fun getCurrent(offset: Int = 0): Any? // this is used to get metadata about the current playing, can return null
- fun getAll(): List? // this us used to get the metadata about all entries, not needed
-
- /* not safe, must use try catch */
- suspend fun generateLinks(
+ @Throws
+ abstract suspend fun generateLinks(
clearCache: Boolean,
sourceTypes: Set,
callback: (Pair) -> Unit,
subtitleCallback: (SubtitleData) -> Unit,
- offset: Int = 0,
- isCasting: Boolean = false
+ offset: Int,
+ isCasting: Boolean
): Boolean
}
diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/LinkGenerator.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/LinkGenerator.kt
index 71513af2c..db06e26e9 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/LinkGenerator.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/LinkGenerator.kt
@@ -40,7 +40,8 @@ class LinkGenerator(
private val links: List,
private val extract: Boolean = true,
private val refererUrl: String? = null,
-) : NoVideoGenerator() {
+ id: Int?
+) : NoVideoGenerator(id) {
override suspend fun generateLinks(
clearCache: Boolean,
sourceTypes: Set,
@@ -78,10 +79,8 @@ class LinkGenerator(
class MinimalLinkGenerator(
private val links: List,
private val subs: List,
- private val id: Int? = null
-) : NoVideoGenerator() {
- override fun getCurrentId(): Int? = id
-
+ id: Int?
+) : NoVideoGenerator(id) {
override suspend fun generateLinks(
clearCache: Boolean,
sourceTypes: Set,
diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/OfflinePlaybackHelper.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/OfflinePlaybackHelper.kt
index eb9f5c249..ac25347b6 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/OfflinePlaybackHelper.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/OfflinePlaybackHelper.kt
@@ -1,7 +1,6 @@
package com.lagradost.cloudstream3.ui.player
import android.app.Activity
-import android.content.ContentUris
import android.content.Intent
import android.net.Uri
import androidx.core.content.ContextCompat.getString
@@ -19,8 +18,8 @@ object OfflinePlaybackHelper {
LinkGenerator(
listOf(
BasicLink(url)
- )
- )
+ ), id = url.hashCode()
+ ), 0
)
)
}
@@ -52,7 +51,7 @@ object OfflinePlaybackHelper {
links,
subs,
if (id != -1) id else null,
- )
+ ), 0
)
)
return true
@@ -73,11 +72,10 @@ object OfflinePlaybackHelper {
name = name ?: getString(activity, R.string.downloaded_file),
// well not the same as a normal id, but we take it as users may want to
// play downloaded files and save the location
- id = kotlin.runCatching { ContentUris.parseId(uri) }.getOrNull()
- ?.hashCode()
+ id = uri.lastPathSegment?.toLongOrNull()?.hashCode() ?: uri.lastPathSegment?.hashCode()
)
)
- )
+ ), 0
)
)
}
diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerGeneratorViewModel.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerGeneratorViewModel.kt
index 96468490a..049ed06d6 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerGeneratorViewModel.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerGeneratorViewModel.kt
@@ -9,29 +9,137 @@ import com.lagradost.cloudstream3.LoadResponse
import com.lagradost.cloudstream3.mvvm.Resource
import com.lagradost.cloudstream3.mvvm.launchSafe
import com.lagradost.cloudstream3.mvvm.logError
-import com.lagradost.cloudstream3.mvvm.safe
import com.lagradost.cloudstream3.mvvm.safeApiCall
+import com.lagradost.cloudstream3.ui.player.source_priority.QualityDataHelper.getLinkPriority
import com.lagradost.cloudstream3.ui.result.ResultEpisode
import com.lagradost.cloudstream3.utils.Coroutines.ioSafe
import com.lagradost.cloudstream3.utils.ExtractorLink
import com.lagradost.cloudstream3.utils.ExtractorLinkType
import com.lagradost.cloudstream3.utils.videoskip.SkipAPI
import com.lagradost.cloudstream3.utils.videoskip.VideoSkipStamp
+import kotlinx.collections.immutable.PersistentList
+import kotlinx.collections.immutable.PersistentSet
+import kotlinx.collections.immutable.persistentListOf
+import kotlinx.collections.immutable.persistentSetOf
+import kotlinx.collections.immutable.toPersistentList
+import kotlinx.collections.immutable.toPersistentSet
import kotlinx.coroutines.Job
+import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
+import org.jetbrains.annotations.Contract
+import java.util.concurrent.ConcurrentHashMap
+
+typealias VideoLink = Pair
+
+data class GeneratorState(
+ val meta: Any?,
+ val nextMeta: Any?,
+ val allMeta: List<*>?,
+ val response: LoadResponse?,
+ val index: Int,
+ val id: Int?,
+)
+
+/** Immutable state of all current links relevant to displaying the video */
+// @MustUseReturnValues
+// @Immutable
+data class VideoState(
+ val subtitles: PersistentSet = persistentSetOf(),
+ val links: PersistentSet = persistentSetOf(),
+ val stamps: PersistentList = persistentListOf(),
+ val loading: Resource = Resource.Loading(),
+ val generatorState: GeneratorState? = null,
+) {
+ /**
+ * This acts as a local cache for sorted links that are not copied over by the copy constructor.
+ *
+ * sortedBy is not exactly expensive, but each hasNextMirror does it again, so this alleviates unnecessary recomputation
+ * */
+ private val sortedLinks: ConcurrentHashMap> = ConcurrentHashMap()
+
+ fun clearSortedLinksCache() = sortedLinks.clear()
+
+ // Modifying sortedLinks is not considered a "visible" side effect, and rerunning it does not change the result
+ // It is by all standards, idempotent and by extension also pure as it has no "visible" side effect
+ /** Returns .links in the sorted order according to the qualityProfile.
+ * Use .links if order is not needed */
+ @Contract(pure = true)
+ fun sortLinks(qualityProfile: Int): List {
+ return sortedLinks[qualityProfile] ?: links.sortedBy { link ->
+ // negative because we want to sort highest quality first
+ -getLinkPriority(qualityProfile, link.first)
+ }.also { value -> sortedLinks[qualityProfile] = value }
+ }
+
+ @Contract(pure = true)
+ fun add(item: SubtitleData): VideoState = copy(subtitles = subtitles.add(item))
+
+ @Contract(pure = true)
+ fun add(item: VideoLink): VideoState = copy(links = links.add(item))
+
+ @Contract(pure = true)
+ fun add(item: VideoSkipStamp): VideoState = copy(stamps = stamps.add(item))
+
+ @JvmName("addSubtitleData")
+ @Contract(pure = true)
+ fun add(items: Collection): VideoState = copy(subtitles = subtitles.addAll(items))
+
+ @JvmName("addVideoLink")
+ @Contract(pure = true)
+ fun add(items: Collection): VideoState = copy(links = links.addAll(items))
+
+ @JvmName("addVideoSkipStamp")
+ @Contract(pure = true)
+ fun add(items: Collection): VideoState = copy(stamps = stamps.addAll(items))
+
+ @Contract(pure = true)
+ fun set(item: SubtitleData): VideoState = copy(subtitles = persistentSetOf(item))
+
+ @Contract(pure = true)
+ fun set(item: VideoLink): VideoState = copy(links = persistentSetOf(item))
+
+ @Contract(pure = true)
+ fun set(item: VideoSkipStamp): VideoState = copy(stamps = persistentListOf(item))
+
+ @JvmName("setSubtitleData")
+ @Contract(pure = true)
+ fun set(items: Collection): VideoState = copy(subtitles = items.toPersistentSet())
+
+ @JvmName("setVideoLink")
+ @Contract(pure = true)
+ fun set(items: Collection): VideoState = copy(links = items.toPersistentSet())
+
+ @JvmName("setVideoSkipStamp")
+ @Contract(pure = true)
+ fun set(items: Collection): VideoState = copy(stamps = items.toPersistentList())
+}
class PlayerGeneratorViewModel : ViewModel() {
companion object {
const val TAG = "PlayViewGen"
}
- private var generator: IGenerator? = null
+ @Volatile
+ var generator: VideoGenerator<*>? = null
+
+ @Volatile
+ private var episodeIndex: Int = 0
+
+ /**
+ * The state of the video player, only modify it by modifyState to make sure observe is called,
+ * and avoid concurrency issues.
+ *
+ * This value can be used without Synchronized or locking when reading, as all fields are immutable.
+ * */
+ @Volatile
+ var state = VideoState()
+ private set
private val _currentLinks = MutableLiveData>>(setOf())
val currentLinks: LiveData>> = _currentLinks
- private val _currentSubs = MutableLiveData>(setOf())
- val currentSubs: LiveData> = _currentSubs
+ private val _currentSubtitles = MutableLiveData>(setOf())
+ val currentSubtitles: LiveData> = _currentSubtitles
private val _loadingLinks = MutableLiveData>()
val loadingLinks: LiveData> = _loadingLinks
@@ -39,6 +147,35 @@ class PlayerGeneratorViewModel : ViewModel() {
private val _currentStamps = MutableLiveData>(emptyList())
val currentStamps: LiveData> = _currentStamps
+ /**
+ * Modifies the `state` variable safely, and with the correct observe behavior.
+ *
+ * Synchronized to avoid concurrency issues, and make this operation atomic.
+ * Otherwise, one update may be lost if they are done in parallel.
+ * */
+ @Synchronized
+ fun modifyState(op: VideoState.() -> VideoState) {
+ val oldState = state
+ state = op.invoke(oldState)
+
+ /**
+ * Only post the changed values, this makes sure we do not invoke the "observe"
+ *
+ * We do this by "Referential equality" https://kotlinlang.org/docs/equality.html#referential-equality
+ * to avoid comparing the entire set or list as "Persistent" classes will hold the same reference if they are unchanged.
+ * */
+ if (state.links !== oldState.links)
+ _currentLinks.postValue(state.links)
+ if (state.stamps !== oldState.stamps)
+ _currentStamps.postValue(state.stamps)
+ if (state.subtitles !== oldState.subtitles)
+ _currentSubtitles.postValue(state.subtitles)
+
+ /** Normal equality here as it is not a collection */
+ if (state.loading != oldState.loading)
+ _loadingLinks.postValue(state.loading)
+ }
+
private val _currentSubtitleYear = MutableLiveData(null)
val currentSubtitleYear: LiveData = _currentSubtitleYear
@@ -53,41 +190,32 @@ class PlayerGeneratorViewModel : ViewModel() {
_currentSubtitleYear.postValue(year)
}
- fun getId(): Int? {
- return generator?.getCurrentId()
- }
-
- fun loadLinks(episode: Int) {
- generator?.goto(episode)
- loadLinks()
- }
-
fun loadLinksPrev() {
Log.i(TAG, "loadLinksPrev")
- if (generator?.hasPrev() == true) {
- generator?.prev()
+ if (generator?.hasPrev(episodeIndex) == true) {
+ episodeIndex += 1
loadLinks()
}
}
fun loadLinksNext() {
Log.i(TAG, "loadLinksNext")
- if (generator?.hasNext() == true) {
- generator?.next()
+ if (generator?.hasNext(episodeIndex) == true) {
+ episodeIndex += 1
loadLinks()
}
}
fun hasNextEpisode(): Boolean? {
- return generator?.hasNext()
+ return generator?.hasNext(episodeIndex)
}
fun hasPrevEpisode(): Boolean? {
- return generator?.hasPrev()
+ return generator?.hasPrev(episodeIndex)
}
fun preLoadNextLinks() {
- val id = getId()
+ val id = generator?.getId(episodeIndex)
// Do not preload if already loading
if (id == currentLoadingEpisodeId) return
@@ -97,14 +225,15 @@ class PlayerGeneratorViewModel : ViewModel() {
currentJob = viewModelScope.launch {
try {
- if (generator?.hasCache == true && generator?.hasNext() == true) {
+ if (generator?.hasCache == true && generator?.hasNext(episodeIndex) == true) {
safeApiCall {
generator?.generateLinks(
sourceTypes = LOADTYPE_INAPP,
clearCache = false,
+ isCasting = false,
callback = {},
subtitleCallback = {},
- offset = 1
+ offset = episodeIndex + 1
)
}
}
@@ -118,129 +247,132 @@ class PlayerGeneratorViewModel : ViewModel() {
}
}
- fun getLoadResponse(): LoadResponse? {
- return safe { (generator as? RepoLinkGenerator?)?.page }
- }
-
- fun getMeta(): Any? {
- return safe { generator?.getCurrent() }
- }
-
- fun getAllMeta(): List? {
- return safe { generator?.getAll() }
- }
-
- fun getNextMeta(): Any? {
- return safe {
- if (generator?.hasNext() == false) return@safe null
- generator?.getCurrent(offset = 1)
- }
- }
-
- fun loadThisEpisode(index:Int) {
- generator?.goto(index)
+ fun loadThisEpisode(index: Int) {
+ episodeIndex = index
loadLinks()
}
- fun getCurrentIndex():Int?{
- val repoGen = generator as? RepoLinkGenerator ?: return null
- return repoGen.videoIndex
- }
-
- fun attachGenerator(newGenerator: IGenerator?) {
+ fun attachGenerator(newGenerator: VideoGenerator<*>?, index: Int?) {
if (generator == null) {
generator = newGenerator
+ if (index != null) {
+ episodeIndex = index
+ }
}
}
- private var extraSubtitles : MutableSet = mutableSetOf()
-
/**
* If duplicate nothing will happen
* */
- fun addSubtitles(file: Set) = synchronized(extraSubtitles) {
- extraSubtitles += file
- val current = _currentSubs.value ?: emptySet()
- val next = extraSubtitles + current
-
- // if it is of a different size then we have added distinct items
- if (next.size != current.size) {
- // Posting will refresh subtitles which will in turn
- // make the subs to english if previously unselected
- _currentSubs.postValue(next)
- }
+ fun addSubtitles(file: Set) {
+ val validFile = file.filter(::isValidSubtitle)
+ if (validFile.isNotEmpty())
+ modifyState {
+ add(validFile)
+ }
}
private var currentJob: Job? = null
private var currentStampJob: Job? = null
fun loadStamps(duration: Long) {
- //currentStampJob?.cancel()
currentStampJob = ioSafe {
- val meta = generator?.getCurrent()
- val page = (generator as? RepoLinkGenerator?)?.page
- if (page != null && meta is ResultEpisode) {
- _currentStamps.postValue(listOf())
- _currentStamps.postValue(
- SkipAPI.videoStamps(
- page,
- meta,
- duration,
- hasNextEpisode() ?: false
- )
- )
+ val genState = state.generatorState ?: return@ioSafe
+ val meta = genState.meta
+ val page = genState.response
+ val id = genState.id
+ if (page == null || meta !is ResultEpisode) {
+ return@ioSafe
}
+ val stamps = SkipAPI.videoStamps(
+ page,
+ meta,
+ duration,
+ hasNextEpisode() ?: false
+ )
+
+ /** Avoid adding stamps to the wrong video */
+ modifyState {
+ if (id != this.generatorState?.id) {
+ this
+ } else {
+ set(stamps)
+ }
+ }
+ }
+ }
+
+ var langFilterList = listOf()
+ var filterSubByLang = false
+
+ fun isValidSubtitle(subtitle: SubtitleData): Boolean {
+ if (langFilterList.isEmpty() || !filterSubByLang) {
+ return true
+ }
+
+ /** Only filter out subtitles fetched online */
+ if (subtitle.origin != SubtitleOrigin.URL) {
+ return true
+ }
+
+ return langFilterList.any { lang ->
+ subtitle.originalName.contains(lang, ignoreCase = true)
}
}
fun loadLinks(sourceTypes: Set = LOADTYPE_INAPP) {
Log.i(TAG, "loadLinks")
currentJob?.cancel()
+ val index = episodeIndex
currentJob = viewModelScope.launchSafe {
- // if we load links then we clear the prev loaded links
- synchronized(extraSubtitles) {
- extraSubtitles.clear()
+ // Clear old data and reset the state
+ modifyState {
+ VideoState(
+ generatorState = generator?.let { gen ->
+ GeneratorState(
+ meta = gen.videos.getOrNull(index),
+ nextMeta = gen.videos.getOrNull(index + 1),
+ id = gen.getId(index),
+ response = (gen as? RepoLinkGenerator)?.page,
+ index = index,
+ allMeta = gen.videos
+ )
+ }
+ )
}
- val currentLinks = mutableSetOf>()
- val currentSubs = mutableSetOf()
- // clear old data
- _currentSubs.postValue(emptySet())
- _currentLinks.postValue(emptySet())
-
- // load more data
- _loadingLinks.postValue(Resource.Loading())
+ // Load more data
val loadingState = safeApiCall {
generator?.generateLinks(
sourceTypes = sourceTypes,
clearCache = forceClearCache,
- callback = {
- synchronized(currentLinks) {
- currentLinks.add(it)
- // Clone to prevent ConcurrentModificationException
- safe {
- // Extra safe since .toSet() iterates.
- _currentLinks.postValue(currentLinks.toSet())
- }
+ callback = { link ->
+ modifyState {
+ add(link)
}
},
- subtitleCallback = {
- synchronized(extraSubtitles) {
- currentSubs.add(it)
- safe {
- _currentSubs.postValue(currentSubs + extraSubtitles)
+ isCasting = false,
+ offset = index,
+ subtitleCallback = { link ->
+ if (isValidSubtitle(link))
+ modifyState {
+ add(link)
}
- }
})
}
- _loadingLinks.postValue(loadingState)
- _currentLinks.postValue(currentLinks)
- synchronized(extraSubtitles) {
- _currentSubs.postValue(currentSubs + extraSubtitles)
+ if (!isActive) {
+ return@launchSafe
+ }
+
+ /** Only mark as success if we have not skipped loading */
+ modifyState {
+ when (loading) {
+ is Resource.Loading -> copy(loading = loadingState)
+ else -> this
+ }
}
}
-
}
}
\ No newline at end of file
diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/RepoLinkGenerator.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/RepoLinkGenerator.kt
index 0dddf58a1..0668a194b 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/RepoLinkGenerator.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/RepoLinkGenerator.kt
@@ -9,8 +9,8 @@ import com.lagradost.cloudstream3.ui.result.ResultEpisode
import com.lagradost.cloudstream3.utils.AppContextUtils.html
import com.lagradost.cloudstream3.utils.ExtractorLink
import com.lagradost.cloudstream3.utils.ExtractorLinkType
-import kotlin.math.max
-import kotlin.math.min
+import java.util.concurrent.ConcurrentHashMap
+import java.util.concurrent.atomic.AtomicInteger
data class Cache(
val linkCache: MutableSet,
@@ -23,9 +23,8 @@ data class Cache(
class RepoLinkGenerator(
episodes: List,
- currentIndex: Int = 0,
val page: LoadResponse? = null,
-) : VideoGenerator(episodes, currentIndex) {
+) : VideoGenerator(episodes) {
companion object {
const val TAG = "RepoLink"
val cache: HashMap, Cache> =
@@ -34,6 +33,7 @@ class RepoLinkGenerator(
override val hasCache = true
override val canSkipLoading = true
+ override fun getId(index: Int): Int? = videos.getOrNull(index)?.id
// this is a simple array that is used to instantly load links if they are already loaded
//var linkCache = Array>(size = episodes.size, init = { setOf() })
@@ -48,7 +48,7 @@ class RepoLinkGenerator(
offset: Int,
isCasting: Boolean,
): Boolean {
- val current = getCurrent(offset) ?: return false
+ val current = videos.getOrNull(offset) ?: return false
val currentCache = synchronized(cache) {
cache[current.apiName to current.id] ?: Cache(
@@ -61,10 +61,12 @@ class RepoLinkGenerator(
}
}
- // these act as a general filter to prevent duplication of links or names
- val currentLinksUrls = mutableSetOf() // makes all urls unique
- val currentSubsUrls = mutableSetOf() // makes all subs urls unique
- val lastCountedSuffix = mutableMapOf()
+ // These act as a general filter to prevent duplication of links or names
+ // Avoid any possible ConcurrentModificationException
+ val currentLinksUrls = ConcurrentHashMap.newKeySet()
+ val currentSubsUrls = ConcurrentHashMap.newKeySet()
+ // Use atomics as otherwise we get race conditions when incrementing, while rare it did actually happen!
+ val lastCountedSuffix = ConcurrentHashMap()
synchronized(currentCache) {
val outdatedCache =
@@ -75,7 +77,10 @@ class RepoLinkGenerator(
currentCache.subtitleCache.clear()
currentCache.saturated = false
} else if (currentCache.linkCache.isNotEmpty()) {
- Log.d(TAG, "Resumed previous loading from ${unixTime - currentCache.lastCachedTimestamp}s ago")
+ Log.d(
+ TAG,
+ "Resumed previous loading from ${unixTime - currentCache.lastCachedTimestamp}s ago"
+ )
}
// call all callbacks
@@ -88,8 +93,7 @@ class RepoLinkGenerator(
currentCache.subtitleCache.forEach { sub ->
currentSubsUrls.add(sub.url)
- val suffixCount = lastCountedSuffix.getOrDefault(sub.originalName, 0u) + 1u
- lastCountedSuffix[sub.originalName] = suffixCount
+ lastCountedSuffix.getOrPut(sub.originalName) { AtomicInteger(0) }.incrementAndGet()
subtitleCallback(sub)
}
@@ -108,17 +112,15 @@ class RepoLinkGenerator(
subtitleCallback = { file ->
Log.d(TAG, "Loaded SubtitleFile: $file")
val correctFile = PlayerSubtitleHelper.getSubtitleData(file)
- if (correctFile.url.isBlank() || currentSubsUrls.contains(correctFile.url)) {
+ if (correctFile.url.isBlank() || !currentSubsUrls.add(correctFile.url)) {
return@loadLinks
}
- currentSubsUrls.add(correctFile.url)
// this part makes sure that all names are unique for UX
-
- val nameDecoded = correctFile.originalName.html().toString().trim() // `%3Ch1%3Esub%20name…` → `sub name…` → `sub name…`
-
- val suffixCount = lastCountedSuffix.getOrDefault(nameDecoded, 0u) +1u
- lastCountedSuffix[nameDecoded] = suffixCount
+ val nameDecoded = correctFile.originalName.html().toString()
+ .trim() // `%3Ch1%3Esub%20name…` → `sub name…` → `sub name…`
+ val suffixCount =
+ lastCountedSuffix.getOrPut(nameDecoded) { AtomicInteger(0) }.incrementAndGet()
val updatedFile =
correctFile.copy(originalName = nameDecoded, nameSuffix = "$suffixCount")
@@ -132,10 +134,9 @@ class RepoLinkGenerator(
},
callback = { link ->
Log.d(TAG, "Loaded ExtractorLink: $link")
- if (link.url.isBlank() || currentLinksUrls.contains(link.url)) {
+ if (link.url.isBlank() || !currentLinksUrls.add(link.url)) {
return@loadLinks
}
- currentLinksUrls.add(link.url)
synchronized(currentCache) {
if (currentCache.linkCache.add(link)) {
diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultFragmentTv.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultFragmentTv.kt
index 70ca11743..cfbacc5d1 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultFragmentTv.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultFragmentTv.kt
@@ -559,7 +559,7 @@ class ResultFragmentTv : BaseFragment(
ExtractorLinkGenerator(
extractedTrailerLinks,
emptyList()
- )
+ ), 0
)
)
}
@@ -925,8 +925,12 @@ class ResultFragmentTv : BaseFragment(
resultTvComingSoon.isVisible = d.comingSoon
populateChips(resultTag, d.tags)
- val prefs = androidx.preference.PreferenceManager.getDefaultSharedPreferences(root.context)
- val showCast = prefs.getBoolean(root.context.getString(R.string.show_cast_in_details_key), true)
+ val prefs =
+ androidx.preference.PreferenceManager.getDefaultSharedPreferences(root.context)
+ val showCast = prefs.getBoolean(
+ root.context.getString(R.string.show_cast_in_details_key),
+ true
+ )
resultCastItems.isGone = !showCast || d.actors.isNullOrEmpty()
(resultCastItems.adapter as? ActorAdaptor)?.submitList(if (showCast) d.actors else emptyList())
diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultViewModel2.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultViewModel2.kt
index cf563df8e..7dfe3cf59 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultViewModel2.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultViewModel2.kt
@@ -1,7 +1,8 @@
package com.lagradost.cloudstream3.ui.result
import android.app.Activity
-import android.content.*
+import android.content.Context
+import android.content.DialogInterface
import android.util.Log
import android.widget.Toast
import androidx.annotation.MainThread
@@ -10,24 +11,50 @@ import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
-import com.lagradost.cloudstream3.*
-import com.lagradost.cloudstream3.actions.AlwaysAskAction
-import com.lagradost.cloudstream3.actions.VideoClickActionHolder
+import com.lagradost.cloudstream3.APIHolder
import com.lagradost.cloudstream3.APIHolder.apis
import com.lagradost.cloudstream3.APIHolder.getApiFromNameNull
import com.lagradost.cloudstream3.APIHolder.unixTime
import com.lagradost.cloudstream3.APIHolder.unixTimeMS
+import com.lagradost.cloudstream3.ActorData
+import com.lagradost.cloudstream3.AnimeLoadResponse
import com.lagradost.cloudstream3.CloudStreamApp.Companion.context
import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey
import com.lagradost.cloudstream3.CommonActivity.activity
import com.lagradost.cloudstream3.CommonActivity.getCastSession
import com.lagradost.cloudstream3.CommonActivity.showToast
+import com.lagradost.cloudstream3.DubStatus
+import com.lagradost.cloudstream3.EpisodeResponse
+import com.lagradost.cloudstream3.IDownloadableMinimum
+import com.lagradost.cloudstream3.LiveStreamLoadResponse
+import com.lagradost.cloudstream3.LoadResponse
import com.lagradost.cloudstream3.LoadResponse.Companion.addTrailer
import com.lagradost.cloudstream3.LoadResponse.Companion.getAniListId
import com.lagradost.cloudstream3.LoadResponse.Companion.getKitsuId
import com.lagradost.cloudstream3.LoadResponse.Companion.getMalId
import com.lagradost.cloudstream3.LoadResponse.Companion.isMovie
import com.lagradost.cloudstream3.LoadResponse.Companion.readIdFromString
+import com.lagradost.cloudstream3.MainActivity
+import com.lagradost.cloudstream3.MovieLoadResponse
+import com.lagradost.cloudstream3.ProviderType
+import com.lagradost.cloudstream3.R
+import com.lagradost.cloudstream3.Score
+import com.lagradost.cloudstream3.SearchResponse
+import com.lagradost.cloudstream3.SeasonData
+import com.lagradost.cloudstream3.ShowStatus
+import com.lagradost.cloudstream3.SimklSyncServices
+import com.lagradost.cloudstream3.SubtitleFile
+import com.lagradost.cloudstream3.TorrentLoadResponse
+import com.lagradost.cloudstream3.TrackerType
+import com.lagradost.cloudstream3.TrailerData
+import com.lagradost.cloudstream3.TvSeriesLoadResponse
+import com.lagradost.cloudstream3.TvType
+import com.lagradost.cloudstream3.VPNStatus
+import com.lagradost.cloudstream3.actions.AlwaysAskAction
+import com.lagradost.cloudstream3.actions.VideoClickActionHolder
+import com.lagradost.cloudstream3.amap
+import com.lagradost.cloudstream3.isEpisodeBased
+import com.lagradost.cloudstream3.isLiveStream
import com.lagradost.cloudstream3.metaproviders.SyncRedirector
import com.lagradost.cloudstream3.mvvm.Resource
import com.lagradost.cloudstream3.mvvm.debugAssert
@@ -44,9 +71,7 @@ import com.lagradost.cloudstream3.syncproviders.SyncAPI
import com.lagradost.cloudstream3.syncproviders.providers.Kitsu
import com.lagradost.cloudstream3.ui.APIRepository
import com.lagradost.cloudstream3.ui.WatchType
-import com.lagradost.cloudstream3.utils.downloader.DownloadQueueManager
import com.lagradost.cloudstream3.ui.player.GeneratorPlayer
-import com.lagradost.cloudstream3.ui.player.IGenerator
import com.lagradost.cloudstream3.ui.player.LOADTYPE_ALL
import com.lagradost.cloudstream3.ui.player.LOADTYPE_CHROMECAST
import com.lagradost.cloudstream3.ui.player.LOADTYPE_INAPP
@@ -105,8 +130,8 @@ import com.lagradost.cloudstream3.utils.UIHelper.navigate
import com.lagradost.cloudstream3.utils.UiText
import com.lagradost.cloudstream3.utils.VIDEO_WATCH_STATE
import com.lagradost.cloudstream3.utils.downloader.DownloadFileManagement.sanitizeFilename
-import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager.getDownloadEpisodeMetadata
import com.lagradost.cloudstream3.utils.downloader.DownloadObjects
+import com.lagradost.cloudstream3.utils.downloader.DownloadQueueManager
import com.lagradost.cloudstream3.utils.downloader.DownloadUtils.downloadSubtitle
import com.lagradost.cloudstream3.utils.loadExtractor
import com.lagradost.cloudstream3.utils.newExtractorLink
@@ -423,7 +448,7 @@ fun SelectPopup.getOptions(context: Context): List {
}
data class ExtractedTrailerData(
- var mirros: List>,//Pair of extracted trailer link and original trailer link
+ var mirros: List>,//Pair of extracted trailer link and original trailer link
var subtitles: List = emptyList(),
)
@@ -454,7 +479,7 @@ class ResultViewModel2 : ViewModel() {
var currentRepo: APIRepository? = null
private var currentId: Int? = null
private var fillers: HashSet = hashSetOf()
- private var generator: IGenerator? = null
+ private var generator: RepoLinkGenerator? = null
private var preferDubStatus: DubStatus? = null
private var preferStartEpisode: Int? = null
private var preferStartSeason: Int? = null
@@ -1267,9 +1292,10 @@ class ResultViewModel2 : ViewModel() {
subs += sub
updatePage()
},
- isCasting = isCasting
+ isCasting = isCasting,
+ offset = 0
)
- } catch (e: CancellationException) {
+ } catch (_: CancellationException) {
// Do nothing
} catch (e: Exception) {
logError(e)
@@ -1518,26 +1544,24 @@ class ResultViewModel2 : ViewModel() {
ACTION_PLAY_EPISODE_IN_PLAYER -> {
val list = HashMap(currentResponse?.syncData ?: emptyMap())
+ val generator = generator ?: return
+
+ // I know kinda shit to iterate all, but it is 100% sure to work
+ val index = generator.videos.indexOfFirst { value -> value.id == click.data.id }
- generator?.also {
- it.getAll() // I know kinda shit to iterate all, but it is 100% sure to work
- ?.indexOfFirst { value -> value is ResultEpisode && value.id == click.data.id }
- ?.let { index ->
- if (index >= 0)
- it.goto(index)
- }
- }
if (currentResponse?.type == TvType.CustomMedia) {
- generator?.generateLinks(
+ generator.generateLinks(
+ offset = index,
clearCache = true,
- LOADTYPE_ALL,
+ isCasting = false,
+ sourceTypes = LOADTYPE_ALL,
callback = {},
subtitleCallback = {})
} else {
activity?.navigate(
R.id.global_to_navigation_player,
GeneratorPlayer.newInstance(
- generator ?: return, list
+ generator, index,list
)
)
}
@@ -1807,7 +1831,7 @@ class ResultViewModel2 : ViewModel() {
}
- private suspend fun updateFillers(data : LoadResponse) {
+ private suspend fun updateFillers(data: LoadResponse) {
fillers = ioWorkSafe {
FillerEpisodeCheck.getFillerEpisodes(data)
} ?: hashSetOf()
@@ -2429,26 +2453,34 @@ class ResultViewModel2 : ViewModel() {
loadResponse.trailers.windowed(limit, limit, true).takeWhile { list ->
list.amap { trailerData ->
try {
- val links = arrayListOf>()
+ val links = arrayListOf>()
val subs = arrayListOf()
if (!loadExtractor(
trailerData.extractorUrl,
trailerData.referer,
{ subs.add(it) },
- { links.add(Pair(it,trailerData.extractorUrl))}) && trailerData.raw
+ {
+ links.add(
+ Pair(
+ it,
+ trailerData.extractorUrl
+ )
+ )
+ }) && trailerData.raw
) {
arrayListOf(
Pair(
newExtractorLink(
- "",
- "Trailer",
- trailerData.extractorUrl,
- type = INFER_TYPE
- ) {
- this.referer = trailerData.referer ?: ""
- this.quality = Qualities.Unknown.value
- this.headers = trailerData.headers
- },trailerData.extractorUrl)
+ "",
+ "Trailer",
+ trailerData.extractorUrl,
+ type = INFER_TYPE
+ ) {
+ this.referer = trailerData.referer ?: ""
+ this.quality = Qualities.Unknown.value
+ this.headers = trailerData.headers
+ }, trailerData.extractorUrl
+ )
) to arrayListOf()
} else {
links to subs
diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/downloader/DownloadManager.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/downloader/DownloadManager.kt
index f1d7ed742..12fcc0c33 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/utils/downloader/DownloadManager.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/utils/downloader/DownloadManager.kt
@@ -2000,6 +2000,8 @@ object VideoDownloadManager {
linkLoadingJob = ioSafe {
generator.generateLinks(
+ offset = 0,
+ isCasting = false,
clearCache = false,
sourceTypes = LOADTYPE_INAPP_DOWNLOAD,
callback = {
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index dc65cc4ee..a97145c3f 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -26,6 +26,7 @@ junitKtx = "1.3.0"
junitVersion = "1.3.0"
juniversalchardet = "2.5.0"
kotlinGradlePlugin = "2.3.20"
+kotlinxCollectionsImmutable = "0.4.0"
kotlinxCoroutinesCore = "1.10.2"
lifecycleKtx = "2.10.0"
material = "1.14.0-beta01"
@@ -80,6 +81,7 @@ jsoup = { module = "org.jsoup:jsoup", version.ref = "jsoup" }
junit = { module = "junit:junit", version.ref = "junit" }
junit-ktx = { module = "androidx.test.ext:junit-ktx", version.ref = "junitKtx" }
juniversalchardet = { module = "com.github.albfernandez:juniversalchardet", version.ref = "juniversalchardet" }
+kotlinx-collections-immutable = { module = "org.jetbrains.kotlinx:kotlinx-collections-immutable", version.ref = "kotlinxCollectionsImmutable" }
kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinxCoroutinesCore" }
lifecycle-livedata-ktx = { module = "androidx.lifecycle:lifecycle-livedata-ktx", version.ref = "lifecycleKtx" }
lifecycle-viewmodel-ktx = { module = "androidx.lifecycle:lifecycle-viewmodel-ktx", version.ref = "lifecycleKtx" }
From bfc926814c53a8c422937f843bf7df680faa037f Mon Sep 17 00:00:00 2001
From: firelight <147925818+fire-light42@users.noreply.github.com>
Date: Wed, 6 May 2026 18:13:13 +0000
Subject: [PATCH 13/82] Fixobserveorder (#2766)
---
.../cloudstream3/ui/player/GeneratorPlayer.kt | 63 +++++++---
.../ui/player/PlayerGeneratorViewModel.kt | 113 +++++++++++-------
2 files changed, 113 insertions(+), 63 deletions(-)
diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/GeneratorPlayer.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/GeneratorPlayer.kt
index 9ee85a941..2dfd5ef4d 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/GeneratorPlayer.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/GeneratorPlayer.kt
@@ -143,7 +143,11 @@ class GeneratorPlayer : FullScreenPlayer() {
const val STOP_ACTION = "stopcs3"
private val generators = ConcurrentHashMap>()
- fun newInstance(generator: VideoGenerator<*>, index : Int, syncData: HashMap? = null): Bundle {
+ fun newInstance(
+ generator: VideoGenerator<*>,
+ index: Int,
+ syncData: HashMap? = null
+ ): Bundle {
Log.i(TAG, "newInstance = $syncData")
val uuid = UUID.randomUUID().toString()
generators[uuid] = generator
@@ -178,12 +182,14 @@ class GeneratorPlayer : FullScreenPlayer() {
private var isNextEpisode: Boolean = false // this is used to reset the watch time
private var preferredAutoSelectSubtitles: String? = null // null means do nothing, "" means none
- private val allMeta: List? get() = viewModel.state.generatorState?.allMeta?.filterIsInstance()?.map { episode ->
- // Refresh all the episodes watch duration
- getViewPos(episode.id)?.let { data ->
- episode.copy(position = data.position, duration = data.duration)
- } ?: episode
- }
+ private val allMeta: List?
+ get() = viewModel.state.generatorState?.allMeta?.filterIsInstance()
+ ?.map { episode ->
+ // Refresh all the episodes watch duration
+ getViewPos(episode.id)?.let { data ->
+ episode.copy(position = data.position, duration = data.duration)
+ } ?: episode
+ }
private fun setSubtitles(subtitle: SubtitleData?, userInitiated: Boolean): Boolean {
// If subtitle is changed and user initiated -> Save the language
@@ -1541,7 +1547,7 @@ class GeneratorPlayer : FullScreenPlayer() {
private fun startPlayer() {
// We don't want double load when you skip loading
- if(isPlayerActive.get()) {
+ if (isPlayerActive.get()) {
return
}
@@ -2140,6 +2146,7 @@ class GeneratorPlayer : FullScreenPlayer() {
fun releasePlayer() {
player.release()
currentSelectedSubtitles = null
+ currentSelectedLink = null
isPlayerActive.set(false)
binding?.overlayLoadingSkipButton?.isVisible = false
binding?.playerLoadingOverlay?.isVisible = true
@@ -2152,19 +2159,31 @@ class GeneratorPlayer : FullScreenPlayer() {
activity?.popCurrentPage()
}
+ override fun onSaveInstanceState(outState: Bundle) {
+ outState.putInt("index", viewModel.episodeIndex)
+ super.onSaveInstanceState(outState)
+ }
+
override fun onBindingCreated(binding: FragmentPlayerBinding, savedInstanceState: Bundle?) {
viewModel = ViewModelProvider(this)[PlayerGeneratorViewModel::class.java]
sync = ViewModelProvider(this)[SyncViewModel::class.java]
val uuid = savedInstanceState?.getString("uuid") ?: arguments?.getString("uuid")
val index = savedInstanceState?.getInt("index") ?: arguments?.getInt("index")
-
- viewModel.attachGenerator(generators[uuid], index)
+ val generator = generators[uuid]
unwrapBundle(savedInstanceState)
unwrapBundle(arguments)
super.onBindingCreated(binding, savedInstanceState)
+
+ // Avoid showing no links found
+ if (generator == null || index == null) {
+ exitPlayer()
+ return
+ }
+ viewModel.attachGenerator(generator, index)
+
context?.let { ctx ->
val settingsManager = PreferenceManager.getDefaultSharedPreferences(ctx)
showName = settingsManager.getBoolean(ctx.getString(R.string.show_name_key), true)
@@ -2193,14 +2212,18 @@ class GeneratorPlayer : FullScreenPlayer() {
preferredAutoSelectSubtitles = getAutoSelectLanguageTagIETF()
- if (currentSelectedLink == null) {
+ val selectedLink = currentSelectedLink
+ if (selectedLink == null) {
viewModel.loadLinks()
+ } else {
+ // Recreated view, so we need to recreate the
+ loadLink(selectedLink, true)
}
binding.overlayLoadingSkipButton.setOnClickListener {
// Mark as "success" early
viewModel.modifyState {
- copy(loading = Resource.Success(true))
+ copy(loading = Resource.Success(Unit))
}
}
@@ -2218,11 +2241,13 @@ class GeneratorPlayer : FullScreenPlayer() {
}
}
- observe(viewModel.currentStamps) { stamps ->
+ observe(viewModel.currentStamps) { (stamps, instance) ->
+ if (instance != viewModel.state.instance) return@observe // Outdated observe
player.addTimeStamps(stamps)
}
- observe(viewModel.currentSubtitles) { subtitles ->
+ observe(viewModel.currentSubtitles) { (subtitles, instance) ->
+ if (instance != viewModel.state.instance) return@observe // Outdated observe
player.setActiveSubtitles(subtitles)
// If the file is downloaded then do not select auto select the subtitles
@@ -2233,7 +2258,9 @@ class GeneratorPlayer : FullScreenPlayer() {
autoSelectSubtitles()
}
}
- observe(viewModel.loadingLinks) { loading ->
+ observe(viewModel.loadingLinks) { (loading, instance) ->
+ if (instance != viewModel.state.instance) return@observe // Outdated observe
+
when (loading) {
is Resource.Loading -> {
releasePlayer()
@@ -2254,7 +2281,9 @@ class GeneratorPlayer : FullScreenPlayer() {
}
}
- observe(viewModel.currentLinks) { links ->
+ observe(viewModel.currentLinks) { (links, instance) ->
+ if (instance != viewModel.state.instance) return@observe // Outdated observe
+
val turnVisible = links.isNotEmpty() && viewModel.generator?.canSkipLoading == true
val wasGone = binding.overlayLoadingSkipButton.isGone
@@ -2269,7 +2298,7 @@ class GeneratorPlayer : FullScreenPlayer() {
}
safe {
- if (viewModel.state.links.any { link ->
+ if (!isPlayerActive.get() && viewModel.state.links.any { link ->
getLinkPriority(currentQualityProfile, link.first) >=
QualityDataHelper.AUTO_SKIP_PRIORITY
}
diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerGeneratorViewModel.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerGeneratorViewModel.kt
index 049ed06d6..e3c390d50 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerGeneratorViewModel.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerGeneratorViewModel.kt
@@ -47,8 +47,9 @@ data class VideoState(
val subtitles: PersistentSet = persistentSetOf(),
val links: PersistentSet = persistentSetOf(),
val stamps: PersistentList = persistentListOf(),
- val loading: Resource = Resource.Loading(),
+ val loading: Resource = Resource.Loading(),
val generatorState: GeneratorState? = null,
+ val instance: Int,
) {
/**
* This acts as a local cache for sorted links that are not copied over by the copy constructor.
@@ -114,6 +115,11 @@ data class VideoState(
fun set(items: Collection): VideoState = copy(stamps = items.toPersistentList())
}
+data class VideoLive(
+ val value: T,
+ val instance: Int,
+)
+
class PlayerGeneratorViewModel : ViewModel() {
companion object {
const val TAG = "PlayViewGen"
@@ -123,7 +129,7 @@ class PlayerGeneratorViewModel : ViewModel() {
var generator: VideoGenerator<*>? = null
@Volatile
- private var episodeIndex: Int = 0
+ var episodeIndex: Int = 0
/**
* The state of the video player, only modify it by modifyState to make sure observe is called,
@@ -132,20 +138,21 @@ class PlayerGeneratorViewModel : ViewModel() {
* This value can be used without Synchronized or locking when reading, as all fields are immutable.
* */
@Volatile
- var state = VideoState()
+ var state = VideoState(instance = 0)
private set
- private val _currentLinks = MutableLiveData>>(setOf())
- val currentLinks: LiveData>> = _currentLinks
+ private val _currentLinks =
+ MutableLiveData>>>(null)
+ val currentLinks: LiveData>>> = _currentLinks
- private val _currentSubtitles = MutableLiveData>(setOf())
- val currentSubtitles: LiveData> = _currentSubtitles
+ private val _currentSubtitles = MutableLiveData>>(null)
+ val currentSubtitles: LiveData>> = _currentSubtitles
- private val _loadingLinks = MutableLiveData>()
- val loadingLinks: LiveData> = _loadingLinks
+ private val _loadingLinks = MutableLiveData>>()
+ val loadingLinks: LiveData>> = _loadingLinks
- private val _currentStamps = MutableLiveData>(emptyList())
- val currentStamps: LiveData> = _currentStamps
+ private val _currentStamps = MutableLiveData>>(null)
+ val currentStamps: LiveData>> = _currentStamps
/**
* Modifies the `state` variable safely, and with the correct observe behavior.
@@ -158,6 +165,15 @@ class PlayerGeneratorViewModel : ViewModel() {
val oldState = state
state = op.invoke(oldState)
+ /** New instance, always push state */
+ if (state.instance != oldState.instance) {
+ _currentSubtitles.postValue(VideoLive(state.subtitles, state.instance))
+ _currentStamps.postValue(VideoLive(state.stamps, state.instance))
+ _currentLinks.postValue(VideoLive(state.links, state.instance))
+ _loadingLinks.postValue(VideoLive(state.loading, state.instance))
+ return
+ }
+
/**
* Only post the changed values, this makes sure we do not invoke the "observe"
*
@@ -165,15 +181,15 @@ class PlayerGeneratorViewModel : ViewModel() {
* to avoid comparing the entire set or list as "Persistent" classes will hold the same reference if they are unchanged.
* */
if (state.links !== oldState.links)
- _currentLinks.postValue(state.links)
+ _currentLinks.postValue(VideoLive(state.links, state.instance))
if (state.stamps !== oldState.stamps)
- _currentStamps.postValue(state.stamps)
+ _currentStamps.postValue(VideoLive(state.stamps, state.instance))
if (state.subtitles !== oldState.subtitles)
- _currentSubtitles.postValue(state.subtitles)
+ _currentSubtitles.postValue(VideoLive(state.subtitles, state.instance))
/** Normal equality here as it is not a collection */
if (state.loading != oldState.loading)
- _loadingLinks.postValue(state.loading)
+ _loadingLinks.postValue(VideoLive(state.loading, state.instance))
}
private val _currentSubtitleYear = MutableLiveData(null)
@@ -252,13 +268,10 @@ class PlayerGeneratorViewModel : ViewModel() {
loadLinks()
}
- fun attachGenerator(newGenerator: VideoGenerator<*>?, index: Int?) {
- if (generator == null) {
- generator = newGenerator
- if (index != null) {
- episodeIndex = index
- }
- }
+ fun attachGenerator(newGenerator: VideoGenerator<*>, index: Int) {
+ Log.i(TAG, "attachGenerator with generator=$newGenerator and index=$index")
+ generator = newGenerator
+ episodeIndex = index
}
/**
@@ -321,45 +334,49 @@ class PlayerGeneratorViewModel : ViewModel() {
}
fun loadLinks(sourceTypes: Set = LOADTYPE_INAPP) {
- Log.i(TAG, "loadLinks")
+ Log.i(TAG, "loadLinks with generator=$generator and index=$episodeIndex")
currentJob?.cancel()
val index = episodeIndex
- currentJob = viewModelScope.launchSafe {
- // Clear old data and reset the state
- modifyState {
- VideoState(
- generatorState = generator?.let { gen ->
- GeneratorState(
- meta = gen.videos.getOrNull(index),
- nextMeta = gen.videos.getOrNull(index + 1),
- id = gen.getId(index),
- response = (gen as? RepoLinkGenerator)?.page,
- index = index,
- allMeta = gen.videos
- )
- }
- )
- }
+ // Clear old data and reset the state
+ modifyState {
+ VideoState(
+ loading = Resource.Loading(),
+ generatorState = generator?.let { gen ->
+ GeneratorState(
+ meta = gen.videos.getOrNull(index),
+ nextMeta = gen.videos.getOrNull(index + 1),
+ id = gen.getId(index),
+ response = (gen as? RepoLinkGenerator)?.page,
+ index = index,
+ allMeta = gen.videos
+ )
+ },
+ instance = instance + 1
+ )
+ }
+ currentJob = viewModelScope.launchSafe {
// Load more data
val loadingState = safeApiCall {
generator?.generateLinks(
sourceTypes = sourceTypes,
clearCache = forceClearCache,
callback = { link ->
- modifyState {
- add(link)
- }
+ if (isActive)
+ modifyState {
+ add(link)
+ }
},
isCasting = false,
offset = index,
subtitleCallback = { link ->
- if (isValidSubtitle(link))
+ if (isActive && isValidSubtitle(link))
modifyState {
add(link)
}
})
+ Unit
}
if (!isActive) {
@@ -368,9 +385,13 @@ class PlayerGeneratorViewModel : ViewModel() {
/** Only mark as success if we have not skipped loading */
modifyState {
- when (loading) {
- is Resource.Loading -> copy(loading = loadingState)
- else -> this
+ if (!isActive) {
+ this
+ } else {
+ when (loading) {
+ is Resource.Loading -> copy(loading = loadingState)
+ else -> this
+ }
}
}
}
From 0d16a636e22a9baa24bc8b50dc80417e0e319b38 Mon Sep 17 00:00:00 2001
From: firelight <147925818+fire-light42@users.noreply.github.com>
Date: Wed, 6 May 2026 18:13:29 +0000
Subject: [PATCH 14/82] Fix: playerVideoTitleRez visibility (#2767)
---
.../com/lagradost/cloudstream3/ui/player/FullScreenPlayer.kt | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/FullScreenPlayer.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/FullScreenPlayer.kt
index 4141aef51..26706699b 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/FullScreenPlayer.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/FullScreenPlayer.kt
@@ -773,7 +773,7 @@ open class FullScreenPlayer : AbstractPlayerFragment(
playerEpisodesButtonRoot.isVisible = showPlayerEpisodes
playerEpisodesButton.isVisible = showPlayerEpisodes
playerVideoTitleHolder.isGone = togglePlayerTitleGone
- playerVideoTitleRez.isGone = isGone
+ playerVideoTitleRez.isGone = isGone || playerVideoTitleRez.text.isBlank()
playerEpisodeFiller.isGone = isGone
playerCenterMenu.isGone = isGone
playerLock.isGone = !isShowing
From aa6e702b5932d796bb85d8d6d539ebf3d3c72211 Mon Sep 17 00:00:00 2001
From: Hosted Weblate
Date: Mon, 11 May 2026 13:12:28 +0200
Subject: [PATCH 15/82] Translated using Weblate (Portuguese (Brazil))
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Currently translated at 100.0% (729 of 729 strings)
Translated using Weblate (Chinese (Traditional Han script))
Currently translated at 100.0% (729 of 729 strings)
Translated using Weblate (Vietnamese)
Currently translated at 100.0% (729 of 729 strings)
Translated using Weblate (Indonesian)
Currently translated at 100.0% (729 of 729 strings)
Merge remote-tracking branch 'origin/master'
Merge remote-tracking branch 'origin/master'
Merge remote-tracking branch 'origin/master'
Translated using Weblate (Vietnamese)
Currently translated at 100.0% (729 of 729 strings)
Translated using Weblate (Belarusian)
Currently translated at 99.5% (726 of 729 strings)
Translated using Weblate (Croatian)
Currently translated at 100.0% (729 of 729 strings)
Translated using Weblate (French)
Currently translated at 100.0% (729 of 729 strings)
Translated using Weblate (Turkish)
Currently translated at 100.0% (729 of 729 strings)
Translated using Weblate (Albanian)
Currently translated at 100.0% (729 of 729 strings)
Translated using Weblate (Arabic)
Currently translated at 99.5% (726 of 729 strings)
Translated using Weblate (Italian)
Currently translated at 100.0% (729 of 729 strings)
Translated using Weblate (Arabic (Levantine))
Currently translated at 100.0% (729 of 729 strings)
Translated using Weblate (Albanian)
Currently translated at 77.2% (563 of 729 strings)
Translated using Weblate (Italian)
Currently translated at 99.8% (728 of 729 strings)
Translated using Weblate (Swedish)
Currently translated at 100.0% (729 of 729 strings)
Translated using Weblate (Swedish)
Currently translated at 100.0% (729 of 729 strings)
Translated using Weblate (Spanish)
Currently translated at 100.0% (729 of 729 strings)
Translated using Weblate (Chinese (Simplified Han script))
Currently translated at 100.0% (729 of 729 strings)
Translated using Weblate (Korean)
Currently translated at 100.0% (729 of 729 strings)
Translated using Weblate (Polish)
Currently translated at 100.0% (729 of 729 strings)
Translated using Weblate (Czech)
Currently translated at 100.0% (729 of 729 strings)
Translated using Weblate (Ukrainian)
Currently translated at 100.0% (729 of 729 strings)
Update translation files
Updated by "Remove blank strings" hook in Weblate.
Translated using Weblate (Czech)
Currently translated at 100.0% (726 of 726 strings)
Translated using Weblate (Czech)
Currently translated at 100.0% (726 of 726 strings)
Translated using Weblate (Norwegian Nynorsk)
Currently translated at 25.2% (183 of 726 strings)
Translated using Weblate (Norwegian Nynorsk)
Currently translated at 25.2% (183 of 726 strings)
Translated using Weblate (Norwegian Nynorsk)
Currently translated at 25.2% (183 of 726 strings)
Translated using Weblate (Norwegian Nynorsk)
Currently translated at 25.2% (183 of 726 strings)
Translated using Weblate (Norwegian Nynorsk)
Currently translated at 25.2% (183 of 726 strings)
Translated using Weblate (Norwegian Nynorsk)
Currently translated at 25.2% (183 of 726 strings)
Translated using Weblate (Norwegian Nynorsk)
Currently translated at 25.2% (183 of 726 strings)
Translated using Weblate (Norwegian Nynorsk)
Currently translated at 25.2% (183 of 726 strings)
Translated using Weblate (Esperanto)
Currently translated at 23.6% (172 of 726 strings)
Translated using Weblate (Esperanto)
Currently translated at 23.6% (172 of 726 strings)
Translated using Weblate (Esperanto)
Currently translated at 23.6% (172 of 726 strings)
Translated using Weblate (Esperanto)
Currently translated at 23.6% (172 of 726 strings)
Translated using Weblate (Esperanto)
Currently translated at 23.6% (172 of 726 strings)
Translated using Weblate (Esperanto)
Currently translated at 23.6% (172 of 726 strings)
Translated using Weblate (Esperanto)
Currently translated at 23.6% (172 of 726 strings)
Translated using Weblate (Esperanto)
Currently translated at 23.6% (172 of 726 strings)
Translated using Weblate (Assamese)
Currently translated at 86.9% (631 of 726 strings)
Translated using Weblate (Assamese)
Currently translated at 86.9% (631 of 726 strings)
Translated using Weblate (Assamese)
Currently translated at 86.9% (631 of 726 strings)
Translated using Weblate (Arabic (Najdi))
Currently translated at 45.0% (327 of 726 strings)
Translated using Weblate (Arabic (Najdi))
Currently translated at 45.0% (327 of 726 strings)
Translated using Weblate (Arabic (Najdi))
Currently translated at 45.0% (327 of 726 strings)
Translated using Weblate (Arabic (Najdi))
Currently translated at 45.0% (327 of 726 strings)
Translated using Weblate (Arabic (Najdi))
Currently translated at 45.0% (327 of 726 strings)
Translated using Weblate (Spanish)
Currently translated at 99.8% (725 of 726 strings)
Translated using Weblate (Spanish)
Currently translated at 99.8% (725 of 726 strings)
Translated using Weblate (Spanish)
Currently translated at 99.8% (725 of 726 strings)
Translated using Weblate (Spanish)
Currently translated at 99.8% (725 of 726 strings)
Translated using Weblate (Spanish)
Currently translated at 99.8% (725 of 726 strings)
Translated using Weblate (Chinese (Traditional Han script))
Currently translated at 100.0% (726 of 726 strings)
Translated using Weblate (Chinese (Traditional Han script))
Currently translated at 100.0% (726 of 726 strings)
Translated using Weblate (Chinese (Traditional Han script))
Currently translated at 100.0% (726 of 726 strings)
Translated using Weblate (Bulgarian)
Currently translated at 99.0% (719 of 726 strings)
Translated using Weblate (Bulgarian)
Currently translated at 99.0% (719 of 726 strings)
Translated using Weblate (Bulgarian)
Currently translated at 99.0% (719 of 726 strings)
Translated using Weblate (Bulgarian)
Currently translated at 99.0% (719 of 726 strings)
Translated using Weblate (Bulgarian)
Currently translated at 99.0% (719 of 726 strings)
Translated using Weblate (Bulgarian)
Currently translated at 99.0% (719 of 726 strings)
Translated using Weblate (Persian)
Currently translated at 46.5% (338 of 726 strings)
Translated using Weblate (Persian)
Currently translated at 46.5% (338 of 726 strings)
Translated using Weblate (Persian)
Currently translated at 46.5% (338 of 726 strings)
Translated using Weblate (Persian)
Currently translated at 46.5% (338 of 726 strings)
Translated using Weblate (Persian)
Currently translated at 46.5% (338 of 726 strings)
Translated using Weblate (Persian)
Currently translated at 46.5% (338 of 726 strings)
Translated using Weblate (Persian)
Currently translated at 46.5% (338 of 726 strings)
Translated using Weblate (Persian)
Currently translated at 46.5% (338 of 726 strings)
Translated using Weblate (Galician)
Currently translated at 38.9% (283 of 726 strings)
Translated using Weblate (Galician)
Currently translated at 38.9% (283 of 726 strings)
Translated using Weblate (Galician)
Currently translated at 38.9% (283 of 726 strings)
Translated using Weblate (Galician)
Currently translated at 38.9% (283 of 726 strings)
Translated using Weblate (Galician)
Currently translated at 38.9% (283 of 726 strings)
Translated using Weblate (Galician)
Currently translated at 38.9% (283 of 726 strings)
Translated using Weblate (Catalan)
Currently translated at 45.0% (327 of 726 strings)
Translated using Weblate (Dutch)
Currently translated at 88.9% (646 of 726 strings)
Translated using Weblate (Dutch)
Currently translated at 88.9% (646 of 726 strings)
Translated using Weblate (Dutch)
Currently translated at 88.9% (646 of 726 strings)
Translated using Weblate (Dutch)
Currently translated at 88.9% (646 of 726 strings)
Translated using Weblate (Dutch)
Currently translated at 88.9% (646 of 726 strings)
Translated using Weblate (Dutch)
Currently translated at 88.9% (646 of 726 strings)
Translated using Weblate (Chinese (Simplified Han script))
Currently translated at 100.0% (726 of 726 strings)
Translated using Weblate (Chinese (Simplified Han script))
Currently translated at 100.0% (726 of 726 strings)
Translated using Weblate (Chinese (Simplified Han script))
Currently translated at 100.0% (726 of 726 strings)
Translated using Weblate (Chinese (Simplified Han script))
Currently translated at 100.0% (726 of 726 strings)
Translated using Weblate (Chinese (Simplified Han script))
Currently translated at 100.0% (726 of 726 strings)
Translated using Weblate (Chinese (Simplified Han script))
Currently translated at 100.0% (726 of 726 strings)
Translated using Weblate (Chinese (Simplified Han script))
Currently translated at 100.0% (726 of 726 strings)
Translated using Weblate (Chinese (Simplified Han script))
Currently translated at 100.0% (726 of 726 strings)
Translated using Weblate (Portuguese)
Currently translated at 99.8% (725 of 726 strings)
Translated using Weblate (Portuguese)
Currently translated at 99.8% (725 of 726 strings)
Translated using Weblate (Portuguese)
Currently translated at 99.8% (725 of 726 strings)
Translated using Weblate (Portuguese)
Currently translated at 99.8% (725 of 726 strings)
Translated using Weblate (Portuguese)
Currently translated at 99.8% (725 of 726 strings)
Translated using Weblate (Portuguese)
Currently translated at 99.8% (725 of 726 strings)
Translated using Weblate (Portuguese)
Currently translated at 99.8% (725 of 726 strings)
Translated using Weblate (Amharic)
Currently translated at 14.4% (105 of 726 strings)
Translated using Weblate (Amharic)
Currently translated at 14.4% (105 of 726 strings)
Translated using Weblate (Amharic)
Currently translated at 14.4% (105 of 726 strings)
Translated using Weblate (Amharic)
Currently translated at 14.4% (105 of 726 strings)
Translated using Weblate (Amharic)
Currently translated at 14.4% (105 of 726 strings)
Translated using Weblate (Amharic)
Currently translated at 14.4% (105 of 726 strings)
Translated using Weblate (Amharic)
Currently translated at 14.4% (105 of 726 strings)
Translated using Weblate (Albanian)
Currently translated at 73.1% (531 of 726 strings)
Translated using Weblate (Albanian)
Currently translated at 73.1% (531 of 726 strings)
Translated using Weblate (Korean)
Currently translated at 100.0% (726 of 726 strings)
Translated using Weblate (Korean)
Currently translated at 100.0% (726 of 726 strings)
Translated using Weblate (Korean)
Currently translated at 100.0% (726 of 726 strings)
Translated using Weblate (Korean)
Currently translated at 100.0% (726 of 726 strings)
Translated using Weblate (Kannada)
Currently translated at 17.4% (127 of 726 strings)
Translated using Weblate (Kannada)
Currently translated at 17.4% (127 of 726 strings)
Translated using Weblate (Kannada)
Currently translated at 17.4% (127 of 726 strings)
Translated using Weblate (Kannada)
Currently translated at 17.4% (127 of 726 strings)
Translated using Weblate (Kannada)
Currently translated at 17.4% (127 of 726 strings)
Translated using Weblate (Kannada)
Currently translated at 17.4% (127 of 726 strings)
Translated using Weblate (Kannada)
Currently translated at 17.4% (127 of 726 strings)
Translated using Weblate (Nepali)
Currently translated at 17.0% (124 of 726 strings)
Translated using Weblate (Nepali)
Currently translated at 17.0% (124 of 726 strings)
Translated using Weblate (Nepali)
Currently translated at 17.0% (124 of 726 strings)
Translated using Weblate (Nepali)
Currently translated at 17.0% (124 of 726 strings)
Translated using Weblate (Swedish)
Currently translated at 100.0% (726 of 726 strings)
Translated using Weblate (Swedish)
Currently translated at 100.0% (726 of 726 strings)
Translated using Weblate (Swedish)
Currently translated at 100.0% (726 of 726 strings)
Translated using Weblate (Swedish)
Currently translated at 100.0% (726 of 726 strings)
Translated using Weblate (Swedish)
Currently translated at 100.0% (726 of 726 strings)
Translated using Weblate (Swedish)
Currently translated at 100.0% (726 of 726 strings)
Translated using Weblate (Japanese)
Currently translated at 99.8% (725 of 726 strings)
Translated using Weblate (Japanese)
Currently translated at 99.8% (725 of 726 strings)
Translated using Weblate (Japanese)
Currently translated at 99.8% (725 of 726 strings)
Translated using Weblate (Japanese)
Currently translated at 99.8% (725 of 726 strings)
Translated using Weblate (Azerbaijani)
Currently translated at 19.8% (144 of 726 strings)
Translated using Weblate (Azerbaijani)
Currently translated at 19.8% (144 of 726 strings)
Translated using Weblate (Norwegian Bokmål)
Currently translated at 65.7% (477 of 726 strings)
Translated using Weblate (Norwegian Bokmål)
Currently translated at 65.7% (477 of 726 strings)
Translated using Weblate (Norwegian Bokmål)
Currently translated at 65.7% (477 of 726 strings)
Translated using Weblate (Norwegian Bokmål)
Currently translated at 65.7% (477 of 726 strings)
Translated using Weblate (Norwegian Bokmål)
Currently translated at 65.7% (477 of 726 strings)
Translated using Weblate (Norwegian Bokmål)
Currently translated at 65.7% (477 of 726 strings)
Translated using Weblate (Romanian)
Currently translated at 84.8% (616 of 726 strings)
Translated using Weblate (Romanian)
Currently translated at 84.8% (616 of 726 strings)
Translated using Weblate (Romanian)
Currently translated at 84.8% (616 of 726 strings)
Translated using Weblate (Romanian)
Currently translated at 84.8% (616 of 726 strings)
Translated using Weblate (Romanian)
Currently translated at 84.8% (616 of 726 strings)
Translated using Weblate (Romanian)
Currently translated at 84.8% (616 of 726 strings)
Translated using Weblate (Odia)
Currently translated at 21.4% (156 of 726 strings)
Translated using Weblate (Odia)
Currently translated at 21.4% (156 of 726 strings)
Translated using Weblate (Odia)
Currently translated at 21.4% (156 of 726 strings)
Translated using Weblate (Odia)
Currently translated at 21.4% (156 of 726 strings)
Translated using Weblate (Vietnamese)
Currently translated at 100.0% (726 of 726 strings)
Translated using Weblate (Vietnamese)
Currently translated at 100.0% (726 of 726 strings)
Translated using Weblate (Vietnamese)
Currently translated at 100.0% (726 of 726 strings)
Translated using Weblate (Russian)
Currently translated at 99.8% (725 of 726 strings)
Translated using Weblate (Russian)
Currently translated at 99.8% (725 of 726 strings)
Translated using Weblate (Russian)
Currently translated at 99.8% (725 of 726 strings)
Translated using Weblate (Russian)
Currently translated at 99.8% (725 of 726 strings)
Translated using Weblate (Russian)
Currently translated at 99.8% (725 of 726 strings)
Translated using Weblate (Russian)
Currently translated at 99.8% (725 of 726 strings)
Translated using Weblate (Russian)
Currently translated at 99.8% (725 of 726 strings)
Translated using Weblate (Russian)
Currently translated at 99.8% (725 of 726 strings)
Translated using Weblate (Russian)
Currently translated at 99.8% (725 of 726 strings)
Translated using Weblate (Croatian)
Currently translated at 99.8% (725 of 726 strings)
Translated using Weblate (Croatian)
Currently translated at 99.8% (725 of 726 strings)
Translated using Weblate (Croatian)
Currently translated at 99.8% (725 of 726 strings)
Translated using Weblate (Arabic)
Currently translated at 100.0% (726 of 726 strings)
Translated using Weblate (Arabic)
Currently translated at 100.0% (726 of 726 strings)
Translated using Weblate (Arabic)
Currently translated at 100.0% (726 of 726 strings)
Translated using Weblate (Maltese)
Currently translated at 16.6% (121 of 726 strings)
Translated using Weblate (Maltese)
Currently translated at 16.6% (121 of 726 strings)
Translated using Weblate (Maltese)
Currently translated at 16.6% (121 of 726 strings)
Translated using Weblate (Greek)
Currently translated at 90.4% (657 of 726 strings)
Translated using Weblate (Greek)
Currently translated at 90.4% (657 of 726 strings)
Translated using Weblate (Greek)
Currently translated at 90.4% (657 of 726 strings)
Translated using Weblate (Greek)
Currently translated at 90.4% (657 of 726 strings)
Translated using Weblate (Greek)
Currently translated at 90.4% (657 of 726 strings)
Translated using Weblate (Greek)
Currently translated at 90.4% (657 of 726 strings)
Translated using Weblate (Hebrew)
Currently translated at 72.5% (527 of 726 strings)
Translated using Weblate (Hebrew)
Currently translated at 72.5% (527 of 726 strings)
Translated using Weblate (Hebrew)
Currently translated at 72.5% (527 of 726 strings)
Translated using Weblate (Hebrew)
Currently translated at 72.5% (527 of 726 strings)
Translated using Weblate (Hebrew)
Currently translated at 72.5% (527 of 726 strings)
Translated using Weblate (Hebrew)
Currently translated at 72.5% (527 of 726 strings)
Translated using Weblate (Hebrew)
Currently translated at 72.5% (527 of 726 strings)
Translated using Weblate (Hebrew)
Currently translated at 72.5% (527 of 726 strings)
Translated using Weblate (Hungarian)
Currently translated at 81.9% (595 of 726 strings)
Translated using Weblate (Hungarian)
Currently translated at 81.9% (595 of 726 strings)
Translated using Weblate (Hungarian)
Currently translated at 81.9% (595 of 726 strings)
Translated using Weblate (Hungarian)
Currently translated at 81.9% (595 of 726 strings)
Translated using Weblate (Hungarian)
Currently translated at 81.9% (595 of 726 strings)
Translated using Weblate (Hungarian)
Currently translated at 81.9% (595 of 726 strings)
Translated using Weblate (Hungarian)
Currently translated at 81.9% (595 of 726 strings)
Translated using Weblate (Slovak)
Currently translated at 62.5% (454 of 726 strings)
Translated using Weblate (Slovak)
Currently translated at 62.5% (454 of 726 strings)
Translated using Weblate (Slovak)
Currently translated at 62.5% (454 of 726 strings)
Translated using Weblate (Slovak)
Currently translated at 62.5% (454 of 726 strings)
Translated using Weblate (Slovak)
Currently translated at 62.5% (454 of 726 strings)
Translated using Weblate (Slovak)
Currently translated at 62.5% (454 of 726 strings)
Translated using Weblate (Slovak)
Currently translated at 62.5% (454 of 726 strings)
Translated using Weblate (Afrikaans)
Currently translated at 16.2% (118 of 726 strings)
Translated using Weblate (Afrikaans)
Currently translated at 16.2% (118 of 726 strings)
Translated using Weblate (Afrikaans)
Currently translated at 16.2% (118 of 726 strings)
Translated using Weblate (Afrikaans)
Currently translated at 16.2% (118 of 726 strings)
Translated using Weblate (Afrikaans)
Currently translated at 16.2% (118 of 726 strings)
Translated using Weblate (Polish)
Currently translated at 100.0% (726 of 726 strings)
Translated using Weblate (Polish)
Currently translated at 100.0% (726 of 726 strings)
Translated using Weblate (Polish)
Currently translated at 100.0% (726 of 726 strings)
Translated using Weblate (Polish)
Currently translated at 100.0% (726 of 726 strings)
Translated using Weblate (Polish)
Currently translated at 100.0% (726 of 726 strings)
Translated using Weblate (Bengali)
Currently translated at 48.2% (350 of 726 strings)
Translated using Weblate (Bengali)
Currently translated at 48.2% (350 of 726 strings)
Translated using Weblate (Bengali)
Currently translated at 48.2% (350 of 726 strings)
Translated using Weblate (Bengali)
Currently translated at 48.2% (350 of 726 strings)
Translated using Weblate (Bengali)
Currently translated at 48.2% (350 of 726 strings)
Translated using Weblate (Bengali)
Currently translated at 48.2% (350 of 726 strings)
Translated using Weblate (Bengali)
Currently translated at 48.2% (350 of 726 strings)
Translated using Weblate (Bengali)
Currently translated at 48.2% (350 of 726 strings)
Translated using Weblate (Italian)
Currently translated at 100.0% (726 of 726 strings)
Translated using Weblate (Italian)
Currently translated at 100.0% (726 of 726 strings)
Translated using Weblate (Italian)
Currently translated at 100.0% (726 of 726 strings)
Translated using Weblate (Italian)
Currently translated at 100.0% (726 of 726 strings)
Translated using Weblate (Italian)
Currently translated at 100.0% (726 of 726 strings)
Translated using Weblate (Italian)
Currently translated at 100.0% (726 of 726 strings)
Translated using Weblate (Indonesian)
Currently translated at 100.0% (726 of 726 strings)
Translated using Weblate (Indonesian)
Currently translated at 100.0% (726 of 726 strings)
Translated using Weblate (Indonesian)
Currently translated at 100.0% (726 of 726 strings)
Translated using Weblate (Indonesian)
Currently translated at 100.0% (726 of 726 strings)
Translated using Weblate (Macedonian)
Currently translated at 96.4% (700 of 726 strings)
Translated using Weblate (Macedonian)
Currently translated at 96.4% (700 of 726 strings)
Translated using Weblate (Malay)
Currently translated at 65.8% (478 of 726 strings)
Translated using Weblate (Malay)
Currently translated at 65.8% (478 of 726 strings)
Translated using Weblate (Malay)
Currently translated at 65.8% (478 of 726 strings)
Translated using Weblate (Malay)
Currently translated at 65.8% (478 of 726 strings)
Translated using Weblate (Malay)
Currently translated at 65.8% (478 of 726 strings)
Translated using Weblate (Malay)
Currently translated at 65.8% (478 of 726 strings)
Translated using Weblate (Malay)
Currently translated at 65.8% (478 of 726 strings)
Translated using Weblate (Lithuanian)
Currently translated at 29.7% (216 of 726 strings)
Translated using Weblate (Lithuanian)
Currently translated at 29.7% (216 of 726 strings)
Translated using Weblate (Lithuanian)
Currently translated at 29.7% (216 of 726 strings)
Translated using Weblate (Lithuanian)
Currently translated at 29.7% (216 of 726 strings)
Translated using Weblate (Lithuanian)
Currently translated at 29.7% (216 of 726 strings)
Translated using Weblate (Arabic (Egyptian))
Currently translated at 0.0% (0 of 726 strings)
Translated using Weblate (Filipino)
Currently translated at 21.2% (154 of 726 strings)
Translated using Weblate (Filipino)
Currently translated at 21.2% (154 of 726 strings)
Translated using Weblate (Filipino)
Currently translated at 21.2% (154 of 726 strings)
Translated using Weblate (Filipino)
Currently translated at 21.2% (154 of 726 strings)
Translated using Weblate (Hindi)
Currently translated at 56.6% (411 of 726 strings)
Translated using Weblate (Hindi)
Currently translated at 56.6% (411 of 726 strings)
Translated using Weblate (Hindi)
Currently translated at 56.6% (411 of 726 strings)
Translated using Weblate (Hindi)
Currently translated at 56.6% (411 of 726 strings)
Translated using Weblate (Hindi)
Currently translated at 56.6% (411 of 726 strings)
Translated using Weblate (Hindi)
Currently translated at 56.6% (411 of 726 strings)
Translated using Weblate (Hindi)
Currently translated at 56.6% (411 of 726 strings)
Translated using Weblate (Hindi)
Currently translated at 56.6% (411 of 726 strings)
Translated using Weblate (Hindi)
Currently translated at 56.6% (411 of 726 strings)
Translated using Weblate (Tigrinya)
Currently translated at 0.4% (3 of 726 strings)
Translated using Weblate (Tigrinya)
Currently translated at 0.4% (3 of 726 strings)
Translated using Weblate (Tigrinya)
Currently translated at 0.4% (3 of 726 strings)
Translated using Weblate (Tigrinya)
Currently translated at 0.4% (3 of 726 strings)
Translated using Weblate (Ukrainian)
Currently translated at 100.0% (726 of 726 strings)
Translated using Weblate (Tamil)
Currently translated at 99.8% (725 of 726 strings)
Translated using Weblate (Tamil)
Currently translated at 99.8% (725 of 726 strings)
Translated using Weblate (Kurdish (Central))
Currently translated at 11.2% (82 of 726 strings)
Translated using Weblate (Tagalog)
Currently translated at 32.5% (236 of 726 strings)
Translated using Weblate (Tagalog)
Currently translated at 32.5% (236 of 726 strings)
Translated using Weblate (Tagalog)
Currently translated at 32.5% (236 of 726 strings)
Translated using Weblate (Tagalog)
Currently translated at 32.5% (236 of 726 strings)
Translated using Weblate (Tagalog)
Currently translated at 32.5% (236 of 726 strings)
Translated using Weblate (Tagalog)
Currently translated at 32.5% (236 of 726 strings)
Translated using Weblate (Tagalog)
Currently translated at 32.5% (236 of 726 strings)
Translated using Weblate (Burmese)
Currently translated at 69.1% (502 of 726 strings)
Translated using Weblate (Burmese)
Currently translated at 69.1% (502 of 726 strings)
Translated using Weblate (Burmese)
Currently translated at 69.1% (502 of 726 strings)
Translated using Weblate (Burmese)
Currently translated at 69.1% (502 of 726 strings)
Translated using Weblate (Burmese)
Currently translated at 69.1% (502 of 726 strings)
Translated using Weblate (Portuguese (Brazil))
Currently translated at 99.8% (725 of 726 strings)
Translated using Weblate (Portuguese (Brazil))
Currently translated at 99.8% (725 of 726 strings)
Translated using Weblate (Portuguese (Brazil))
Currently translated at 99.8% (725 of 726 strings)
Translated using Weblate (Portuguese (Brazil))
Currently translated at 99.8% (725 of 726 strings)
Translated using Weblate (Portuguese (Brazil))
Currently translated at 99.8% (725 of 726 strings)
Translated using Weblate (Urdu)
Currently translated at 81.8% (594 of 726 strings)
Translated using Weblate (Urdu)
Currently translated at 81.8% (594 of 726 strings)
Translated using Weblate (Urdu)
Currently translated at 81.8% (594 of 726 strings)
Translated using Weblate (Urdu)
Currently translated at 81.8% (594 of 726 strings)
Translated using Weblate (Urdu)
Currently translated at 81.8% (594 of 726 strings)
Translated using Weblate (Urdu)
Currently translated at 81.8% (594 of 726 strings)
Translated using Weblate (German)
Currently translated at 99.8% (725 of 726 strings)
Translated using Weblate (German)
Currently translated at 99.8% (725 of 726 strings)
Translated using Weblate (German)
Currently translated at 99.8% (725 of 726 strings)
Translated using Weblate (German)
Currently translated at 99.8% (725 of 726 strings)
Translated using Weblate (German)
Currently translated at 99.8% (725 of 726 strings)
Translated using Weblate (German)
Currently translated at 99.8% (725 of 726 strings)
Translated using Weblate (German)
Currently translated at 99.8% (725 of 726 strings)
Translated using Weblate (German)
Currently translated at 99.8% (725 of 726 strings)
Translated using Weblate (German)
Currently translated at 99.8% (725 of 726 strings)
Translated using Weblate (German)
Currently translated at 99.8% (725 of 726 strings)
Translated using Weblate (Arabic (Levantine))
Currently translated at 96.9% (704 of 726 strings)
Translated using Weblate (Arabic (Levantine))
Currently translated at 96.9% (704 of 726 strings)
Translated using Weblate (Latvian)
Currently translated at 80.8% (587 of 726 strings)
Translated using Weblate (Latvian)
Currently translated at 80.8% (587 of 726 strings)
Translated using Weblate (Latvian)
Currently translated at 80.8% (587 of 726 strings)
Translated using Weblate (Latvian)
Currently translated at 80.8% (587 of 726 strings)
Translated using Weblate (Latvian)
Currently translated at 80.8% (587 of 726 strings)
Translated using Weblate (Somali)
Currently translated at 60.4% (439 of 726 strings)
Translated using Weblate (Somali)
Currently translated at 60.4% (439 of 726 strings)
Translated using Weblate (Somali)
Currently translated at 60.4% (439 of 726 strings)
Translated using Weblate (Somali)
Currently translated at 60.4% (439 of 726 strings)
Translated using Weblate (Somali)
Currently translated at 60.4% (439 of 726 strings)
Translated using Weblate (Somali)
Currently translated at 60.4% (439 of 726 strings)
Translated using Weblate (Somali)
Currently translated at 60.4% (439 of 726 strings)
Translated using Weblate (Malayalam)
Currently translated at 31.2% (227 of 726 strings)
Translated using Weblate (Malayalam)
Currently translated at 31.2% (227 of 726 strings)
Translated using Weblate (Malayalam)
Currently translated at 31.2% (227 of 726 strings)
Translated using Weblate (Malayalam)
Currently translated at 31.2% (227 of 726 strings)
Translated using Weblate (Malayalam)
Currently translated at 31.2% (227 of 726 strings)
Translated using Weblate (Malayalam)
Currently translated at 31.2% (227 of 726 strings)
Translated using Weblate (Malayalam)
Currently translated at 31.2% (227 of 726 strings)
Translated using Weblate (Turkish)
Currently translated at 100.0% (726 of 726 strings)
Translated using Weblate (Turkish)
Currently translated at 100.0% (726 of 726 strings)
Translated using Weblate (Turkish)
Currently translated at 100.0% (726 of 726 strings)
Translated using Weblate (Turkish)
Currently translated at 100.0% (726 of 726 strings)
Translated using Weblate (Turkish)
Currently translated at 100.0% (726 of 726 strings)
Translated using Weblate (qt (generated) (qt))
Currently translated at 85.3% (620 of 726 strings)
Translated using Weblate (qt (generated) (qt))
Currently translated at 85.3% (620 of 726 strings)
Translated using Weblate (qt (generated) (qt))
Currently translated at 85.3% (620 of 726 strings)
Translated using Weblate (qt (generated) (qt))
Currently translated at 85.3% (620 of 726 strings)
Translated using Weblate (qt (generated) (qt))
Currently translated at 85.3% (620 of 726 strings)
Translated using Weblate (qt (generated) (qt))
Currently translated at 85.3% (620 of 726 strings)
Translated using Weblate (French)
Currently translated at 100.0% (726 of 726 strings)
Translated using Weblate (French)
Currently translated at 100.0% (726 of 726 strings)
Translated using Weblate (French)
Currently translated at 100.0% (726 of 726 strings)
Translated using Weblate (French)
Currently translated at 100.0% (726 of 726 strings)
Translated using Weblate (French)
Currently translated at 100.0% (726 of 726 strings)
Translated using Weblate (French)
Currently translated at 100.0% (726 of 726 strings)
Translated using Weblate (French)
Currently translated at 100.0% (726 of 726 strings)
Translated using Weblate (French)
Currently translated at 100.0% (726 of 726 strings)
Translated using Weblate (French)
Currently translated at 100.0% (726 of 726 strings)
Translated using Weblate (French)
Currently translated at 100.0% (726 of 726 strings)
Translated using Weblate (French)
Currently translated at 100.0% (726 of 726 strings)
Translated using Weblate (French)
Currently translated at 100.0% (726 of 726 strings)
Translated using Weblate (French)
Currently translated at 100.0% (726 of 726 strings)
Translated using Weblate (French)
Currently translated at 100.0% (726 of 726 strings)
Translated using Weblate (French)
Currently translated at 100.0% (726 of 726 strings)
Translated using Weblate (French)
Currently translated at 100.0% (726 of 726 strings)
Translated using Weblate (French)
Currently translated at 100.0% (726 of 726 strings)
Co-authored-by: 007
Co-authored-by: Aayush Shah
Co-authored-by: Adolfo Jayme Barrientos
Co-authored-by: Ahmed Abd El-Fattah
Co-authored-by: Aitor Salaberria
Co-authored-by: Akhlak Ur Rahman
Co-authored-by: Alex Georgiou
Co-authored-by: Alexander Svärd
Co-authored-by: Alexandru
Co-authored-by: Allan Nordhøy
Co-authored-by: Anarchydr
Co-authored-by: Andre Costa
Co-authored-by: Anonymous
Co-authored-by: Apostol Penkov
Co-authored-by: Argo Carpathians
Co-authored-by: Azgar
Co-authored-by: Balmunk
Co-authored-by: Bananenbrot
Co-authored-by: Beabfekad Zikie
Co-authored-by: Bhupesh Yadav
Co-authored-by: Bitpaint
Co-authored-by: BluTiger
Co-authored-by: ButterflyOfFire
Co-authored-by: Bálint László
Co-authored-by: Carlos Luiz
Co-authored-by: Chaya Endot
Co-authored-by: Cloudburst <18114966+C10udburst@users.noreply.github.com>
Co-authored-by: Clxff H3r4ld0 <123844876+clxf12@users.noreply.github.com>
Co-authored-by: Dan
Co-authored-by: DarkOrbFX
Co-authored-by: Davide Marcoli
Co-authored-by: Deleted User
Co-authored-by: Deleted User
Co-authored-by: Deleted User
Co-authored-by: Deleted User
Co-authored-by: Deleted User
Co-authored-by: Deleted User
Co-authored-by: Doctorredits_here <182783629+doctorreditshere@users.noreply.github.com>
Co-authored-by: Emmanuel HEMERIT
Co-authored-by: Eryk Michalak
Co-authored-by: FastAct
Co-authored-by: Felicity
Co-authored-by: Fjuro
Co-authored-by: Francisco Serrador
Co-authored-by: Gabriel
Co-authored-by: Gabriel Cnudde
Co-authored-by: Giuseppe Terrana
Co-authored-by: Gnkalk
Co-authored-by: H Tamás
Co-authored-by: Hosted Weblate
Co-authored-by: Imprevisible
Co-authored-by: Itsmechinmoy <167056923+itsmechinmoy@users.noreply.github.com>
Co-authored-by: J. Lavoie
Co-authored-by: JL Pilgram
Co-authored-by: Jean-Michel
Co-authored-by: Jen Xie
Co-authored-by: Joana Trashlieva
Co-authored-by: John Titor
Co-authored-by: Joshua Joseph
Co-authored-by: Julia Sugawara
Co-authored-by: Kardi Demha
Co-authored-by: Konstantinos Tranoudis
Co-authored-by: Kraptor123
Co-authored-by: LagradOst <46196380+Blatzar@users.noreply.github.com>
Co-authored-by: Leon de Klerk