diff --git a/.github/workflows/build_to_archive.yml b/.github/workflows/build_to_archive.yml index f72dd10c62d..b5960d5d942 100644 --- a/.github/workflows/build_to_archive.yml +++ b/.github/workflows/build_to_archive.yml @@ -9,6 +9,9 @@ on: - '**/wcokey.txt' workflow_dispatch: +permissions: + contents: read + concurrency: group: "Archive-build" cancel-in-progress: true diff --git a/.github/workflows/generate_dokka.yml b/.github/workflows/generate_dokka.yml index 91f03a43485..d67b8a519d7 100644 --- a/.github/workflows/generate_dokka.yml +++ b/.github/workflows/generate_dokka.yml @@ -6,6 +6,9 @@ on: paths-ignore: - '*.md' +permissions: + contents: read + concurrency: group: "dokka" cancel-in-progress: true @@ -51,9 +54,6 @@ jobs: with: cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} - - name: Set up Android SDK - uses: android-actions/setup-android@v4 - - name: Generate Dokka run: | cd $GITHUB_WORKSPACE/src/ diff --git a/.github/workflows/issue_action.yml b/.github/workflows/issue_action.yml index 4286e6b683e..e354d657d50 100644 --- a/.github/workflows/issue_action.yml +++ b/.github/workflows/issue_action.yml @@ -4,6 +4,10 @@ on: issues: types: [opened] +permissions: + contents: read + issues: write + jobs: issue-moderator: runs-on: ubuntu-latest @@ -29,7 +33,7 @@ jobs: - name: Label if possible duplicate if: steps.similarity.outputs.similar-issues-found =='true' - uses: actions/github-script@v8 + uses: actions/github-script@v9 with: github-token: ${{ steps.generate_token.outputs.token }} script: | @@ -75,7 +79,7 @@ jobs: - name: Label if mentions provider if: steps.provider_check.outputs.name != 'none' - uses: actions/github-script@v8 + uses: actions/github-script@v9 with: github-token: ${{ steps.generate_token.outputs.token }} script: | diff --git a/.github/workflows/prerelease.yml b/.github/workflows/prerelease.yml index 03cb68cbc46..d9a20a04b2b 100644 --- a/.github/workflows/prerelease.yml +++ b/.github/workflows/prerelease.yml @@ -12,6 +12,9 @@ concurrency: group: "pre-release" cancel-in-progress: true +permissions: + contents: write + jobs: build: runs-on: ubuntu-latest diff --git a/.github/workflows/pull_request.yml b/.github/workflows/pull_request.yml index a5a7d56e37b..675ce3b2f77 100644 --- a/.github/workflows/pull_request.yml +++ b/.github/workflows/pull_request.yml @@ -2,6 +2,9 @@ name: Artifact Build on: [pull_request] +permissions: + contents: read + jobs: build: runs-on: ubuntu-latest diff --git a/.github/workflows/update_locales.yml b/.github/workflows/update_locales.yml index 5b170d540f7..0a538d5d4da 100644 --- a/.github/workflows/update_locales.yml +++ b/.github/workflows/update_locales.yml @@ -11,6 +11,9 @@ concurrency: group: "locale" cancel-in-progress: true +permissions: + contents: read + jobs: create: runs-on: ubuntu-latest diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 0b201d1cb35..ae530192998 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -8,7 +8,6 @@ import org.jetbrains.kotlin.gradle.tasks.KotlinJvmCompile plugins { alias(libs.plugins.android.application) alias(libs.plugins.dokka) - alias(libs.plugins.kotlin.android) } val javaTarget = JvmTarget.fromTarget(libs.versions.jvmTarget.get()) @@ -183,7 +182,6 @@ android { } lint { - abortOnError = false checkReleaseBuilds = false } @@ -215,10 +213,12 @@ 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) implementation(libs.bundles.navigation) + implementation(libs.kotlinx.collections.immutable) // Design & UI implementation(libs.preference.ktx) @@ -235,6 +235,9 @@ dependencies { // FFmpeg Decoding implementation(libs.bundles.nextlib) + // Anime-db for filler + implementation(libs.anime.db) + // PlayBack implementation(libs.colorpicker) // Subtitle Color Picker implementation(libs.newpipeextractor) // For Trailers @@ -314,8 +317,10 @@ tasks.withType { dokka { moduleName = "App" dokkaSourceSets { - main { + configureEach { + suppress = name != "prereleaseDebug" analysisPlatform = KotlinPlatform.JVM + displayName = "JVM" documentedVisibilities( VisibilityModifier.Public, VisibilityModifier.Protected diff --git a/app/lint.xml b/app/lint.xml index 48cdec04ac3..b2f5e8f2bc3 100644 --- a/app/lint.xml +++ b/app/lint.xml @@ -5,4 +5,9 @@ + + + + + diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index b2c7091b088..ee4c978f2be 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -22,6 +22,47 @@ android:name="android.permission.QUERY_ALL_PACKAGES" tools:ignore="QueryAllPackagesPermission" /> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + >() 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/MainActivity.kt b/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt index 709e92a41fa..8a98bd2972e 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt @@ -189,6 +189,8 @@ import kotlin.math.abs import kotlin.math.absoluteValue import kotlin.system.exitProcess import com.lagradost.cloudstream3.utils.downloader.DownloadQueueManager +import kotlinx.coroutines.Job +import kotlinx.coroutines.cancel class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCallback { companion object { @@ -360,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) { @@ -440,6 +443,7 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa var lastPopup: SearchResponse? = null + var lastPopupJob: Job? = null fun loadPopup(result: SearchResponse, load: Boolean = true) { lastPopup = result val syncName = syncViewModel.syncName(result.apiName) @@ -455,7 +459,8 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa syncViewModel.clear() } - if (load) { + lastPopupJob?.cancel() + lastPopupJob = if (load) { viewModel.load( this, result.url, result.apiName, false, if (getApiDubstatusSettings() .contains(DubStatus.Dubbed) @@ -555,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 + } } /** @@ -566,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 } @@ -849,6 +860,8 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa private fun hidePreviewPopupDialog() { bottomPreviewPopup.dismissSafe(this) + lastPopupJob?.cancel() + lastPopupJob = null bottomPreviewPopup = null bottomPreviewBinding = null } @@ -1169,7 +1182,10 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa } override fun onCreate(savedInstanceState: Bundle?) { - app.initClient(this) + app.initClient(this, ignoreSSL = false) + @OptIn(UnsafeSSL::class) + insecureApp.initClient(this, ignoreSSL = true) + val settingsManager = PreferenceManager.getDefaultSharedPreferences(this) setLastError(this) @@ -2034,7 +2050,7 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa updateLocale() runDefault() } - + // Start the download queue DownloadQueueManager.init(this) } @@ -2059,4 +2075,4 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa false } } -} \ No newline at end of file +} 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 d69619b45a1..56512377bae 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/mvvm/Lifecycle.kt b/app/src/main/java/com/lagradost/cloudstream3/mvvm/Lifecycle.kt index 3df5197cd00..482ec05fc1b 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/network/RequestsHelper.kt b/app/src/main/java/com/lagradost/cloudstream3/network/RequestsHelper.kt index ec486d61db6..6234297d080 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/network/RequestsHelper.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/network/RequestsHelper.kt @@ -2,6 +2,7 @@ package com.lagradost.cloudstream3.network import android.content.Context import androidx.preference.PreferenceManager +import com.lagradost.cloudstream3.Prerelease import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.USER_AGENT import com.lagradost.cloudstream3.mvvm.safe @@ -15,11 +16,26 @@ import org.conscrypt.Conscrypt import java.io.File import java.security.Security +// Backwards compatible constructor, mark as deprecated later fun Requests.initClient(context: Context) { this.baseClient = buildDefaultClient(context) } +/** Only use ignoreSSL if you know what you are doing*/ +@Prerelease +fun Requests.initClient(context: Context, ignoreSSL: Boolean = false) { + this.baseClient = buildDefaultClient(context, ignoreSSL) +} + + +// Backwards compatible constructor, mark as deprecated later fun buildDefaultClient(context: Context): OkHttpClient { + return buildDefaultClient(context, false) +} + +/** Only use ignoreSSL if you know what you are doing*/ +@Prerelease +fun buildDefaultClient(context: Context, ignoreSSL: Boolean = false): OkHttpClient { safe { Security.insertProviderAt(Conscrypt.newProvider(), 1) } val settingsManager = PreferenceManager.getDefaultSharedPreferences(context) @@ -27,7 +43,11 @@ fun buildDefaultClient(context: Context): OkHttpClient { val baseClient = OkHttpClient.Builder() .followRedirects(true) .followSslRedirects(true) - .ignoreAllSSLErrors() + .apply { + if (ignoreSSL) { + ignoreAllSSLErrors() + } + } .cache( // Note that you need to add a ResponseInterceptor to make this 100% active. // The server response dictates if and when stuff should be cached. @@ -52,11 +72,6 @@ fun buildDefaultClient(context: Context): OkHttpClient { return baseClient } -//val Request.cookies: Map -// get() { -// return this.headers.getCookies("Cookie") -// } - private val DEFAULT_HEADERS = mapOf("user-agent" to USER_AGENT) /** diff --git a/app/src/main/java/com/lagradost/cloudstream3/plugins/PluginManager.kt b/app/src/main/java/com/lagradost/cloudstream3/plugins/PluginManager.kt index feb0ba6d458..eae14a6c0c3 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/plugins/PluginManager.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/plugins/PluginManager.kt @@ -13,6 +13,7 @@ import android.os.Build import android.os.Environment import android.util.Log import android.widget.Toast +import androidx.annotation.WorkerThread import androidx.core.app.ActivityCompat import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat @@ -45,6 +46,7 @@ import com.lagradost.cloudstream3.plugins.RepositoryManager.ONLINE_PLUGINS_FOLDE import com.lagradost.cloudstream3.plugins.RepositoryManager.PREBUILT_REPOSITORIES import com.lagradost.cloudstream3.plugins.RepositoryManager.downloadPluginToFile import com.lagradost.cloudstream3.plugins.RepositoryManager.getRepoPlugins +import com.lagradost.cloudstream3.plugins.RepositoryManager.sha256 import com.lagradost.cloudstream3.ui.settings.extensions.REPOSITORIES_KEY import com.lagradost.cloudstream3.ui.settings.extensions.RepositoryData import com.lagradost.cloudstream3.utils.AppContextUtils.getApiProviderLangSettings @@ -78,6 +80,7 @@ data class PluginData( @JsonProperty("filePath") val filePath: String, @JsonProperty("version") val version: Int, ) { + @WorkerThread fun toSitePlugin(): SitePlugin { return SitePlugin( this.filePath, @@ -92,7 +95,9 @@ data class PluginData( null, null, null, - File(this.filePath).length() + File(this.filePath).length(), + // No file hash for local plugins. Local plugins have no use for the hash, and it's expensive to compute. + null ) } } @@ -302,6 +307,7 @@ object PluginManager { downloadPlugin( activity, pluginData.onlineData.second.url, + pluginData.onlineData.second.fileHash, pluginData.savedData.internalName, File(pluginData.savedData.filePath), true @@ -413,6 +419,7 @@ object PluginManager { downloadPlugin( activity, pluginData.onlineData.second.url, + pluginData.onlineData.second.fileHash, pluginData.savedData.internalName, pluginData.onlineData.first, !pluginData.isDisabled @@ -691,16 +698,25 @@ object PluginManager { APIHolder.allProviders.removeIf { provider: MainAPI -> provider.sourcePlugin == plugin.filename } } - extractorApis.removeIf { provider: ExtractorApi -> provider.sourcePlugin == plugin.filename } + synchronized(extractorApis) { + extractorApis.removeIf { provider: ExtractorApi -> provider.sourcePlugin == plugin.filename } + } synchronized(VideoClickActionHolder.allVideoClickActions) { VideoClickActionHolder.allVideoClickActions.removeIf { action: VideoClickAction -> action.sourcePlugin == plugin.filename } } - classLoaders.values.removeIf { v -> v == plugin } + synchronized(classLoaders) { + classLoaders.values.removeIf { v -> v == plugin } + } + + synchronized(plugins) { + plugins.remove(absolutePath) + } - plugins.remove(absolutePath) - urlPlugins.values.removeIf { v -> v == plugin } + synchronized(urlPlugins) { + urlPlugins.values.removeIf { v -> v == plugin } + } } /** @@ -730,25 +746,27 @@ object PluginManager { suspend fun downloadPlugin( activity: Activity, pluginUrl: String, + pluginHash: String?, internalName: String, repositoryUrl: String, loadPlugin: Boolean ): Boolean { val file = getPluginPath(activity, internalName, repositoryUrl) - return downloadPlugin(activity, pluginUrl, internalName, file, loadPlugin) + return downloadPlugin(activity, pluginUrl, pluginHash, internalName, file, loadPlugin) } suspend fun downloadPlugin( activity: Activity, pluginUrl: String, + pluginHash: String?, internalName: String, file: File, - loadPlugin: Boolean + loadPlugin: Boolean, ): Boolean { try { Log.d(TAG, "Downloading plugin: $pluginUrl to ${file.absolutePath}") // The plugin file needs to be salted with the repository url hash as to allow multiple repositories with the same internal plugin names - val newFile = downloadPluginToFile(pluginUrl, file) ?: return false + val newFile = downloadPluginToFile(activity, pluginUrl, file, pluginHash) ?: return false val data = PluginData( internalName, @@ -836,6 +854,7 @@ object PluginManager { if (downloadPlugin( activity, pluginData.onlineData.second.url, + pluginData.onlineData.second.fileHash, pluginData.savedData.internalName, existingFile, true @@ -934,4 +953,4 @@ object PluginManager { return null } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/plugins/RepositoryManager.kt b/app/src/main/java/com/lagradost/cloudstream3/plugins/RepositoryManager.kt index 45ed65611e7..07d6aaa37bc 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/plugins/RepositoryManager.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/plugins/RepositoryManager.kt @@ -1,6 +1,7 @@ package com.lagradost.cloudstream3.plugins import android.content.Context +import androidx.annotation.WorkerThread import com.fasterxml.jackson.annotation.JsonProperty import com.lagradost.cloudstream3.CloudStreamApp.Companion.context import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey @@ -18,10 +19,12 @@ import com.lagradost.cloudstream3.ui.settings.extensions.RepositoryData import com.lagradost.cloudstream3.utils.AppUtils.tryParseJson import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock -import java.io.BufferedInputStream import java.io.File -import java.io.InputStream -import java.io.OutputStream +import java.nio.file.AtomicMoveNotSupportedException +import java.nio.file.Files +import java.nio.file.StandardCopyOption +import java.security.MessageDigest +import java.util.concurrent.atomic.AtomicInteger /** * Comes with the app, always available in the app, non removable. @@ -67,6 +70,7 @@ data class SitePlugin( @JsonProperty("iconUrl") val iconUrl: String?, // Automatically generated by the gradle plugin @JsonProperty("fileSize") val fileSize: Long?, + @JsonProperty("fileHash") val fileHash: String?, ) @@ -75,7 +79,26 @@ object RepositoryManager { val PREBUILT_REPOSITORIES: Array by lazy { getKey("PREBUILT_REPOSITORIES") ?: emptyArray() } - private val GH_REGEX = Regex("^https://raw.githubusercontent.com/([A-Za-z0-9-]+)/([A-Za-z0-9_.-]+)/(.*)$") + private val GH_REGEX = + Regex("^https://raw.githubusercontent.com/([A-Za-z0-9-]+)/([A-Za-z0-9_.-]+)/(.*)$") + + + /** Returns a SHA-256 string of the file content. + * Example: "sha256-b70462c264cb7f90fc2860a8e58d7544ce747ff347d1d11fa093623901853573" **/ + @WorkerThread + fun sha256(file: File): String { + val digest = MessageDigest.getInstance("SHA-256") + + file.inputStream().use { fis -> + val buffer = ByteArray(8192) + var read = fis.read(buffer) + while (read != -1) { + digest.update(buffer, 0, read) + read = fis.read(buffer) + } + } + return "sha256-" + digest.digest().joinToString("") { "%02x".format(it) } + } /* Convert raw.githubusercontent.com urls to cdn.jsdelivr.net if enabled in settings */ fun convertRawGitUrl(url: String): String { @@ -140,21 +163,52 @@ object RepositoryManager { }.flatten() } + suspend fun downloadPluginToFile( + context: Context, pluginUrl: String, - file: File + file: File, + expectedFileHash: String? ): File? { return safeAsync { - file.mkdirs() + val parentDir = file.parentFile ?: return@safeAsync null + parentDir.mkdirs() - // Overwrite if exists - if (file.exists()) { - file.delete() - } - file.createNewFile() + // Prevent corrupting the plugin file if the operation fails + val tempFile = File.createTempFile(file.name, ".tmp", context.cacheDir) val body = app.get(convertRawGitUrl(pluginUrl)).okhttpResponse.body - write(body.byteStream(), file.outputStream()) + + body.byteStream().use { body -> + tempFile.outputStream().use { fileSteam -> + body.copyTo(fileSteam) + } + } + + if (expectedFileHash != null) { + val downloadHash = sha256(tempFile) + if (expectedFileHash != downloadHash) { + tempFile.delete() + throw IllegalStateException("Extension hash mismatch when validating '${file.name}'! Expected: '$expectedFileHash', got: '$downloadHash'.") + } + } + + // We prefer the operation to be atomic + try { + Files.move( + tempFile.toPath(), + file.toPath(), + StandardCopyOption.REPLACE_EXISTING, + StandardCopyOption.ATOMIC_MOVE + ) + } catch (_: AtomicMoveNotSupportedException) { + Files.move( + tempFile.toPath(), + file.toPath(), + StandardCopyOption.REPLACE_EXISTING + ) + } + file } } @@ -202,13 +256,4 @@ object RepositoryManager { PluginManager.deleteRepositoryData(file.absolutePath) } - - private fun write(stream: InputStream, output: OutputStream) { - val input = BufferedInputStream(stream) - val dataBuffer = ByteArray(512) - var readBytes: Int - while (input.read(dataBuffer).also { readBytes = it } != -1) { - output.write(dataBuffer, 0, readBytes) - } - } } diff --git a/app/src/main/java/com/lagradost/cloudstream3/services/DownloadQueueService.kt b/app/src/main/java/com/lagradost/cloudstream3/services/DownloadQueueService.kt index 37b9a100228..e07747a860c 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/services/DownloadQueueService.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/services/DownloadQueueService.kt @@ -1,8 +1,10 @@ package com.lagradost.cloudstream3.services +import android.Manifest import android.app.Service import android.content.Context import android.content.Intent +import android.content.pm.PackageManager import android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC import android.os.Build.VERSION.SDK_INT import android.os.IBinder @@ -34,6 +36,7 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.takeWhile import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.updateAndGet @@ -104,6 +107,10 @@ class DownloadQueueService : Service() { private fun updateNotification(context: Context, downloads: Int, queued: Int) { + if (context.checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) + != PackageManager.PERMISSION_GRANTED + ) return + val activeDownloads = resources.getQuantityString(R.plurals.downloads_active, downloads).format(downloads) val activeQueue = @@ -180,6 +187,16 @@ class DownloadQueueService : Service() { debugAssert({ timeTaken == null }, { "Downloader startup should not time out" }) totalDownloadFlow + .debounce { (instances, queue) -> + // Filter away incorrect transient queue states. + // For example when we pop the queue and add a download instance there exists a transient state where + // there is no queue and no download instances (leading to an early exit) + if (instances.isEmpty() && queue.isEmpty()) { + 500.milliseconds + } else { + 0.milliseconds + } + } .takeWhile { (instances, queue) -> // Stop if destroyed isRunning diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/AccountManager.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/AccountManager.kt index 0d95de086be..3bc5f273397 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/AccountManager.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/AccountManager.kt @@ -13,6 +13,7 @@ import com.lagradost.cloudstream3.syncproviders.providers.SimklApi import com.lagradost.cloudstream3.syncproviders.providers.SubDlApi import com.lagradost.cloudstream3.syncproviders.providers.SubSourceApi import com.lagradost.cloudstream3.utils.DataStoreHelper +import com.lagradost.cloudstream3.utils.videoskip.AnimeSkipAuth import java.util.concurrent.TimeUnit abstract class AccountManager { @@ -28,6 +29,7 @@ abstract class AccountManager { val addic7ed = Addic7ed() val subDlApi = SubDlApi() val subSourceApi = SubSourceApi() + val animeSkipApi = AnimeSkipAuth() var cachedAccounts: MutableMap> var cachedAccountIds: MutableMap @@ -67,7 +69,8 @@ abstract class AccountManager { SyncRepo(localListApi), SubtitleRepo(openSubtitlesApi), SubtitleRepo(addic7ed), - SubtitleRepo(subDlApi) + SubtitleRepo(subDlApi), + PlainAuthRepo(animeSkipApi) ) fun updateAccountIds() { diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/AuthRepo.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/AuthRepo.kt index 4ae629ab944..645a19e3a60 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/AuthRepo.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/AuthRepo.kt @@ -9,6 +9,9 @@ import com.lagradost.cloudstream3.mvvm.safe import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.NONE_ID import com.lagradost.cloudstream3.utils.txt +/** General-purpose repo */ +class PlainAuthRepo(api: AuthAPI) : AuthRepo(api) + /** Safe abstraction for AuthAPI that provides both a catching interface, and automatic token management. */ abstract class AuthRepo(open val api: AuthAPI) { fun isValidRedirectUrl(url: String) = safe { api.isValidRedirectUrl(url) } ?: false 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 ed273a3cef2..f91d40f28e0 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/account/AccountSelectActivity.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/account/AccountSelectActivity.kt index 29c35dea6c0..ad323c7d124 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/account/AccountSelectActivity.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/account/AccountSelectActivity.kt @@ -48,10 +48,16 @@ class AccountSelectActivity : FragmentActivity(), BiometricCallback { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + // Are we editing and coming from MainActivity? + val isEditingFromMainActivity = intent.getBooleanExtra( + "isEditingFromMainActivity", + false + ) + // Sometimes we start this activity when we have already logged in // For example when using cloudstreamsearch:// // In those cases we want to just go to the main activity instantly - if (hasLoggedIn) { + if (hasLoggedIn && !isEditingFromMainActivity) { navigateToMainActivity() return } @@ -61,12 +67,6 @@ class AccountSelectActivity : FragmentActivity(), BiometricCallback { enableEdgeToEdgeCompat() setNavigationBarColorCompat(R.attr.primaryBlackBackground) - // Are we editing and coming from MainActivity? - val isEditingFromMainActivity = intent.getBooleanExtra( - "isEditingFromMainActivity", - false - ) - val settingsManager = PreferenceManager.getDefaultSharedPreferences(this) val skipStartup = settingsManager.getBoolean( getString(R.string.skip_startup_account_select_key), false @@ -216,4 +216,4 @@ class AccountSelectActivity : FragmentActivity(), BiometricCallback { override fun onAuthenticationError() { finish() } -} \ No newline at end of file +} 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 884eebd6292..dae70ebd7ad 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 be9f768a829..abc432ef959 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/download/button/BaseFetchButton.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/download/button/BaseFetchButton.kt index 82c4dcc3bed..382a770cd28 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/download/button/PieFetchButton.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/download/button/PieFetchButton.kt index a414dedf56b..f6f8a5ff846 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) { 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 375b2313f50..b68ef59625c 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 a292c2da2dd..959806e566c 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/player/AbstractPlayerFragment.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/AbstractPlayerFragment.kt index 1e6d827e63f..e5a460b9a02 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/AbstractPlayerFragment.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/AbstractPlayerFragment.kt @@ -1,64 +1,16 @@ package com.lagradost.cloudstream3.ui.player -import android.annotation.SuppressLint -import android.content.BroadcastReceiver -import android.content.Context -import android.content.Intent -import android.content.IntentFilter -import android.graphics.drawable.AnimatedImageDrawable -import android.graphics.drawable.AnimatedVectorDrawable -import android.media.metrics.PlaybackErrorEvent -import android.os.Build import android.os.Bundle -import android.util.Log -import android.view.LayoutInflater import android.view.View -import android.view.ViewGroup -import android.view.WindowManager -import android.widget.FrameLayout import android.widget.ImageView -import android.widget.ProgressBar -import android.widget.Toast -import androidx.annotation.LayoutRes import androidx.annotation.OptIn import androidx.annotation.StringRes -import androidx.core.view.isGone -import androidx.core.view.isVisible -import androidx.fragment.app.Fragment -import androidx.media3.common.PlaybackException import androidx.media3.common.util.UnstableApi -import androidx.media3.exoplayer.ExoPlayer import androidx.media3.session.MediaSession -import androidx.media3.ui.AspectRatioFrameLayout -import androidx.media3.ui.DefaultTimeBar -import androidx.media3.ui.PlayerView import androidx.media3.ui.SubtitleView -import androidx.media3.ui.TimeBar -import androidx.preference.PreferenceManager -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.keyEventListener -import com.lagradost.cloudstream3.CommonActivity.playerEventListener -import com.lagradost.cloudstream3.CommonActivity.screenWidth -import com.lagradost.cloudstream3.CommonActivity.showToast -import com.lagradost.cloudstream3.ErrorLoadingException +import androidx.viewbinding.ViewBinding import com.lagradost.cloudstream3.R -import com.lagradost.cloudstream3.mvvm.logError -import com.lagradost.cloudstream3.mvvm.safe -import com.lagradost.cloudstream3.ui.settings.Globals.PHONE -import com.lagradost.cloudstream3.ui.settings.Globals.isLayout -import com.lagradost.cloudstream3.ui.subtitles.SaveCaptionStyle -import com.lagradost.cloudstream3.ui.subtitles.SubtitlesFragment -import com.lagradost.cloudstream3.utils.AppContextUtils -import com.lagradost.cloudstream3.utils.AppContextUtils.requestLocalAudioFocus -import com.lagradost.cloudstream3.utils.DataStoreHelper -import com.lagradost.cloudstream3.utils.UIHelper -import com.lagradost.cloudstream3.utils.UIHelper.hideSystemUI -import com.lagradost.cloudstream3.utils.UIHelper.popCurrentPage -import com.lagradost.cloudstream3.utils.videoskip.VideoSkipStamp -import java.net.SocketTimeoutException +import com.lagradost.cloudstream3.ui.BaseFragment enum class PlayerResize(@StringRes val nameRes: Int) { Fit(R.string.resize_fit), @@ -79,676 +31,131 @@ const val NEXT_WATCH_EPISODE_PERCENTAGE = 90 const val UPDATE_SYNC_PROGRESS_PERCENTAGE = 80 @OptIn(UnstableApi::class) -abstract class AbstractPlayerFragment( - var player: IPlayer = CS3IPlayer() -) : Fragment() { - var resizeMode: Int = 0 - var subView: SubtitleView? = null - protected open var hasPipModeSupport = true - - var playerPausePlayHolderHolder: FrameLayout? = null - var playerPausePlay: ImageView? = null - var playerBuffering: ProgressBar? = null - var playerView: PlayerView? = null - var piphide: FrameLayout? = null - var subtitleHolder: FrameLayout? = null - var currentPlayerStatus = CSPlayerLoading.IsBuffering - - @LayoutRes - protected open var layout: Int = R.layout.fragment_player - - open fun nextEpisode() { - throw NotImplementedError() - } +abstract class AbstractPlayerFragment( + bindingCreator: BindingCreator +) : BaseFragment(bindingCreator), PlayerView.Callbacks { - open fun prevEpisode() { - throw NotImplementedError() - } + // Stored pre-initialization so subclasses can set them before onBindingCreated. + private var _player: IPlayer = CS3IPlayer() - open fun playerPositionChanged(position: Long, duration: Long) { - throw NotImplementedError() - } + /** The shared [PlayerView] host that owns all player state and view references. */ + protected var playerHostView: PlayerView? = null - open fun playerStatusChanged() {} + var player: IPlayer + get() = playerHostView?.player ?: _player + set(value) { + _player = value + playerHostView?.player = value + } - open fun playerDimensionsLoaded(width: Int, height: Int) { - throw NotImplementedError() - } + val subView: SubtitleView? get() = playerHostView?.subView + val playerPausePlay: ImageView? get() = playerHostView?.playerPausePlay - open fun subtitlesChanged() { - throw NotImplementedError() - } + /** The underlying [androidx.media3.ui.PlayerView] widget (named to avoid conflict with our [PlayerView]). */ + val playerView: androidx.media3.ui.PlayerView? + get() = playerHostView?.exoPlayerView - open fun embeddedSubtitlesFetched(subtitles: List) { - throw NotImplementedError() - } + var currentPlayerStatus: CSPlayerLoading + get() = playerHostView?.currentPlayerStatus ?: CSPlayerLoading.IsBuffering + set(value) { playerHostView?.currentPlayerStatus = value } - open fun onTracksInfoChanged() { - throw NotImplementedError() - } + protected var mMediaSession: MediaSession? + get() = playerHostView?.mMediaSession + set(value) { playerHostView?.mMediaSession = value } - open fun onTimestamp(timestamp: VideoSkipStamp?) { + // No-op callbacks (nextEpisode, prevEpisode, etc.) are intentionally left as + // open so subclasses can override only what they need. The ones below throw + // to make it obvious when an implementation is missing. + override fun nextEpisode() { + throw NotImplementedError() } - open fun onTimestampSkipped(timestamp: VideoSkipStamp) { - + override fun prevEpisode() { + throw NotImplementedError() } - open fun exitedPipMode() { + override fun playerPositionChanged(position: Long, duration: Long) { throw NotImplementedError() } - private fun keepScreenOn(on: Boolean) { - if (on) { - activity?.window?.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) - } else { - activity?.window?.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) - } + override fun playerDimensionsLoaded(width: Int, height: Int) { + throw NotImplementedError() } - private fun updateIsPlaying( - wasPlaying: CSPlayerLoading, - isPlaying: CSPlayerLoading - ) { - val isPlayingRightNow = CSPlayerLoading.IsPlaying == isPlaying - val isBuffering = CSPlayerLoading.IsBuffering == isPlaying - currentPlayerStatus = isPlaying - - keepScreenOn(isPlayingRightNow || isBuffering) - - if (isBuffering) { - playerPausePlayHolderHolder?.isVisible = false - playerBuffering?.isVisible = true - } else { - playerPausePlayHolderHolder?.isVisible = true - playerBuffering?.isVisible = false - - if (isPlaying == CSPlayerLoading.IsEnded && isLayout(PHONE)) { - playerPausePlay?.setImageResource(R.drawable.ic_baseline_replay_24) - } else if (wasPlaying != isPlaying) { - playerPausePlay?.setImageResource(if (isPlayingRightNow) R.drawable.play_to_pause else R.drawable.pause_to_play) - val drawable = playerPausePlay?.drawable - - var startedAnimation = false - if (Build.VERSION.SDK_INT > Build.VERSION_CODES.P) { - if (drawable is AnimatedImageDrawable) { - drawable.start() - startedAnimation = true - } - } - - if (drawable is AnimatedVectorDrawable) { - drawable.start() - startedAnimation = true - } - - if (drawable is AnimatedVectorDrawableCompat) { - drawable.start() - startedAnimation = true - } - - // somehow the phone is wacked - if (!startedAnimation) { - playerPausePlay?.setImageResource(if (isPlayingRightNow) R.drawable.netflix_pause else R.drawable.netflix_play) - } - } else { - playerPausePlay?.setImageResource(if (isPlayingRightNow) R.drawable.netflix_pause else R.drawable.netflix_play) - } - } - - PlayerPipHelper.updatePIPModeActions( - activity, - isPlaying, - hasPipModeSupport, - player.getAspectRatio() - ) + override fun subtitlesChanged() { + throw NotImplementedError() } - private var pipReceiver: BroadcastReceiver? = null - override fun onPictureInPictureModeChanged(isInPictureInPictureMode: Boolean) { - super.onPictureInPictureModeChanged(isInPictureInPictureMode) - try { - isInPIPMode = isInPictureInPictureMode - if (isInPictureInPictureMode) { - // Hide the full-screen UI (controls, etc.) while in picture-in-picture mode. - piphide?.isVisible = false - pipReceiver = object : BroadcastReceiver() { - override fun onReceive( - context: Context, - intent: Intent, - ) { - if (ACTION_MEDIA_CONTROL != intent.action) { - return - } - player.handleEvent( - CSPlayerEvent.entries[intent.getIntExtra( - EXTRA_CONTROL_TYPE, - 0 - )], source = PlayerEventSource.UI - ) - } - } - - val filter = IntentFilter() - filter.addAction(ACTION_MEDIA_CONTROL) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - activity?.registerReceiver(pipReceiver, filter, Context.RECEIVER_EXPORTED) - } else { - @SuppressLint("UnspecifiedRegisterReceiverFlag") - activity?.registerReceiver(pipReceiver, filter) - } - - val isPlaying = player.getIsPlaying() - val isPlayingValue = - if (isPlaying) CSPlayerLoading.IsPlaying else CSPlayerLoading.IsPaused - updateIsPlaying(isPlayingValue, isPlayingValue) - } else { - // Restore the full-screen UI. - piphide?.isVisible = true - exitedPipMode() - pipReceiver?.let { - // Prevents java.lang.IllegalArgumentException: Receiver not registered - safe { - activity?.unregisterReceiver(it) - } - } - activity?.hideSystemUI() - this.view?.let { UIHelper.hideKeyboard(it) } - } - } catch (e: Exception) { - logError(e) - } + override fun embeddedSubtitlesFetched(subtitles: List) { + throw NotImplementedError() } - open fun hasNextMirror(): Boolean { + override fun onTracksInfoChanged() { throw NotImplementedError() } - open fun nextMirror() { + override fun exitedPipMode() { throw NotImplementedError() } - private fun requestAudioFocus() { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - activity?.requestLocalAudioFocus(AppContextUtils.getFocusRequest()) - } + override fun hasNextMirror(): Boolean { + throw NotImplementedError() } - open fun playerError(exception: Throwable) { - fun showToast(message: String, gotoNext: Boolean = false) { - if (gotoNext && hasNextMirror()) { - showToast( - message, - Toast.LENGTH_SHORT - ) - nextMirror() - } else { - showToast( - context?.getString(R.string.no_links_found_toast) + "\n" + message, - Toast.LENGTH_LONG - ) - activity?.popCurrentPage() - } - } - - val ctx = context ?: return - when (exception) { - is PlaybackException -> { - val msg = exception.message ?: "" - val errorName = exception.errorCodeName - when (val code = exception.errorCode) { - PlaybackException.ERROR_CODE_IO_FILE_NOT_FOUND, - PlaybackException.ERROR_CODE_IO_NO_PERMISSION, - PlaybackException.ERROR_CODE_IO_READ_POSITION_OUT_OF_RANGE, - PlaybackException.ERROR_CODE_IO_UNSPECIFIED, - PlaybackException.ERROR_CODE_PARSING_CONTAINER_UNSUPPORTED -> { - showToast( - "${ctx.getString(R.string.source_error)}\n$errorName ($code)\n$msg", - gotoNext = true - ) - } - - PlaybackException.ERROR_CODE_REMOTE_ERROR, - PlaybackException.ERROR_CODE_IO_BAD_HTTP_STATUS, - PlaybackException.ERROR_CODE_TIMEOUT, - PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_FAILED, - PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_TIMEOUT, - PlaybackException.ERROR_CODE_IO_INVALID_HTTP_CONTENT_TYPE -> { - showToast( - "${ctx.getString(R.string.remote_error)}\n$errorName ($code)\n$msg", - gotoNext = true - ) - } - - PlaybackErrorEvent.ERROR_AUDIO_TRACK_INIT_FAILED, - PlaybackErrorEvent.ERROR_AUDIO_TRACK_OTHER, - PlaybackException.ERROR_CODE_DECODING_FAILED, - PlaybackException.ERROR_CODE_AUDIO_TRACK_WRITE_FAILED, - PlaybackException.ERROR_CODE_DECODER_INIT_FAILED, - PlaybackException.ERROR_CODE_DECODER_QUERY_FAILED -> { - showToast( - "${ctx.getString(R.string.render_error)}\n$errorName ($code)\n$msg", - gotoNext = true - ) - } - - PlaybackException.ERROR_CODE_DECODING_FORMAT_UNSUPPORTED, - PlaybackException.ERROR_CODE_DECODING_FORMAT_EXCEEDS_CAPABILITIES -> { - showToast( - "${ctx.getString(R.string.unsupported_error)}\n$errorName ($code)\n$msg", - gotoNext = true - ) - } - - PlaybackException.ERROR_CODE_PARSING_CONTAINER_MALFORMED, - PlaybackException.ERROR_CODE_PARSING_MANIFEST_MALFORMED -> { - showToast( - "${ctx.getString(R.string.encoding_error)}\n$errorName ($code)\n$msg", - gotoNext = true - ) - } - - else -> { - showToast( - "${ctx.getString(R.string.unexpected_error)}\n$errorName ($code)\n$msg", - gotoNext = false - ) - } - } - } - - is InvalidFileException -> { - showToast( - "${ctx.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. - */ - activity?.runOnUiThread { - showToast( - "${ctx.getString(R.string.remote_error)}\n${exception.message}", - gotoNext = true - ) - } - } - - is ErrorLoadingException -> { - exception.message?.let { - showToast( - it, - gotoNext = true - ) - } ?: showToast( - exception.toString(), - gotoNext = true - ) - } - - else -> { - exception.message?.let { - showToast( - it, - gotoNext = false - ) - } ?: showToast( - exception.toString(), - gotoNext = false - ) - } - } + override fun nextMirror() { + throw NotImplementedError() } - private fun onSubStyleChanged(style: SaveCaptionStyle) { - player.updateSubtitleStyle(style) - // Forcefully update the subtitle encoding in case the edge size is changed - player.seekTime(-1) + /** Delegates to [PlayerView.playerError] by default; override to customize. */ + override fun playerError(exception: Throwable) { + playerHostView?.playerError(exception) } + /** Player fragments don't need system-bar padding adjustment by default. */ + override fun fixLayout(view: View) = Unit - @SuppressLint("UnsafeOptInUsageError") - open fun playerUpdated(player: Any?) { - if (player is ExoPlayer) { - context?.let { ctx -> - mMediaSession?.release() - mMediaSession = MediaSession.Builder(ctx, player) - // Ensure unique ID for concurrent players - .setId(System.currentTimeMillis().toString()) - .build() - } - - // Necessary for multiple combined videos - @Suppress("DEPRECATION") - playerView?.setShowMultiWindowTimeBar(true) - playerView?.player = player - playerView?.performClick() - } - } - - protected var mMediaSession: MediaSession? = null - - // this can be used in the future for players other than exoplayer - //private val mMediaSessionCallback: MediaSessionCompat.Callback = object : MediaSessionCompat.Callback() { - // override fun onMediaButtonEvent(mediaButtonEvent: Intent): Boolean { - // val keyEvent = mediaButtonEvent.getParcelableExtra(Intent.EXTRA_KEY_EVENT) as KeyEvent? - // if (keyEvent != null) { - // if (keyEvent.action == KeyEvent.ACTION_DOWN) { // NO DOUBLE SKIP - // val consumed = when (keyEvent.keyCode) { - // KeyEvent.KEYCODE_MEDIA_PAUSE -> callOnPause() - // KeyEvent.KEYCODE_MEDIA_PLAY -> callOnPlay() - // KeyEvent.KEYCODE_MEDIA_STOP -> callOnStop() - // KeyEvent.KEYCODE_MEDIA_NEXT -> callOnNext() - // else -> false - // } - // if (consumed) return true - // } - // } - // - // return super.onMediaButtonEvent(mediaButtonEvent) - // } - //} - - open fun onDownload(event: DownloadEvent) = Unit - - /** This receives the events from the player, if you want to append functionality you do it here, - * do note that this only receives events for UI changes, - * and returning early WONT stop it from changing in eg the player time or pause status */ - open fun mainCallback(event: PlayerEvent) { - // we don't want to spam DownloadEvent - if (event !is DownloadEvent) { - Log.i(TAG, "Handle event: $event") - } - when (event) { - is DownloadEvent -> { - onDownload(event) - } - - is ResizedEvent -> { - playerDimensionsLoaded(event.width, event.height) - } - - is PlayerAttachedEvent -> { - playerUpdated(event.player) - } - - is SubtitlesUpdatedEvent -> { - subtitlesChanged() - } - - is TimestampSkippedEvent -> { - onTimestampSkipped(event.timestamp) - } - - is TimestampInvokedEvent -> { - onTimestamp(event.timestamp) - } - - is TracksChangedEvent -> { - onTracksInfoChanged() - } - - is EmbeddedSubtitlesFetchedEvent -> { - embeddedSubtitlesFetched(event.tracks) - } - - is ErrorEvent -> { - playerError(event.error) - } - - is RequestAudioFocusEvent -> { - requestAudioFocus() - } - - is EpisodeSeekEvent -> { - when (event.offset) { - -1 -> prevEpisode() - 1 -> nextEpisode() - else -> {} - } - } - - is StatusEvent -> { - updateIsPlaying(wasPlaying = event.wasPlaying, isPlaying = event.isPlaying) - playerStatusChanged() - } - - is PositionEvent -> { - playerPositionChanged(position = event.toMs, duration = event.durationMs) - } - - is VideoEndedEvent -> { - context?.let { ctx -> - // Only play next episode if autoplay is on (default) - if (PreferenceManager.getDefaultSharedPreferences(ctx) - ?.getBoolean( - ctx.getString(R.string.autoplay_next_key), - true - ) == true - ) { - player.handleEvent( - CSPlayerEvent.NextEpisode, - source = PlayerEventSource.Player - ) - } - } - } - - is PauseEvent -> Unit - is PlayEvent -> Unit - } + override fun onBindingCreated(binding: T, savedInstanceState: Bundle?) { + val ctx = context ?: return + playerHostView = PlayerView(ctx) + playerHostView?.player = _player + playerHostView?.callbacks = this + playerHostView?.bindViews(binding.root) + playerHostView?.initialize() } - @SuppressLint("SetTextI18n", "UnsafeOptInUsageError") - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - resizeMode = DataStoreHelper.resizeMode - resize(resizeMode, false) - - player.releaseCallbacks() - player.initCallbacks( - eventHandler = ::mainCallback, - requestedListeningPercentages = listOf( - SKIP_OP_VIDEO_PERCENTAGE, - PRELOAD_NEXT_EPISODE_PERCENTAGE, - NEXT_WATCH_EPISODE_PERCENTAGE, - UPDATE_SYNC_PROGRESS_PERCENTAGE, - ), - ) - - val player = player - if (player is CS3IPlayer) { - // preview bar - val progressBar: PreviewTimeBar? = playerView?.findViewById(R.id.exo_progress) - val previewImageView: ImageView? = playerView?.findViewById(R.id.previewImageView) - val previewFrameLayout: FrameLayout? = playerView?.findViewById(R.id.previewFrameLayout) - if (progressBar != null && previewImageView != null && previewFrameLayout != null) { - var resume = false - progressBar.addOnScrubListener(object : PreviewBar.OnScrubListener { - override fun onScrubStart(previewBar: PreviewBar?) { - val hasPreview = player.hasPreview() - progressBar.isPreviewEnabled = hasPreview - resume = player.getIsPlaying() - if (resume) player.handleEvent( - CSPlayerEvent.Pause, - PlayerEventSource.Player - ) - - // No clashing UI - if (hasPreview) { - subView?.isVisible = false - } - } - - override fun onScrubMove( - previewBar: PreviewBar?, - progress: Int, - fromUser: Boolean - ) { - } - - override fun onScrubStop(previewBar: PreviewBar?) { - if (resume) player.handleEvent(CSPlayerEvent.Play, PlayerEventSource.Player) - // Delay to prevent the small flicker of subtitle before seeking - subView?.postDelayed({ - // If we are not scrubbing then show subtitles again - if (previewBar == null || !previewBar.isPreviewEnabled || !previewBar.isShowingPreview) { - subView?.isVisible = true - } - }, 200) - } - }) - progressBar.attachPreviewView(previewFrameLayout) - progressBar.setPreviewLoader { currentPosition, max -> - val bitmap = player.getPreview(currentPosition.toFloat().div(max.toFloat())) - previewImageView.isGone = bitmap == null - previewImageView.setImageBitmap(bitmap) - } - } - - subView = playerView?.findViewById(androidx.media3.ui.R.id.exo_subtitles) - player.initSubtitles(subView, subtitleHolder, CustomDecoder.style) - (player.imageGenerator as? PreviewGenerator)?.params = ImageParams.new16by9(screenWidth) - - /*previewImageView?.doOnLayout { - (player.imageGenerator as? PreviewGenerator)?.params = ImageParams( - it.measuredWidth, - it.measuredHeight - ) - }*/ - /** this might seam a bit fucky and that is because it is, the seek event is captured twice, once by the player - * and once by the UI even if it should only be registered once by the UI */ - playerView?.findViewById(R.id.exo_progress) - ?.addListener(object : TimeBar.OnScrubListener { - override fun onScrubStart(timeBar: TimeBar, position: Long) = Unit - override fun onScrubMove(timeBar: TimeBar, position: Long) = Unit - override fun onScrubStop(timeBar: TimeBar, position: Long, canceled: Boolean) { - if (canceled) return - val playerDuration = player.getDuration() ?: return - val playerPosition = player.getPosition() ?: return - mainCallback( - PositionEvent( - source = PlayerEventSource.UI, - durationMs = playerDuration, - fromMs = playerPosition, - toMs = position - ) - ) - } - }) - - SubtitlesFragment.applyStyleEvent += ::onSubStyleChanged - - try { - context?.let { ctx -> - val settingsManager = PreferenceManager.getDefaultSharedPreferences( - ctx - ) - - val currentPrefCacheSize = - settingsManager.getInt(getString(R.string.video_buffer_size_key), 0) - val currentPrefDiskSize = - settingsManager.getInt(getString(R.string.video_buffer_disk_key), 0) - val currentPrefBufferSec = - settingsManager.getInt(getString(R.string.video_buffer_length_key), 0) - - player.cacheSize = currentPrefCacheSize * 1024L * 1024L - player.simpleCacheSize = currentPrefDiskSize * 1024L * 1024L - player.videoBufferMs = currentPrefBufferSec * 1000L - } - } catch (e: Exception) { - logError(e) - } - } - - /*context?.let { ctx -> - player.loadPlayer( - ctx, - false, - ExtractorLink( - "idk", - "bunny", - "http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4", - "", - Qualities.P720.value, - false - ), - ) - }*/ + override fun onPictureInPictureModeChanged(isInPictureInPictureMode: Boolean) { + super.onPictureInPictureModeChanged(isInPictureInPictureMode) + playerHostView?.onPictureInPictureModeChanged(isInPictureInPictureMode, activity) } override fun onDestroy() { - player.release() - player.releaseCallbacks() - player = CS3IPlayer() - - playerEventListener = null - keyEventListener = null - - PlayerPipHelper.updatePIPModeActions(activity, CSPlayerLoading.IsPaused, false, null) - - mMediaSession?.release() - mMediaSession = null - playerView?.player = null - SubtitlesFragment.applyStyleEvent -= ::onSubStyleChanged - - keepScreenOn(false) + playerHostView?.release() super.onDestroy() } - fun nextResize() { - resizeMode = (resizeMode + 1) % PlayerResize.entries.size - resize(resizeMode, true) - } - - fun resize(resize: Int, showToast: Boolean) { - resize(PlayerResize.entries[resize], showToast) - } - - @SuppressLint("UnsafeOptInUsageError") - open fun resize(resize: PlayerResize, showToast: Boolean) { - DataStoreHelper.resizeMode = resize.ordinal - val type = when (resize) { - PlayerResize.Fill -> AspectRatioFrameLayout.RESIZE_MODE_FILL - PlayerResize.Fit -> AspectRatioFrameLayout.RESIZE_MODE_FIT - PlayerResize.Zoom -> AspectRatioFrameLayout.RESIZE_MODE_ZOOM - } - playerView?.resizeMode = type - - if (showToast) - showToast(resize.nameRes, Toast.LENGTH_SHORT) + override fun onPause() { + playerHostView?.releaseKeyEventListener() + super.onPause() } override fun onStop() { - player.onStop() + playerHostView?.onStop() super.onStop() } override fun onResume() { context?.let { ctx -> - player.onResume(ctx) + playerHostView?.onResume(ctx) } - super.onResume() } - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View? { - val root = inflater.inflate(layout, container, false) - playerPausePlayHolderHolder = root.findViewById(R.id.player_pause_play_holder_holder) - playerPausePlay = root.findViewById(R.id.player_pause_play) - playerBuffering = root.findViewById(R.id.player_buffering) - playerView = root.findViewById(R.id.player_view) - piphide = root.findViewById(R.id.piphide) - subtitleHolder = root.findViewById(R.id.subtitle_holder) - return root + fun nextResize() { + playerHostView?.nextResize() } -} \ No newline at end of file + + open fun resize(resize: PlayerResize, showToast: Boolean) { + playerHostView?.resize(resize, showToast) + } +} 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 43b281a2855..aa44b92359b 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 @@ -29,6 +30,7 @@ import androidx.media3.common.TrackGroup import androidx.media3.common.TrackSelectionOverride import androidx.media3.common.Tracks import androidx.media3.common.VideoSize +// import androidx.media3.common.util.ExperimentalApi import androidx.media3.common.util.UnstableApi import androidx.media3.database.StandaloneDatabaseProvider import androidx.media3.datasource.DataSource @@ -42,6 +44,7 @@ import androidx.media3.datasource.cronet.CronetDataSource import androidx.media3.datasource.okhttp.OkHttpDataSource import androidx.media3.exoplayer.DecoderCounters import androidx.media3.exoplayer.DecoderReuseEvaluation +import androidx.media3.exoplayer.DefaultLivePlaybackSpeedControl import androidx.media3.exoplayer.DefaultLoadControl import androidx.media3.exoplayer.DefaultRenderersFactory import androidx.media3.exoplayer.ExoPlayer @@ -54,6 +57,7 @@ import androidx.media3.exoplayer.drm.DefaultDrmSessionManager import androidx.media3.exoplayer.drm.FrameworkMediaDrm import androidx.media3.exoplayer.drm.HttpMediaDrmCallback import androidx.media3.exoplayer.drm.LocalMediaDrmCallback +import androidx.media3.exoplayer.hls.playlist.HlsPlaylistTracker import androidx.media3.exoplayer.source.ClippingMediaSource import androidx.media3.exoplayer.source.ConcatenatingMediaSource import androidx.media3.exoplayer.source.ConcatenatingMediaSource2 @@ -83,6 +87,8 @@ import com.lagradost.cloudstream3.mvvm.debugAssert import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.mvvm.safe import com.lagradost.cloudstream3.ui.player.CustomDecoder.Companion.fixSubtitleAlignment +import com.lagradost.cloudstream3.ui.player.live.LiveHelper +import com.lagradost.cloudstream3.ui.player.live.PREFERRED_LIVE_OFFSET import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR import com.lagradost.cloudstream3.ui.settings.Globals.PHONE import com.lagradost.cloudstream3.ui.settings.Globals.isLayout @@ -201,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) - } } } @@ -230,8 +234,9 @@ class CS3IPlayer : IPlayer { } } + @AnyThread override fun initCallbacks( - eventHandler: ((PlayerEvent) -> Unit), + @MainThread eventHandler: ((PlayerEvent) -> Unit), requestedListeningPercentages: List?, ) { this.requestedListeningPercentages = requestedListeningPercentages @@ -242,23 +247,6 @@ class CS3IPlayer : IPlayer { } } - // I know, this is not a perfect solution, however it works for fixing subs - private fun reloadSubs() { - exoPlayer?.applicationLooper?.let { - try { - Handler(it).post { - try { - seekTime(1L, source = PlayerEventSource.Player) - } catch (e: Exception) { - logError(e) - } - } - } catch (e: Exception) { - logError(e) - } - } - } - fun String.stripTrackId(): String { return this.replace(Regex("""^\d+:"""), "") } @@ -272,6 +260,10 @@ class CS3IPlayer : IPlayer { } override fun hasPreview(): Boolean { + // No previews on livestreams because the previews get outdated + if (exoPlayer?.isCurrentMediaItemDynamic == true) { + return false + } return imageGenerator.hasPreview() } @@ -399,7 +391,12 @@ class CS3IPlayer : IPlayer { ?.let { group -> exoPlayer?.trackSelectionParameters ?.buildUpon() - ?.setOverrideForType(TrackSelectionOverride(group.mediaTrackGroup, trackFormatIndex)) + ?.setOverrideForType( + TrackSelectionOverride( + group.mediaTrackGroup, + trackFormatIndex + ) + ) ?.build() } ?.let { newParams -> @@ -418,9 +415,9 @@ class CS3IPlayer : IPlayer { * Gets all supported formats in a list * */ private fun List.getFormats(): List> { - return this.map { + return this.flatMap { it.getFormats() - }.flatten() + } } private fun Tracks.Group.getFormats(): List> { @@ -516,10 +513,12 @@ class CS3IPlayer : IPlayer { Log.i(TAG, "setPreferredSubtitles REQUIRES_RELOAD") return true } + SubtitleStatus.NOT_FOUND -> { Log.i(TAG, "setPreferredSubtitles NOT_FOUND") return true } + SubtitleStatus.IS_ACTIVE -> { Log.i(TAG, "setPreferredSubtitles IS_ACTIVE") exoPlayer?.currentTracks?.groups @@ -945,6 +944,22 @@ class CS3IPlayer : IPlayer { when (event) { CSPlayerEvent.Play -> { event(PlayEvent(source)) + // If the player was stopped (e.g. notification dismissed) it lands in + // STATE_IDLE. A bare play() call is a no-op in that state, re-prepare and + // then resume to the current position once we are in STATE_READY again. + if (playbackState == Player.STATE_IDLE) { + val seekPosition = currentPosition + exoPlayer?.addListener(object : Player.Listener { + private var seekApplied = false + override fun onPlaybackStateChanged(playbackState: Int) { + if (seekApplied || playbackState != Player.STATE_READY) return + seekApplied = true + exoPlayer?.seekTo(currentWindow, seekPosition) + exoPlayer?.removeListener(this) + } + }) + prepare() + } play() } @@ -1067,7 +1082,18 @@ class CS3IPlayer : IPlayer { ): ExoPlayer { val exoPlayerBuilder = ExoPlayer.Builder(context) - .setRenderersFactory { eventHandler, videoRendererEventListener, audioRendererEventListener, textRendererOutput, metadataRendererOutput -> + .setMediaSourceFactory( + DefaultMediaSourceFactory(context).setLiveTargetOffsetMs( + PREFERRED_LIVE_OFFSET + ) + ) + .setLivePlaybackSpeedControl( + DefaultLivePlaybackSpeedControl.Builder() + .setFallbackMaxPlaybackSpeed(1.03f) + .setFallbackMinPlaybackSpeed(0.97f) + .build() + ) + .setRenderersFactory { eventHandler, videoRendererEventListener, audioRendererEventListener, _, metadataRendererOutput -> val settingsManager = PreferenceManager.getDefaultSharedPreferences(context) val current = settingsManager.getInt( context.getString(R.string.software_decoding_key), @@ -1101,7 +1127,7 @@ class CS3IPlayer : IPlayer { // Custom TextOutput to apply cue styling and rules to all subtitles val customTextOutput = TextOutput { cue -> // Do not remove filterNotNull as Java typesystem is fucked - val (bitmapCues, textCues) = cue.cues.filterNotNull() + val (bitmapCues, textCues) = cue.cues.toList() .partition { it.bitmap != null } val styledBitmapCues = bitmapCues.map { bitmapCue -> @@ -1169,6 +1195,7 @@ class CS3IPlayer : IPlayer { CustomDecoder.subtitleOffset = subtitleOffset val decoder = CustomSubtitleDecoderFactory() + // @OptIn(ExperimentalApi::class) val currentTextRenderer = TextRenderer( customTextOutput, eventHandler.looper, @@ -1307,7 +1334,7 @@ class CS3IPlayer : IPlayer { } else { try { val source = ConcatenatingMediaSource2.Builder() - mediaItemSlices.map { item -> + mediaItemSlices.forEach { item -> source.add( // The duration MUST be known for it to work properly, see https://github.com/google/ExoPlayer/issues/4727 ClippingMediaSource( @@ -1321,7 +1348,7 @@ class CS3IPlayer : IPlayer { @Suppress("DEPRECATION") val source = ConcatenatingMediaSource() // FIXME figure out why ConcatenatingMediaSource2 seems to fail with Torrents only - mediaItemSlices.map { item -> + mediaItemSlices.forEach { item -> source.addMediaSource( // The duration MUST be known for it to work properly, see https://github.com/google/ExoPlayer/issues/4727 ClippingMediaSource( @@ -1386,6 +1413,23 @@ class CS3IPlayer : IPlayer { event(PlayerAttachedEvent(exoPlayer)) exoPlayer?.prepare() + // For offline fragmented MP4s, FLAG_MERGE_FRAGMENTED_SIDX builds the SIDX seek map + // incrementally as data is buffered. The initial seek resolves to the nearest merged + // entry (~first fragment, 3 s). On STATE_READY, re-seek to the actual saved position. + // This may only be reproducible on large and fairly long fragmented MP4 files with + // multiple sidx boxes. + if (onlineSource == null && playbackPosition > (exoPlayer?.duration ?: 0L)) { + exoPlayer?.addListener(object : Player.Listener { + private var seekApplied = false + override fun onPlaybackStateChanged(playbackState: Int) { + if (seekApplied || playbackState != Player.STATE_READY) return + seekApplied = true + exoPlayer?.seekTo(currentWindow, playbackPosition) + exoPlayer?.removeListener(this) + } + }) + } + exoPlayer?.let { exo -> event(StatusEvent(CSPlayerLoading.IsBuffering, CSPlayerLoading.IsBuffering)) isPlaying = exo.isPlaying @@ -1398,6 +1442,8 @@ class CS3IPlayer : IPlayer { return } + LiveHelper.registerPlayer(exoPlayer) + exoPlayer?.addListener(object : Player.Listener { override fun onTracksChanged(tracks: Tracks) { safe { @@ -1506,6 +1552,23 @@ class CS3IPlayer : IPlayer { exoPlayer?.prepare() } + // PlaylistStuckException usually happens when the player position is ahead of the live window. + // Seek to the default location in that case + error.cause is HlsPlaylistTracker.PlaylistStuckException -> { + val position = exoPlayer?.currentPosition ?: exoPlayer?.duration ?: 0 + + // Seek to live head + val aheadOfLive = LiveHelper.getLiveManager(exoPlayer)?.getTimeAheadOfLive(position) ?: 0 + + if (aheadOfLive > 100) { + exoPlayer?.seekTo(position - aheadOfLive) + } else { + exoPlayer?.seekToDefaultPosition() + } + exoPlayer?.prepare() + } + + else -> { event(ErrorEvent(error)) } @@ -1707,7 +1770,6 @@ class CS3IPlayer : IPlayer { return exoPlayer != null } - @MainThread private fun loadTorrent(context: Context, link: ExtractorLink) { ioSafe { @@ -1757,7 +1819,7 @@ class CS3IPlayer : IPlayer { defaultSet ) ?.mapNotNull { it.toIntOrNull() ?: return@mapNotNull null } - } catch (e: Throwable) { + } catch (_: Throwable) { null } ?: default @@ -1958,4 +2020,3 @@ class CS3IPlayer : IPlayer { } } - 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 eb1bcd00d42..35f8dcfd8ae 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/DownloadedPlayerActivity.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/DownloadedPlayerActivity.kt index a3a9b7125ed..7a42cea93f7 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") - val data = intent.data + 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 +} 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 a52a3c64665..85db33fc094 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/FullScreenPlayer.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/FullScreenPlayer.kt index 4bec57f9c5f..26706699bcc 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/FullScreenPlayer.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/FullScreenPlayer.kt @@ -1,7 +1,6 @@ package com.lagradost.cloudstream3.ui.player import android.animation.ObjectAnimator -import android.animation.ValueAnimator import android.annotation.SuppressLint import android.app.Activity import android.app.Dialog @@ -11,63 +10,42 @@ import android.content.pm.ActivityInfo import android.content.res.ColorStateList import android.content.res.Configuration import android.graphics.Color -import android.graphics.Matrix -import android.media.AudioManager -import android.media.audiofx.LoudnessEnhancer import android.os.Build import android.os.Bundle -import android.os.Handler -import android.os.Looper -import android.provider.Settings import android.text.Editable -import android.text.format.DateUtils import android.view.KeyEvent import android.view.LayoutInflater import android.view.MotionEvent -import android.view.ScaleGestureDetector import android.view.Surface import android.view.View import android.view.ViewGroup -import android.view.WindowInsets import android.view.WindowManager -import android.view.WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES import android.view.animation.AccelerateDecelerateInterpolator import android.view.animation.AlphaAnimation -import android.view.animation.Animation -import android.view.animation.AnimationUtils import android.view.animation.DecelerateInterpolator import android.widget.LinearLayout import androidx.annotation.OptIn import androidx.appcompat.app.AlertDialog -import androidx.core.content.ContextCompat import androidx.core.graphics.blue import androidx.core.graphics.green import androidx.core.graphics.red import androidx.core.view.children import androidx.core.view.isGone -import androidx.core.view.isInvisible import androidx.core.view.isVisible import androidx.core.widget.doOnTextChanged import androidx.media3.common.MimeTypes import androidx.media3.common.util.UnstableApi -import androidx.media3.exoplayer.ExoPlayer -import androidx.media3.ui.AspectRatioFrameLayout import androidx.preference.PreferenceManager import androidx.recyclerview.widget.SimpleItemAnimator import com.google.android.material.button.MaterialButton -import com.lagradost.cloudstream3.BuildConfig import com.lagradost.cloudstream3.CommonActivity.keyEventListener -import com.lagradost.cloudstream3.CommonActivity.playerEventListener -import com.lagradost.cloudstream3.CommonActivity.screenHeightWithOrientation -import com.lagradost.cloudstream3.CommonActivity.screenWidthWithOrientation -import com.lagradost.cloudstream3.CommonActivity.showToast import com.lagradost.cloudstream3.LoadResponse import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.databinding.FragmentPlayerBinding import com.lagradost.cloudstream3.databinding.PlayerCustomLayoutBinding import com.lagradost.cloudstream3.databinding.SpeedDialogBinding import com.lagradost.cloudstream3.databinding.SubtitleOffsetBinding import com.lagradost.cloudstream3.mvvm.logError -import com.lagradost.cloudstream3.mvvm.safe import com.lagradost.cloudstream3.ui.player.GeneratorPlayer.Companion.subsProvidersIsActive import com.lagradost.cloudstream3.ui.player.source_priority.QualityDataHelper import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR @@ -75,74 +53,38 @@ import com.lagradost.cloudstream3.ui.settings.Globals.PHONE import com.lagradost.cloudstream3.ui.settings.Globals.TV import com.lagradost.cloudstream3.ui.settings.Globals.isLayout import com.lagradost.cloudstream3.utils.AppContextUtils.isUsingMobileData +import com.lagradost.cloudstream3.utils.AppContextUtils.shouldShowPlayerMetadata import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.attachBackPressedCallback import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.detachBackPressedCallback import com.lagradost.cloudstream3.utils.DataStoreHelper import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute import com.lagradost.cloudstream3.utils.UIHelper.dismissSafe import com.lagradost.cloudstream3.utils.UIHelper.fixSystemBarsPadding -import com.lagradost.cloudstream3.utils.UIHelper.getNavigationBarHeight -import com.lagradost.cloudstream3.utils.UIHelper.getStatusBarHeight import com.lagradost.cloudstream3.utils.UIHelper.hideSystemUI import com.lagradost.cloudstream3.utils.UIHelper.popCurrentPage -import com.lagradost.cloudstream3.utils.UIHelper.showSystemUI import com.lagradost.cloudstream3.utils.UIHelper.toPx -import com.lagradost.cloudstream3.utils.UserPreferenceDelegate -import com.lagradost.cloudstream3.utils.Vector2 import com.lagradost.cloudstream3.utils.setText import com.lagradost.cloudstream3.utils.txt -import kotlin.math.abs -import kotlin.math.absoluteValue -import kotlin.math.ceil -import kotlin.math.max -import kotlin.math.min -import kotlin.math.round import kotlin.math.roundToInt -import com.lagradost.cloudstream3.utils.AppContextUtils.shouldShowPlayerMetadata - - -// You can zoom out more than 100%, but it will zoom back into 100% -const val MINIMUM_ZOOM = 0.95f -// How sensitive the auto zoom is to center at the min zoom -const val ZOOM_SNAP_SENSITIVITY = 0.07f - -// Maximum zoom to avoid getting lost -const val MAXIMUM_ZOOM = 4.0f - -const val MINIMUM_SEEK_TIME = 7000L // when swipe seeking -const val MINIMUM_VERTICAL_SWIPE = 2.0f // in percentage -const val MINIMUM_HORIZONTAL_SWIPE = 2.0f // in percentage -const val VERTICAL_MULTIPLIER = 2.0f -const val HORIZONTAL_MULTIPLIER = 2.0f -const val DOUBLE_TAB_MAXIMUM_HOLD_TIME = 200L -const val DOUBLE_TAB_MINIMUM_TIME_BETWEEN = 200L // this also affects the UI show response time -const val DOUBLE_TAB_PAUSE_PERCENTAGE = 0.15 // in both directions private const val SUBTITLE_DELAY_BUNDLE_KEY = "subtitle_delay" // All the UI Logic for the player @OptIn(UnstableApi::class) -open class FullScreenPlayer : AbstractPlayerFragment() { - private var isVerticalOrientation: Boolean = false +open class FullScreenPlayer : AbstractPlayerFragment( + BindingCreator.Bind(FragmentPlayerBinding::bind) +) { + override fun pickLayout(): Int = R.layout.fragment_player protected open var lockRotation = true - protected open var isFullScreenPlayer = true protected var playerBinding: PlayerCustomLayoutBinding? = null - protected var brightnessOverlay: View? = null - - private var durationMode: Boolean by UserPreferenceDelegate("duration_mode", false) // state of player UI protected var isShowing = false - private var uiShowingBeforeGesture = false protected var isLocked = false protected var timestampShowState = false private var metadataVisibilityToken = 0 protected var hasEpisodes = false private set - // protected val hasEpisodes - // get() = episodes.isNotEmpty() - - // options for player /** * Default profile 1 @@ -151,23 +93,13 @@ open class FullScreenPlayer : AbstractPlayerFragment() { **/ protected var currentQualityProfile = 1 - // protected var currentPrefQuality = -// Qualities.P2160.value // preferred maximum quality, used for ppl w bad internet or on cell - protected var extraBrightnessEnabled = false - protected var fastForwardTime = 10000L protected var androidTVInterfaceOffSeekTime = 10000L protected var androidTVInterfaceOnSeekTime = 30000L - protected var swipeHorizontalEnabled = false - protected var swipeVerticalEnabled = false protected var playBackSpeedEnabled = false protected var playerResizeEnabled = false - protected var doubleTapEnabled = false - protected var doubleTapPauseEnabled = true protected var playerRotateEnabled = false protected var rotatedManually = false - protected var autoPlayerRotateEnabled = false private var hideControlsNames = false - protected var speedupEnabled = false protected var subtitleDelay set(value) = try { player.setSubtitleOffset(-value) @@ -181,65 +113,10 @@ open class FullScreenPlayer : AbstractPlayerFragment() { 0L } - // private var useSystemBrightness = false - protected var useTrueSystemBrightness = true - private val fullscreenNotch = true // TODO SETTING - - private var statusBarHeight: Int? = null - private var navigationBarHeight: Int? = null - - private val brightnessIcons = listOf( - R.drawable.sun_1, - R.drawable.sun_2, - R.drawable.sun_3, - R.drawable.sun_4, - R.drawable.sun_5, - R.drawable.sun_6, - R.drawable.sun_7, - // R.drawable.ic_baseline_brightness_1_24, - // R.drawable.ic_baseline_brightness_2_24, - // R.drawable.ic_baseline_brightness_3_24, - // R.drawable.ic_baseline_brightness_4_24, - // R.drawable.ic_baseline_brightness_5_24, - // R.drawable.ic_baseline_brightness_6_24, - // R.drawable.ic_baseline_brightness_7_24, - ) - - private val volumeIcons = listOf( - R.drawable.ic_baseline_volume_mute_24, - R.drawable.ic_baseline_volume_down_24, - R.drawable.ic_baseline_volume_up_24, - ) - private var isShowingEpisodeOverlay: Boolean = false private var previousPlayStatus: Boolean = false - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View? { - val root = super.onCreateView(inflater, container, savedInstanceState) ?: return null - playerBinding = PlayerCustomLayoutBinding.bind(root.findViewById(R.id.player_holder)) - - // Inject the overlay from a separate XML into the PlayerView content frame - safe { - val pv = root.findViewById(R.id.player_view) - val packageName = context?.packageName ?: return@safe - val contentId = resources.getIdentifier("exo_content_frame", "id", packageName) - val contentFrame = pv?.findViewById(contentId) - if (contentFrame != null) { - brightnessOverlay = contentFrame.findViewById(R.id.extra_brightness_overlay) - brightnessOverlay = LayoutInflater.from(context).inflate( - R.layout.extra_brightness_overlay, - contentFrame, - false - ) - contentFrame.addView(brightnessOverlay) - requestUpdateBrightnessOverlayOnNextLayout() - } - } - return root - } + + override fun fixLayout(view: View) = Unit /** * Wet code but this can not be made into a function as it is a setter. @@ -287,6 +164,7 @@ open class FullScreenPlayer : AbstractPlayerFragment() { || selectTrackDialog?.isShowing == true || selectSpeedDialog?.isShowing == true || selectSubtitlesDialog?.isShowing == true + || isShowingEpisodeOverlay private fun scheduleMetadataVisibility() { val metadataScrim = playerBinding?.playerMetadataScrim ?: return @@ -342,108 +220,12 @@ open class FullScreenPlayer : AbstractPlayerFragment() { } } - @SuppressLint("UnsafeOptInUsageError") - override fun playerUpdated(player: Any?) { - super.playerUpdated(player) - } - override fun onDestroyView() { - // Clean up brightness overlay if created - safe { - // remove overlay if present - brightnessOverlay?.let { overlay -> - val oParent = overlay.parent as? ViewGroup - oParent?.removeView(overlay) - } - } - brightnessOverlay = null + playerHostView?.releaseOverlayLayoutListener() playerBinding = null super.onDestroyView() } - /** - * Resize/position the brightness overlay to exactly match the visible video surface. - * This copies the video surface size, scale and translation so the overlay won't cover - * letterbox/pillarbox areas when zooming or panning. - */ - private fun updateBrightnessOverlayBounds() { - val overlay = brightnessOverlay ?: return - val pv = playerView ?: return - val video = pv.videoSurfaceView ?: return - - // Compute accurate transformed bounding box of the video view after scale+translation - val vw = video.width.toFloat() - val vh = video.height.toFloat() - val sx = video.scaleX - val sy = video.scaleY - if (vw > 0f && vh > 0f) { - // pivot defaults to center if not set - val pivotX = if (video.pivotX != 0f) video.pivotX else vw * 0.5f - val pivotY = if (video.pivotY != 0f) video.pivotY else vh * 0.5f - // Use view position (includes translation) as base; avoid double-counting translation - val tx = video.x - val ty = video.y - - // transform function for a local point (lx,ly) - fun transform(lx: Float, ly: Float): Pair { - val gx = tx + pivotX + (lx - pivotX) * sx - val gy = ty + pivotY + (ly - pivotY) * sy - return Pair(gx, gy) - } - - val p0 = transform(0f, 0f) - val p1 = transform(vw, 0f) - val p2 = transform(0f, vh) - val p3 = transform(vw, vh) - - val minX = min(min(p0.first, p1.first), min(p2.first, p3.first)) - val maxX = max(max(p0.first, p1.first), max(p2.first, p3.first)) - val minY = min(min(p0.second, p1.second), min(p2.second, p3.second)) - val maxY = max(max(p0.second, p1.second), max(p2.second, p3.second)) - - val newW = ceil(maxX - minX).toInt().coerceAtLeast(0) - val newH = ceil(maxY - minY).toInt().coerceAtLeast(0) - - val lp = overlay.layoutParams - if (lp == null) { - overlay.layoutParams = ViewGroup.LayoutParams(newW, newH) - } else { - if (lp.width != newW || lp.height != newH) { - lp.width = newW - lp.height = newH - overlay.layoutParams = lp - } - } - - overlay.scaleX = 1.0f - overlay.scaleY = 1.0f - overlay.x = minX - overlay.y = minY - } - } - - /** - * Ensure the overlay is updated once the next layout pass completes. - * Adds a one-time global layout listener (PiP/resizing/rotation frames). - */ - private fun requestUpdateBrightnessOverlayOnNextLayout() { - val pv = playerView ?: return - safe { - val obs = pv.viewTreeObserver - val listener = object : android.view.ViewTreeObserver.OnGlobalLayoutListener { - override fun onGlobalLayout() { - safe { - updateBrightnessOverlayBounds() - } - if (obs.isAlive) { - obs.removeOnGlobalLayoutListener(this) - } - } - } - if (obs.isAlive) obs.addOnGlobalLayoutListener(listener) - } - } - open fun showMirrorsDialogue() { throw NotImplementedError() } @@ -468,43 +250,6 @@ open class FullScreenPlayer : AbstractPlayerFragment() { return false } - /** - * [isValidTouch] should be called on a [View] spanning across the screen for reliable results. - * - * Android has supported gesture navigation properly since API-30. We get the absolute screen dimens using - * [WindowManager.getCurrentWindowMetrics] and remove the stable insets - * {[WindowInsets.getInsetsIgnoringVisibility]} to get a safe perimeter. - * This approach supports any and all types of necessary system insets. - * - * @return false if the touch is on the status bar or navigation bar - * */ - private fun View.isValidTouch(rawX: Float, rawY: Float): Boolean { - // NOTE: screenWidth is without the navbar width when 3button nav is turned on. - if (Build.VERSION.SDK_INT >= 30) { - // real = absolute dimen without any default deductions like navbar width - val windowMetrics = - (context?.getSystemService(Context.WINDOW_SERVICE) as? WindowManager)?.currentWindowMetrics - val realScreenHeight = - windowMetrics?.let { windowMetrics.bounds.bottom - windowMetrics.bounds.top } - ?: screenHeightWithOrientation - val realScreenWidth = - windowMetrics?.let { windowMetrics.bounds.right - windowMetrics.bounds.left } - ?: screenWidthWithOrientation - - val insets = - rootWindowInsets.getInsetsIgnoringVisibility(WindowInsets.Type.systemBars()) - val isOutsideHeight = rawY < insets.top || rawY > (realScreenHeight - insets.bottom) - val isOutsideWidth = if (windowMetrics == null) { - rawX < screenWidthWithOrientation - } else rawX < insets.left || rawX > realScreenWidth - insets.right - - return !(isOutsideWidth || isOutsideHeight) - } else { - val statusHeight = statusBarHeight ?: 0 - return rawY > statusHeight && rawX < screenWidthWithOrientation - } - } - override fun exitedPipMode() { animateLayoutChanges() } @@ -601,25 +346,10 @@ open class FullScreenPlayer : AbstractPlayerFragment() { } if (!isLocked) { - playerFfwdHolder.alpha = 1f - playerRewHolder.alpha = 1f - // player_pause_play_holder?.alpha = 1f + playerHostView?.gestureHelper?.animateCenterControls(fadeTo) shadowOverlay.isVisible = true shadowOverlay.startAnimation(fadeAnimation) - playerFfwdHolder.startAnimation(fadeAnimation) - playerRewHolder.startAnimation(fadeAnimation) - playerPausePlay.startAnimation(fadeAnimation) downloadBothHeader.startAnimation(fadeAnimation) - - /*if (isBuffering) { - player_pause_play?.isVisible = false - player_pause_play_holder?.isVisible = false - } else { - player_pause_play?.isVisible = true - player_pause_play_holder?.startAnimation(fadeAnimation) - player_pause_play?.startAnimation(fadeAnimation) - }*/ - // player_buffering?.startAnimation(fadeAnimation) } bottomPlayerBar.startAnimation(fadeAnimation) @@ -631,7 +361,7 @@ open class FullScreenPlayer : AbstractPlayerFragment() { override fun subtitlesChanged() { val tracks = player.getVideoTracks() val isBuiltinSubtitles = tracks.currentTextTracks.all { track -> - track.sampleMimeType == MimeTypes.APPLICATION_MEDIA3_CUES + track.sampleMimeType == MimeTypes.APPLICATION_MEDIA3_CUES } // Subtitle offset is not possible on built-in media3 tracks playerBinding?.playerSubtitleOffsetBtt?.isGone = @@ -647,7 +377,7 @@ open class FullScreenPlayer : AbstractPlayerFragment() { Configuration.ORIENTATION_PORTRAIT -> ActivityInfo.SCREEN_ORIENTATION_SENSOR_PORTRAIT - else -> dynamicOrientation() + else -> playerHostView?.dynamicOrientation() ?: return } activity.requestedOrientation = orientation } @@ -661,14 +391,14 @@ open class FullScreenPlayer : AbstractPlayerFragment() { Configuration.ORIENTATION_PORTRAIT -> ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE - else -> dynamicOrientation() + else -> playerHostView?.dynamicOrientation() ?: return } activity.requestedOrientation = orientation } - open fun lockOrientation(activity: Activity) { - @Suppress("DEPRECATION") + private fun lockOrientation(activity: Activity) { val display = if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) + @Suppress("DEPRECATION") (activity.getSystemService(Context.WINDOW_SERVICE) as WindowManager).defaultDisplay else activity.display!! val rotation = display.rotation @@ -689,7 +419,7 @@ open class FullScreenPlayer : AbstractPlayerFragment() { else ActivityInfo.SCREEN_ORIENTATION_REVERSE_PORTRAIT - else -> orientation = dynamicOrientation() + else -> orientation = playerHostView?.dynamicOrientation() ?: return } activity.requestedOrientation = orientation } @@ -701,50 +431,36 @@ open class FullScreenPlayer : AbstractPlayerFragment() { lockOrientation(this) } else { if (ignoreDynamicOrientation || rotatedManually) { - // restore when lock is disabled + // Restore when lock is disabled. restoreOrientationWithSensor(this) } else { - this.requestedOrientation = dynamicOrientation() + this.requestedOrientation = + playerHostView?.dynamicOrientation() ?: return@apply } } } } } - protected fun enterFullscreen() { - if (isFullScreenPlayer) { - activity?.hideSystemUI() - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P && fullscreenNotch) { - val params = activity?.window?.attributes - params?.layoutInDisplayCutoutMode = LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES - activity?.window?.attributes = params - } - } - updateOrientation() - } + private fun setupKeyEventListener() { + keyEventListener = { (event, hasNavigated) -> + when { + event == null -> false + event.action == KeyEvent.ACTION_DOWN && + (event.keyCode == KeyEvent.KEYCODE_VOLUME_UP || + event.keyCode == KeyEvent.KEYCODE_VOLUME_DOWN) -> + playerHostView?.handleVolumeKey(event.keyCode) ?: false - protected fun exitFullscreen() { - resetZoomToDefault() - // if (lockRotation) - activity?.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_USER - - // simply resets brightness and notch settings that might have been overridden - val lp = activity?.window?.attributes - lp?.screenBrightness = WindowManager.LayoutParams.BRIGHTNESS_OVERRIDE_NONE - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { - lp?.layoutInDisplayCutoutMode = - WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_DEFAULT + player.isActive() -> handleKeyEvent(event, hasNavigated) + else -> false + } } - activity?.window?.attributes = lp - activity?.showSystemUI() - } - private fun resetZoomToDefault() { - if (zoomMatrix != null) resize(PlayerResize.Fit, false) } override fun onResume() { - enterFullscreen() - verifyVolume() + playerHostView?.enterFullscreen { updateOrientation() } + setupKeyEventListener() + playerHostView?.verifyVolume() activity?.attachBackPressedCallback("FullScreenPlayer") { if (isShowingEpisodeOverlay) { // isShowingEpisodeOverlay pauses, so this makes it easier to unpause @@ -760,7 +476,7 @@ open class FullScreenPlayer : AbstractPlayerFragment() { activity?.popCurrentPage("FullScreenPlayer") } } - requestUpdateBrightnessOverlayOnNextLayout() + playerHostView?.requestUpdateBrightnessOverlayOnNextLayout() super.onResume() } @@ -770,7 +486,7 @@ open class FullScreenPlayer : AbstractPlayerFragment() { } override fun onDestroy() { - exitFullscreen() + playerHostView?.exitFullscreen() super.onDestroy() } @@ -815,22 +531,9 @@ open class FullScreenPlayer : AbstractPlayerFragment() { var currentOffset = subtitleDelay binding.apply { - var subtitleAdapter: SubtitleOffsetItemAdapter? = null - subtitleOffsetInput.doOnTextChanged { text, _, _, _ -> text?.toString()?.toLongOrNull()?.let { time -> currentOffset = time - - // Scroll to the first active subtitle - val playerPosition = player.getPosition() ?: 0 - val totalPosition = playerPosition - currentOffset - subtitleAdapter?.updateTime(totalPosition) - - subtitleAdapter?.getLatestActiveItem(totalPosition) - ?.let { subtitlePos -> - subtitleOffsetRecyclerview.scrollToPosition(subtitlePos) - } - val str = when { time > 0L -> { txt(R.string.subtitle_offset_extra_hint_later_format, time) @@ -856,7 +559,7 @@ open class FullScreenPlayer : AbstractPlayerFragment() { noSubtitlesLoadedNotice.isVisible = subtitles.isEmpty() val initialSubtitlePosition = (player.getPosition() ?: 0) - currentOffset - subtitleAdapter = + val subtitleAdapter = SubtitleOffsetItemAdapter(initialSubtitlePosition) { subtitleCue -> val playerPosition = player.getPosition() ?: 0 subtitleOffsetInput.text = Editable.Factory.getInstance() @@ -897,8 +600,7 @@ open class FullScreenPlayer : AbstractPlayerFragment() { dialog.setOnDismissListener { selectSubtitlesDialog = null - if (isFullScreenPlayer) - activity?.hideSystemUI() + activity?.hideSystemUI() } applyBtt.setOnClickListener { selectSubtitlesDialog = null @@ -919,7 +621,6 @@ open class FullScreenPlayer : AbstractPlayerFragment() { } } - @SuppressLint("SetTextI18n") fun updateSpeedDialogBinding(binding: SpeedDialogBinding) { val speed = player.getPlaybackSpeed() @@ -963,7 +664,7 @@ open class FullScreenPlayer : AbstractPlayerFragment() { updateSpeedDialogBinding(binding) } - binding.speedBar.addOnChangeListener { slider, value, fromUser -> + binding.speedBar.addOnChangeListener { _, value, fromUser -> if (fromUser) { setPlayBackSpeed(value) updateSpeedDialogBinding(binding) @@ -971,8 +672,7 @@ open class FullScreenPlayer : AbstractPlayerFragment() { } val dismiss = DialogInterface.OnDismissListener { - if (isFullScreenPlayer) - activity?.hideSystemUI() + activity?.hideSystemUI() if (isPlaying) { player.handleEvent(CSPlayerEvent.Play, PlayerEventSource.UI) } @@ -996,90 +696,10 @@ open class FullScreenPlayer : AbstractPlayerFragment() { //} } - fun resetRewindText() { - playerBinding?.exoRewText?.text = - getString(R.string.rew_text_regular_format).format(fastForwardTime / 1000) - } - - fun resetFastForwardText() { - playerBinding?.exoFfwdText?.text = - getString(R.string.ffw_text_regular_format).format(fastForwardTime / 1000) - } - - private fun rewind() { - try { - playerBinding?.apply { - playerCenterMenu.isGone = false - playerRewHolder.alpha = 1f - - val rotateLeft = AnimationUtils.loadAnimation(context, R.anim.rotate_left) - playerRew.startAnimation(rotateLeft) - - val goLeft = AnimationUtils.loadAnimation(context, R.anim.go_left) - goLeft.setAnimationListener(object : Animation.AnimationListener { - override fun onAnimationStart(animation: Animation?) {} - - override fun onAnimationRepeat(animation: Animation?) {} - - override fun onAnimationEnd(animation: Animation?) { - exoRewText.post { - resetRewindText() - playerCenterMenu.isGone = !isShowing - playerRewHolder.alpha = if (isShowing) 1f else 0f - } - } - }) - exoRewText.startAnimation(goLeft) - exoRewText.text = - getString(R.string.rew_text_format).format(fastForwardTime / 1000) - } - player.seekTime(-fastForwardTime) - } catch (e: Exception) { - logError(e) - } - } - - private fun fastForward() { - try { - playerBinding?.apply { - playerCenterMenu.isGone = false - playerFfwdHolder.alpha = 1f - - val rotateRight = AnimationUtils.loadAnimation(context, R.anim.rotate_right) - playerFfwd.startAnimation(rotateRight) - - val goRight = AnimationUtils.loadAnimation(context, R.anim.go_right) - goRight.setAnimationListener(object : Animation.AnimationListener { - override fun onAnimationStart(animation: Animation?) {} - - override fun onAnimationRepeat(animation: Animation?) {} - - override fun onAnimationEnd(animation: Animation?) { - exoFfwdText.post { - resetFastForwardText() - playerCenterMenu.isGone = !isShowing - playerFfwdHolder.alpha = if (isShowing) 1f else 0f - } - } - }) - exoFfwdText.startAnimation(goRight) - exoFfwdText.text = - getString(R.string.ffw_text_format).format(fastForwardTime / 1000) - } - player.seekTime(fastForwardTime) - } catch (e: Exception) { - logError(e) - } - } - private fun onClickChange() { isShowing = !isShowing - if (isShowing) { - playerBinding?.playerIntroPlay?.isGone = true - autoHide() - } - if (isFullScreenPlayer) - activity?.hideSystemUI() + if (isShowing) autoHide() + activity?.hideSystemUI() animateLayoutChanges() if (playerBinding?.playerEpisodeOverlay?.isGone == true) playerBinding?.playerPausePlay?.requestFocus() } @@ -1090,6 +710,7 @@ open class FullScreenPlayer : AbstractPlayerFragment() { } isLocked = !isLocked + playerHostView?.isLocked = isLocked updateOrientation(true) // set true to ignore auto rotate to stay in current orientation if (isLocked && isShowing) { @@ -1101,6 +722,7 @@ open class FullScreenPlayer : AbstractPlayerFragment() { } val fadeTo = if (isLocked) 0f else 1f + playerHostView?.gestureHelper?.animateCenterControls(fadeTo) playerBinding?.apply { val fadeAnimation = AlphaAnimation(playerVideoTitleHolder.alpha, fadeTo).apply { duration = 100 @@ -1108,11 +730,6 @@ open class FullScreenPlayer : AbstractPlayerFragment() { } updateUIVisibility() - // MENUS - // centerMenu.startAnimation(fadeAnimation) - playerPausePlay.startAnimation(fadeAnimation) - playerFfwdHolder.startAnimation(fadeAnimation) - playerRewHolder.startAnimation(fadeAnimation) downloadBothHeader.startAnimation(fadeAnimation) if (hasEpisodes) @@ -1135,7 +752,7 @@ open class FullScreenPlayer : AbstractPlayerFragment() { updateLockUI() } - open fun updateUIVisibility() { + private fun updateUIVisibility() { val isGone = isLocked || !isShowing var togglePlayerTitleGone = isGone context?.let { @@ -1146,24 +763,23 @@ 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 playerEpisodesButton.isVisible = showPlayerEpisodes playerVideoTitleHolder.isGone = togglePlayerTitleGone -// player_video_title_rez?.isGone = isGone + playerVideoTitleRez.isGone = isGone || playerVideoTitleRez.text.isBlank() 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 } } @@ -1171,27 +787,27 @@ open class FullScreenPlayer : AbstractPlayerFragment() { private fun updateLockUI() { playerBinding?.apply { playerLock.setIconResource(if (isLocked) R.drawable.video_locked else R.drawable.video_unlocked) - if (layout == R.layout.fragment_player) { - val color = if (isLocked) context?.colorFromAttribute(R.attr.colorPrimary) - else Color.WHITE - if (color != null) { - playerLock.setTextColor(color) - playerLock.iconTint = ColorStateList.valueOf(color) - playerLock.rippleColor = - ColorStateList.valueOf(Color.argb(50, color.red, color.green, color.blue)) - } + val color = if (isLocked) context?.colorFromAttribute(R.attr.colorPrimary) + else Color.WHITE + if (color != null) { + playerLock.setTextColor(color) + playerLock.iconTint = ColorStateList.valueOf(color) + playerLock.rippleColor = + ColorStateList.valueOf(Color.argb(50, color.red, color.green, color.blue)) } } } - private var currentTapIndex = 0 protected fun autoHide() { metadataVisibilityToken++ - currentTapIndex++ - delayHide() + playerHostView?.scheduleAutoHide() scheduleMetadataVisibility() } + override fun onAutoHideUI() { + if (player.getIsPlaying()) onClickChange() + } + protected fun hidePlayerUI() { if (isShowing) { isShowing = false @@ -1199,861 +815,203 @@ open class FullScreenPlayer : AbstractPlayerFragment() { } } - override fun playerStatusChanged() { - super.playerStatusChanged() - scheduleMetadataVisibility() - delayHide() - } + /** PlayerView.Callbacks touch overrides */ - private fun delayHide() { - val index = currentTapIndex - playerBinding?.playerHolder?.postDelayed({ - if (!isCurrentTouchValid && isShowing && index == currentTapIndex && player.getIsPlaying()) { - onClickChange() - } - }, 3000) - } + override fun isUIShowing(): Boolean = isShowing - // this is used because you don't want to hide UI when double tap seeking - private var currentDoubleTapIndex = 0 - private fun toggleShowDelayed() { - if (doubleTapEnabled || doubleTapPauseEnabled) { - val index = currentDoubleTapIndex - playerBinding?.playerHolder?.postDelayed({ - if (index == currentDoubleTapIndex) { - onClickChange() - } - }, DOUBLE_TAB_MINIMUM_TIME_BETWEEN) - } else { - onClickChange() - } + override fun onSingleTap() { + onClickChange() } - private var isCurrentTouchValid = false - private var currentTouchStart: Vector2? = null - private var currentTouchLast: Vector2? = null - private var currentTouchAction: TouchAction? = null - private var currentLastTouchAction: TouchAction? = null - private var currentTouchStartPlayerTime: Long? = - null // the time in the player when you first click - private var currentTouchStartTime: Long? = null // the system time when you first click - private var currentLastTouchEndTime: Long = 0 // the system time when you released your finger - private var currentClickCount: Int = - 0 // amount of times you have double clicked, will reset when other action is taken - - // requested volume and brightness is used to make swiping smoother - // to make it not jump between values, - // this value is within the range [0,2] where 1+ is loudness - private var currentRequestedVolume: Float = 0.0f - - // from [0.0f, 1.0f] where 1.0f is max extra brightness, used only to track extra brightness - private var currentExtraBrightness: Float = 0.0f - - // this value is within the range [0,2] where 1+ is extra brightness - private var currentRequestedBrightness: Float = 1.0f - - enum class TouchAction { - Brightness, - Volume, - Time, + override fun onTouchDown() { + if (isShowingEpisodeOverlay) toggleEpisodesOverlay(show = false) } - companion object { - /** - * Gets the translationXY + scale form a matrix with no rotation. - * - * @return (translationX, translationY, scale) - * */ - fun matrixToTranslationAndScale(matrix: Matrix): Triple { - val points = floatArrayOf(0.0f, 0.0f, 1.0f, 1.0f) - matrix.mapPoints(points) - - // A linear matrix will map (0,0) to the translation - val translationX = points[0] - val translationY = points[1] - - // The unit vectors (1,0) and (0,1) will map to the scale if you remove the translation - // As this assumes a uniform scaling, only a single vector is needed - val scaleX = points[2] - translationX - val scaleY = points[3] - translationY - - // The matrix should have the same scaleX and scaleY - if (BuildConfig.DEBUG) { - assert((scaleX - scaleY).absoluteValue < 0.1f) { - "$scaleY != $scaleX" - } - } - - return Triple(translationX, translationY, scaleX) - } - - private fun forceLetters(inp: Long, letters: Int = 2): String { - val added: Int = letters - inp.toString().length - return if (added > 0) { - "0".repeat(added) + inp.toString() - } else { - inp.toString() - } - } - - private fun convertTimeToString(sec: Long): String { - val rsec = sec % 60L - val min = ceil((sec - rsec) / 60.0).toInt() - val rmin = min % 60L - val h = ceil((min - rmin) / 60.0).toLong() - // int rh = h;// h % 24; - return (if (h > 0) forceLetters(h) + ":" else "") + (if (rmin >= 0 || h >= 0) forceLetters( - rmin - ) + ":" else "") + forceLetters( - rsec - ) - } - } - - private fun calculateNewTime( - startTime: Long?, - touchStart: Vector2?, - touchEnd: Vector2? - ): Long? { - if (touchStart == null || touchEnd == null || startTime == null) return null - val diffX = - (touchEnd.x - touchStart.x) * HORIZONTAL_MULTIPLIER / screenWidthWithOrientation.toFloat() - val duration = player.getDuration() ?: return null - return max( - min( - startTime + ((duration * (diffX * diffX)) * (if (diffX < 0) -1 else 1)).toLong(), - duration - ), 0 - ) - } - - /** - * Returns screen brightness in <0.0f, 1.0f> range - */ - private fun getBrightness(): Float? { - return if (useTrueSystemBrightness) { - try { - Settings.System.getInt( - context?.contentResolver, - Settings.System.SCREEN_BRIGHTNESS - ) / 255f - } catch (e: Exception) { - // because true system brightness requires - // permission, this is a lazy way to check - // as it will throw an error if we do not have it - useTrueSystemBrightness = false - return getBrightness() - } - } else { - try { - activity?.window?.attributes?.screenBrightness - } catch (e: Exception) { - logError(e) - null - } + @SuppressLint("SetTextI18n") + override fun onSeekPreviewText(text: String?) { + playerBinding?.playerTimeText?.apply { + isVisible = text != null + if (text != null) this.text = text } } - /** - * Sets the screen brightness in the range <0.0f, 1.0f>. Values outside this range - * will be clamped to the minimum (0.0f) or maximum (1.0f). - * - * @param brightness desired brightness (values outside the range will be clamped) - */ - private fun setBrightness(brightness: Float) { - if (useTrueSystemBrightness) { - try { - Settings.System.putInt( - context?.contentResolver, - Settings.System.SCREEN_BRIGHTNESS_MODE, - Settings.System.SCREEN_BRIGHTNESS_MODE_MANUAL - ) - - Settings.System.putInt( - context?.contentResolver, - Settings.System.SCREEN_BRIGHTNESS, - min(1, (brightness.coerceIn(0.0f, 1.0f) * 255).toInt()) - ) - } catch (e: Exception) { - useTrueSystemBrightness = false - setBrightness(brightness) - } - } else { - try { - val lp = activity?.window?.attributes - // use 0.004f instead of 0, because on some devices setting too small value - // causes system to override it and in turn system makes the screen apply system brightness level instead - // which can be too bright, and it is very hard to fine tune very low brightness, because of it. - // Without this clamp, it can jump from almost 0% to 100% brightness when this threshold is crossed. - lp?.screenBrightness = brightness.coerceIn(0.004f, 1.0f) - // Log.i("Brightness", "clamped brightness: ${lp?.screenBrightness}") - activity?.window?.attributes = lp - } catch (e: Exception) { - logError(e) - } - } + override fun onHidePlayerUI() { + hidePlayerUI() } - private var isVolumeLocked: Boolean = false - private var hasShownVolumeToast: Boolean = false - - private var isBrightnessLocked: Boolean = false - private var hasShownBrightnessToast: Boolean = false - - private var progressBarLeftHideRunnable: Runnable? = null - private var progressBarRightHideRunnable: Runnable? = null - - // Verifies that the currentRequestedVolume matches the system volume - // if not, then it removes changes currentRequestedVolume and removes the loudnessEnhancer - // if the real volume is less than 100% - // - // This is here to make returning to the player less jarring, if we change the volume outside - // the app. Note that this will make it a bit wierd when using loudness in PiP, then returning - // however that is the cost of correctness. - private fun verifyVolume() { - (activity?.getSystemService(Context.AUDIO_SERVICE) as? AudioManager)?.let { audioManager -> - val currentVolumeStep = - audioManager.getStreamVolume(AudioManager.STREAM_MUSIC) - val maxVolumeStep = - audioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC) - - // if we can set the volume directly then do it - if (currentVolumeStep < maxVolumeStep || currentRequestedVolume <= 1.0f) { - currentRequestedVolume = - currentVolumeStep.toFloat() / maxVolumeStep.toFloat() - - loudnessEnhancer?.release() - loudnessEnhancer = null - } + override fun onGestureEnd(hadSwipe: Boolean, wasUiShowing: Boolean) { + if (!player.getIsPlaying() && hadSwipe && wasUiShowing && !isShowing) { + isShowing = true + animateLayoutChanges() } + autoHide() } - val holdhandler = Handler(Looper.getMainLooper()) - var hasTriggeredSpeedUp = false - val holdRunnable = Runnable { - if (isShowing) { - onClickChange() - } - player.setPlaybackSpeed(2.0f) - showOrHideSpeedUp(true) - hasTriggeredSpeedUp = true + override fun playerStatusChanged() { + super.playerStatusChanged() + scheduleMetadataVisibility() } - private fun showOrHideSpeedUp(show: Boolean) { - playerBinding?.playerSpeedupButton?.let { button -> - button.clearAnimation() - button.alpha = if (show) 0f else 1f - button.isVisible = show - button.animate() - .alpha(if (show) 1f else 0f) - .setDuration(200L) - .start() - } + // 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) onClickChange() } override fun onConfigurationChanged(newConfig: Configuration) { super.onConfigurationChanged(newConfig) // If we rotate the device we need to recalculate the zoom - val matrix = zoomMatrix - val animation = matrixAnimation + val gh = playerHostView?.gestureHelper ?: return + val matrix = gh.zoomMatrix + val animation = gh.matrixAnimation if ((animation == null || !animation.isRunning) && matrix != null) { - // Ignore if we have no zoom or mid animation + // Ignore if we have no zoom or mid-animation playerView?.post { - applyZoomMatrix(matrix, true) - requestUpdateBrightnessOverlayOnNextLayout() + gh.applyZoomMatrix(matrix, true) + playerHostView?.requestUpdateBrightnessOverlayOnNextLayout() } } } - private var scaleGestureDetector: ScaleGestureDetector? = null - private var lastPan: Vector2? = null - - /** - * Gets the non-null zoom matrix, - * this is different from `zoomMatrix ?: Matrix()` - * because it allows used to start zooming at different resizeModes. - * - * The main issue is that RESIZE_MODE_FIT = 100% zoom, but if you are in RESIZE_MODE_ZOOM - * 100% will make the zoom snap to less zoomed in then you already are. - * */ - fun currentZoomMatrix(): Matrix { - val current = zoomMatrix - if (current != null) { - // Already assigned - return current - } - - val playerView = playerView - val videoView = playerView?.videoSurfaceView - - if (playerView == null || videoView == null || playerView.resizeMode != AspectRatioFrameLayout.RESIZE_MODE_ZOOM) { - // This is a fit or fill resize mode so start at 100% zoom - return Matrix() - } - - val videoWidth = videoView.width.toFloat() - val videoHeight = videoView.height.toFloat() - val playerWidth = screenWidthWithOrientation - val playerHeight = screenHeightWithOrientation - - // Sanity check - if (videoWidth <= 1.0f || videoHeight <= 1.0f || playerWidth <= 1.0f || playerHeight <= 1.0f) { - // Something is wrong with the video, return the default 100% zoom - return Matrix() - } - - val initAspect = - (playerHeight * videoWidth) / (playerWidth * videoHeight) - val aspect = max(initAspect, 1.0f / initAspect) - - // Return the matrix with the correct zoom, as it is already zoomed in - return Matrix().apply { postScale(aspect, aspect) } - } - - /** A Matrix encoding the translation and scale of the current zoom */ - private var zoomMatrix: Matrix? = null - - /** A Matrix encoding the translation and scale of the desired zoom, - * aka after you release the zoom */ - private var desiredMatrix: Matrix? = null - - /** The animation of zooming to the desiredMatrix */ - private var matrixAnimation: ValueAnimator? = null - - @SuppressLint("UnsafeOptInUsageError") override fun resize(resize: PlayerResize, showToast: Boolean) { - // Clear all zoom stuff if we resize - matrixAnimation?.cancel() - matrixAnimation = null - zoomMatrix = null - desiredMatrix = null - playerView?.videoSurfaceView?.apply { - scaleX = 1.0f - scaleY = 1.0f - translationX = 0.0f - translationY = 0.0f - } - super.resize(resize, showToast) - requestUpdateBrightnessOverlayOnNextLayout() + playerHostView?.requestUpdateBrightnessOverlayOnNextLayout() } - /** - * Applies a new zoom matrix to the screen. Matrix should only contain a scale + translation. - * - * @param newMatrix The new zoom matrix - * @param animation If this zoom is part of an animation, - * as then it will not auto zoom after we are done - */ - @OptIn(UnstableApi::class) - fun applyZoomMatrix(newMatrix: Matrix, animation: Boolean) { - if (!animation) { - matrixAnimation?.cancel() - matrixAnimation = null - } - val (translationX, translationY, scale) = matrixToTranslationAndScale(newMatrix) - - playerView?.let { player -> - if (player.resizeMode == AspectRatioFrameLayout.RESIZE_MODE_FIT) { - player.resizeMode = AspectRatioFrameLayout.RESIZE_MODE_ZOOM - } - - val videoView = player.videoSurfaceView ?: return@let - - val videoWidth = videoView.width.toFloat() - val videoHeight = videoView.height.toFloat() - val playerWidth = screenWidthWithOrientation - val playerHeight = screenHeightWithOrientation - - // Sanity check - if (videoWidth <= 1.0f || videoHeight <= 1.0f || playerWidth <= 1.0f || playerHeight <= 1.0f || scale <= 0.01f) { - return - } - - // Calculate the scaled aspect ratio as the view height is not real, check the debugger - // and you will see videoView.height > screen.heigh - val initAspect = - (playerHeight * videoWidth) / (playerWidth * videoHeight) - val aspect = min(initAspect, 1.0f / initAspect) - val scaledAspect = scale * aspect - - // Calculate clamp, this is very weird because we need to use aspect here as videoHeight > playerHeight - val maxTransX = max(0.0f, videoWidth * scaledAspect - playerWidth) * 0.5f - val maxTransY = max(0.0f, videoHeight * scaledAspect - playerHeight) * 0.5f - - // Correct the translation to clamp within the viewing area - val expectedTranslationX = translationX.coerceIn(-maxTransX, maxTransX) - val expectedTranslationY = translationY.coerceIn(-maxTransY, maxTransY) - - // Set the transform to the correct x and y - newMatrix.postTranslate( - expectedTranslationX - translationX, - expectedTranslationY - translationY - ) - zoomMatrix = newMatrix - - if (!animation) { - // If we are not in an animation, set up the values for the animation - if ((scaledAspect - 1.0f).absoluteValue < ZOOM_SNAP_SENSITIVITY) { - // We are within the correct scaling, so center and fit it - playerBinding?.videoOutline?.isVisible = true - val desired = Matrix() - desired.setScale(1.0f / aspect, 1.0f / aspect) - desiredMatrix = desired - } else if (scale < 1.0f) { - // We have zoomed too far, zoom to 100% - playerBinding?.videoOutline?.isVisible = false - desiredMatrix = Matrix() - } else { - // Keep the same scaling after zoom - playerBinding?.videoOutline?.isVisible = false - desiredMatrix = null - } + 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) } - // Finally set the actual scale + translation - videoView.scaleX = scaledAspect - videoView.scaleY = scaledAspect - videoView.translationX = expectedTranslationX - videoView.translationY = expectedTranslationY - updateBrightnessOverlayBounds() - } - } + KeyEvent.KEYCODE_A, KeyEvent.KEYCODE_MEDIA_SKIP_BACKWARD, KeyEvent.KEYCODE_MEDIA_REWIND -> { + player.handleEvent(CSPlayerEvent.SeekBack) + } - fun createScaleGestureDetector(context: Context) { - scaleGestureDetector = ScaleGestureDetector( - context, - object : ScaleGestureDetector.SimpleOnScaleGestureListener() { - override fun onScale(detector: ScaleGestureDetector): Boolean { - val matrix = currentZoomMatrix() - val (_, _, scale) = matrixToTranslationAndScale(matrix) - // Clamp scale of the zoom, do it here as it is easier then doing it within applyZoomMatrix - val newScale = (scale * detector.scaleFactor).coerceIn( - MINIMUM_ZOOM, - MAXIMUM_ZOOM - ) - // How much we should scale it with to prevent inf scaling - val actualScaleFactor = newScale / scale - - // Scale around the focus point, this is more natural than just zoom - val pivotX = detector.focusX - screenWidthWithOrientation.toFloat() * 0.5f - val pivotY = detector.focusY - screenHeightWithOrientation.toFloat() * 0.5f - matrix.postScale( - actualScaleFactor, - actualScaleFactor, - pivotX, - pivotY - ) - applyZoomMatrix(matrix, false) - return true - } - }) - } + KeyEvent.KEYCODE_MEDIA_NEXT, KeyEvent.KEYCODE_BUTTON_R1, KeyEvent.KEYCODE_N, KeyEvent.KEYCODE_NUMPAD_2, KeyEvent.KEYCODE_CHANNEL_UP -> { + player.handleEvent(CSPlayerEvent.NextEpisode) + } - @SuppressLint("SetTextI18n") - private fun handleMotionEvent(view: View?, event: MotionEvent?): Boolean { - if (event == null || view == null) return false - val currentTouch = Vector2(event.x, event.y) - val startTouch = currentTouchStart + KeyEvent.KEYCODE_MEDIA_PREVIOUS, KeyEvent.KEYCODE_BUTTON_L1, KeyEvent.KEYCODE_B, KeyEvent.KEYCODE_NUMPAD_1, KeyEvent.KEYCODE_CHANNEL_DOWN -> { + player.handleEvent(CSPlayerEvent.PrevEpisode) + } - playerBinding?.playerIntroPlay?.isGone = true + KeyEvent.KEYCODE_MEDIA_PAUSE -> { + player.handleEvent(CSPlayerEvent.Pause) + } - // Handle pan with two fingers - if ((event.pointerCount == 2 || lastPan != null) && !isLocked && isFullScreenPlayer && !hasTriggeredSpeedUp && currentTouchAction == null) { - holdhandler.removeCallbacks(holdRunnable) // remove 2x speed + KeyEvent.KEYCODE_MEDIA_PLAY, KeyEvent.KEYCODE_BUTTON_START -> { + player.handleEvent(CSPlayerEvent.Play) + } - // Gesture detectors for zoom & pan - if (scaleGestureDetector == null) { - createScaleGestureDetector(view.context) + KeyEvent.KEYCODE_L, KeyEvent.KEYCODE_NUMPAD_7, KeyEvent.KEYCODE_7 -> { + toggleLock() } - isCurrentTouchValid = false // Prevent other touches - scaleGestureDetector?.onTouchEvent(event) + KeyEvent.KEYCODE_H -> { + onClickChange() + } - when (event.actionMasked) { - MotionEvent.ACTION_POINTER_DOWN -> { - // Hide UI - if (isShowing) { - onClickChange() - } - } + KeyEvent.KEYCODE_M, KeyEvent.KEYCODE_VOLUME_MUTE -> { + player.handleEvent(CSPlayerEvent.ToggleMute) + } - MotionEvent.ACTION_MOVE -> { - val newPan = Vector2( - (event.getX(0) + event.getX(1)) / 2f, - (event.getY(0) + event.getY(1)) / 2f - ) - val oldPan = lastPan - if (oldPan != null) { - val matrix = currentZoomMatrix() - // Delta move - matrix.postTranslate(newPan.x - oldPan.x, newPan.y - oldPan.y) - applyZoomMatrix(matrix, false) - updateBrightnessOverlayBounds() - } - lastPan = newPan + 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) {} } + } - MotionEvent.ACTION_CANCEL, MotionEvent.ACTION_POINTER_UP, MotionEvent.ACTION_UP -> { - // Reset touch - lastPan = null - currentTouchStart = null - currentLastTouchAction = null - currentTouchAction = null - currentTouchStartPlayerTime = null - currentTouchLast = null - currentTouchStartTime = null - - // Reset views - playerBinding?.videoOutline?.isVisible = false - matrixAnimation?.cancel() - matrixAnimation = null - - // After we have zoomed in, snap to - matrixAnimation = ValueAnimator.ofFloat(0.0f, 1.0f).apply { - startDelay = 0 - duration = 200 - - val startMatrix = currentZoomMatrix() - val endMatrix = desiredMatrix ?: return@apply - - val (startX, startY, startScale) = matrixToTranslationAndScale(startMatrix) - val (endX, endY, endScale) = matrixToTranslationAndScale(endMatrix) - - addUpdateListener { animation -> - val value = animation.animatedValue as Float // ValueAnimator.ofFloat + KeyEvent.KEYCODE_E, KeyEvent.KEYCODE_NUMPAD_3, KeyEvent.KEYCODE_3 -> { + showSpeedDialog() + } - // Linear interpolation of scale and translation between startMatrix and endMatrix - val valueInv = 1.0f - value - val x = startX * valueInv + endX * value - val y = startY * valueInv + endY * value - val s = startScale * valueInv + endScale * value - val m = Matrix() - m.setScale(s, s) - m.postTranslate(x, y) - applyZoomMatrix(m, true) - } - start() - } - } + KeyEvent.KEYCODE_R, KeyEvent.KEYCODE_NUMPAD_0, KeyEvent.KEYCODE_0 -> { + nextResize() } - return true - } - playerBinding?.apply { - when (event.action) { - MotionEvent.ACTION_DOWN -> { - // validates if the touch is inside of the player area - isCurrentTouchValid = view.isValidTouch(currentTouch.x, currentTouch.y) - if (isCurrentTouchValid && isShowingEpisodeOverlay) { - toggleEpisodesOverlay(show = false) - } else if (isCurrentTouchValid) { - if (speedupEnabled) { - hasTriggeredSpeedUp = false - if (player.getIsPlaying() && !isLocked && isFullScreenPlayer) { - holdhandler.postDelayed(holdRunnable, 500) - } - } - isVolumeLocked = currentRequestedVolume < 1.0f - if (currentRequestedVolume <= 1.0f) { - hasShownVolumeToast = false - } + KeyEvent.KEYCODE_C, KeyEvent.KEYCODE_NUMPAD_4, KeyEvent.KEYCODE_4 -> { + skipOp() + } - isBrightnessLocked = currentRequestedBrightness < 1.0f - if (currentRequestedBrightness <= 1.0f) { - hasShownBrightnessToast = false - } + KeyEvent.KEYCODE_V, KeyEvent.KEYCODE_NUMPAD_5, KeyEvent.KEYCODE_5 -> { + player.handleEvent(CSPlayerEvent.SkipCurrentChapter) + } - currentTouchStartTime = System.currentTimeMillis() - currentTouchStart = currentTouch - currentTouchLast = currentTouch - currentTouchStartPlayerTime = player.getPosition() + 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) + } - getBrightness()?.let { - currentRequestedBrightness = it + currentExtraBrightness - } - verifyVolume() - } + 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() + } - MotionEvent.ACTION_CANCEL, MotionEvent.ACTION_UP -> { - holdhandler.removeCallbacks(holdRunnable) - if (hasTriggeredSpeedUp) { - player.setPlaybackSpeed(DataStoreHelper.playBackSpeed) - showOrHideSpeedUp(false) - } - if (isCurrentTouchValid && !isLocked && isFullScreenPlayer) { - // seek time - if (swipeHorizontalEnabled && currentTouchAction == TouchAction.Time) { - val startTime = currentTouchStartPlayerTime - if (startTime != null) { - calculateNewTime( - startTime, - startTouch, - currentTouch - )?.let { seekTo -> - if (abs(seekTo - startTime) > MINIMUM_SEEK_TIME) { - player.seekTo(seekTo, PlayerEventSource.UI) - } - } - } - } - } - - // see if click is eligible for seek 10s - val holdTime = currentTouchStartTime?.minus(System.currentTimeMillis()) - if (isCurrentTouchValid // is valid - && currentTouchAction == null // no other action like swiping is taking place - && currentLastTouchAction == null // last action was none, this prevents mis input random seek - && holdTime != null - && holdTime < DOUBLE_TAB_MAXIMUM_HOLD_TIME // it is a click not a long hold - ) { - if (!isLocked - && (System.currentTimeMillis() - currentLastTouchEndTime) < DOUBLE_TAB_MINIMUM_TIME_BETWEEN // the time since the last action is short - ) { - currentClickCount++ - - if (currentClickCount >= 1) { // have double clicked - currentDoubleTapIndex++ - if (doubleTapPauseEnabled && isFullScreenPlayer) { // you can pause if your tap is in the middle of the screen - when { - currentTouch.x < screenWidthWithOrientation / 2 - (DOUBLE_TAB_PAUSE_PERCENTAGE * screenWidthWithOrientation) -> { - if (doubleTapEnabled) - rewind() - } - - currentTouch.x > screenWidthWithOrientation / 2 + (DOUBLE_TAB_PAUSE_PERCENTAGE * screenWidthWithOrientation) -> { - if (doubleTapEnabled) - fastForward() - } - - else -> { - player.handleEvent( - CSPlayerEvent.PlayPauseToggle, - PlayerEventSource.UI - ) - } - } - } else if (doubleTapEnabled && isFullScreenPlayer) { - if (currentTouch.x < screenWidthWithOrientation / 2) { - rewind() - } else { - fastForward() - } - } - } - } else { - // is a valid click but not fast enough for seek - currentClickCount = 0 - if (!hasTriggeredSpeedUp) { - toggleShowDelayed() - } - // onClickChange() - } - } else { - currentClickCount = 0 - } - - // If we hid the UI for a gesture and playback is paused, show it again - if (!player.getIsPlaying()) { - val didGesture = - currentTouchAction != null || currentLastTouchAction != null - if (didGesture && uiShowingBeforeGesture && !isShowing) { - isShowing = true - animateLayoutChanges() - } - } - - // call auto hide as it wont hide when you have your finger down - autoHide() - - // reset variables - isCurrentTouchValid = false - currentTouchStart = null - currentLastTouchAction = currentTouchAction - currentTouchAction = null - currentTouchStartPlayerTime = null - currentTouchLast = null - currentTouchStartTime = null - uiShowingBeforeGesture = false - - // resets UI - playerTimeText.isVisible = false + KeyEvent.KEYCODE_DPAD_DOWN, + KeyEvent.KEYCODE_DPAD_UP -> { + if (isShowing || isShowingEpisodeOverlay) { + return null + } + onClickChange() + } - currentLastTouchEndTime = System.currentTimeMillis() + 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 } + } - MotionEvent.ACTION_MOVE -> { - // if current touch is valid + KeyEvent.KEYCODE_DPAD_RIGHT -> { + if (!isShowing && !isLocked && !isShowingEpisodeOverlay) { + player.seekTime(androidTVInterfaceOffSeekTime) + } else if (playerBinding?.playerPausePlay?.isFocused == true) { + player.seekTime(androidTVInterfaceOnSeekTime) + } else { + return null + } + } - if (hasTriggeredSpeedUp) { - return true - } - if (startTouch != null && isCurrentTouchValid && !isLocked && isFullScreenPlayer) { - // action is unassigned and can therefore be assigned - - if (currentTouchAction == null) { - val diffFromStart = startTouch - currentTouch - if (swipeVerticalEnabled) { - if (abs(diffFromStart.y * 100 / screenHeightWithOrientation) > MINIMUM_VERTICAL_SWIPE) { - // left = Brightness, right = Volume, but the UI is reversed to show the UI better - uiShowingBeforeGesture = isShowing - currentTouchAction = - if (startTouch.x < screenWidthWithOrientation / 2) { - // hide the UI if you hold brightness to show screen better, better UX - hidePlayerUI() - TouchAction.Brightness - } else { - // hide the UI if you hold volume to show screen better, better UX - hidePlayerUI() - TouchAction.Volume - } - } - } - if (swipeHorizontalEnabled) { - if (abs(diffFromStart.x * 100 / screenHeightWithOrientation) > MINIMUM_HORIZONTAL_SWIPE) { - currentTouchAction = TouchAction.Time - } - } - } + KeyEvent.KEYCODE_VOLUME_DOWN, + KeyEvent.KEYCODE_VOLUME_UP -> { + // Handled entirely by PlayerView.handleVolumeKey (checks PHONE/EMULATOR). + if (playerHostView?.handleVolumeKey(keyCode) != true) { + return null + } + } - // display action - val lastTouch = currentTouchLast - if (lastTouch != null) { - val diffFromLast = lastTouch - currentTouch - val verticalAddition = - diffFromLast.y * VERTICAL_MULTIPLIER / screenHeightWithOrientation.toFloat() - - // update UI - playerTimeText.isVisible = false - - when (currentTouchAction) { - TouchAction.Time -> { - holdhandler.removeCallbacks(holdRunnable) - // this simply updates UI as the seek logic happens on release - // startTime is rounded to make the UI sync in a nice way - val startTime = - currentTouchStartPlayerTime?.div(1000L)?.times(1000L) - if (startTime != null) { - calculateNewTime( - startTime, - startTouch, - currentTouch - )?.let { newMs -> - val skipMs = newMs - startTime - playerTimeText.apply { - text = - "${convertTimeToString(newMs / 1000)} [${ - (if (abs(skipMs) < 1000) "" else (if (skipMs > 0) "+" else "-")) - }${convertTimeToString(abs(skipMs / 1000))}]" - isVisible = true - } - } - } - } - - TouchAction.Brightness -> { - holdhandler.removeCallbacks(holdRunnable) - playerBinding?.playerProgressbarRightHolder?.apply { - if (!isVisible || alpha < 1f) { - alpha = 1f - isVisible = true - } - - progressBarRightHideRunnable?.let { removeCallbacks(it) } - progressBarRightHideRunnable = Runnable { - // Fade out the progress bar - animate().cancel() - animate() - .alpha(0f) - .setDuration(300) - .withEndAction { isVisible = false } - .start() - } - // Show the progress bar for 1.5 seconds - postDelayed(progressBarRightHideRunnable, 1500) - } - - val lastRequested = currentRequestedBrightness - val nextBrightness = if (extraBrightnessEnabled) { - currentRequestedBrightness + verticalAddition - } else { - (currentRequestedBrightness + verticalAddition).coerceIn( - 0.0f, - 1.0f - ) - } - // Log.e("Brightness", "Current: $currentRequestedBrightness, Next: $nextBrightness") - // show toast - if (extraBrightnessEnabled && nextBrightness > 1.0f && isBrightnessLocked && !hasShownBrightnessToast) { - showToast(R.string.slide_up_again_to_exceed_100) - hasShownBrightnessToast = true - } - currentRequestedBrightness = nextBrightness - - // this is to not spam request it, just in case it fucks over someone - if (lastRequested != currentRequestedBrightness) - setBrightness(currentRequestedBrightness) - - val level1ProgressBar = playerProgressbarRightLevel1 - - // max is set high to make it smooth - level1ProgressBar.max = 100_000 - level1ProgressBar.progress = - max( - 2_000, - (min( - 1.0f, - currentRequestedBrightness - ) * 100_000f).toInt() - ) - - if (extraBrightnessEnabled && !isBrightnessLocked) { - val level2ProgressBar = playerProgressbarRightLevel2 - - currentExtraBrightness = if (currentRequestedBrightness > 1.0f) min(2.0f, currentRequestedBrightness) - 1.0f else 0.0f - level2ProgressBar.max = 100_000 - level2ProgressBar.progress = - (currentExtraBrightness * 100_000f).toInt().coerceIn(2_000, 100_000) - level2ProgressBar.isVisible = currentRequestedBrightness > 1.0f - brightnessOverlay?.let { - it.alpha = currentExtraBrightness - } - } - - // Log.i("Brightness", "current: $currentRequestedBrightness, ce: $currentExtraBrightness L1: ${level1ProgressBar.progress}, L2: ${level2ProgressBar.progress}") - playerProgressbarRightIcon.setImageResource( - brightnessIcons[min( // clamp the value in case of extra brightness - brightnessIcons.size - 1, - max( - 0, - round(currentRequestedBrightness * (brightnessIcons.size - 1)).toInt() - ) - )] - ) - } - - TouchAction.Volume -> { - holdhandler.removeCallbacks(holdRunnable) - handleVolumeAdjustment( - verticalAddition, - false - ) - } - - else -> Unit - } - } - } + KeyEvent.KEYCODE_MENU, + KeyEvent.KEYCODE_SETTINGS -> { + if (isLocked || !isThereEpisodes()) { + return null } + toggleEpisodesOverlay(true) } + else -> return null // Avoid capturing all input } - currentTouchLast = currentTouch return true } - @SuppressLint("GestureBackNavigation") private fun handleKeyEvent(event: KeyEvent, hasNavigated: Boolean): Boolean { if (hasNavigated) { autoHide() @@ -2062,74 +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 -> { - if (isLayout(PHONE or EMULATOR) && isFullScreenPlayer) { - /** - * Some TVs do not support volume boosting, and overriding - * the volume buttons can be inconvenient for TV users. - * Since boosting volume is mainly useful on phones and emulators, - * we limit this feature to those devices. - */ - verifyVolume() - if (currentRequestedVolume <= 1.0f) { - hasShownVolumeToast = false - } - isVolumeLocked = currentRequestedVolume < 1.0f - handleVolumeAdjustment( - // +- 5% - if (keyCode == KeyEvent.KEYCODE_VOLUME_UP) { - 0.05f - } else { - -0.05f - }, - true - ) - return true - } - } + val value = handleKeyDownEvent(keyCode) + if (value != null) { + return value } } @@ -2162,138 +1055,6 @@ open class FullScreenPlayer : AbstractPlayerFragment() { return false } - private var loudnessEnhancer: LoudnessEnhancer? = null - - private fun handleVolumeAdjustment( - delta: Float, - fromButton: Boolean, - ) { - val audioManager = - activity?.getSystemService(Context.AUDIO_SERVICE) as? AudioManager ?: return - val currentVolumeStep = audioManager.getStreamVolume(AudioManager.STREAM_MUSIC) - val maxVolumeStep = audioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC) - - val currentVolume = currentRequestedVolume - val isCurrentVolumeLocked = isVolumeLocked - - val nextVolume = - (currentVolume + delta).coerceIn(0.0f, if (isCurrentVolumeLocked) 1.0f else 2.0f) - - val nextVolumeStep = - (nextVolume * maxVolumeStep.toFloat()).roundToInt().coerceIn(0, maxVolumeStep) - - // show toast - if (fromButton) { - // for button related request we only show a toast when we exceeded the volume - if (currentVolume <= 1.0f && nextVolume > 1.0f && !hasShownVolumeToast) { - showToast(R.string.volume_exceeded_100) - hasShownVolumeToast = true - } - } else { - val nextRequestedVolume = currentVolume + delta - - // for swipes, we show toast that we need to swipe again - if (nextRequestedVolume > 1.0 && isCurrentVolumeLocked && !hasShownVolumeToast) { - showToast(R.string.slide_up_again_to_exceed_100) - hasShownVolumeToast = true - } - } - - // set the current volume step - if (nextVolumeStep != currentVolumeStep) { - audioManager.setStreamVolume(AudioManager.STREAM_MUSIC, nextVolumeStep, 0) - } - - var hasBoostError = false - - // Apply loudness enhancer for volumes > 100%, removes it if less - if (nextVolume > 1.0f) { - val boostFactor = ((nextVolume - 1.0f) * 1000).toInt() - val currentEnhancer = loudnessEnhancer - - if (currentEnhancer != null) { - currentEnhancer.setTargetGain(boostFactor) - } else { - val audioSessionId = (playerView?.player as? ExoPlayer)?.audioSessionId - if (audioSessionId != null && audioSessionId != AudioManager.ERROR) { - try { - loudnessEnhancer = LoudnessEnhancer(audioSessionId).apply { - setTargetGain(boostFactor) - enabled = true - } - } catch (t: Throwable) { - logError(t) - hasBoostError = true - } - } - } - } else { - loudnessEnhancer?.release() - loudnessEnhancer = null - } - - currentRequestedVolume = nextVolume - - // Update the progress bar - playerBinding?.apply { - val level1ProgressBar = playerProgressbarLeftLevel1 - val level2ProgressBar = playerProgressbarLeftLevel2 - - // Change color to show that LoudnessEnhancer broke - // this is not a real fix, but solves the crash issue - if (nextVolume > 1.0f) { - level2ProgressBar.progressTintList = ColorStateList.valueOf( - ContextCompat.getColor( - level2ProgressBar.context, if (hasBoostError) { - R.color.colorPrimaryRed - } else { - R.color.colorPrimaryOrange - } - ) - ) - } - - level1ProgressBar.max = 100_000 - level1ProgressBar.progress = - (nextVolume * 100_000f).toInt().coerceIn(2_000, 100_000) - - level2ProgressBar.max = 100_000 - level2ProgressBar.progress = - if (nextVolume > 1.0f) ((nextVolume - 1.0) * 100_000f).toInt() - .coerceIn(2_000, 100_000) else 0 - level2ProgressBar.isVisible = nextVolume > 1.0f - - // Calculate the clamped index for the volume icon based on the requested volume - val iconIndex = (nextVolume * (volumeIcons.lastIndex)) - .roundToInt() - .coerceIn(0, volumeIcons.lastIndex) - - // Update icon - playerProgressbarLeftIcon.setImageResource(volumeIcons[iconIndex]) - } - - // alpha fade - playerBinding?.playerProgressbarLeftHolder?.apply { - if (!isVisible || alpha < 1f) { - alpha = 1f - isVisible = true - } - - progressBarLeftHideRunnable?.let { removeCallbacks(it) } - progressBarLeftHideRunnable = Runnable { - // Fade out the progress bar - animate().cancel() - animate() - .alpha(0f) - .setDuration(300) - .withEndAction { isVisible = false } - .start() - } - // Show the progress bar for 1.5 seconds - postDelayed(progressBarLeftHideRunnable, 1500) - } - } - protected fun uiReset() { metadataVisibilityToken++ playerBinding?.playerMetadataScrim?.let { @@ -2314,8 +1075,8 @@ open class FullScreenPlayer : AbstractPlayerFragment() { updateLockUI() updateUIVisibility() animateLayoutChanges() - resetFastForwardText() - resetRewindText() + playerHostView?.gestureHelper?.resetFastForwardText() + playerHostView?.gestureHelper?.resetRewindText() } override fun onSaveInstanceState(outState: Bundle) { @@ -2324,109 +1085,35 @@ open class FullScreenPlayer : AbstractPlayerFragment() { super.onSaveInstanceState(outState) } - @SuppressLint("ClickableViewAccessibility") - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - // init variables - setPlayBackSpeed(DataStoreHelper.playBackSpeed) - savedInstanceState?.getLong(SUBTITLE_DELAY_BUNDLE_KEY)?.let { - 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) - } + 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)) - PlayerEventType.ToggleHide -> { - onClickChange() - } + super.onBindingCreated(binding, savedInstanceState) - PlayerEventType.ShowMirrors -> { - showMirrorsDialogue() - } + // This player is always full-screen; tell PlayerView so volume-key handling is active. + playerHostView?.isFullScreen = true - PlayerEventType.SearchSubtitlesOnline -> { - if (subsProvidersIsActive) { - openOnlineSubPicker(view.context, null) {} - } - } + // Wire up the snap-hint outline view and schedule brightness overlay bounds update + playerHostView?.videoOutline = playerBinding?.videoOutline + playerHostView?.requestUpdateBrightnessOverlayOnNextLayout() - PlayerEventType.SkipOp -> { - skipOp() - } - } + val view = binding.root + // init variables + setPlayBackSpeed(DataStoreHelper.playBackSpeed) + savedInstanceState?.getLong(SUBTITLE_DELAY_BUNDLE_KEY)?.let { + subtitleDelay = it } // handle tv controls directly based on player state - keyEventListener = { eventNav -> - // Don't hook player keys if player isn't active - if (player.isActive()) { - val (event, hasNavigated) = eventNav - if (event != null) - handleKeyEvent(event, hasNavigated) - else false - } else false - } + setupKeyEventListener() try { context?.let { ctx -> val settingsManager = PreferenceManager.getDefaultSharedPreferences(ctx) - fastForwardTime = - settingsManager.getInt(ctx.getString(R.string.double_tap_seek_time_key), 10) - .toLong() * 1000L - androidTVInterfaceOffSeekTime = settingsManager.getInt( ctx.getString(R.string.android_tv_interface_off_seek_key), @@ -2440,16 +1127,6 @@ open class FullScreenPlayer : AbstractPlayerFragment() { ) .toLong() * 1000L - navigationBarHeight = ctx.getNavigationBarHeight() - statusBarHeight = ctx.getStatusBarHeight() - - swipeHorizontalEnabled = - settingsManager.getBoolean(ctx.getString(R.string.swipe_enabled_key), true) - swipeVerticalEnabled = - settingsManager.getBoolean( - ctx.getString(R.string.swipe_vertical_enabled_key), - true - ) playBackSpeedEnabled = settingsManager.getBoolean( ctx.getString(R.string.playback_speed_enabled_key), false @@ -2458,57 +1135,25 @@ open class FullScreenPlayer : AbstractPlayerFragment() { ctx.getString(R.string.rotate_video_key), false ) - autoPlayerRotateEnabled = settingsManager.getBoolean( - ctx.getString(R.string.auto_rotate_video_key), - true - ) playerResizeEnabled = settingsManager.getBoolean( ctx.getString(R.string.player_resize_enabled_key), true ) - doubleTapEnabled = - settingsManager.getBoolean( - ctx.getString(R.string.double_tap_enabled_key), - false - ) - - doubleTapPauseEnabled = - settingsManager.getBoolean( - ctx.getString(R.string.double_tap_pause_enabled_key), - false - ) - hideControlsNames = settingsManager.getBoolean( ctx.getString(R.string.hide_player_control_names_key), false ) - speedupEnabled = settingsManager.getBoolean( - ctx.getString(R.string.speedup_key), - false - ) - - extraBrightnessEnabled = settingsManager.getBoolean( - ctx.getString(R.string.extra_brightness_key), - false - ) - val profiles = QualityDataHelper.getProfiles() val type = if (ctx.isUsingMobileData()) QualityDataHelper.QualityProfileType.Data else QualityDataHelper.QualityProfileType.WiFi currentQualityProfile = - profiles.firstOrNull { it.types.contains(type) }?.id ?: profiles.firstOrNull()?.id - ?: currentQualityProfile - -// currentPrefQuality = settingsManager.getInt( -// ctx.getString(if (ctx.isUsingMobileData()) R.string.quality_pref_mobile_data_key else R.string.quality_pref_key), -// currentPrefQuality -// ) - // useSystemBrightness = - // settingsManager.getBoolean(ctx.getString(R.string.use_system_brightness_key), false) + profiles.firstOrNull { it.types.contains(type) }?.id + ?: profiles.firstOrNull()?.id + ?: currentQualityProfile } playerBinding?.apply { playerSpeedBtt.isVisible = playBackSpeedEnabled @@ -2549,23 +1194,6 @@ open class FullScreenPlayer : AbstractPlayerFragment() { } } - playerPausePlay.setOnClickListener { - autoHide() - if (currentPlayerStatus == CSPlayerLoading.IsEnded && isLayout(PHONE)) { - player.handleEvent(CSPlayerEvent.Restart) - } else { - player.handleEvent(CSPlayerEvent.PlayPauseToggle) - } - } - - exoDuration.setOnClickListener { - setRemainingTimeCounter(true) - } - - timeLeft.setOnClickListener { - setRemainingTimeCounter(false) - } - skipChapterButton.setOnClickListener { player.handleEvent(CSPlayerEvent.SkipCurrentChapter) } @@ -2615,16 +1243,6 @@ open class FullScreenPlayer : AbstractPlayerFragment() { showSubtitleOffsetDialog() } - playerRew.setOnClickListener { - autoHide() - rewind() - } - - playerFfwd.setOnClickListener { - autoHide() - fastForward() - } - playerGoBack.setOnClickListener { activity?.popCurrentPage("FullScreenPlayer") } @@ -2637,26 +1255,21 @@ open class FullScreenPlayer : AbstractPlayerFragment() { showTracksDialogue() } - // it is !not! a bug that you cant touch the right side, it does not register inputs on navbar or status bar - playerHolder.setOnTouchListener { callView, event -> - return@setOnTouchListener handleMotionEvent(callView, event) - } - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { playerControlsScroll.setOnScrollChangeListener { _, _, _, _, _ -> autoHide() } } + exoProgress.registerPlayerView(playerView) + + @SuppressLint("ClickableViewAccessibility") exoProgress.setOnTouchListener { _, event -> // this makes the bar not disappear when sliding when (event.action) { - MotionEvent.ACTION_DOWN -> { - currentTapIndex++ - } - + MotionEvent.ACTION_DOWN, MotionEvent.ACTION_MOVE -> { - currentTapIndex++ + playerHostView?.cancelAutoHide() } MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL, MotionEvent.ACTION_BUTTON_RELEASE -> { @@ -2669,11 +1282,6 @@ open class FullScreenPlayer : AbstractPlayerFragment() { toggleEpisodesOverlay(show = true) } } - // cs3 is peak media center - setRemainingTimeCounter(durationMode || isLayout(TV)) - playerBinding?.exoPosition?.doOnTextChanged { _, _, _, _ -> - updateRemainingTime() - } // init UI try { uiReset() @@ -2682,7 +1290,6 @@ open class FullScreenPlayer : AbstractPlayerFragment() { } } - @SuppressLint("SourceLockedOrientationActivity") private fun toggleRotate() { activity?.let { toggleOrientationWithSensor(it) @@ -2707,49 +1314,14 @@ open class FullScreenPlayer : AbstractPlayerFragment() { } override fun playerDimensionsLoaded(width: Int, height: Int) { - // On TV, don't rotate for portrait videos; display with pillarbox (black bars on sides) - if (isLayout(TV or EMULATOR)) { - isVerticalOrientation = false - return - } - isVerticalOrientation = height > width + // PlayerView already set isVerticalOrientation; skip rotation on TV (pillarbox instead). + if (isLayout(TV or EMULATOR)) return + // Skip zero-size events emitted when the player transitions to STATE_IDLE, + // acting on them would reset auto-detected orientation to landscape. + if (width <= 0 || height <= 0) return updateOrientation() } - private fun updateRemainingTime() { - val duration = player.getDuration() - val position = player.getPosition() - - if (duration != null && duration > 1 && position != null) { - val remainingTimeSeconds = (duration - position + 500) / 1000 - val formattedTime = "-${DateUtils.formatElapsedTime(remainingTimeSeconds)}" - - playerBinding?.timeLeft?.text = formattedTime - } - } - - private fun setRemainingTimeCounter(showRemaining: Boolean) { - durationMode = showRemaining - playerBinding?.exoDuration?.isInvisible = showRemaining - playerBinding?.timeLeft?.isVisible = showRemaining - } - - private fun dynamicOrientation(): Int { - // TV should always remain in landscape mode - if (isLayout(TV or EMULATOR)) { - return ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE - } - return if (autoPlayerRotateEnabled) { - if (isVerticalOrientation) { - ActivityInfo.SCREEN_ORIENTATION_SENSOR_PORTRAIT - } else { - ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE - } - } else { - ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE // default orientation - } - } - private fun toggleEpisodesOverlay(show: Boolean) { if (show && !isShowingEpisodeOverlay) { previousPlayStatus = player.getIsPlaying() 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 16b03e4f61c..2dfd5ef4df0 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,18 @@ 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,27 +172,24 @@ 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 binding: FragmentPlayerBinding? = null - 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 { // If subtitle is changed and user initiated -> Save the language @@ -214,7 +221,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) } @@ -233,7 +240,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 @@ -348,16 +355,13 @@ class GeneratorPlayer : FullScreenPlayer() { } // retry several times with a preview in case the preview generator is slow - for (i in 0..10) { + repeat(10) { val preview = this@GeneratorPlayer.player.getPreview(0.5f) - if (preview == null) { - delay(1000L) - continue + if (preview != null) { + callback.onBitmap(preview) + return@repeat } - callback.onBitmap( - preview - ) - break + delay(1000L) } } @@ -373,6 +377,7 @@ class GeneratorPlayer : FullScreenPlayer() { return mutableMapOf( STOP_ACTION to NotificationCompat.Action( R.drawable.baseline_stop_24, + @SuppressLint("PrivateResource") context.getString(androidx.media3.ui.R.string.exo_controls_stop_description), createBroadcastIntent(STOP_ACTION, context, instanceId) ) @@ -386,9 +391,7 @@ class GeneratorPlayer : FullScreenPlayer() { override fun onCustomAction(player: Player, action: String, intent: Intent) { when (action) { STOP_ACTION -> { - exitFullscreen() - this@GeneratorPlayer.player.release() - activity?.popCurrentPage() + exitPlayer() } } } @@ -488,9 +491,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 = @@ -506,16 +509,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) @@ -525,6 +519,7 @@ class GeneratorPlayer : FullScreenPlayer() { // load player context?.let { ctx -> val (url, uri) = link + val subtitles = viewModel.state.subtitles player.loadPlayer( ctx, sameEpisode, @@ -533,11 +528,11 @@ 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 = isFullScreenPlayer + preview = true ) } @@ -548,13 +543,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, @@ -633,7 +621,6 @@ class GeneratorPlayer : FullScreenPlayer() { imageViewEnd.setImageDrawable(drawableEnd) } - @SuppressLint("SetTextI18n") override fun getView(position: Int, convertView: View?, parent: ViewGroup): View { val view = convertView ?: LayoutInflater.from(context).inflate(layout, null) @@ -649,6 +636,7 @@ class GeneratorPlayer : FullScreenPlayer() { item?.let { fromTagToLanguageName(it.lang) ?: it.lang } ?: "" val providerSuffix = if (isSingleProvider || item == null) "" else " · ${item.source}" + @SuppressLint("SetTextI18n") secondaryTextView?.text = language + providerSuffix setHearingImpairedIcon(drawableEnd, position) @@ -880,20 +868,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 @@ -992,7 +978,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()) @@ -1015,7 +1001,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 = @@ -1057,7 +1043,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 @@ -1115,7 +1101,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 @@ -1280,16 +1266,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 { @@ -1433,11 +1431,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 -> @@ -1445,7 +1444,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 @@ -1467,7 +1468,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(" • ") @@ -1495,7 +1497,7 @@ class GeneratorPlayer : FullScreenPlayer() { binding.applyBtt.setOnClickListener { val currentTrack = currentAudioTracks.getOrNull(audioIndexStart) player.setPreferredAudioTrack( - currentTrack?.language, + currentTrack?.language, currentTrack?.id, currentTrack?.formatIndex, ) @@ -1515,7 +1517,6 @@ class GeneratorPlayer : FullScreenPlayer() { } } - override fun playerError(exception: Throwable) { val currentUrl = currentSelectedLink?.let { it.first?.url ?: it.second?.uri?.toString() } ?: "unknown" @@ -1545,13 +1546,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() } @@ -1564,7 +1572,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 @@ -1606,7 +1614,7 @@ class GeneratorPlayer : FullScreenPlayer() { override fun nextEpisode() { if (viewModel.hasNextEpisode() == true) { isNextEpisode = true - player.release() + releasePlayer() viewModel.loadLinksNext() } } @@ -1614,18 +1622,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 @@ -1672,7 +1680,7 @@ class GeneratorPlayer : FullScreenPlayer() { val percentage = position * 100L / duration DataStoreHelper.setViewPosAndResume( - viewModel.getId(), + viewModel.state.generatorState?.id, position, duration, currentMeta, @@ -1724,14 +1732,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 @@ -1748,7 +1760,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() @@ -1762,20 +1774,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() { @@ -1841,8 +1853,6 @@ class GeneratorPlayer : FullScreenPlayer() { return "" } - - @SuppressLint("SetTextI18n") fun setTitle() { var playerVideoTitle = getPlayerVideoTitle() @@ -1861,10 +1871,9 @@ 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 } - @SuppressLint("SetTextI18n") fun setPlayerDimen(widthHeight: Pair?) { val resolution = widthHeight?.let { "${it.first}x${it.second}" } val name = currentSelectedLink?.first?.name ?: currentSelectedLink?.second?.name @@ -1976,29 +1985,13 @@ class GeneratorPlayer : FullScreenPlayer() { } } - override fun onCreateView( - inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? - ): View? { - // this is used instead of layout-television to follow the settings and some TV devices are not classified as TV for some reason - layout = - if (isLayout(TV or EMULATOR)) R.layout.fragment_player_tv else R.layout.fragment_player - - viewModel = ViewModelProvider(this)[PlayerGeneratorViewModel::class.java] - sync = ViewModelProvider(this)[SyncViewModel::class.java] - - viewModel.attachGenerator(lastUsedGenerator) - unwrapBundle(savedInstanceState) - unwrapBundle(arguments) - - val root = super.onCreateView(inflater, container, savedInstanceState) ?: return null - binding = FragmentPlayerBinding.bind(root) - return root - } - - override fun onDestroyView() { - binding = null - super.onDestroyView() - } + /** + * This is used instead of layout-television to follow the + * settings and some TV devices are not classified as TV + * for some reason. + */ + override fun pickLayout(): Int = + if (isLayout(TV or EMULATOR)) R.layout.fragment_player_tv else R.layout.fragment_player var skipAnimator: ValueAnimator? = null var skipIndex = 0 @@ -2019,6 +2012,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 @@ -2027,12 +2026,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 @@ -2071,8 +2065,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() { @@ -2084,7 +2079,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) } } @@ -2103,7 +2098,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)) { @@ -2122,15 +2117,14 @@ class GeneratorPlayer : FullScreenPlayer() { // update overlay season title var lastTopIndex = -1 playerEpisodeList.addOnScrollListener(object : RecyclerView.OnScrollListener() { - @SuppressLint("SetTextI18n", "DefaultLocale") override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { val layoutManager = recyclerView.layoutManager as? LinearLayoutManager ?: return val topIndex = layoutManager.findFirstCompletelyVisibleItemPosition() if (topIndex != RecyclerView.NO_POSITION && topIndex != lastTopIndex) { + @Suppress("AssignedValueIsNeverRead") lastTopIndex = topIndex val topItem = episodes.getOrNull(topIndex) - topItem?.let { playerEpisodeOverlayTitle.setText( ResultViewModel2.seasonToTxt( @@ -2148,26 +2142,64 @@ class GeneratorPlayer : FullScreenPlayer() { } } - @SuppressLint("SetTextI18n") - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - var langFilterList = listOf() - var filterSubByLang = false + @MainThread + fun releasePlayer() { + player.release() + currentSelectedSubtitles = null + currentSelectedLink = null + isPlayerActive.set(false) + binding?.overlayLoadingSkipButton?.isVisible = false + binding?.playerLoadingOverlay?.isVisible = true + uiReset() + } + + fun exitPlayer() { + playerHostView?.exitFullscreen() + player.release() + 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") + 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) - 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) + 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) + 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() } @@ -2180,18 +2212,23 @@ 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 { - startPlayer() + binding.overlayLoadingSkipButton.setOnClickListener { + // Mark as "success" early + viewModel.modifyState { + copy(loading = Resource.Success(Unit)) + } } - binding?.playerLoadingGoBack?.setOnClickListener { - exitFullscreen() - player.release() - activity?.popCurrentPage() + binding.playerLoadingGoBack.setOnClickListener { + exitPlayer() } playerBinding?.downloadHeader?.setOnClickListener { @@ -2204,14 +2241,29 @@ 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.loadingLinks) { - when (it) { + 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 + // 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, instance) -> + if (instance != viewModel.state.instance) return@observe // Outdated observe + + when (loading) { is Resource.Loading -> { - startLoading() + releasePlayer() } is Resource.Success -> { @@ -2223,29 +2275,30 @@ 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 - val wasGone = binding?.overlayLoadingSkipButton?.isGone == true + 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 - binding?.overlayLoadingSkipButton?.apply { + binding.overlayLoadingSkipButton.apply { isVisible = turnVisible - val value = viewModel.currentLinks.value - if (value.isNullOrEmpty()) { + if (links.isEmpty()) { setText(R.string.skip_loading) } else { - text = "${context.getString(R.string.skip_loading)} (${value.size})" + @SuppressLint("SetTextI18n") + text = "${context.getString(R.string.skip_loading)} (${links.size})" } } safe { - if (currentLinks.any { link -> + if (!isPlayerActive.get() && viewModel.state.links.any { link -> getLinkPriority(currentQualityProfile, link.first) >= QualityDataHelper.AUTO_SKIP_PRIORITY } @@ -2255,37 +2308,10 @@ class GeneratorPlayer : FullScreenPlayer() { } if (turnVisible && wasGone) { - 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() + binding.overlayLoadingSkipButton.requestFocus() } } } - } @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 0a34feee30c..3ab46ce215a 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/IPlayer.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/IPlayer.kt index 43ec756edec..0342372667f 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,31 +3,12 @@ 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 -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), @@ -220,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" @@ -243,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, ) @@ -311,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/LinkGenerator.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/LinkGenerator.kt index 71513af2ce4..db06e26e9a6 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 eb9f5c249eb..ac25347b6bd 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 96468490ab3..e3c390d504c 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,35 +9,188 @@ 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, + val instance: Int, +) { + /** + * 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()) +} + +data class VideoLive( + val value: T, + val instance: Int, +) class PlayerGeneratorViewModel : ViewModel() { companion object { const val TAG = "PlayViewGen" } - private var generator: IGenerator? = null + @Volatile + var generator: VideoGenerator<*>? = null + + @Volatile + var episodeIndex: Int = 0 - private val _currentLinks = MutableLiveData>>(setOf()) - val currentLinks: LiveData>> = _currentLinks + /** + * 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(instance = 0) + private set + + private val _currentLinks = + MutableLiveData>>>(null) + val currentLinks: LiveData>>> = _currentLinks - private val _currentSubs = MutableLiveData>(setOf()) - val currentSubs: LiveData> = _currentSubs + 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. + * + * 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) + + /** 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" + * + * 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(VideoLive(state.links, state.instance)) + if (state.stamps !== oldState.stamps) + _currentStamps.postValue(VideoLive(state.stamps, state.instance)) + if (state.subtitles !== oldState.subtitles) + _currentSubtitles.postValue(VideoLive(state.subtitles, state.instance)) + + /** Normal equality here as it is not a collection */ + if (state.loading != oldState.loading) + _loadingLinks.postValue(VideoLive(state.loading, state.instance)) + } private val _currentSubtitleYear = MutableLiveData(null) val currentSubtitleYear: LiveData = _currentSubtitleYear @@ -53,41 +206,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 +241,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 +263,137 @@ 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?) { - if (generator == null) { - generator = newGenerator - } + fun attachGenerator(newGenerator: VideoGenerator<*>, index: Int) { + Log.i(TAG, "attachGenerator with generator=$newGenerator and index=$index") + generator = newGenerator + 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") + Log.i(TAG, "loadLinks with generator=$generator and index=$episodeIndex") currentJob?.cancel() + val index = episodeIndex + + // 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 { - // if we load links then we clear the prev loaded links - synchronized(extraSubtitles) { - extraSubtitles.clear() - } - 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 -> + if (isActive) + modifyState { + add(link) } - } }, - subtitleCallback = { - synchronized(extraSubtitles) { - currentSubs.add(it) - safe { - _currentSubs.postValue(currentSubs + extraSubtitles) + isCasting = false, + offset = index, + subtitleCallback = { link -> + if (isActive && isValidSubtitle(link)) + modifyState { + add(link) } - } }) + Unit } - _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 { + if (!isActive) { + this + } else { + 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/PlayerGestureHelper.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerGestureHelper.kt new file mode 100644 index 00000000000..1c7086d1238 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerGestureHelper.kt @@ -0,0 +1,1220 @@ +package com.lagradost.cloudstream3.ui.player + +import android.animation.ValueAnimator +import android.annotation.SuppressLint +import android.app.Activity +import android.content.Context +import android.content.res.ColorStateList +import android.graphics.Matrix +import android.media.AudioManager +import android.media.audiofx.LoudnessEnhancer +import android.os.Build +import android.os.Handler +import android.os.Looper +import android.provider.Settings +import android.view.KeyEvent +import android.view.LayoutInflater +import android.view.MotionEvent +import android.view.ScaleGestureDetector +import android.view.View +import android.view.ViewGroup +import android.view.WindowInsets +import android.view.animation.AlphaAnimation +import android.view.animation.Animation +import android.view.animation.AnimationUtils +import androidx.annotation.OptIn +import androidx.core.content.ContextCompat +import androidx.core.view.isGone +import androidx.core.view.isVisible +import androidx.media3.common.util.UnstableApi +import androidx.media3.exoplayer.ExoPlayer +import androidx.media3.ui.AspectRatioFrameLayout +import androidx.preference.PreferenceManager +import com.lagradost.cloudstream3.CommonActivity.keyEventListener +import com.lagradost.cloudstream3.CommonActivity.screenHeightWithOrientation +import com.lagradost.cloudstream3.CommonActivity.screenWidthWithOrientation +import com.lagradost.cloudstream3.CommonActivity.showToast +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.mvvm.logError +import com.lagradost.cloudstream3.mvvm.safe +import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR +import com.lagradost.cloudstream3.ui.settings.Globals.PHONE +import com.lagradost.cloudstream3.ui.settings.Globals.isLayout +import com.lagradost.cloudstream3.utils.DataStoreHelper +import com.lagradost.cloudstream3.utils.UIHelper.getStatusBarHeight +import com.lagradost.cloudstream3.utils.Vector2 +import kotlin.math.abs +import kotlin.math.absoluteValue +import kotlin.math.ceil +import kotlin.math.max +import kotlin.math.min +import kotlin.math.round +import kotlin.math.roundToInt + +/** + * Handles all gesture, volume, brightness, speed-up, zoom, and hardware-key-event input for a + * [PlayerView]. Keeps these separate from the player-view setup and lifecycle + * code in [PlayerView] itself. + * + * Instantiated and owned by [PlayerView]; accessed from host fragments via the delegate + * properties [PlayerView] exposes. + */ +@OptIn(UnstableApi::class) +class PlayerGestureHelper(private val playerView: PlayerView) { + + companion object { + /** Swipe-seek constants */ + const val MINIMUM_SEEK_TIME = 7000L + const val MINIMUM_VERTICAL_SWIPE = 2.0f // % of screen height + const val MINIMUM_HORIZONTAL_SWIPE = 2.0f // % of screen height + const val VERTICAL_MULTIPLIER = 2.0f + const val HORIZONTAL_MULTIPLIER = 2.0f + + /** Double-tap constants */ + /** Maximum finger-hold time (ms) for a tap to qualify as a double-tap seek. */ + const val DOUBLE_TAP_MAXIMUM_HOLD_TIME = 200L + /** Time window (ms) between taps to count as a double-tap. + * Also determines how long a single-tap is delayed before firing. */ + const val DOUBLE_TAP_MINIMUM_TIME_BETWEEN = 200L + /** Fraction of view width on each side that counts as "left" / "right" seek zone. */ + const val DOUBLE_TAP_PAUSE_PERCENTAGE = 0.15 + + /** Zoom constants */ + /** Minimum zoom; allows zooming out past 100% but snaps back. */ + const val MINIMUM_ZOOM = 0.95f + /** Sensitivity for the auto-snap to 100% at the minimum zoom boundary. */ + const val ZOOM_SNAP_SENSITIVITY = 0.07f + /** Maximum zoom to prevent the user from getting lost. */ + const val MAXIMUM_ZOOM = 4.0f + + /** Extracts translation and uniform scale from a matrix with no rotation. */ + fun matrixToTranslationAndScale(matrix: Matrix): Triple { + val points = floatArrayOf(0f, 0f, 1f, 1f) + matrix.mapPoints(points) + val translationX = points[0] + val translationY = points[1] + val scale = points[2] - translationX + return Triple(translationX, translationY, scale) + } + } + + private val context: Context get() = playerView.context + + /** Set true by the host when the player occupies the full screen. + * Controls whether hardware volume-key overrides are active (phones/emulators only). */ + var isFullScreen: Boolean = false + + /** Volume state */ + var currentRequestedVolume: Float = 0.0f + var isVolumeLocked: Boolean = false + var hasShownVolumeToast: Boolean = false + private var loudnessEnhancer: LoudnessEnhancer? = null + private var progressBarLeftHideRunnable: Runnable? = null + + /** Brightness state */ + var currentRequestedBrightness: Float = 1.0f + var currentExtraBrightness: Float = 0.0f + var isBrightnessLocked: Boolean = false + var hasShownBrightnessToast: Boolean = false + /** When true, read/write system brightness via [Settings.System.SCREEN_BRIGHTNESS]. + * Automatically falls back to window-attribute brightness if the permission is missing. */ + var useTrueSystemBrightness: Boolean = true + /** White overlay inflated into exo_content_frame; alpha encodes extra brightness (0–1). */ + var brightnessOverlay: View? = null + private var progressBarRightHideRunnable: Runnable? = null + + /** Gesture settings (read from prefs in initialize) */ + var swipeVerticalEnabled: Boolean = true + var swipeHorizontalEnabled: Boolean = false + var extraBrightnessEnabled: Boolean = false + var speedupEnabled: Boolean = false + + /** Hold / speed-up */ + val holdHandler = Handler(Looper.getMainLooper()) + var hasTriggeredSpeedUp = false + val holdRunnable = Runnable { + playerView.player.setPlaybackSpeed(2.0f) + showOrHideSpeedUp(true) + playerView.callbacks?.onHoldSpeedUp(true) + hasTriggeredSpeedUp = true + } + + enum class TouchAction { Brightness, Volume, Time } + + /** Mirrors the host's lock state; suppresses gesture interactions when true. */ + var isLocked: Boolean = false + + /** Touch tracking */ + var isCurrentTouchValid = false + private set + private var currentTouchStart: Vector2? = null + private var currentTouchLast: Vector2? = null + /** Current in-progress swipe action, null when no swipe is active. */ + var currentTouchAction: TouchAction? = null + /** Action from the previous touch sequence; guards against mis-detected double-taps after swipes. */ + var currentLastTouchAction: TouchAction? = null + /** The time in the player when you first click. */ + private var currentTouchStartPlayerTime: Long? = null + /** The system time when you first click. */ + private var currentTouchStartTime: Long? = null + /** Whether the player UI was visible when the current swipe gesture began. */ + var uiShowingBeforeGesture: Boolean = false + + /** Icons */ + private val brightnessIcons = listOf( + R.drawable.sun_1, R.drawable.sun_2, R.drawable.sun_3, + R.drawable.sun_4, R.drawable.sun_5, R.drawable.sun_6, R.drawable.sun_7, + ) + private val volumeIcons = listOf( + R.drawable.ic_baseline_volume_mute_24, + R.drawable.ic_baseline_volume_down_24, + R.drawable.ic_baseline_volume_up_24, + ) + + /** Double-tap / tap state */ + + /** Whether double-tapping left/right seeks backward/forward. */ + var doubleTapEnabled: Boolean = false + + /** Whether double-tapping the center of the screen pauses (left/right still seeks if [doubleTapEnabled]). */ + var doubleTapPauseEnabled: Boolean = false + + /** Seek distance (ms) for each double-tap seek. Read from prefs in [initialize]. */ + var fastForwardTime: Long = 10_000L + + /** Monotonically-incremented token; cancels any pending single-tap runnable when a double-tap arrives. */ + private var doubleTapToken = 0 + + /** Number of consecutive taps in the current double-tap window. */ + private var tapCount = 0 + + /** System time of the most-recent touch end. Updated by callers at the end of every ACTION_UP. */ + var lastTouchEndTime: Long = 0L + + /** Zoom state */ + + /** Optional view for showing the snap-hint outline during zoom (set by FullScreenPlayer). */ + var videoOutline: View? = null + + /** Current zoom+pan matrix, or null when no zoom is active. */ + var zoomMatrix: Matrix? = null + + /** The matrix the zoom will animate to after the user lifts fingers. */ + var desiredMatrix: Matrix? = null + + /** Running snap-back animation, or null. */ + var matrixAnimation: ValueAnimator? = null + + private var scaleGestureDetector: ScaleGestureDetector? = null + + /** Midpoint of the two-finger pan, null when no pan is active. */ + var lastPan: Vector2? = null + + private var overlayLayoutListener: View.OnLayoutChangeListener? = null + + /** Called from [PlayerView.initialize] after views are bound. */ + fun initialize() { + try { + val sm = PreferenceManager.getDefaultSharedPreferences(context) + swipeVerticalEnabled = sm.getBoolean(context.getString(R.string.swipe_vertical_enabled_key), true) + swipeHorizontalEnabled = sm.getBoolean(context.getString(R.string.swipe_enabled_key), true) + extraBrightnessEnabled = sm.getBoolean(context.getString(R.string.extra_brightness_key), false) + speedupEnabled = sm.getBoolean(context.getString(R.string.speedup_key), false) + doubleTapEnabled = sm.getBoolean(context.getString(R.string.double_tap_enabled_key), false) + doubleTapPauseEnabled = sm.getBoolean(context.getString(R.string.double_tap_pause_enabled_key), false) + fastForwardTime = sm.getInt(context.getString(R.string.double_tap_seek_time_key), 10).toLong() * 1000L + } catch (_: Exception) { + } + + // Inject the brightness overlay into the ExoPlayer content frame so it sits + // directly on top of the video surface. Alpha is set by handleBrightnessAdjustment. + safe { + val pkg = context.packageName + @SuppressLint("DiscouragedApi") + val contentId = context.resources.getIdentifier("exo_content_frame", "id", pkg) + val contentFrame = playerView.exoPlayerView?.findViewById(contentId) + if (contentFrame != null) { + brightnessOverlay?.let { + (it.parent as? ViewGroup)?.removeView(it) + } + brightnessOverlay = LayoutInflater.from(context) + .inflate(R.layout.extra_brightness_overlay, contentFrame, false) + contentFrame.addView(brightnessOverlay) + } + } + + setupTouchGestures() + } + + /** Called from [PlayerView.release]. */ + fun release() { + safe { + brightnessOverlay?.let { + (it.parent as? ViewGroup)?.removeView(it) + } + } + brightnessOverlay = null + loudnessEnhancer?.release() + loudnessEnhancer = null + holdHandler.removeCallbacksAndMessages(null) + clearZoomState() + releaseOverlayLayoutListener() + } + + /** Key-event listener */ + + /** + * Registers the basic volume-key listener on [keyEventListener]. + * Called from [PlayerView.initialize] and from the host fragment's onResume. + */ + fun setupKeyEventListener() { + keyEventListener = { (event, _) -> + if (event != null && event.action == KeyEvent.ACTION_DOWN) + handleVolumeKey(event.keyCode) + else false + } + } + + /** Nulls [keyEventListener]. Called from the host fragment's onPause. */ + fun releaseKeyEventListener() { + keyEventListener = null + } + + /** Speed-up */ + + fun showOrHideSpeedUp(show: Boolean) { + playerView.playerSpeedupButton?.let { btn -> + btn.clearAnimation() + btn.alpha = if (show) 0f else 1f + btn.isVisible = show + btn.animate() + .alpha(if (show) 1f else 0f) + .setDuration(200L) + .withEndAction { if (!show) btn.isVisible = false } + .start() + } + } + + /** Volume helpers */ + + /** + * Syncs [currentRequestedVolume] with the current system stream volume. + * + * This is here to make returning to the player less jarring, if we change the volume outside + * the app. Note that this will make it a bit wierd when using loudness in PiP, then returning + * however that is the cost of correctness. + */ + fun verifyVolume() { + ((context as? Activity)?.getSystemService(Context.AUDIO_SERVICE) as? AudioManager)?.let { am -> + val cur = am.getStreamVolume(AudioManager.STREAM_MUSIC) + val max = am.getStreamMaxVolume(AudioManager.STREAM_MUSIC) + if (cur < max || currentRequestedVolume <= 1.0f) { + currentRequestedVolume = cur.toFloat() / max.toFloat() + loudnessEnhancer?.release() + loudnessEnhancer = null + } + } + } + + /** + * Handles a hardware volume key press. + * Only active on phones/emulators when [isFullScreen] is true. + * + * @return true if the key was consumed (suppresses the system volume UI). + */ + fun handleVolumeKey(keyCode: Int): Boolean { + /** + * Some TVs do not support volume boosting, and overriding + * the volume buttons can be inconvenient for TV users. + * Since boosting volume is mainly useful on phones and emulators, + * we limit this feature to those devices. + */ + if (!isLayout(PHONE or EMULATOR) || !isFullScreen) return false + if (keyCode != KeyEvent.KEYCODE_VOLUME_UP && keyCode != KeyEvent.KEYCODE_VOLUME_DOWN) return false + verifyVolume() + if (currentRequestedVolume <= 1.0f) hasShownVolumeToast = false + isVolumeLocked = currentRequestedVolume < 1.0f + // +- 5% + handleVolumeAdjustment(if (keyCode == KeyEvent.KEYCODE_VOLUME_UP) 0.05f else -0.05f, fromButton = true) + return true + } + + fun handleVolumeAdjustment(delta: Float, fromButton: Boolean) { + val am = (context as? Activity)?.getSystemService(Context.AUDIO_SERVICE) as? AudioManager ?: return + val curStep = am.getStreamVolume(AudioManager.STREAM_MUSIC) + val maxStep = am.getStreamMaxVolume(AudioManager.STREAM_MUSIC) + + val cur = currentRequestedVolume + val locked = isVolumeLocked + val next = (cur + delta).coerceIn(0.0f, if (locked) 1.0f else 2.0f) + val nextStep = (next * maxStep.toFloat()).roundToInt().coerceIn(0, maxStep) + + // Show toast + if (fromButton) { + // For button related request we only show a toast when we exceeded the volume. + if (cur <= 1.0f && next > 1.0f && !hasShownVolumeToast) { + showToast(R.string.volume_exceeded_100) + hasShownVolumeToast = true + } + } else { + val raw = cur + delta + // For swipes, we show toast that we need to swipe again. + if (raw > 1.0 && locked && !hasShownVolumeToast) { + showToast(R.string.slide_up_again_to_exceed_100) + hasShownVolumeToast = true + } + } + + // Set the current volume step. + if (nextStep != curStep) am.setStreamVolume(AudioManager.STREAM_MUSIC, nextStep, 0) + + var hasBoostError = false + // Apply loudness enhancer for volumes > 100%, removes it if less. + if (next > 1.0f) { + val boost = ((next - 1.0f) * 1000).toInt() + val existing = loudnessEnhancer + if (existing != null) { + existing.setTargetGain(boost) + } else { + val sessionId = (playerView.exoPlayerView?.player as? ExoPlayer)?.audioSessionId + if (sessionId != null && sessionId != AudioManager.ERROR) { + try { + loudnessEnhancer = LoudnessEnhancer(sessionId).apply { + setTargetGain(boost); enabled = true + } + } catch (t: Throwable) { logError(t); hasBoostError = true } + } + } + } else { + loudnessEnhancer?.release(); loudnessEnhancer = null + } + + currentRequestedVolume = next + + val leftHolder = playerView.playerProgressbarLeftHolder ?: return + val level1 = playerView.playerProgressbarLeftLevel1 ?: return + val level2 = playerView.playerProgressbarLeftLevel2 ?: return + val icon = playerView.playerProgressbarLeftIcon ?: return + + if (next > 1.0f) { + // Change color to show that LoudnessEnhancer broke + // this is not a real fix, but solves the crash issue. + level2.progressTintList = ColorStateList.valueOf( + ContextCompat.getColor(context, if (hasBoostError) R.color.colorPrimaryRed else R.color.colorPrimaryOrange) + ) + } + // Max is set high to make it smooth. + level1.max = 100_000 + level1.progress = (next * 100_000f).toInt().coerceIn(2_000, 100_000) + level2.max = 100_000 + level2.progress = if (next > 1.0f) ((next - 1.0) * 100_000f).toInt().coerceIn(2_000, 100_000) else 0 + level2.isVisible = next > 1.0f + // Calculate the clamped index for the volume icon based on the requested volume. + val iconIdx = (next * volumeIcons.lastIndex).roundToInt().coerceIn(0, volumeIcons.lastIndex) + icon.setImageResource(volumeIcons[iconIdx]) + + if (!leftHolder.isVisible || leftHolder.alpha < 1f) { + leftHolder.animate().cancel(); leftHolder.alpha = 1f; leftHolder.isVisible = true + } + progressBarLeftHideRunnable?.let { leftHolder.removeCallbacks(it) } + progressBarLeftHideRunnable = Runnable { + leftHolder.animate().cancel() + leftHolder.animate().alpha(0f).setDuration(300).withEndAction { leftHolder.isVisible = false }.start() + } + // Show the progress bar for 1.5 seconds. + leftHolder.postDelayed(progressBarLeftHideRunnable, 1500) + } + + /** Brightness helpers */ + + /** + * Reads from [Settings.System.SCREEN_BRIGHTNESS], falling back to the window + * attribute if the permission is absent. + */ + fun getBrightness(): Float? { + return if (useTrueSystemBrightness) { + try { + Settings.System.getInt( + (context as? Activity)?.contentResolver, + Settings.System.SCREEN_BRIGHTNESS + ) / 255f + } catch (_: Exception) { + // Because true system brightness requires + // permission, this is a lazy way to check + // as it will throw an error if we do not have it. + useTrueSystemBrightness = false + getBrightness() + } + } else { + try { + (context as? Activity)?.window?.attributes?.screenBrightness?.takeIf { it >= 0f } + } catch (e: Exception) { + logError(e) + null + } + } + } + + /** + * Sets [Settings.System.SCREEN_BRIGHTNESS], falling back to the window + * attribute if the permission is absent. + */ + fun setBrightness(brightness: Float) { + if (useTrueSystemBrightness) { + try { + Settings.System.putInt( + (context as? Activity)?.contentResolver, + Settings.System.SCREEN_BRIGHTNESS_MODE, + Settings.System.SCREEN_BRIGHTNESS_MODE_MANUAL + ) + Settings.System.putInt( + (context as? Activity)?.contentResolver, + Settings.System.SCREEN_BRIGHTNESS, + min(1, (brightness.coerceIn(0.0f, 1.0f) * 255).toInt()) + ) + } catch (_: Exception) { + useTrueSystemBrightness = false + setBrightness(brightness) + } + } else { + try { + val lp = (context as? Activity)?.window?.attributes ?: return + // Use 0.004f instead of 0: on some devices a value too close to 0 causes the + // system to override with its own brightness, making fine-tuning impossible. + lp.screenBrightness = brightness.coerceIn(0.004f, 1.0f) + (context as? Activity)?.window?.attributes = lp + } catch (e: Exception) { + logError(e) + } + } + } + + fun handleBrightnessAdjustment(verticalAddition: Float) { + val lastBrightness = currentRequestedBrightness + val raw = currentRequestedBrightness + verticalAddition + val next = raw.coerceIn(0.0f, if (extraBrightnessEnabled && !isBrightnessLocked) 2.0f else 1.0f) + + if (extraBrightnessEnabled && isBrightnessLocked && raw > 1.0f && !hasShownBrightnessToast) { + showToast(R.string.slide_up_again_to_exceed_100) + hasShownBrightnessToast = true + } + + currentRequestedBrightness = next + if (lastBrightness != currentRequestedBrightness) setBrightness(currentRequestedBrightness) + + currentExtraBrightness = if (extraBrightnessEnabled && next > 1.0f) min(2.0f, next) - 1.0f else 0.0f + brightnessOverlay?.alpha = currentExtraBrightness + playerView.callbacks?.onBrightnessExtra(currentExtraBrightness) + + val rightHolder = playerView.playerProgressbarRightHolder ?: return + val level1 = playerView.playerProgressbarRightLevel1 ?: return + val level2 = playerView.playerProgressbarRightLevel2 ?: return + val icon = playerView.playerProgressbarRightIcon ?: return + + level1.max = 100_000 + level1.progress = max(2_000, (min(1.0f, next) * 100_000f).toInt()) + + if (extraBrightnessEnabled) { + level2.max = 100_000 + level2.progress = (currentExtraBrightness * 100_000f).toInt().coerceIn(2_000, 100_000) + level2.isVisible = next > 1.0f + } + + icon.setImageResource( + // Clamp the value in case of extra brightness. + brightnessIcons[min(brightnessIcons.lastIndex, max(0, round(next * brightnessIcons.lastIndex).toInt()))] + ) + + if (!rightHolder.isVisible || rightHolder.alpha < 1f) { + rightHolder.animate().cancel(); rightHolder.alpha = 1f; rightHolder.isVisible = true + } + progressBarRightHideRunnable?.let { rightHolder.removeCallbacks(it) } + progressBarRightHideRunnable = Runnable { + rightHolder.animate().cancel() + rightHolder.animate().alpha(0f).setDuration(300).withEndAction { rightHolder.isVisible = false }.start() + } + rightHolder.postDelayed(progressBarRightHideRunnable, 1500) + } + + /** Zoom helpers */ + + /** + * Returns the current zoom matrix, accounting for RESIZE_MODE_ZOOM which already has + * an implicit zoom applied. + * + * This is different from `zoomMatrix ?: Matrix()` + * because it allows used to start zooming at different resizeModes. + * + * The main issue is that RESIZE_MODE_FIT = 100% zoom, but if you are in RESIZE_MODE_ZOOM + * 100% will make the zoom snap to less zoomed in then you already are. + */ + fun currentZoomMatrix(): Matrix { + val current = zoomMatrix + if (current != null) return current + + val exoView = playerView.exoPlayerView + val videoView = exoView?.videoSurfaceView + + if (exoView == null || videoView == null || + exoView.resizeMode != AspectRatioFrameLayout.RESIZE_MODE_ZOOM) { + return Matrix() + } + + val videoWidth = videoView.width.toFloat() + val videoHeight = videoView.height.toFloat() + val playerWidth = screenWidthWithOrientation.toFloat() + val playerHeight = screenHeightWithOrientation.toFloat() + + if (videoWidth <= 1f || videoHeight <= 1f || playerWidth <= 1f || playerHeight <= 1f) { + return Matrix() + } + + val initAspect = (playerHeight * videoWidth) / (playerWidth * videoHeight) + val aspect = max(initAspect, 1f / initAspect) + return Matrix().apply { postScale(aspect, aspect) } + } + + /** + * Applies [newMatrix] (scale + translation only) to the video surface view. + * + * @param newMatrix The new zoom matrix + * @param animation If this zoom is part of an animation, as then it will not auto zoom after we are done. + */ + fun applyZoomMatrix(newMatrix: Matrix, animation: Boolean) { + val exoView = playerView.exoPlayerView ?: return + if (!animation) { + matrixAnimation?.cancel() + matrixAnimation = null + } + val (translationX, translationY, scale) = matrixToTranslationAndScale(newMatrix) + + if (exoView.resizeMode == AspectRatioFrameLayout.RESIZE_MODE_FIT) { + exoView.resizeMode = AspectRatioFrameLayout.RESIZE_MODE_ZOOM + } + + val videoView = exoView.videoSurfaceView ?: return + val videoWidth = videoView.width.toFloat() + val videoHeight = videoView.height.toFloat() + val playerWidth = screenWidthWithOrientation.toFloat() + val playerHeight = screenHeightWithOrientation.toFloat() + + // Sanity check + if (videoWidth <= 1f || videoHeight <= 1f || playerWidth <= 1f || playerHeight <= 1f || scale <= 0.01f) return + + // Calculate the scaled aspect ratio as the view height is not real, check the debugger + // and you will see videoView.height > screen.height. + val initAspect = (playerHeight * videoWidth) / (playerWidth * videoHeight) + val aspect = min(initAspect, 1f / initAspect) + val scaledAspect = scale * aspect + + // Calculate clamp, this is very weird because we need to use aspect here as videoHeight > playerHeight. + val maxTransX = max(0f, videoWidth * scaledAspect - playerWidth) * 0.5f + val maxTransY = max(0f, videoHeight * scaledAspect - playerHeight) * 0.5f + + // Correct the translation to clamp within the viewing area. + val expectedTranslationX = translationX.coerceIn(-maxTransX, maxTransX) + val expectedTranslationY = translationY.coerceIn(-maxTransY, maxTransY) + + // Set the transform to the correct x and y. + newMatrix.postTranslate( + expectedTranslationX - translationX, + expectedTranslationY - translationY + ) + zoomMatrix = newMatrix + + if (!animation) { + // If we are not in an animation, set up the values for the animation. + if ((scaledAspect - 1f).absoluteValue < ZOOM_SNAP_SENSITIVITY) { + // We are within the correct scaling, so center and fit it. + videoOutline?.isVisible = true + val desired = Matrix() + desired.setScale(1f / aspect, 1f / aspect) + desiredMatrix = desired + } else if (scale < 1f) { + // We have zoomed too far, zoom to 100%. + videoOutline?.isVisible = false + desiredMatrix = Matrix() + } else { + // Keep the same scaling after zoom. + videoOutline?.isVisible = false + desiredMatrix = null + } + } + + // Finally set the actual scale + translation. + videoView.scaleX = scaledAspect + videoView.scaleY = scaledAspect + videoView.translationX = expectedTranslationX + videoView.translationY = expectedTranslationY + updateBrightnessOverlayBounds() + } + + /** + * Clears all zoom state and resets the video surface view to 1:1 scale. + * Does NOT change the ExoPlayer resize mode - call [PlayerView.resize] separately. + */ + fun clearZoomState() { + matrixAnimation?.cancel() + matrixAnimation = null + zoomMatrix = null + desiredMatrix = null + scaleGestureDetector = null + lastPan = null + playerView.exoPlayerView?.videoSurfaceView?.apply { + scaleX = 1f + scaleY = 1f + translationX = 0f + translationY = 0f + } + } + + /** + * Resets zoom to fit mode if any zoom is currently active. + * Calls [PlayerView.resize] to update the ExoPlayer resize mode. + */ + fun resetZoomToDefault() { + if (zoomMatrix != null) { + clearZoomState() + playerView.resize(PlayerResize.Fit, false) + } + } + + private fun createScaleGestureDetector(ctx: Context) { + scaleGestureDetector = ScaleGestureDetector( + ctx, + object : ScaleGestureDetector.SimpleOnScaleGestureListener() { + override fun onScale(detector: ScaleGestureDetector): Boolean { + val matrix = currentZoomMatrix() + val (_, _, scale) = matrixToTranslationAndScale(matrix) + // Clamp scale of the zoom, do it here as it is easier than doing it within applyZoomMatrix. + val newScale = (scale * detector.scaleFactor).coerceIn(MINIMUM_ZOOM, MAXIMUM_ZOOM) + // This is how much we should scale it with to prevent infinite scaling. + val actualScaleFactor = newScale / scale + // Scale around the focus point, this is more natural than just zoom. + val pivotX = detector.focusX - screenWidthWithOrientation.toFloat() * 0.5f + val pivotY = detector.focusY - screenHeightWithOrientation.toFloat() * 0.5f + matrix.postScale(actualScaleFactor, actualScaleFactor, pivotX, pivotY) + applyZoomMatrix(matrix, false) + return true + } + } + ) + } + + /** + * Processes a two-finger zoom/pan gesture event. + * Handles scale detection, panning, and the snap-back animation after finger lift. + * + * @param event The motion event (should have pointerCount >= 2 or [lastPan] != null). + * @param ctx Context used to create the [ScaleGestureDetector] on first call. + * @param onFirstPointerDown Called on [MotionEvent.ACTION_POINTER_DOWN] (e.g. hide player UI). + * @param onGestureEnd Called when the gesture ends (e.g. reset caller touch state). + * @return Always true (event consumed). + */ + fun handleZoomPanGesture( + event: MotionEvent, + ctx: Context, + onFirstPointerDown: () -> Unit, + onGestureEnd: () -> Unit + ): Boolean { + if (scaleGestureDetector == null) createScaleGestureDetector(ctx) + scaleGestureDetector?.onTouchEvent(event) + + when (event.actionMasked) { + MotionEvent.ACTION_POINTER_DOWN -> { + onFirstPointerDown() + } + + MotionEvent.ACTION_MOVE -> { + if (event.pointerCount >= 2) { + val newPan = Vector2( + (event.getX(0) + event.getX(1)) / 2f, + (event.getY(0) + event.getY(1)) / 2f + ) + val oldPan = lastPan + if (oldPan != null) { + val matrix = currentZoomMatrix() + matrix.postTranslate(newPan.x - oldPan.x, newPan.y - oldPan.y) + applyZoomMatrix(matrix, false) + } + lastPan = newPan + } + } + + MotionEvent.ACTION_CANCEL, + MotionEvent.ACTION_POINTER_UP, + MotionEvent.ACTION_UP -> { + lastPan = null + videoOutline?.isVisible = false + matrixAnimation?.cancel() + matrixAnimation = null + + // Snap to desired matrix after zoom gesture ends + matrixAnimation = ValueAnimator.ofFloat(0f, 1f).apply { + startDelay = 0 + duration = 200 + val startMatrix = currentZoomMatrix() + val endMatrix = desiredMatrix ?: return@apply + val (startX, startY, startScale) = matrixToTranslationAndScale(startMatrix) + val (endX, endY, endScale) = matrixToTranslationAndScale(endMatrix) + addUpdateListener { anim -> + val v = anim.animatedValue as Float + val vInv = 1f - v + val m = Matrix() + m.setScale(startScale * vInv + endScale * v, startScale * vInv + endScale * v) + m.postTranslate(startX * vInv + endX * v, startY * vInv + endY * v) + applyZoomMatrix(m, true) + } + start() + } + + onGestureEnd() + } + } + return true + } + + /** + * Resizes and repositions [brightnessOverlay] to exactly match the visible video surface, + * accounting for zoom scale and translation. + */ + fun updateBrightnessOverlayBounds() { + val overlay = brightnessOverlay ?: return + val pv = playerView.exoPlayerView ?: return + val video = pv.videoSurfaceView ?: return + + // Compute accurate transformed bounding box of the video view after scale+translation. + val vw = video.width.toFloat() + val vh = video.height.toFloat() + val sx = video.scaleX + val sy = video.scaleY + if (vw <= 0f || vh <= 0f) return + + // Pivot defaults to center if not set. + val pivotX = if (video.pivotX != 0f) video.pivotX else vw * 0.5f + val pivotY = if (video.pivotY != 0f) video.pivotY else vh * 0.5f + // Use view position (includes translation) as base; avoid double-counting translation. + val tx = video.x + val ty = video.y + + // Transform function for a local point (lx,ly). + fun transform(lx: Float, ly: Float): Pair { + val gx = tx + pivotX + (lx - pivotX) * sx + val gy = ty + pivotY + (ly - pivotY) * sy + return Pair(gx, gy) + } + + val p0 = transform(0f, 0f); val p1 = transform(vw, 0f) + val p2 = transform(0f, vh); val p3 = transform(vw, vh) + + val minX = min(min(p0.first, p1.first), min(p2.first, p3.first)) + val maxX = max(max(p0.first, p1.first), max(p2.first, p3.first)) + val minY = min(min(p0.second, p1.second), min(p2.second, p3.second)) + val maxY = max(max(p0.second, p1.second), max(p2.second, p3.second)) + + val newW = ceil(maxX - minX).toInt().coerceAtLeast(0) + val newH = ceil(maxY - minY).toInt().coerceAtLeast(0) + + val lp = overlay.layoutParams + if (lp == null) { + overlay.layoutParams = ViewGroup.LayoutParams(newW, newH) + } else if (lp.width != newW || lp.height != newH) { + lp.width = newW; lp.height = newH + overlay.layoutParams = lp + } + + overlay.scaleX = 1f; overlay.scaleY = 1f + overlay.x = minX; overlay.y = minY + } + + /** + * Attaches a persistent layout-change listener to the ExoPlayer view so + * [updateBrightnessOverlayBounds] is called on every layout pass (orientation change, + * aspect-ratio change, zoom, PiP transition, etc.). + */ + fun requestUpdateBrightnessOverlayOnNextLayout() { + val exoView = playerView.exoPlayerView ?: return + overlayLayoutListener?.let { exoView.removeOnLayoutChangeListener(it) } + val listener = View.OnLayoutChangeListener { _, _, _, _, _, _, _, _, _ -> + safe { updateBrightnessOverlayBounds() } + } + overlayLayoutListener = listener + exoView.addOnLayoutChangeListener(listener) + } + + /** Removes the overlay layout listener registered by [requestUpdateBrightnessOverlayOnNextLayout]. */ + fun releaseOverlayLayoutListener() { + overlayLayoutListener?.let { playerView.exoPlayerView?.removeOnLayoutChangeListener(it) } + overlayLayoutListener = null + } + + /** Rewind / fast-forward animations */ + + /** Resets the rewind button label to the standard "–Xs" format. */ + fun resetRewindText() { + playerView.exoRewText?.text = context.getString(R.string.rew_text_regular_format) + .format(fastForwardTime / 1000) + } + + /** Resets the fast-forward button label to the standard "+Xs" format. */ + fun resetFastForwardText() { + playerView.exoFfwdText?.text = context.getString(R.string.ffw_text_regular_format) + .format(fastForwardTime / 1000) + } + + /** + * Fades playerRewHolder, playerFfwdHolder, and playerPausePlay to [fadeTo] (0f or 1f). + * Always resets the holder alphas to 1f first so any stale fillAfter state is cleared. + * Called from host fragments' show/hide control animations so both GeneratorPlayer and trailer share + * the same fade logic. + */ + fun animateCenterControls(fadeTo: Float) { + val from = if (fadeTo > 0.5f) 0f else 1f + fun makeAnim() = AlphaAnimation(from, fadeTo).apply { duration = 100; fillAfter = true } + // Each view needs its own Animation instance; sharing one causes fillAfter to + // not hold reliably across all views once any of them restarts the animation. + playerView.playerRewHolder?.let { it.alpha = 1f; it.startAnimation(makeAnim()) } + playerView.playerFfwdHolder?.let { it.alpha = 1f; it.startAnimation(makeAnim()) } + playerView.playerPausePlay?.startAnimation(makeAnim()) + } + + /** Plays the rewind animation and seeks back by [fastForwardTime]. */ + fun rewind() { + try { + val rewHolder = playerView.playerRewHolder ?: return + val rew = playerView.playerRew + val rewText = playerView.exoRewText + val wasShowing = playerView.callbacks?.isUIShowing() ?: false + + // Only expose the parent chain when controls are currently hidden. + val prevCenterMenuGone = playerView.playerCenterMenu?.isGone ?: false + val prevVideoHolderVisible = playerView.playerVideoHolder?.isVisible ?: true + if (!wasShowing) { + playerView.playerCenterMenu?.isGone = false + playerView.playerVideoHolder?.isVisible = true + } + // Always clear any stale fillAfter alpha so the button is visible during animation. + rewHolder.alpha = 1f + + rew?.startAnimation(AnimationUtils.loadAnimation(context, R.anim.rotate_left)) + val goLeft = AnimationUtils.loadAnimation(context, R.anim.go_left) + goLeft.setAnimationListener(object : Animation.AnimationListener { + override fun onAnimationStart(animation: Animation?) {} + override fun onAnimationRepeat(animation: Animation?) {} + override fun onAnimationEnd(animation: Animation?) { + rewText?.post { + resetRewindText() + // Restore parent chain only if we changed it and controls are still hidden. + if (!wasShowing && !(playerView.callbacks?.isUIShowing() ?: false)) { + playerView.playerCenterMenu?.isGone = prevCenterMenuGone + playerView.playerVideoHolder?.isVisible = prevVideoHolderVisible + rewHolder.alpha = 0f + } + } + } + }) + rewText?.startAnimation(goLeft) + rewText?.text = context.getString(R.string.rew_text_format).format(fastForwardTime / 1000) + playerView.player.seekTime(-fastForwardTime) + } catch (e: Exception) { logError(e) } + } + + /** Plays the fast-forward animation and seeks forward by [fastForwardTime]. */ + fun fastForward() { + try { + val ffwdHolder = playerView.playerFfwdHolder ?: return + val ffwd = playerView.playerFfwd + val ffwdText = playerView.exoFfwdText + val wasShowing = playerView.callbacks?.isUIShowing() ?: false + + val prevCenterMenuGone = playerView.playerCenterMenu?.isGone ?: false + val prevVideoHolderVisible = playerView.playerVideoHolder?.isVisible ?: true + if (!wasShowing) { + playerView.playerCenterMenu?.isGone = false + playerView.playerVideoHolder?.isVisible = true + } + // Always clear any stale fillAfter alpha so the button is visible during animation. + ffwdHolder.alpha = 1f + + ffwd?.startAnimation(AnimationUtils.loadAnimation(context, R.anim.rotate_right)) + val goRight = AnimationUtils.loadAnimation(context, R.anim.go_right) + goRight.setAnimationListener(object : Animation.AnimationListener { + override fun onAnimationStart(animation: Animation?) {} + override fun onAnimationRepeat(animation: Animation?) {} + override fun onAnimationEnd(animation: Animation?) { + ffwdText?.post { + resetFastForwardText() + if (!wasShowing && !(playerView.callbacks?.isUIShowing() ?: false)) { + playerView.playerCenterMenu?.isGone = prevCenterMenuGone + playerView.playerVideoHolder?.isVisible = prevVideoHolderVisible + ffwdHolder.alpha = 0f + } + } + } + }) + ffwdText?.startAnimation(goRight) + ffwdText?.text = context.getString(R.string.ffw_text_format).format(fastForwardTime / 1000) + playerView.player.seekTime(fastForwardTime) + } catch (e: Exception) { logError(e) } + } + + /** Double-tap detection */ + + /** + * Call when a valid tap is detected (short hold, minimal movement, valid touch area). + * Routes to double-tap seeking/pausing or schedules a delayed single-tap callback. + * + * Updates [lastTouchEndTime] when a confirmed tap (single or double) is recorded. + * + * @param x X coordinate of the tap in the view's coordinate space. + * @param viewWidth Width of the view (used to compute left/center/right zones). + * @param isLocked Whether player controls are locked (suppresses double-tap seek). + * @param onSingleTap Invoked when it is determined to be a single tap; may be deferred. + * @return true if a double-tap action was performed. + */ + fun onTapDetected(x: Float, viewWidth: Int, isLocked: Boolean, onSingleTap: () -> Unit): Boolean { + val anyDoubleTap = doubleTapEnabled || doubleTapPauseEnabled + if (!anyDoubleTap) { + onSingleTap() + return false + } + + val timeSinceLast = System.currentTimeMillis() - lastTouchEndTime + return if (!isLocked && timeSinceLast < DOUBLE_TAP_MINIMUM_TIME_BETWEEN) { + /** Double-tap */ + tapCount++ + doubleTapToken++ // cancel any pending single-tap runnable + if (doubleTapPauseEnabled) { + when { + x < viewWidth / 2f - (DOUBLE_TAP_PAUSE_PERCENTAGE * viewWidth) -> { + if (doubleTapEnabled) rewind() + } + x > viewWidth / 2f + (DOUBLE_TAP_PAUSE_PERCENTAGE * viewWidth) -> { + if (doubleTapEnabled) fastForward() + } + else -> { + playerView.player.handleEvent(CSPlayerEvent.PlayPauseToggle, PlayerEventSource.UI) + } + } + } else if (doubleTapEnabled) { + if (x < viewWidth / 2f) rewind() else fastForward() + } + true + } else { + /** Single tap (first tap, or too slow for double-tap) */ + tapCount = 0 + val token = ++doubleTapToken + playerView.playerHolder?.postDelayed({ + if (token == doubleTapToken) { + onSingleTap() + } + }, DOUBLE_TAP_MINIMUM_TIME_BETWEEN) + false + } + } + + /** Seek time helpers */ + + private fun calculateNewTime(startTime: Long?, touchStart: Vector2?, touchEnd: Vector2?): Long? { + if (touchStart == null || touchEnd == null || startTime == null) return null + val diffX = (touchEnd.x - touchStart.x) * HORIZONTAL_MULTIPLIER / screenWidthWithOrientation.toFloat() + val duration = playerView.player.getDuration() ?: return null + return max(min(startTime + ((duration * (diffX * diffX)) * (if (diffX < 0) -1 else 1)).toLong(), duration), 0) + } + + private fun forceLetters(inp: Long, letters: Int = 2): String { + val added = letters - inp.toString().length + return if (added > 0) "0".repeat(added) + inp.toString() else inp.toString() + } + + private fun convertTimeToString(sec: Long): String { + val rsec = sec % 60L + val min = ceil((sec - rsec) / 60.0).toInt() + val rmin = min % 60L + val h = ceil((min - rmin) / 60.0).toLong() + // int rh = h;// h % 24; + return (if (h > 0) forceLetters(h) + ":" else "") + + (if (rmin >= 0 || h >= 0) forceLetters(rmin) + ":" else "") + + forceLetters(rsec) + } + + /** Touch gestures */ + + fun setupTouchGestures() { + val holder = playerView.playerHolder ?: return + @SuppressLint("ClickableViewAccessibility") + holder.setOnTouchListener(::handleGesture) + } + + private fun isValidTouch(rawX: Float, rawY: Float): Boolean { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + val holder = playerView.playerHolder ?: return true + val insets = holder.rootWindowInsets.getInsetsIgnoringVisibility(WindowInsets.Type.systemBars()) + val validHeight = rawY > insets.top && rawY < screenHeightWithOrientation - insets.bottom + val validWidth = rawX > insets.left && rawX < screenWidthWithOrientation - insets.right + return validHeight && validWidth + } + + return rawY > context.getStatusBarHeight() && rawX < screenWidthWithOrientation + } + + private fun handleGesture(view: View, event: MotionEvent): Boolean { + val currentTouch = Vector2(event.x, event.y) + val startTouch = currentTouchStart + + /** Two-finger zoom/pan (fullscreen, unlocked) */ + if ((event.pointerCount >= 2 || lastPan != null) && isFullScreen && !isLocked + && !hasTriggeredSpeedUp && currentTouchAction == null) { + holdHandler.removeCallbacks(holdRunnable) // Remove 2x speed. + isCurrentTouchValid = false // Prevent other touches + return handleZoomPanGesture( + event = event, + ctx = view.context, + onFirstPointerDown = { + uiShowingBeforeGesture = playerView.callbacks?.isUIShowing() ?: false + playerView.callbacks?.onHidePlayerUI() + }, + onGestureEnd = { + currentTouchStart = null + currentLastTouchAction = null + currentTouchAction = null + currentTouchStartPlayerTime = null + currentTouchLast = null + currentTouchStartTime = null + } + ) + } + + when (event.actionMasked) { + MotionEvent.ACTION_DOWN -> { + isCurrentTouchValid = isValidTouch(event.rawX, event.rawY) + if (isCurrentTouchValid) { + playerView.callbacks?.onTouchDown() + hasTriggeredSpeedUp = false + if (speedupEnabled && playerView.player.getIsPlaying() && !isLocked) { + holdHandler.postDelayed(holdRunnable, 500) + } + isVolumeLocked = currentRequestedVolume < 1.0f + if (currentRequestedVolume <= 1.0f) hasShownVolumeToast = false + isBrightnessLocked = currentRequestedBrightness < 1.0f + if (currentRequestedBrightness <= 1.0f) hasShownBrightnessToast = false + currentTouchStartTime = System.currentTimeMillis() + currentTouchStart = currentTouch + currentTouchLast = currentTouch + currentTouchStartPlayerTime = playerView.player.getPosition() + getBrightness()?.let { currentRequestedBrightness = it + currentExtraBrightness } + verifyVolume() + } + return true + } + + MotionEvent.ACTION_MOVE -> { + if (hasTriggeredSpeedUp) return true + if (!isCurrentTouchValid) return true + + if (currentTouchAction == null && startTouch != null) { + val diffFromStart = startTouch - currentTouch + if (swipeVerticalEnabled) { + if (abs(diffFromStart.y * 100 / screenHeightWithOrientation) > MINIMUM_VERTICAL_SWIPE) { + holdHandler.removeCallbacks(holdRunnable) + uiShowingBeforeGesture = playerView.callbacks?.isUIShowing() ?: false + playerView.callbacks?.onHidePlayerUI() + currentTouchAction = if ((startTouch.x) >= view.width / 2f) + TouchAction.Volume else TouchAction.Brightness + } + } + if (swipeHorizontalEnabled && !isLocked) { + if (abs(diffFromStart.x * 100 / screenHeightWithOrientation) > MINIMUM_HORIZONTAL_SWIPE) { + holdHandler.removeCallbacks(holdRunnable) + currentTouchAction = TouchAction.Time + } + } + } + + val lastTouch = currentTouchLast + if (lastTouch != null) { + val diffFromLast = lastTouch - currentTouch + val verticalAddition = diffFromLast.y * VERTICAL_MULTIPLIER / view.height.toFloat() + when (currentTouchAction) { + TouchAction.Time -> { + // This simply updates UI as the seek logic happens on release + // startTime is rounded to make the UI sync in a nice way. + val startTime = currentTouchStartPlayerTime?.div(1000L)?.times(1000L) + if (startTime != null) { + calculateNewTime(startTime, startTouch, currentTouch)?.let { newMs -> + val skipMs = newMs - startTime + playerView.callbacks?.onSeekPreviewText( + "${convertTimeToString(newMs / 1000)} [${ + if (abs(skipMs) < 1000) "" else if (skipMs > 0) "+" else "-" + }${convertTimeToString(abs(skipMs / 1000))}]" + ) + } + } + } + TouchAction.Brightness -> if (!isLocked) handleBrightnessAdjustment(verticalAddition) + TouchAction.Volume -> if (!isLocked) handleVolumeAdjustment(verticalAddition, false) + null -> Unit + } + if (currentTouchAction != TouchAction.Time) { + playerView.callbacks?.onSeekPreviewText(null) + } + } + currentTouchLast = currentTouch + return true + } + + MotionEvent.ACTION_CANCEL, MotionEvent.ACTION_UP -> { + holdHandler.removeCallbacks(holdRunnable) + if (hasTriggeredSpeedUp) { + playerView.player.setPlaybackSpeed(DataStoreHelper.playBackSpeed) + showOrHideSpeedUp(false) + playerView.callbacks?.onHoldSpeedUp(false) + hasTriggeredSpeedUp = false + } + + if (isCurrentTouchValid) { + // Horizontal seek on release + if (swipeHorizontalEnabled && currentTouchAction == TouchAction.Time && !isLocked) { + val startTime = currentTouchStartPlayerTime + if (startTime != null) { + calculateNewTime(startTime, startTouch, currentTouch)?.let { seekTo -> + if (abs(seekTo - startTime) > MINIMUM_SEEK_TIME) { + playerView.player.seekTo(seekTo, PlayerEventSource.UI) + } + } + } + } + // Tap detection: only fire if the finger was held briefly (not a long-press). + val holdTime = currentTouchStartTime?.let { System.currentTimeMillis() - it } + if (currentTouchAction == null && currentLastTouchAction == null + && !hasTriggeredSpeedUp + && (holdTime == null || holdTime < DOUBLE_TAP_MAXIMUM_HOLD_TIME)) { + onTapDetected( + x = currentTouch.x, + viewWidth = view.width, + isLocked = isLocked, + onSingleTap = { playerView.callbacks?.onSingleTap() } + ) + } + } + + playerView.callbacks?.onSeekPreviewText(null) + val hadSwipe = currentTouchAction != null || currentLastTouchAction != null + playerView.callbacks?.onGestureEnd(hadSwipe, uiShowingBeforeGesture) + + // Reset touch + lastTouchEndTime = System.currentTimeMillis() + isCurrentTouchValid = false + currentTouchStart = null + currentLastTouchAction = currentTouchAction + currentTouchAction = null + currentTouchStartPlayerTime = null + currentTouchLast = null + currentTouchStartTime = null + uiShowingBeforeGesture = false + return true + } + } + return false + } +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerPipHelper.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerPipHelper.kt index 3431558088e..0db06499efe 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerPipHelper.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerPipHelper.kt @@ -128,7 +128,7 @@ object PlayerPipHelper { getRemoteAction( activity, R.drawable.baseline_headphones_24, - R.string.audio_singluar, + R.string.audio_singular, CSPlayerEvent.PlayAsAudio ) ) 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 new file mode 100644 index 00000000000..0e6f1a3677d --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerView.kt @@ -0,0 +1,842 @@ +package com.lagradost.cloudstream3.ui.player + +import android.annotation.SuppressLint +import android.app.Activity +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.content.pm.ActivityInfo +import android.graphics.drawable.AnimatedImageDrawable +import android.graphics.drawable.AnimatedVectorDrawable +import android.media.metrics.PlaybackErrorEvent +import android.os.Build +import android.os.Handler +import android.os.Looper +import android.text.format.DateUtils +import android.util.AttributeSet +import android.util.Log +import android.view.View +import android.view.WindowManager +import android.view.WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES +import android.widget.FrameLayout +import android.widget.ImageView +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 +import androidx.core.view.isVisible +import androidx.core.widget.doOnTextChanged +import androidx.fragment.app.FragmentActivity +import androidx.media3.common.PlaybackException +import androidx.media3.common.util.UnstableApi +import androidx.media3.exoplayer.ExoPlayer +import androidx.media3.session.MediaSession +import androidx.media3.ui.AspectRatioFrameLayout +import androidx.media3.ui.DefaultTimeBar +import androidx.media3.ui.SubtitleView +import androidx.media3.ui.TimeBar +import androidx.preference.PreferenceManager +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.screenWidth +import com.lagradost.cloudstream3.CommonActivity.showToast +import com.lagradost.cloudstream3.ErrorLoadingException +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.mvvm.logError +import com.lagradost.cloudstream3.mvvm.safe +import com.lagradost.cloudstream3.ui.player.live.LivePreviewTimeBar +import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR +import com.lagradost.cloudstream3.ui.settings.Globals.PHONE +import com.lagradost.cloudstream3.ui.settings.Globals.TV +import com.lagradost.cloudstream3.ui.settings.Globals.isLayout +import com.lagradost.cloudstream3.ui.subtitles.SaveCaptionStyle +import com.lagradost.cloudstream3.ui.subtitles.SubtitlesFragment +import com.lagradost.cloudstream3.utils.AppContextUtils +import com.lagradost.cloudstream3.utils.AppContextUtils.requestLocalAudioFocus +import com.lagradost.cloudstream3.utils.DataStoreHelper +import com.lagradost.cloudstream3.utils.UIHelper.hideKeyboard +import com.lagradost.cloudstream3.utils.UIHelper.hideSystemUI +import com.lagradost.cloudstream3.utils.UIHelper.popCurrentPage +import com.lagradost.cloudstream3.utils.UIHelper.showSystemUI +import com.lagradost.cloudstream3.utils.UserPreferenceDelegate +import com.lagradost.cloudstream3.utils.videoskip.VideoSkipStamp +import java.net.SocketTimeoutException + +/** + * Shared player view - manages ExoPlayer setup, view binding, lifecycle, and event + * dispatching. Gesture/volume/brightness/key-event input is handled by [gestureHelper] + * ([PlayerGestureHelper]), which is exposed via delegate properties for easier access. + */ +@OptIn(UnstableApi::class) +class PlayerView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null +) : FrameLayout(context, attrs) { + + companion object { + private const val TAG = "PlayerView" + } + + /** All gesture, volume, brightness and key-event logic lives here. */ + val gestureHelper = PlayerGestureHelper(this) + + /** Delegate properties (forwarded to gestureHelper for external callers to have easier access) */ + var isFullScreen: Boolean + get() = gestureHelper.isFullScreen + set(value) { gestureHelper.isFullScreen = value } + + var isLocked: Boolean + get() = gestureHelper.isLocked + set(value) { gestureHelper.isLocked = value } + + var videoOutline: View? + get() = gestureHelper.videoOutline + set(value) { gestureHelper.videoOutline = value } + + /** Delegate methods */ + fun handleVolumeKey(keyCode: Int) = gestureHelper.handleVolumeKey(keyCode) + fun verifyVolume() = gestureHelper.verifyVolume() + fun setupKeyEventListener() = gestureHelper.setupKeyEventListener() + fun releaseKeyEventListener() = gestureHelper.releaseKeyEventListener() + fun requestUpdateBrightnessOverlayOnNextLayout() = gestureHelper.requestUpdateBrightnessOverlayOnNextLayout() + fun releaseOverlayLayoutListener() = gestureHelper.releaseOverlayLayoutListener() + + /** Callbacks */ + + /** Host-fragment-level callbacks invoked by [mainCallback]. */ + interface Callbacks { + fun nextEpisode() {} + fun prevEpisode() {} + fun playerPositionChanged(position: Long, duration: Long) {} + fun playerStatusChanged() {} + fun playerDimensionsLoaded(width: Int, height: Int) {} + fun subtitlesChanged() {} + fun embeddedSubtitlesFetched(subtitles: List) {} + fun onTracksInfoChanged() {} + fun onTimestamp(timestamp: VideoSkipStamp?) {} + fun onTimestampSkipped(timestamp: VideoSkipStamp) {} + fun exitedPipMode() {} + fun hasNextMirror(): Boolean = false + fun nextMirror() {} + fun onDownload(event: DownloadEvent) {} + fun playerError(exception: Throwable) {} + /** Called after [PlayerView] finishes its own player-attached setup (MediaSession, ExoPlayer view). */ + fun playerUpdated(player: Any?) {} + /** Called on a short single-tap on empty player area (no swipe, no double-tap). */ + fun onSingleTap() {} + /** Called when the hold-for-speedup gesture starts (show=true) or ends (show=false). */ + fun onHoldSpeedUp(show: Boolean) {} + /** Called during brightness swipe with the current extra-brightness alpha (0–1). */ + fun onBrightnessExtra(alpha: Float) {} + + /** Touch event callbacks */ + + /** Returns whether the player UI (controls overlay) is currently visible. */ + fun isUIShowing(): Boolean = false + /** Called on a valid ACTION_DOWN; use for e.g. dismissing an episode overlay. */ + fun onTouchDown() {} + /** Called with seek-preview text during a horizontal-swipe, or null to clear it. */ + fun onSeekPreviewText(text: String?) {} + /** Called when a swipe gesture begins; hide the player UI if desired. */ + fun onHidePlayerUI() {} + /** + * Called at the end of each touch sequence. + * @param hadSwipe true if a swipe (brightness/volume/time) was in progress. + * @param wasUiShowing true if the UI was visible when the swipe began. + */ + fun onGestureEnd(hadSwipe: Boolean, wasUiShowing: Boolean) {} + /** + * Called when the auto-hide timer fires: UI is showing, no touch is active. + * Implement to hide the player controls. + */ + fun onAutoHideUI() {} + } + + var callbacks: Callbacks? = null + + /** Player state */ + + var player: IPlayer = CS3IPlayer() + var resizeMode: Int = 0 + var hasPipModeSupport: Boolean = true + var currentPlayerStatus: CSPlayerLoading = CSPlayerLoading.IsBuffering + var mMediaSession: MediaSession? = null + private var pipReceiver: BroadcastReceiver? = null + + /** Auto-hide */ + private var autoHideToken = 0 + private val autoHideHandler = Handler(Looper.getMainLooper()) + + /** View references (populated by bindViews) */ + + var subView: SubtitleView? = null + var playerPausePlayHolderHolder: FrameLayout? = null + var playerPausePlay: ImageView? = null + var playerBuffering: ProgressBar? = null + /** The Media3/ExoPlayer [androidx.media3.ui.PlayerView] widget. */ + var exoPlayerView: androidx.media3.ui.PlayerView? = null + var piphide: FrameLayout? = null + var subtitleHolder: FrameLayout? = null + internal var playerRew: View? = null + internal var playerFfwd: View? = null + internal var exoRewText: TextView? = null + internal var exoFfwdText: TextView? = null + internal var playerCenterMenu: View? = null + internal var playerRewHolder: View? = null + internal var playerFfwdHolder: View? = null + internal var playerVideoHolder: View? = null + var playerProgressbarLeftHolder: RelativeLayout? = null + var playerProgressbarLeftIcon: ImageView? = null + var playerProgressbarLeftLevel1: ProgressBar? = null + var playerProgressbarLeftLevel2: ProgressBar? = null + var playerProgressbarRightHolder: RelativeLayout? = null + var playerProgressbarRightIcon: ImageView? = null + var playerProgressbarRightLevel1: ProgressBar? = null + var playerProgressbarRightLevel2: ProgressBar? = null + /** Accessed by [PlayerGestureHelper.showOrHideSpeedUp]. */ + internal var playerSpeedupButton: View? = null + var playerHolder: FrameLayout? = null + private var exoDuration: TextView? = null + private var timeLeft: TextView? = null + private var exoPosition: TextView? = null + private var timeLive: View? = null + private var exoProgress: LivePreviewTimeBar? = null + + /** Seek delta used by the basic rew/ffwd click listeners. Read from settings in [initialize]. */ + var seekTime: Long = 10_000L + + /** True when the current video is taller than it is wide. Set by [mainCallback] on [ResizedEvent]. */ + var isVerticalOrientation: Boolean = false + + /** When true, [dynamicOrientation] returns portrait for portrait videos. Read from settings in [initialize]. */ + var autoPlayerRotateEnabled: Boolean = false + + var durationMode: Boolean by UserPreferenceDelegate("duration_mode", false) + + // Kept so SubtitlesFragment can unsubscribe the exact same reference. + private val subStyleListener: (SaveCaptionStyle) -> Unit = ::onSubStyleChanged + + /** View discovery */ + + /** + * Discovers player-related views from [root]. IDs absent in compact layouts (e.g. trailer) simply + * remain null, all usage is null-safe. + */ + fun bindViews(root: View) { + exoDuration = root.findViewById(androidx.media3.ui.R.id.exo_duration) + exoFfwdText = root.findViewById(R.id.exo_ffwd_text) + exoPlayerView = root.findViewById(R.id.player_view) + exoPosition = root.findViewById(R.id.exo_position) + exoRewText = root.findViewById(R.id.exo_rew_text) + piphide = root.findViewById(R.id.piphide) + playerBuffering = root.findViewById(R.id.player_buffering) + playerCenterMenu = root.findViewById(R.id.player_center_menu) + playerFfwd = root.findViewById(R.id.player_ffwd) + playerFfwdHolder = root.findViewById(R.id.player_ffwd_holder) + playerHolder = root.findViewById(R.id.player_holder) + playerPausePlay = root.findViewById(R.id.player_pause_play) + playerPausePlayHolderHolder = root.findViewById(R.id.player_pause_play_holder_holder) + playerProgressbarLeftHolder = root.findViewById(R.id.player_progressbar_left_holder) + playerProgressbarLeftIcon = root.findViewById(R.id.player_progressbar_left_icon) + playerProgressbarLeftLevel1 = root.findViewById(R.id.player_progressbar_left_level1) + playerProgressbarLeftLevel2 = root.findViewById(R.id.player_progressbar_left_level2) + playerProgressbarRightHolder = root.findViewById(R.id.player_progressbar_right_holder) + playerProgressbarRightIcon = root.findViewById(R.id.player_progressbar_right_icon) + playerProgressbarRightLevel1 = root.findViewById(R.id.player_progressbar_right_level1) + playerProgressbarRightLevel2 = root.findViewById(R.id.player_progressbar_right_level2) + playerRew = root.findViewById(R.id.player_rew) + playerRewHolder = root.findViewById(R.id.player_rew_holder) + playerSpeedupButton = root.findViewById(R.id.player_speedup_button) + playerVideoHolder = root.findViewById(R.id.player_video_holder) + subtitleHolder = root.findViewById(R.id.subtitle_holder) + timeLeft = root.findViewById(R.id.time_left) + timeLive = root.findViewById(R.id.time_live) + } + + /** + * Called once after [bindViews]. Sets up the preview seek-bar, subtitle style listener, + * player callbacks and basic controls; then delegates gesture/input setup to [gestureHelper]. + */ + fun initialize() { + resizeMode = DataStoreHelper.resizeMode + resize(resizeMode, false) + + player.releaseCallbacks() + player.initCallbacks( + eventHandler = ::mainCallback, + requestedListeningPercentages = listOf( + SKIP_OP_VIDEO_PERCENTAGE, + PRELOAD_NEXT_EPISODE_PERCENTAGE, + NEXT_WATCH_EPISODE_PERCENTAGE, + UPDATE_SYNC_PROGRESS_PERCENTAGE, + ), + ) + + if (player is CS3IPlayer) { + // Preview bar + val progressBar: PreviewTimeBar? = exoPlayerView?.findViewById(R.id.exo_progress) + exoProgress = progressBar as? LivePreviewTimeBar + val previewImageView: ImageView? = exoPlayerView?.findViewById(R.id.previewImageView) + val previewFrameLayout: FrameLayout? = + exoPlayerView?.findViewById(R.id.previewFrameLayout) + + /** 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?) { + val cs3 = player as? CS3IPlayer ?: return + val hasPreview = cs3.hasPreview() + progressBar.isPreviewEnabled = hasPreview + resume = cs3.getIsPlaying() + if (resume) cs3.handleEvent(CSPlayerEvent.Pause, PlayerEventSource.Player) + // No clashing UI + if (hasPreview) subView?.isVisible = false + } + + override fun onScrubMove(previewBar: PreviewBar?, progress: Int, fromUser: Boolean) {} + + override fun onScrubStop(previewBar: PreviewBar?) { + val cs3 = player as? CS3IPlayer ?: return + if (resume) cs3.handleEvent(CSPlayerEvent.Play, PlayerEventSource.Player) + // Delay to prevent the small flicker of subtitle before seeking. + subView?.postDelayed({ + // If we are not scrubbing then show subtitles again. + if (previewBar == null || !previewBar.isPreviewEnabled || !previewBar.isShowingPreview) { + subView?.isVisible = true + } + }, 200) + } + }) + progressBar.attachPreviewView(previewFrameLayout) + progressBar.setPreviewLoader { currentPosition, max -> + val cs3 = player as? CS3IPlayer ?: return@setPreviewLoader + val bitmap = cs3.getPreview(currentPosition.toFloat().div(max.toFloat())) + previewImageView.isGone = bitmap == null + previewImageView.setImageBitmap(bitmap) + } + } + + subView = exoPlayerView?.findViewById(androidx.media3.ui.R.id.exo_subtitles) + (player as? CS3IPlayer)?.initSubtitles(subView, subtitleHolder, CustomDecoder.style) + (player as? CS3IPlayer)?.let { + (it.imageGenerator as? PreviewGenerator)?.params = + ImageParams.new16by9(screenWidth) + } + + /** + * This might seam a bit fucky and that is because it is, the seek event is captured twice, once by the player + * and once by the UI even if it should only be registered once by the UI. + */ + exoPlayerView?.findViewById(R.id.exo_progress) + ?.addListener(object : TimeBar.OnScrubListener { + override fun onScrubStart(timeBar: TimeBar, position: Long) = Unit + override fun onScrubMove(timeBar: TimeBar, position: Long) = Unit + override fun onScrubStop(timeBar: TimeBar, position: Long, canceled: Boolean) { + if (canceled) return + val playerDuration = player.getDuration() ?: return + val playerPosition = player.getPosition() ?: return + mainCallback( + PositionEvent( + source = PlayerEventSource.UI, + durationMs = playerDuration, + fromMs = playerPosition, + toMs = position + ) + ) + } + }) + + // Read seek time and rotation settings. + try { + val sm = PreferenceManager.getDefaultSharedPreferences(context) + seekTime = sm.getInt(context.getString(R.string.double_tap_seek_time_key), 10) + .toLong() * 1000L + autoPlayerRotateEnabled = sm.getBoolean( + context.getString(R.string.auto_rotate_video_key), true + ) + } catch (_: Exception) { + } + + val seekSecs = (seekTime / 1000).toInt() + exoRewText?.text = context.getString(R.string.rew_text_regular_format).format(seekSecs) + exoFfwdText?.text = context.getString(R.string.ffw_text_regular_format).format(seekSecs) + + playerPausePlay?.setOnClickListener { + scheduleAutoHide() + if (currentPlayerStatus == CSPlayerLoading.IsEnded && isLayout(PHONE)) { + player.handleEvent(CSPlayerEvent.Restart, PlayerEventSource.UI) + } else { + player.handleEvent(CSPlayerEvent.PlayPauseToggle, PlayerEventSource.UI) + } + } + playerRew?.setOnClickListener { + scheduleAutoHide() + gestureHelper.rewind() + } + playerFfwd?.setOnClickListener { + scheduleAutoHide() + gestureHelper.fastForward() + } + + SubtitlesFragment.applyStyleEvent += subStyleListener + + try { + val ctx = context + val settingsManager = PreferenceManager.getDefaultSharedPreferences(ctx) + val cs3 = player as? CS3IPlayer ?: return + cs3.cacheSize = + settingsManager.getInt(context.getString(R.string.video_buffer_size_key), 0) * 1024L * 1024L + cs3.simpleCacheSize = + settingsManager.getInt(context.getString(R.string.video_buffer_disk_key), 0) * 1024L * 1024L + cs3.videoBufferMs = + settingsManager.getInt(context.getString(R.string.video_buffer_length_key), 0) * 1000L + } catch (e: Exception) { + logError(e) + } + + // Duration toggle click listeners + exoDuration?.setOnClickListener { setRemainingTimeCounter(true) } + timeLeft?.setOnClickListener { setRemainingTimeCounter(false) } + // Keep remaining-time text in sync with playback position + exoPosition?.doOnTextChanged { _, _, _, _ -> updateRemainingTime() } + + // Delegate gesture/input setup (settings, brightness overlay, touch gestures, key listener) + gestureHelper.initialize() + setupKeyEventListener() + + // Apply duration-mode display (remaining time vs elapsed); TV always shows remaining + setRemainingTimeCounter(durationMode || isLayout(TV)) + } + } + + /** Lifecycle delegation */ + + var fullscreenNotch: Boolean = true // TODO SETTING + + fun enterFullscreen(updateOrientation: () -> Unit = {}) { + val activity = context as? Activity + if (isFullScreen) { + activity?.hideSystemUI() + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P && fullscreenNotch) { + val params = activity?.window?.attributes + params?.layoutInDisplayCutoutMode = LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES + activity?.window?.attributes = params + } + } + updateOrientation() + } + + fun exitFullscreen() { + val activity = context as? Activity + gestureHelper.resetZoomToDefault() + activity?.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_USER + // Simply resets brightness and notch settings that might have been overridden. + val lp = activity?.window?.attributes + lp?.screenBrightness = WindowManager.LayoutParams.BRIGHTNESS_OVERRIDE_NONE + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + lp?.layoutInDisplayCutoutMode = WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_DEFAULT + } + activity?.window?.attributes = lp + activity?.showSystemUI() + } + + fun onStop() { + player.onStop() + } + + fun onResume(ctx: Context) { + player.onResume(ctx) + } + + /** Releases all player resources. */ + fun release() { + player.release() + player.releaseCallbacks() + player = CS3IPlayer() + + // keyEventListener is deregistered in onPause so that the incoming player's + // onResume can register its own listener without racing against release(). + + PlayerPipHelper.updatePIPModeActions( + context as? Activity, + CSPlayerLoading.IsPaused, + false, + null + ) + + mMediaSession?.release() + mMediaSession = null + exoPlayerView?.player = null + + SubtitlesFragment.applyStyleEvent -= subStyleListener + + gestureHelper.release() + autoHideHandler.removeCallbacksAndMessages(null) + + keepScreenOn(false) + } + + fun onPictureInPictureModeChanged( + isInPictureInPictureMode: Boolean, + activity: Activity? + ) { + try { + isInPIPMode = isInPictureInPictureMode + if (isInPictureInPictureMode) { + // Hide the full-screen UI (controls, etc.) while in picture-in-picture mode. + piphide?.isVisible = false + pipReceiver = object : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + if (ACTION_MEDIA_CONTROL != intent.action) return + player.handleEvent( + CSPlayerEvent.entries[intent.getIntExtra(EXTRA_CONTROL_TYPE, 0)], + source = PlayerEventSource.UI + ) + } + } + val filter = IntentFilter().apply { addAction(ACTION_MEDIA_CONTROL) } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + activity?.registerReceiver(pipReceiver, filter, Context.RECEIVER_EXPORTED) + } else { + @SuppressLint("UnspecifiedRegisterReceiverFlag") + activity?.registerReceiver(pipReceiver, filter) + } + val isPlaying = player.getIsPlaying() + val status = if (isPlaying) CSPlayerLoading.IsPlaying else CSPlayerLoading.IsPaused + updateIsPlaying(status, status) + } else { + // Restore the full-screen UI. + piphide?.isVisible = true + callbacks?.exitedPipMode() + pipReceiver?.let { + // Prevents java.lang.IllegalArgumentException: Receiver not registered + safe { activity?.unregisterReceiver(it) } + } + activity?.hideSystemUI() + hideKeyboard(this) + } + } catch (e: Exception) { + logError(e) + } + } + + /** Player UI helpers */ + + private fun keepScreenOn(on: Boolean) { + val window = (context as? Activity)?.window ?: return + if (on) window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + else window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + } + + fun updateIsPlaying(wasPlaying: CSPlayerLoading, isPlaying: CSPlayerLoading) { + val isPlayingRightNow = CSPlayerLoading.IsPlaying == isPlaying + val isBuffering = CSPlayerLoading.IsBuffering == isPlaying + currentPlayerStatus = isPlaying + + keepScreenOn(isPlayingRightNow || isBuffering) + + if (isBuffering) { + playerPausePlayHolderHolder?.isVisible = false + playerBuffering?.isVisible = true + } else { + playerPausePlayHolderHolder?.isVisible = true + playerBuffering?.isVisible = false + + if (isPlaying == CSPlayerLoading.IsEnded && isLayout(PHONE)) { + playerPausePlay?.setImageResource(R.drawable.ic_baseline_replay_24) + } else if (wasPlaying != isPlaying) { + playerPausePlay?.setImageResource( + if (isPlayingRightNow) R.drawable.play_to_pause else R.drawable.pause_to_play + ) + val drawable = playerPausePlay?.drawable + var startedAnimation = false + if (Build.VERSION.SDK_INT > Build.VERSION_CODES.P) { + if (drawable is AnimatedImageDrawable) { drawable.start(); startedAnimation = true } + } + if (drawable is AnimatedVectorDrawable) { drawable.start(); startedAnimation = true } + if (drawable is AnimatedVectorDrawableCompat) { drawable.start(); startedAnimation = true } + // Somehow the phone is wacked + if (!startedAnimation) { + playerPausePlay?.setImageResource( + if (isPlayingRightNow) R.drawable.netflix_pause else R.drawable.netflix_play + ) + } + } else { + playerPausePlay?.setImageResource( + if (isPlayingRightNow) R.drawable.netflix_pause else R.drawable.netflix_play + ) + } + } + + PlayerPipHelper.updatePIPModeActions( + context as? Activity, + isPlaying, + hasPipModeSupport, + player.getAspectRatio() + ) + } + + private fun requestAudioFocus() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + (context as? Activity)?.requestLocalAudioFocus(AppContextUtils.getFocusRequest()) + } + } + + private fun playerUpdated(player: Any?) { + if (player is ExoPlayer) { + mMediaSession?.release() + mMediaSession = MediaSession.Builder(context, player) + // Ensure unique ID for concurrent players. + .setId(System.currentTimeMillis().toString()) + .build() + + // Necessary for multiple combined videos. + @Suppress("DEPRECATION") + exoPlayerView?.setShowMultiWindowTimeBar(true) + exoPlayerView?.player = player + exoPlayerView?.performClick() + } + callbacks?.playerUpdated(player) + } + + private fun onSubStyleChanged(style: SaveCaptionStyle) { + player.updateSubtitleStyle(style) + // Forcefully update the subtitle encoding in case the edge size is changed. + player.seekTime(-1) + } + + /** Error handling */ + + @MainThread + fun playerError(exception: Throwable) { + fun showErrorToast(message: String) { + if (callbacks?.hasNextMirror() == true) { + showToast(message, Toast.LENGTH_SHORT) + callbacks?.nextMirror() + } else { + showToast( + context.getString(R.string.no_links_found_toast) + "\n" + message, + Toast.LENGTH_LONG + ) + (context as? FragmentActivity)?.popCurrentPage() + } + } + + when (exception) { + is PlaybackException -> { + val msg = exception.message ?: "" + val errorName = exception.errorCodeName + when (val code = exception.errorCode) { + PlaybackException.ERROR_CODE_IO_FILE_NOT_FOUND, + PlaybackException.ERROR_CODE_IO_NO_PERMISSION, + 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") + + PlaybackException.ERROR_CODE_REMOTE_ERROR, + PlaybackException.ERROR_CODE_IO_BAD_HTTP_STATUS, + PlaybackException.ERROR_CODE_TIMEOUT, + 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") + + PlaybackErrorEvent.ERROR_AUDIO_TRACK_INIT_FAILED, + PlaybackErrorEvent.ERROR_AUDIO_TRACK_OTHER, + PlaybackException.ERROR_CODE_DECODING_FAILED, + 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") + + 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") + + PlaybackException.ERROR_CODE_PARSING_CONTAINER_MALFORMED, + PlaybackException.ERROR_CODE_PARSING_MANIFEST_MALFORMED -> + 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") + } + } + + is SocketTimeoutException -> + showErrorToast("${context.getString(R.string.remote_error)}\n${exception.message}") + + is ErrorLoadingException -> + exception.message?.let { showErrorToast(it) } + ?: showErrorToast(exception.toString()) + + else -> + exception.message?.let { showErrorToast(it) } + ?: showErrorToast(exception.toString()) + } + } + + /** Resize */ + + fun nextResize() { + resizeMode = (resizeMode + 1) % PlayerResize.entries.size + resize(resizeMode, true) + } + + fun resize(resize: Int, showToast: Boolean) { + // Clear all zoom state before applying the new resize mode + gestureHelper.clearZoomState() + resize(PlayerResize.entries[resize], showToast) + } + + fun resize(resize: PlayerResize, showToast: Boolean) { + DataStoreHelper.resizeMode = resize.ordinal + val type = when (resize) { + PlayerResize.Fill -> AspectRatioFrameLayout.RESIZE_MODE_FILL + PlayerResize.Fit -> AspectRatioFrameLayout.RESIZE_MODE_FIT + PlayerResize.Zoom -> AspectRatioFrameLayout.RESIZE_MODE_ZOOM + } + exoPlayerView?.resizeMode = type + if (showToast) showToast(resize.nameRes, Toast.LENGTH_SHORT) + } + + /** Orientation */ + + /** + * Returns the desired [ActivityInfo] orientation constant based on [isVerticalOrientation] + * and [autoPlayerRotateEnabled]. TV/emulator always returns sensor-landscape. + * Host fragments call this from [Callbacks.playerDimensionsLoaded] to apply rotation. + */ + fun dynamicOrientation(): Int { + 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 + } + + /** Event dispatch */ + + /** + * This receives the events from the player, if you want to append functionality + * you do it here, do note that this only receives events for UI changes, + * and returning early 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") + when (event) { + is DownloadEvent -> callbacks?.onDownload(event) + is ResizedEvent -> { + // Skip 0x0 dimensions that the player emits when going to STATE_IDLE + // to avoid incorrectly resetting the auto-detected orientation. + if (event.width > 0 && event.height > 0) { + // TV never rotates; otherwise track whether the video is portrait. + isVerticalOrientation = !isLayout(TV or EMULATOR) && event.height > event.width + } + callbacks?.playerDimensionsLoaded(event.width, event.height) + } + is PlayerAttachedEvent -> playerUpdated(event.player) + is SubtitlesUpdatedEvent -> callbacks?.subtitlesChanged() + is TimestampSkippedEvent -> callbacks?.onTimestampSkipped(event.timestamp) + is TimestampInvokedEvent -> callbacks?.onTimestamp(event.timestamp) + is TracksChangedEvent -> callbacks?.onTracksInfoChanged() + is EmbeddedSubtitlesFetchedEvent -> callbacks?.embeddedSubtitlesFetched(event.tracks) + is ErrorEvent -> callbacks?.playerError(event.error) ?: playerError(event.error) + is RequestAudioFocusEvent -> requestAudioFocus() + is EpisodeSeekEvent -> when (event.offset) { + -1 -> callbacks?.prevEpisode() + 1 -> callbacks?.nextEpisode() + } + is StatusEvent -> { + updateIsPlaying(wasPlaying = event.wasPlaying, isPlaying = event.isPlaying) + scheduleAutoHide() + callbacks?.playerStatusChanged() + } + is PositionEvent -> callbacks?.playerPositionChanged( + position = event.toMs, + duration = event.durationMs + ) + is VideoEndedEvent -> { + // Only play next episode if autoplay is on (default). + val ctx = context + if (PreferenceManager.getDefaultSharedPreferences(ctx) + ?.getBoolean(ctx.getString(R.string.autoplay_next_key), true) == true + ) { + player.handleEvent(CSPlayerEvent.NextEpisode, source = PlayerEventSource.Player) + } + } + is PauseEvent -> Unit + is PlayEvent -> Unit + } + } + + /** Duration display */ + + fun setRemainingTimeCounter(showRemaining: Boolean) { + durationMode = showRemaining + exoDuration?.isInvisible = showRemaining + timeLeft?.isVisible = showRemaining + if (showRemaining) updateRemainingTime() + } + + fun updateRemainingTime() { + val duration = player.getDuration() + val position = player.getPosition() + + if (exoProgress?.isAtLiveEdge() == true) { + timeLeft?.alpha = 0f + exoDuration?.alpha = 0f + timeLive?.isVisible = true + } else { + timeLeft?.alpha = 1f + exoDuration?.alpha = 1f + timeLive?.isVisible = false + } + + if (duration != null && duration > 1 && position != null) { + val remainingTimeSeconds = (duration - position + 500) / 1000 + @SuppressLint("SetTextI18n") + timeLeft?.text = "-${DateUtils.formatElapsedTime(remainingTimeSeconds)}" + } + } + + /** Auto-hide */ + + /** + * Schedules a delayed auto-hide of the player UI after [delayMs] ms. + * Any previously pending hide is canceled first. + * The hide fires only when no touch is active and [Callbacks.isUIShowing] is true; + * the actual hide action is delegated to [Callbacks.onAutoHideUI]. + */ + fun scheduleAutoHide(delayMs: Long = 3000L) { + val token = ++autoHideToken + autoHideHandler.removeCallbacksAndMessages(null) + autoHideHandler.postDelayed({ + if (token != autoHideToken) return@postDelayed + if (gestureHelper.isCurrentTouchValid) return@postDelayed + if (callbacks?.isUIShowing() != true) return@postDelayed + callbacks?.onAutoHideUI() + }, delayMs) + } + + /** Cancels any pending auto-hide scheduled by [scheduleAutoHide]. */ + fun cancelAutoHide() { + autoHideToken++ + autoHideHandler.removeCallbacksAndMessages(null) + } +} 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 0dddf58a1d7..0668a194bc3 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/player/live/LiveHelper.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/live/LiveHelper.kt new file mode 100644 index 00000000000..52cd4361bab --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/live/LiveHelper.kt @@ -0,0 +1,77 @@ +package com.lagradost.cloudstream3.ui.player.live + +import androidx.annotation.OptIn +import androidx.media3.common.Player +import androidx.media3.common.Timeline +import androidx.media3.common.util.UnstableApi +import com.lagradost.cloudstream3.mvvm.debugWarning +import java.util.WeakHashMap + +object LiveHelper { + private val liveManagers = WeakHashMap>() + + @OptIn(UnstableApi::class) + fun registerPlayer(player: Player?) { + if (player == null) { + debugWarning { "LiveHelper registerPlayer called with null player!" } + return + } + + // Prevent duplicates + if (liveManagers.contains(player)) { + return + } + + val liveManager = LiveManager(player) + val listener = object : Player.Listener { + override fun onTimelineChanged(timeline: Timeline, reason: Int) { + val window = Timeline.Window() + timeline.getWindow(player.currentMediaItemIndex, window) + if (window.isDynamic) { + liveManager.submitLivestreamChunk(LivestreamChunk(window.durationMs)) + } + super.onTimelineChanged(timeline, reason) + } + + override fun onPositionDiscontinuity( + oldPosition: Player.PositionInfo, + newPosition: Player.PositionInfo, + reason: Int + ) { + super.onPositionDiscontinuity(oldPosition, newPosition, reason) + val timeAheadOfLive = liveManager.getTimeAheadOfLive(newPosition.positionMs) + + // Seek back to the optimal live spot + if (timeAheadOfLive > 100) { + player.seekTo(newPosition.positionMs - timeAheadOfLive) + } + } + } + + synchronized(liveManagers) { + player.addListener(listener) + liveManagers[player] = liveManager to listener + } + } + + fun unregisterPlayer(player: Player?) { + if (player == null) { + debugWarning { "LiveHelper unregisterPlayer called with null player!" } + return + } + + // Prevent duplicates + if (!liveManagers.contains(player)) { + return + } + + synchronized(liveManagers) { + liveManagers[player]?.let { (_, listener) -> + player.removeListener(listener) + } + liveManagers.remove(player) + } + } + + fun getLiveManager(player: Player?) = liveManagers[player]?.first +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/live/LiveManager.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/live/LiveManager.kt new file mode 100644 index 00000000000..8d848d46aa9 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/live/LiveManager.kt @@ -0,0 +1,97 @@ +package com.lagradost.cloudstream3.ui.player.live + +import androidx.media3.common.C +import androidx.media3.common.Player +import java.lang.ref.WeakReference + +// How much margin from the live point is still considered "live" +const val LIVE_MARGIN = 6_000L + +// How many ms should we be behind the real live point? +// Too low, and we cannot pre-buffer +// Too high, and we are no longer live +const val PREFERRED_LIVE_OFFSET = 5_000L + +// An extra offset from the optimal calculated timestamp +// This is to account for chunk updates not always being the same size +const val CHUNK_VARIANCE = 3000L + +// A livestream chunk from the player, the time we get it and the duration can be used to calculate +// the expected live timestamp. +class LivestreamChunk( + durationMs: Long, val receiveTimeMs: Long = System.currentTimeMillis() +) { + // We want to be PREFERRED_LIVE_OFFSET ms after the latest update, but we cannot be ahead of the middle point. + // If we are ahead of the middle point we will reach the end before the new chunk is expected to be released. + val targetPosition = maxOf(0,minOf( + durationMs - PREFERRED_LIVE_OFFSET, + durationMs / 2 - CHUNK_VARIANCE + )) + + fun isPositionLive(position: Long): Boolean { + val currentTime = System.currentTimeMillis() + val livePosition = targetPosition + (currentTime - receiveTimeMs) + val withinLive = position + LIVE_MARGIN > livePosition - PREFERRED_LIVE_OFFSET + // println("Position: $position, livePosition: ${livePosition}, Behind live: ${livePosition-position}, within live: $withinLive") + return withinLive + } + + fun getTimeAheadOfLive(position: Long): Long { + val currentTime = System.currentTimeMillis() + val livePosition = targetPosition + (currentTime - receiveTimeMs) + // println("Ahead of live: ${position-livePosition}") + return position - livePosition + } +} + +// There are two types of livestreams we need to manage +// 1. A livestream with no history, a continually sliding window. +// This livestream has no currentLiveOffset, which means we need to calculate +// the real live point based on when we receive the latest update and the size of that update. +// 2. A livestream with history. +// This livestream has a currentLiveOffset and therefore requires no calculation to get the live point. +// currentLiveOffset can however be inaccurate, and we need to be able to fall back to manual calculations. +class LiveManager { + private var _currentPlayer: WeakReference? = null + val currentPlayer: Player? get() = _currentPlayer?.get() + + constructor(player: Player?) { + _currentPlayer = WeakReference(player) + } + + private var lastLivestreamChunk: LivestreamChunk? = null + + fun submitLivestreamChunk(chunk: LivestreamChunk) { + lastLivestreamChunk = chunk + } + + /** Returns how much a position is ahead of the calculated live window. Returns 0 if not ahead of live window */ + fun getTimeAheadOfLive(position: Long): Long { + val player = currentPlayer ?: return 0 + if (!player.isCurrentMediaItemDynamic || player.duration == C.TIME_UNSET) return 0 + + // If the currentLiveOffset is wrong we fall back to manual calculations + val ahead = if (player.currentLiveOffset != C.TIME_UNSET && player.currentLiveOffset < player.duration) { + val relativeOffset = player.currentLiveOffset - player.currentPosition + position + PREFERRED_LIVE_OFFSET - relativeOffset + } else { + lastLivestreamChunk?.getTimeAheadOfLive(position) ?: 0 + } + + // Ensure min of 0 + return maxOf(0, ahead) + } + + /** Check if the stream is currently at the expected live edge, with margins */ + fun isAtLiveEdge(): Boolean { + val player = currentPlayer ?: return false + if (!player.isCurrentMediaItemDynamic || player.duration == C.TIME_UNSET) return false + + // If the currentLiveOffset is wrong we fall back to manual calculations + return if (player.currentLiveOffset != C.TIME_UNSET && player.currentLiveOffset < player.duration) { + player.currentLiveOffset < LIVE_MARGIN + PREFERRED_LIVE_OFFSET + } else { + lastLivestreamChunk?.isPositionLive(player.currentPosition) == true + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/live/LivePreviewTimeBar.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/live/LivePreviewTimeBar.kt new file mode 100644 index 00000000000..3001281fd45 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/live/LivePreviewTimeBar.kt @@ -0,0 +1,38 @@ +package com.lagradost.cloudstream3.ui.player.live + +import android.content.Context +import android.util.AttributeSet +import androidx.annotation.OptIn +import androidx.media3.common.Player +import androidx.media3.common.util.UnstableApi +import androidx.media3.ui.PlayerControlView +import androidx.media3.ui.PlayerView +import androidx.media3.ui.R +import com.github.rubensousa.previewseekbar.media3.PreviewTimeBar +import java.lang.ref.WeakReference + + +@OptIn(UnstableApi::class) +class LivePreviewTimeBar(val ctx: Context, attrs: AttributeSet) : PreviewTimeBar(ctx, attrs) { + + private var _currentPlayerView: WeakReference? = null + val currentPlayer: Player? get() = _currentPlayerView?.get()?.player + + fun registerPlayerView(player: PlayerView?) { + _currentPlayerView = WeakReference(player) + val controller = + _currentPlayerView?.get()?.findViewById(R.id.exo_controller) + + controller?.setProgressUpdateListener { position, bufferedPosition -> + currentPlayer?.let { player -> + if (isAtLiveEdge()) { + setPosition(player.duration) + } + } + } + } + + fun isAtLiveEdge(): Boolean { + return LiveHelper.getLiveManager(currentPlayer)?.isAtLiveEdge() == true + } +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultFragmentPhone.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultFragmentPhone.kt index c9da385f63b..38b24b26517 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultFragmentPhone.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultFragmentPhone.kt @@ -4,12 +4,11 @@ import android.annotation.SuppressLint import android.app.Dialog import android.content.Intent import android.content.res.ColorStateList -import android.content.res.Configuration import android.graphics.Rect +import android.net.Uri import android.os.Build import android.os.Bundle import android.text.Editable -import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.view.animation.AlphaAnimation @@ -25,6 +24,7 @@ import androidx.core.view.isVisible import androidx.core.widget.NestedScrollView import androidx.core.widget.doOnTextChanged import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope import com.discord.panels.OverlappingPanelsLayout import com.discord.panels.PanelState import com.discord.panels.PanelsChildGestureRegionObserver @@ -45,19 +45,24 @@ import com.lagradost.cloudstream3.databinding.FragmentResultBinding import com.lagradost.cloudstream3.databinding.FragmentResultSwipeBinding import com.lagradost.cloudstream3.databinding.ResultRecommendationsBinding import com.lagradost.cloudstream3.databinding.ResultSyncBinding +import com.lagradost.cloudstream3.databinding.TrailerCustomLayoutBinding import com.lagradost.cloudstream3.mvvm.Resource +import com.lagradost.cloudstream3.mvvm.launchSafe import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.mvvm.observe import com.lagradost.cloudstream3.mvvm.observeNullable import com.lagradost.cloudstream3.mvvm.safe import com.lagradost.cloudstream3.services.SubscriptionWorkManager import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.APP_STRING_SHARE +import com.lagradost.cloudstream3.ui.BaseFragment import com.lagradost.cloudstream3.ui.WatchType import com.lagradost.cloudstream3.ui.download.DOWNLOAD_ACTION_DOWNLOAD import com.lagradost.cloudstream3.ui.download.DOWNLOAD_ACTION_LONG_CLICK import com.lagradost.cloudstream3.ui.download.DownloadButtonSetup +import com.lagradost.cloudstream3.ui.player.CS3IPlayer import com.lagradost.cloudstream3.ui.player.CSPlayerEvent -import com.lagradost.cloudstream3.ui.player.FullScreenPlayer +import com.lagradost.cloudstream3.ui.player.IPlayer +import com.lagradost.cloudstream3.ui.player.PlayerView import com.lagradost.cloudstream3.ui.player.source_priority.QualityProfileDialog import com.lagradost.cloudstream3.ui.quicksearch.QuickSearchFragment import com.lagradost.cloudstream3.ui.result.ResultFragment.bindLogo @@ -66,6 +71,8 @@ import com.lagradost.cloudstream3.ui.result.ResultFragment.updateUIEvent import com.lagradost.cloudstream3.ui.search.SearchAdapter import com.lagradost.cloudstream3.ui.search.SearchHelper import com.lagradost.cloudstream3.ui.setRecycledViewPool +import com.lagradost.cloudstream3.ui.settings.SettingsGeneral.Companion.pickDownloadPath +import com.lagradost.cloudstream3.ui.settings.utils.getChooseFolderLauncher import com.lagradost.cloudstream3.utils.AppContextUtils.getNameFull import com.lagradost.cloudstream3.utils.AppContextUtils.isCastApiAvailable import com.lagradost.cloudstream3.utils.AppContextUtils.loadCache @@ -90,6 +97,7 @@ import com.lagradost.cloudstream3.utils.UIHelper.populateChips import com.lagradost.cloudstream3.utils.UIHelper.popupMenuNoIconsAndNoStringRes import com.lagradost.cloudstream3.utils.UIHelper.setListViewHeightBasedOnItems import com.lagradost.cloudstream3.utils.UIHelper.setNavigationBarColorCompat +import com.lagradost.cloudstream3.utils.downloader.DownloadFileManagement.getBasePath import com.lagradost.cloudstream3.utils.downloader.DownloadObjects import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager import com.lagradost.cloudstream3.utils.getImageFromDrawable @@ -97,9 +105,12 @@ import com.lagradost.cloudstream3.utils.setText import com.lagradost.cloudstream3.utils.setTextHtml import com.lagradost.cloudstream3.utils.txt import java.net.URLEncoder +import java.util.concurrent.ConcurrentLinkedDeque import kotlin.math.roundToInt -open class ResultFragmentPhone : FullScreenPlayer() { +open class ResultFragmentPhone : BaseFragment( + BindingCreator.Inflate(FragmentResultSwipeBinding::inflate) +), PlayerView.Callbacks { private val gestureRegionsListener = object : PanelsChildGestureRegionObserver.GestureRegionsListener { override fun onGestureRegionsUpdate(gestureRegions: List) { @@ -107,34 +118,105 @@ open class ResultFragmentPhone : FullScreenPlayer() { } } + /** Queue of pending actions that is deferred to after a custom path is set */ + private val pendingPathActions = ConcurrentLinkedDeque>() + + /** + * Appends all actions to a queue, and asks for a user to enter the download folder if not already set up. + * + * Then processes the queue in the given order, only after the user has selected a folder. + * This is to defer the download to after a file path is set, due to perms. + * */ + private fun requirePathForActions(list: Collection>) { + pendingPathActions.addAll(list) + val (_, path) = context?.getBasePath() ?: return + if (path == null) { + /** If we have not set any download path, then ask the user for it before we download it */ + try { + /** Give the user some info of what we are doing and why, even if it may be missed */ + showToast(R.string.download_path_pref) + pathPicker.launch(Uri.EMPTY) + } catch (t: Throwable) { + logError(t) + /** Something went wrong, TV Device? + * Use the fallback behavior of just downloading it even if no path is selected, + * and hope it works */ + processPendingActions() + } + } else { + /** + * Otherwise dispatch everything, as we already have a valid download path + * Even if this is "wrong", we do not care as the user has entered something + * */ + processPendingActions() + } + } + + /** Clear all the items in the queue and dispatch them to the viewmodel in order */ + private fun processPendingActions() = viewModel.viewModelScope.launchSafe { + while (!pendingPathActions.isEmpty()) { + try { + val (action, data) = pendingPathActions.pop() + viewModel.handleAction( + EpisodeClickEvent( + action, + data + ) + ) + } catch (_: NoSuchElementException) { + /** In case of a race */ + } + } + } + + private val pathPicker = getChooseFolderLauncher { uri, path -> + if (uri == null) { + /** No path selected, clear the list without acting on it, canceling */ + if (!pendingPathActions.isEmpty()) { + /** Only show on non-empty, just in case */ + showToast(R.string.download_canceled) + pendingPathActions.clear() + } + } else { + /** Select the folder, and dispatch everything */ + pickDownloadPath(uri, path) + processPendingActions() + } + } + protected lateinit var viewModel: ResultViewModel2 protected lateinit var syncModel: SyncViewModel - protected var binding: FragmentResultSwipeBinding? = null protected var resultBinding: FragmentResultBinding? = null protected var recommendationBinding: ResultRecommendationsBinding? = null protected var syncBinding: ResultSyncBinding? = null - override var layout = R.layout.fragment_result_swipe + var player: IPlayer = CS3IPlayer() + protected open var hasPipModeSupport: Boolean = false + protected open var isFullScreenPlayer: Boolean = true + protected open var lockRotation: Boolean = true + protected var playerBinding: TrailerCustomLayoutBinding? = null + protected var isShowing: Boolean = false - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View? { - viewModel = ViewModelProvider(this)[ResultViewModel2::class.java] - syncModel = ViewModelProvider(this)[SyncViewModel::class.java] - updateUIEvent += ::updateUI + protected var playerHostView: PlayerView? = null - val root = super.onCreateView(inflater, container, savedInstanceState) ?: return null - FragmentResultSwipeBinding.bind(root).let { bind -> - resultBinding = bind.fragmentResult - recommendationBinding = bind.resultRecommendations - syncBinding = bind.resultSync - binding = bind - } + open fun updateUIVisibility() {} + + protected fun uiReset() { + isShowing = false + updateUIVisibility() + } + + open fun showMirrorsDialogue() {} + open fun showTracksDialogue() {} + open fun openOnlineSubPicker( + context: android.content.Context, + loadResponse: LoadResponse?, + dismissCallback: () -> Unit + ) {} - return root + override fun fixLayout(view: View) { + fixSystemBarsPadding(view) } override fun onCreate(savedInstanceState: Bundle?) { @@ -158,7 +240,7 @@ open class ResultFragmentPhone : FullScreenPlayer() { override fun playerError(exception: Throwable) { if (player.getIsPlaying()) { // because we don't want random toasts in player - super.playerError(exception) + playerHostView?.playerError(exception) } else { nextMirror() } @@ -258,7 +340,8 @@ open class ResultFragmentPhone : FullScreenPlayer() { } updateUIEvent -= ::updateUI - binding = null + playerHostView?.release() + playerBinding = null resultBinding?.resultScroll?.setOnClickListener(null) resultBinding = null syncBinding = null @@ -282,7 +365,6 @@ open class ResultFragmentPhone : FullScreenPlayer() { var selectSeason: String? = null var selectEpisodeRange: String? = null - var selectSort: EpisodeSortType? = null private fun setUrl(url: String?) { if (url == null) { @@ -325,6 +407,10 @@ open class ResultFragmentPhone : FullScreenPlayer() { override fun onResume() { afterPluginsLoadedEvent += ::reloadViewModel activity?.setNavigationBarColorCompat(R.attr.primaryBlackBackground) + context?.let { ctx -> + playerHostView?.onResume(ctx) + playerHostView?.setupKeyEventListener() + } super.onResume() PanelsChildGestureRegionObserver.Provider.get() .addGestureRegionsUpdateListener(gestureRegionsListener) @@ -332,30 +418,44 @@ open class ResultFragmentPhone : FullScreenPlayer() { override fun onStop() { afterPluginsLoadedEvent -= ::reloadViewModel + playerHostView?.onStop() super.onStop() } + @Suppress("UNUSED_PARAMETER") private fun updateUI(id: Int?) { syncModel.updateUserData() viewModel.reloadEpisodes() } - override fun onConfigurationChanged(newConfig: Configuration) { - super.onConfigurationChanged(newConfig) - view?.let { fixSystemBarsPadding(it) } - } + override fun onBindingCreated(binding: FragmentResultSwipeBinding, savedInstanceState: Bundle?) { + // Set up sub-binding references + viewModel = ViewModelProvider(this)[ResultViewModel2::class.java] + syncModel = ViewModelProvider(this)[SyncViewModel::class.java] + updateUIEvent += ::updateUI - @SuppressLint("SetTextI18n") - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) + resultBinding = binding.fragmentResult + recommendationBinding = binding.resultRecommendations + syncBinding = binding.resultSync + + // Set up trailer player + val ctx = context ?: return + playerHostView = PlayerView(ctx) + playerHostView?.player = player + playerHostView?.hasPipModeSupport = hasPipModeSupport + playerHostView?.callbacks = this + playerHostView?.bindViews(binding.root) + playerBinding = binding.root.findViewById(R.id.player_holder)?.let { + TrailerCustomLayoutBinding.bind(it) + } + playerHostView?.initialize() // ===== setup ===== - fixSystemBarsPadding(view) val storedData = getStoredData() ?: return activity?.window?.decorView?.clearFocus() activity?.loadCache() context?.updateHasTrailers() - hideKeyboard() + hideKeyboard(binding.root) if (storedData.restart || !viewModel.hasLoaded()) viewModel.load( activity, @@ -373,7 +473,7 @@ open class ResultFragmentPhone : FullScreenPlayer() { // This may not be 100% reliable, and may delay for small period // before resultCastItems will be scrollable again, but this does work // most of the time. - binding?.resultOverlappingPanels?.registerEndPanelStateListeners( + binding.resultOverlappingPanels.registerEndPanelStateListeners( object : OverlappingPanelsLayout.PanelStateListener { override fun onPanelStateChange(panelState: PanelState) { PanelsChildGestureRegionObserver.Provider.get().apply { @@ -385,8 +485,8 @@ open class ResultFragmentPhone : FullScreenPlayer() { // ===== ===== ===== - binding?.resultSearch?.isGone = storedData.name.isBlank() - binding?.resultSearch?.setOnClickListener { + binding.resultSearch.isGone = storedData.name.isBlank() + binding.resultSearch.setOnClickListener { QuickSearchFragment.pushSearch(activity, storedData.name) } @@ -415,7 +515,7 @@ open class ResultFragmentPhone : FullScreenPlayer() { focused: View? ): Boolean { // Make the cast always focus the first visible item when focused - // from somewhere else. Otherwise it jumps to the last item. + // from somewhere else. Otherwise, it jumps to the last item. return if (parent.focusedChild == null) { scrollToPosition(this.findFirstCompletelyVisibleItemPosition()) true @@ -433,7 +533,13 @@ open class ResultFragmentPhone : FullScreenPlayer() { EpisodeAdapter( api?.hasDownloadSupport == true, { episodeClick -> - viewModel.handleAction(episodeClick) + when (episodeClick.action) { + ACTION_DOWNLOAD_EPISODE, ACTION_DOWNLOAD_MIRROR -> { + requirePathForActions(listOf(episodeClick.action to episodeClick.data)) + } + + else -> viewModel.handleAction(episodeClick) + } }, { downloadClickEvent -> DownloadButtonSetup.handleDownloadClick(downloadClickEvent) @@ -468,9 +574,9 @@ open class ResultFragmentPhone : FullScreenPlayer() { resultScroll.setOnScrollChangeListener(NestedScrollView.OnScrollChangeListener { _, _, scrollY, _, oldScrollY -> val dy = scrollY - oldScrollY if (dy > 0) { //check for scroll down - binding?.resultBookmarkFab?.shrink() + binding.resultBookmarkFab.shrink() } else if (dy < -5) { - binding?.resultBookmarkFab?.extend() + binding.resultBookmarkFab.extend() } if (!isFullScreenPlayer && player.getIsPlaying()) { if (scrollY > (resultBinding?.fragmentTrailer?.playerBackground?.height @@ -482,7 +588,7 @@ open class ResultFragmentPhone : FullScreenPlayer() { }) } - binding?.apply { + binding.apply { resultOverlappingPanels.setStartPanelLockState(OverlappingPanelsLayout.LockState.CLOSE) resultOverlappingPanels.setEndPanelLockState(OverlappingPanelsLayout.LockState.CLOSE) resultBack.setOnClickListener { @@ -675,7 +781,7 @@ open class ResultFragmentPhone : FullScreenPlayer() { } observeNullable(viewModel.subscribeStatus) { isSubscribed -> - binding?.resultSubscribe?.isVisible = isSubscribed != null + binding.resultSubscribe.isVisible = isSubscribed != null if (isSubscribed == null) return@observeNullable val drawable = if (isSubscribed) { @@ -684,11 +790,11 @@ open class ResultFragmentPhone : FullScreenPlayer() { R.drawable.baseline_notifications_none_24 } - binding?.resultSubscribe?.setImageResource(drawable) + binding.resultSubscribe.setImageResource(drawable) } observeNullable(viewModel.favoriteStatus) { isFavorite -> - binding?.resultFavorite?.isVisible = isFavorite != null + binding.resultFavorite.isVisible = isFavorite != null if (isFavorite == null) return@observeNullable val drawable = if (isFavorite) { @@ -697,7 +803,7 @@ open class ResultFragmentPhone : FullScreenPlayer() { R.drawable.ic_baseline_favorite_border_24 } - binding?.resultFavorite?.setImageResource(drawable) + binding.resultFavorite.setImageResource(drawable) } observeNullable(viewModel.episodes) { episodes -> @@ -753,30 +859,12 @@ open class ResultFragmentPhone : FullScreenPlayer() { .setTitle(R.string.download_all) .setMessage(rangeMessage) .setPositiveButton(R.string.yes) { _, _ -> - ioSafe { - episodes.value.forEach { episode -> - viewModel.handleAction( - EpisodeClickEvent( - ACTION_DOWNLOAD_EPISODE, - episode - ) - ) - // Join to make the episodes ordered - .join() - } - } + requirePathForActions(episodes.value.map { ACTION_DOWNLOAD_EPISODE to it }) } - .setNegativeButton(R.string.cancel) { _, _ -> - - }.show() - + .setNegativeButton(R.string.cancel) { _, _ -> }.show() } - } - - } - } observeNullable(viewModel.movie) { data -> @@ -825,18 +913,11 @@ open class ResultFragmentPhone : FullScreenPlayer() { when (click.action) { DOWNLOAD_ACTION_DOWNLOAD -> { - viewModel.handleAction( - EpisodeClickEvent(ACTION_DOWNLOAD_EPISODE, ep) - ) + requirePathForActions(listOf(ACTION_DOWNLOAD_EPISODE to ep)) } DOWNLOAD_ACTION_LONG_CLICK -> { - viewModel.handleAction( - EpisodeClickEvent( - ACTION_DOWNLOAD_MIRROR, - ep - ) - ) + requirePathForActions(listOf(ACTION_DOWNLOAD_MIRROR to ep)) } else -> DownloadButtonSetup.handleDownloadClick(click) @@ -932,7 +1013,7 @@ open class ResultFragmentPhone : FullScreenPlayer() { syncModel.addFromUrl(d.url) } - binding?.apply { + binding.apply { resultSearch.isGone = d.title.isBlank() resultSearch.setOnClickListener { QuickSearchFragment.pushSearch(activity, d.title) @@ -967,10 +1048,11 @@ open class ResultFragmentPhone : FullScreenPlayer() { } (data as? Resource.Failure)?.let { data -> + @SuppressLint("SetTextI18n") resultErrorText.text = storedData.url.plus("\n") + data.errorString } - binding?.resultBookmarkFab?.isVisible = data is Resource.Success + binding.resultBookmarkFab.isVisible = data is Resource.Success resultFinishLoading.isVisible = data is Resource.Success resultLoading.isVisible = data is Resource.Loading @@ -1018,7 +1100,7 @@ open class ResultFragmentPhone : FullScreenPlayer() { } observe(viewModel.trailers) { trailers -> - setTrailers(trailers.flatMap { it.mirros }) // I dont care about subtitles yet! + setTrailers(trailers.flatMap { it.mirros }) // I don't care about subtitles yet! } observe(syncModel.synced) { list -> @@ -1027,8 +1109,7 @@ open class ResultFragmentPhone : FullScreenPlayer() { val newList = list.filter { it.isSynced && it.hasAccount } - binding?.resultMiniSync?.isVisible = newList.isNotEmpty() - //(binding?.resultMiniSync?.adapter as? ImageAdapter)?.submitList(newList.mapNotNull { it.icon }) + binding.resultMiniSync.isVisible = newList.isNotEmpty() } @@ -1123,7 +1204,7 @@ open class ResultFragmentPhone : FullScreenPlayer() { } } } - binding?.resultOverlappingPanels?.setStartPanelLockState(if (closed) OverlappingPanelsLayout.LockState.CLOSE else OverlappingPanelsLayout.LockState.UNLOCKED) + binding.resultOverlappingPanels.setStartPanelLockState(if (closed) OverlappingPanelsLayout.LockState.CLOSE else OverlappingPanelsLayout.LockState.UNLOCKED) } observe(viewModel.recommendations) { recommendations -> setRecommendations(recommendations, null) @@ -1184,7 +1265,7 @@ open class ResultFragmentPhone : FullScreenPlayer() { } observe(viewModel.watchStatus) { watchType -> - binding?.resultBookmarkFab?.apply { + binding.resultBookmarkFab.apply { setText(watchType.stringRes) if (watchType == WatchType.NONE) { context?.colorFromAttribute(R.attr.white) @@ -1239,6 +1320,7 @@ open class ResultFragmentPhone : FullScreenPlayer() { viewModel.skipLoading() } isVisible = true + @SuppressLint("SetTextI18n") text = "${context.getString(R.string.skip_loading)} (${load.linksLoaded})" } } @@ -1359,6 +1441,7 @@ open class ResultFragmentPhone : FullScreenPlayer() { } override fun onPause() { + playerHostView?.releaseKeyEventListener() super.onPause() PanelsChildGestureRegionObserver.Provider.get() .addGestureRegionsUpdateListener(gestureRegionsListener) 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 70ca117432d..cfbacc5d13f 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/ResultTrailerPlayer.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultTrailerPlayer.kt index 969fa6d95c3..3b1471e6ab2 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 @@ -5,41 +5,74 @@ import android.content.Context import android.content.res.Configuration import android.os.Build import android.os.Bundle -import android.view.View import android.view.ViewGroup import android.widget.FrameLayout +import androidx.core.view.ViewCompat import androidx.core.view.isGone import androidx.core.view.isVisible -import androidx.core.view.ViewCompat import com.lagradost.cloudstream3.CommonActivity.screenHeight import com.lagradost.cloudstream3.CommonActivity.screenWidth import com.lagradost.cloudstream3.LoadResponse import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.databinding.FragmentResultSwipeBinding import com.lagradost.cloudstream3.ui.player.CSPlayerEvent +import com.lagradost.cloudstream3.ui.player.CSPlayerLoading import com.lagradost.cloudstream3.ui.player.PlayerEventSource import com.lagradost.cloudstream3.ui.player.SubtitleData import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.attachBackPressedCallback import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.detachBackPressedCallback import com.lagradost.cloudstream3.utils.UIHelper.fixSystemBarsPadding -open class ResultTrailerPlayer : ResultFragmentPhone() { +class ResultTrailerPlayer : ResultFragmentPhone() { override var lockRotation = false override var isFullScreenPlayer = false override var hasPipModeSupport = false companion object { - const val TAG = "RESULT_TRAILER" + const val TAG = "ResultTrailerPlayer" } private var playerWidthHeight: Pair? = null + private var introVisible = true - override fun nextEpisode() {} + // Single-tap on empty player area: toggle controls. + override fun onSingleTap() { + if (introVisible) return + if (isShowing) uiReset() else showControls() + } - override fun prevEpisode() {} + private fun showControls() { + if (introVisible) return + isShowing = true + updateUIVisibility() + playerHostView?.scheduleAutoHide() + } - override fun playerPositionChanged(position: Long, duration : Long) {} + override fun isUIShowing(): Boolean = isShowing + + override fun onAutoHideUI() { + if (player.getIsPlaying()) uiReset() + } + + 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) {} override fun nextMirror() {} override fun onConfigurationChanged(newConfig: Configuration) { @@ -49,33 +82,28 @@ open class ResultTrailerPlayer : ResultFragmentPhone() { } private fun fixPlayerSize() { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - binding?.apply { - if (isFullScreenPlayer) { - // Remove listener + binding?.apply { + if (isFullScreenPlayer) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { ViewCompat.setOnApplyWindowInsetsListener(root, null) - root.overlay.clear() // Clear the cutout overlay - root.setPadding(0, 0, 0, 0) // Reset padding for full screen - } else { - // Reapply padding when not in full screen - fixSystemBarsPadding(root) + root.overlay.clear() + } + root.setPadding(0, 0, 0, 0) + } else { + fixSystemBarsPadding(root) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { ViewCompat.requestApplyInsets(root) } } } playerWidthHeight?.let { (w, h) -> - if(w <= 0 || h <= 0) return@let + if (w <= 0 || h <= 0) return@let val orientation = context?.resources?.configuration?.orientation ?: return - val sw = if (orientation == Configuration.ORIENTATION_LANDSCAPE) { - screenWidth - } else { - screenHeight - } + val sw = if (orientation == Configuration.ORIENTATION_LANDSCAPE) screenWidth else screenHeight - //result_trailer_loading?.isVisible = false resultBinding?.resultSmallscreenHolder?.isVisible = !isFullScreenPlayer binding?.resultFullscreenHolder?.isVisible = isFullScreenPlayer @@ -83,35 +111,30 @@ open class ResultTrailerPlayer : ResultFragmentPhone() { resultBinding?.fragmentTrailer?.playerBackground?.apply { isVisible = true - layoutParams = - FrameLayout.LayoutParams( - FrameLayout.LayoutParams.MATCH_PARENT, - if (isFullScreenPlayer) FrameLayout.LayoutParams.MATCH_PARENT else to - ) + layoutParams = FrameLayout.LayoutParams( + FrameLayout.LayoutParams.MATCH_PARENT, + if (isFullScreenPlayer) FrameLayout.LayoutParams.MATCH_PARENT else to + ) } playerBinding?.playerIntroPlay?.apply { - layoutParams = - FrameLayout.LayoutParams( - FrameLayout.LayoutParams.MATCH_PARENT, - resultBinding?.resultTopHolder?.measuredHeight - ?: FrameLayout.LayoutParams.MATCH_PARENT - ) + layoutParams = FrameLayout.LayoutParams( + FrameLayout.LayoutParams.MATCH_PARENT, + resultBinding?.resultTopHolder?.measuredHeight ?: FrameLayout.LayoutParams.MATCH_PARENT + ) } if (playerBinding?.playerIntroPlay?.isGone == true) { resultBinding?.resultTopHolder?.apply { - val anim = ValueAnimator.ofInt( measuredHeight, if (isFullScreenPlayer) ViewGroup.LayoutParams.MATCH_PARENT else to ) - anim.addUpdateListener { valueAnimator -> - val `val` = valueAnimator.animatedValue as Int - val layoutParams: ViewGroup.LayoutParams = - layoutParams - layoutParams.height = `val` - setLayoutParams(layoutParams) + anim.addUpdateListener { va -> + val v = va.animatedValue as Int + val lp: ViewGroup.LayoutParams = layoutParams + lp.height = v + layoutParams = lp } anim.duration = 200 anim.start() @@ -120,9 +143,14 @@ open class ResultTrailerPlayer : ResultFragmentPhone() { } } - override fun playerDimensionsLoaded(width: Int, height : Int) { + override fun playerDimensionsLoaded(width: Int, height: Int) { playerWidthHeight = width to height fixPlayerSize() + // Apply autorotation when fullscreen (lockRotation = true). + // PlayerView already set isVerticalOrientation before this callback fires. + if (lockRotation) { + activity?.requestedOrientation = playerHostView?.dynamicOrientation() ?: return + } } override fun showMirrorsDialogue() {} @@ -132,33 +160,39 @@ open class ResultTrailerPlayer : ResultFragmentPhone() { context: Context, loadResponse: LoadResponse?, dismissCallback: () -> Unit - ) { - } + ) {} override fun subtitlesChanged() {} - override fun embeddedSubtitlesFetched(subtitles: List) {} override fun onTracksInfoChanged() {} - override fun exitedPipMode() {} + + override fun onSeekPreviewText(text: String?) { + playerBinding?.playerTimeText?.apply { + isVisible = text != null + if (text != null) this.text = text + } + } + private fun updateFullscreen(fullscreen: Boolean) { isFullScreenPlayer = fullscreen lockRotation = fullscreen + playerHostView?.isFullScreen = fullscreen - playerBinding?.playerFullscreen?.setImageResource(if (fullscreen) R.drawable.baseline_fullscreen_exit_24 else R.drawable.baseline_fullscreen_24) + playerBinding?.playerFullscreen?.setImageResource( + if (fullscreen) R.drawable.baseline_fullscreen_exit_24 else R.drawable.baseline_fullscreen_24 + ) if (fullscreen) { - enterFullscreen() + playerHostView?.enterFullscreen() binding?.apply { resultTopBar.isVisible = false resultFullscreenHolder.isVisible = true resultMainHolder.isVisible = false } - resultBinding?.fragmentTrailer?.playerBackground?.let { view -> (view.parent as ViewGroup?)?.removeView(view) binding?.resultFullscreenHolder?.addView(view) } - } else { binding?.apply { resultTopBar.isVisible = true @@ -169,36 +203,55 @@ open class ResultTrailerPlayer : ResultFragmentPhone() { resultBinding?.resultSmallscreenHolder?.addView(view) } } - exitFullscreen() + playerHostView?.exitFullscreen() } fixPlayerSize() uiReset() if (isFullScreenPlayer) { - activity?.attachBackPressedCallback("ResultTrailerPlayer") { - updateFullscreen(false) - } - } else activity?.detachBackPressedCallback("ResultTrailerPlayer") + activity?.attachBackPressedCallback("ResultTrailerPlayer") { updateFullscreen(false) } + } else { + activity?.detachBackPressedCallback("ResultTrailerPlayer") + } } override fun updateUIVisibility() { super.updateUIVisibility() - playerBinding?.playerGoBackHolder?.isVisible = false + playerBinding?.apply { + playerGoBackHolder.isVisible = false + val controlsVisible = isShowing && !introVisible + playerTopHolder.isVisible = controlsVisible + playerVideoHolder.isVisible = controlsVisible + shadowOverlay.isVisible = controlsVisible + playerPausePlayHolderHolder.isVisible = + controlsVisible && playerHostView?.currentPlayerStatus != CSPlayerLoading.IsBuffering + } + // Fade center controls in/out; also resets stale fillAfter alpha from seek animations. + playerHostView?.gestureHelper?.animateCenterControls(if (isShowing && !introVisible) 1f else 0f) } - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - playerBinding?.playerFullscreen?.setOnClickListener { - updateFullscreen(!isFullScreenPlayer) + override fun playerStatusChanged() { + if (introVisible) { + playerBinding?.playerPausePlayHolderHolder?.isVisible = false } + } + + override fun onBindingCreated(binding: FragmentResultSwipeBinding, savedInstanceState: Bundle?) { + super.onBindingCreated(binding, savedInstanceState) + + playerHostView?.videoOutline = playerBinding?.videoOutline + playerHostView?.requestUpdateBrightnessOverlayOnNextLayout() + + playerBinding?.playerFullscreen?.setOnClickListener { updateFullscreen(!isFullScreenPlayer) } updateFullscreen(isFullScreenPlayer) uiReset() playerBinding?.playerIntroPlay?.setOnClickListener { playerBinding?.playerIntroPlay?.isGone = true + introVisible = false player.handleEvent(CSPlayerEvent.Play, PlayerEventSource.UI) - updateUIVisibility() fixPlayerSize() + showControls() } } -} \ No newline at end of file +} 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 6eab987fc6e..7dfe3cf5988 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 @@ -293,11 +318,12 @@ fun LoadResponse.toResultData(repo: APIRepository): ResultData { TvType.Live -> R.string.live_singular TvType.Others -> R.string.other_singular TvType.NSFW -> R.string.nsfw_singular - TvType.Music -> R.string.music_singlar + TvType.Music -> R.string.music_singular TvType.AudioBook -> R.string.audio_book_singular - TvType.CustomMedia -> R.string.custom_media_singluar - TvType.Audio -> R.string.audio_singluar - TvType.Podcast -> R.string.podcast_singluar + TvType.CustomMedia -> R.string.custom_media_singular + TvType.Audio -> R.string.audio_singular + TvType.Podcast -> R.string.podcast_singular + TvType.Video -> R.string.video_singular } ), yearText = txt(year?.toString()), @@ -422,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(), ) @@ -452,8 +478,8 @@ class ResultViewModel2 : ViewModel() { private var currentShowFillers: Boolean = false var currentRepo: APIRepository? = null private var currentId: Int? = null - private var fillers: Map = emptyMap() - private var generator: IGenerator? = null + private var fillers: HashSet = hashSetOf() + private var generator: RepoLinkGenerator? = null private var preferDubStatus: DubStatus? = null private var preferStartEpisode: Int? = null private var preferStartSeason: Int? = null @@ -1266,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) @@ -1517,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 ) ) } @@ -1806,11 +1831,10 @@ class ResultViewModel2 : ViewModel() { } - private suspend fun updateFillers(name: String) { - fillers = - ioWorkSafe { - FillerEpisodeCheck.getFillerEpisodes(name) - } ?: emptyMap() + private suspend fun updateFillers(data: LoadResponse) { + fillers = ioWorkSafe { + FillerEpisodeCheck.getFillerEpisodes(data) + } ?: hashSetOf() } fun changeDubStatus(status: DubStatus) { @@ -2147,8 +2171,8 @@ class ResultViewModel2 : ViewModel() { ) { _episodes.postValue(Resource.Loading()) - if (updateFillers && loadResponse is AnimeLoadResponse) { - updateFillers(loadResponse.name) + if (updateFillers) { + updateFillers(loadResponse) } val allEpisodes = when (loadResponse) { @@ -2189,7 +2213,7 @@ class ResultViewModel2 : ViewModel() { index, i.score, i.description, - fillers.getOrDefault(episode, false), + fillers.contains(episode), loadResponse.type, mainId, totalIndex, @@ -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/ui/settings/SettingsAccount.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsAccount.kt index 7c24cd7a9a9..8d96a6b140e 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsAccount.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsAccount.kt @@ -28,6 +28,7 @@ import com.lagradost.cloudstream3.databinding.AddAccountInputBinding import com.lagradost.cloudstream3.databinding.DeviceAuthBinding import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.aniListApi +import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.animeSkipApi import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.malApi import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.kitsuApi import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.openSubtitlesApi @@ -36,6 +37,7 @@ import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.subDlAp import com.lagradost.cloudstream3.syncproviders.AuthLoginResponse import com.lagradost.cloudstream3.syncproviders.AuthRepo import com.lagradost.cloudstream3.syncproviders.AuthUser +import com.lagradost.cloudstream3.syncproviders.PlainAuthRepo import com.lagradost.cloudstream3.syncproviders.SubtitleRepo import com.lagradost.cloudstream3.syncproviders.SyncRepo import com.lagradost.cloudstream3.ui.BasePreferenceFragmentCompat @@ -468,6 +470,7 @@ class SettingsAccount : BasePreferenceFragmentCompat(), BiometricCallback { R.string.simkl_key to SyncRepo(simklApi), R.string.opensubtitles_key to SubtitleRepo(openSubtitlesApi), R.string.subdl_key to SubtitleRepo(subDlApi), + R.string.animeskip_key to PlainAuthRepo(animeSkipApi), ) for ((key, api) in syncApis) { diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsGeneral.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsGeneral.kt index b13987f2827..dbf2ff1dc53 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsGeneral.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsGeneral.kt @@ -8,6 +8,7 @@ import android.widget.Toast import androidx.appcompat.app.AlertDialog import androidx.core.content.edit import androidx.core.os.ConfigurationCompat +import androidx.fragment.app.Fragment import androidx.preference.PreferenceManager import com.fasterxml.jackson.annotation.JsonProperty import com.lagradost.cloudstream3.APIHolder.allProviders @@ -155,16 +156,23 @@ class SettingsGeneral : BasePreferenceFragmentCompat() { val lang: String, ) - private val pathPicker = getChooseFolderLauncher { uri, path -> - val context = context ?: CloudStreamApp.context ?: return@getChooseFolderLauncher - (path ?: uri.toString()).let { + companion object { + fun Fragment.pickDownloadPath(uri: Uri?, path: String?) { + if (uri == null) return + + val context = context ?: CloudStreamApp.context ?: return + val visual = path ?: uri.toString() PreferenceManager.getDefaultSharedPreferences(context).edit { putString(getString(R.string.download_path_key), uri.toString()) - putString(getString(R.string.download_path_key_visual), it) + putString(context.getString(R.string.download_path_key_visual), visual) } } } + private val pathPicker = getChooseFolderLauncher { uri, path -> + pickDownloadPath(uri, path) + } + override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { hideKeyboard() setPreferencesFromResource(R.xml.settings_general, rootKey) diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsUpdates.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsUpdates.kt index 118d89ac482..c04215594e1 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsUpdates.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsUpdates.kt @@ -58,6 +58,8 @@ class SettingsUpdates : BasePreferenceFragmentCompat() { } private val pathPicker = getChooseFolderLauncher { uri, path -> + if(uri == null) return@getChooseFolderLauncher + val context = context ?: CloudStreamApp.context ?: return@getChooseFolderLauncher (path ?: uri.toString()).let { PreferenceManager.getDefaultSharedPreferences(context).edit { @@ -205,8 +207,9 @@ class SettingsUpdates : BasePreferenceFragmentCompat() { val prefNames = resources.getStringArray(R.array.apk_installer_pref) val prefValues = resources.getIntArray(R.array.apk_installer_values) + // Use legacy installer as default until we make the new installer completely reliable val currentInstaller = - settingsManager.getInt(getString(R.string.apk_installer_key), 0) + settingsManager.getInt(getString(R.string.apk_installer_key), 1) activity?.showBottomDialog( prefNames.toList(), diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/ExtensionsFragment.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/ExtensionsFragment.kt index bc85cc4782c..af0d3dfe756 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/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/PluginsViewModel.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/PluginsViewModel.kt index e0fd906b49f..dfc61eba54c 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/PluginsViewModel.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/PluginsViewModel.kt @@ -128,6 +128,7 @@ class PluginsViewModel : ViewModel() { PluginManager.downloadPlugin( activity, metadata.url, + metadata.fileHash, metadata.internalName, repo, metadata.status != PROVIDER_STATUS_DOWN @@ -179,6 +180,7 @@ class PluginsViewModel : ViewModel() { PluginManager.downloadPlugin( activity, metadata.url, + metadata.fileHash, metadata.internalName, repo, isEnabled 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 37677c1d8bc..4ec005a094d 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( diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/utils/DirectoryPicker.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/utils/DirectoryPicker.kt index 08a79b4b497..dfc93117481 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/utils/DirectoryPicker.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/utils/DirectoryPicker.kt @@ -10,7 +10,10 @@ import com.lagradost.safefile.SafeFile fun Fragment.getChooseFolderLauncher(dirSelected: (uri: Uri?, path: String?) -> Unit) = registerForActivityResult(ActivityResultContracts.OpenDocumentTree()) { uri -> // It lies, it can be null if file manager quits. - if (uri == null) return@registerForActivityResult + if(uri == null) { + dirSelected(null, null) + return@registerForActivityResult + } val context = context ?: CloudStreamApp.context ?: return@registerForActivityResult // RW perms for the path val flags = Intent.FLAG_GRANT_READ_URI_PERMISSION or diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/DownloadFileWorkManager.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/DownloadFileWorkManager.kt deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/FillerEpisodeCheck.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/FillerEpisodeCheck.kt index 09d4683bc20..8456094d1e9 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/FillerEpisodeCheck.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/FillerEpisodeCheck.kt @@ -1,112 +1,166 @@ package com.lagradost.cloudstream3.utils -import com.lagradost.cloudstream3.app +import androidx.annotation.WorkerThread +import com.fasterxml.jackson.annotation.JsonProperty +import com.lagradost.cloudstream3.LoadResponse +import com.lagradost.cloudstream3.LoadResponse.Companion.getAniListId +import com.lagradost.cloudstream3.LoadResponse.Companion.getImdbId +import com.lagradost.cloudstream3.LoadResponse.Companion.getKitsuId +import com.lagradost.cloudstream3.LoadResponse.Companion.getMalId +import com.lagradost.cloudstream3.LoadResponse.Companion.getTMDbId +import com.lagradost.cloudstream3.TvType +import com.lagradost.cloudstream3.ui.result.getId import com.lagradost.cloudstream3.utils.Coroutines.main -import org.jsoup.Jsoup import java.lang.Thread.sleep import java.util.* import kotlin.concurrent.thread +import com.lagradost.cloudstream3.utils.AppUtils.parseJson +import java.io.InputStream +import kotlin.let object FillerEpisodeCheck { - private const val MAIN_URL = "https://www.animefillerlist.com" + fun String?.toClassDir(): String { + val q = this ?: "null" + val z = (6..10).random().calc() + return q + "cache" + z + } - var list: HashMap? = null - var cache: HashMap> = hashMapOf() + data class Show( + @JsonProperty("slug") + val slug: String, + @JsonProperty("title") + val title: String, + @JsonProperty("filler") + val filler: ArrayList, + @JsonProperty("mixedCanon") + val mixedCanon: ArrayList, + @JsonProperty("mangaCanon") + val mangaCanon: ArrayList, + @JsonProperty("animeCanon") + val animeCanon: ArrayList, + ) - private fun fixName(name: String): String { - return name.lowercase(Locale.ROOT)/*.replace(" ", "")*/.replace("-", " ") - .replace("[^a-zA-Z0-9 ]".toRegex(), "") - } + data class MappingRoot( + @JsonProperty("type") + val type: String?, + @JsonProperty("anidb_id") + val anidbId: Long?, + @JsonProperty("anilist_id") + val anilistId: Long?, + @JsonProperty("animecountdown_id") + val animecountdownId: Long?, + @JsonProperty("animenewsnetwork_id") + val animenewsnetworkId: Long?, + @JsonProperty("anime-planet_id") + val animePlanetId: String?, + @JsonProperty("anisearch_id") + val anisearchId: Long?, + @JsonProperty("imdb_id") + val imdbId: String?, + @JsonProperty("kitsu_id") + val kitsuId: Long?, + @JsonProperty("livechart_id") + val livechartId: Long?, + @JsonProperty("mal_id") + val malId: Long?, + @JsonProperty("simkl_id") + val simklId: Long?, + @JsonProperty("themoviedb_id") + val themoviedbId: Long?, + @JsonProperty("tvdb_id") + val tvdbId: Long?, + @JsonProperty("season") + val season: Season?, + ) - private suspend fun getFillerList(): Boolean { - if (list != null) return true - try { - val result = app.get("$MAIN_URL/shows").text - val documented = Jsoup.parse(result) - val localHTMLList = documented.select("div#ShowList > div.Group > ul > li > a") - val localList = HashMap() - for (i in localHTMLList) { - val name = i.text() - - if (name.lowercase(Locale.ROOT).contains("manga only")) continue - - val href = i.attr("href") - if (name.isNullOrEmpty() || href.isNullOrEmpty()) { - continue - } + data class Season( + @JsonProperty("tvdb") + val tvdb: Long?, + @JsonProperty("tmdb") + val tmdb: Long?, + ) - val values = "(.*) \\((.*)\\)".toRegex().matchEntire(name)?.groups - if (values != null) { - for (index in 1 until values.size) { - val localName = values[index]?.value ?: continue - localList[fixName(localName)] = href - } - } else { - localList[fixName(name)] = href - } - } - if (localList.size > 0) { - list = localList - return true - } - } catch (e: Exception) { - e.printStackTrace() + data class CombinedMedia( + @JsonProperty("mapping") + val mapping: MappingRoot?, + @JsonProperty("show") + val show: Show + ) + + data class Database( + val mal: HashMap = hashMapOf(), + val anilist: HashMap = hashMapOf(), + val kitsu: HashMap = hashMapOf(), + val tmdb: HashMap = hashMapOf(), + val imdb: HashMap = hashMapOf(), + val name: HashMap = hashMapOf(), + ) + + private var database: Database? = null + + private val strip = Regex("[ :\\-.!]") + + /** Makes names more uniform to make partial matches more still give a result */ + fun stripName(name: String): String = + name.replace(strip, "").lowercase() + + + @Synchronized + @Throws + @WorkerThread + fun loadJson(): Database { + database?.let { + return it } - return false - } + + /** The entire "database" is stored as a json file we can parse */ + val stream: InputStream = com.lagradost.AnimeDB.getDatabaseStream()!! + val text = stream.reader().readText() - fun String?.toClassDir(): String { - val q = this ?: "null" - val z = (6..10).random().calc() - return q + "cache" + z + val allMedia = parseJson>(text) + val pending = Database() + for (media in allMedia) { + val lowercase = stripName(media.show.title) + pending.name[lowercase] = media + val map = media.mapping ?: continue + + map.imdbId?.let { id -> pending.imdb[id] = media } + map.malId?.let { id -> pending.mal[id] = media } + map.anilistId?.let { id -> pending.anilist[id] = media } + map.kitsuId?.let { id -> pending.kitsu[id] = media } + map.season?.tmdb?.let { id -> pending.tmdb[id] = media } + } + database = pending + return pending } - suspend fun getFillerEpisodes(query: String): HashMap? { - try { - cache[query]?.let { - return it - } - if (!getFillerList()) return null - val localList = list ?: return null - - // Strips these from the name - val blackList = listOf( - "TV Dubbed", - "(Dub)", - "Subbed", - "(TV)", - "(Uncensored)", - "(Censored)", - "(\\d+)" // year - ) - val blackListRegex = - Regex( - """ (${ - blackList.joinToString(separator = "|").replace("(", "\\(") - .replace(")", "\\)") - })""" - ) - - val realQuery = - fixName(query.replace(blackListRegex, "")).replace("shippuuden", "shippuden") - if (!localList.containsKey(realQuery)) return null - val href = localList[realQuery]?.replace(MAIN_URL, "") ?: return null // JUST IN CASE - val result = app.get("$MAIN_URL$href").text - val documented = Jsoup.parse(result) - val hashMap = HashMap() - documented.select("table.EpisodeList > tbody > tr").forEach { - val type = it.selectFirst("td.Type > span")?.text() == "Filler" - val episodeNumber = it.selectFirst("td.Number")?.text()?.toIntOrNull() - if (episodeNumber != null) { - hashMap[episodeNumber] = type - } - } - cache[query] = hashMap - return hashMap - } catch (e: Exception) { - e.printStackTrace() + val loadCache: HashMap?> = hashMapOf() + + @Synchronized + @Throws + @WorkerThread + fun getFillerEpisodes(data: LoadResponse): HashSet? { + /** Only for anime */ + if (data.type != TvType.Anime) { return null } + /** Try to hit the cache for this entry, to avoid recreating the hashset */ + loadCache[data.getId()]?.let { cachedResponse -> + return cachedResponse + } + val db = loadJson() + + val media = + db.mal[data.getMalId()?.toLongOrNull()] + ?: db.anilist[data.getAniListId()?.toLongOrNull()] + ?: db.kitsu[data.getKitsuId()?.toLongOrNull()] + ?: db.imdb[data.getImdbId()] + ?: db.tmdb[data.getTMDbId()?.toLongOrNull()] + ?: db.name[stripName(data.name)] + + return media?.show?.filler?.toHashSet().also { response -> + loadCache[data.getId()] = response + } } private fun Int.calc(): Int { diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/InAppUpdater.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/InAppUpdater.kt index 9380285ca44..8bcd1b88e70 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/InAppUpdater.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/InAppUpdater.kt @@ -308,7 +308,7 @@ object InAppUpdater { } val currentInstaller = settingsManager.getInt( - getString(R.string.apk_installer_key), 0 + getString(R.string.apk_installer_key), 1 ) when (currentInstaller) { diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/VideoDownloadHelper.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/VideoDownloadHelper.kt deleted file mode 100644 index e69de29bb2d..00000000000 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 11c35e9ec04..d209d544bd7 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 @@ -1039,14 +1039,7 @@ object VideoDownloadManager { startByte = stream.startAt, headers = link.headers.appendAndDontOverride( mapOf( - "Accept-Encoding" to "identity", - "accept" to "*/*", "user-agent" to USER_AGENT, - "sec-ch-ua" to "\"Chromium\";v=\"91\", \" Not;A Brand\";v=\"99\"", - "sec-fetch-mode" to "navigate", - "sec-fetch-dest" to "video", - "sec-fetch-user" to "?1", - "sec-ch-ua-mobile" to "?0", ) ) ) @@ -1168,10 +1161,23 @@ object VideoDownloadManager { // this will take up the first available job and resolve while (true) { if (!isActive) return@launch + + var isTooFarAhead = false fileMutex.withLock { if (metadata.type == DownloadType.IsStopped || metadata.type == DownloadType.IsFailed ) return@launch + + // Limit RAM usage by throttling if too much data is downloaded but not yet written to disk + // 50MB limit + if (metadata.bytesDownloaded - metadata.bytesWritten > 50_000_000) { + isTooFarAhead = true + } + } + + if (isTooFarAhead) { + delay(500) + continue } // mutex just in case, we never want this to fail due to multithreading @@ -1303,8 +1309,6 @@ object VideoDownloadManager { val m3u8 = M3u8Helper.M3u8Stream( link.url, link.quality, link.headers.appendAndDontOverride( mapOf( - "Accept-Encoding" to "identity", - "accept" to "*/*", "user-agent" to USER_AGENT, ) + if (link.referer.isNotBlank()) mapOf("referer" to link.referer) else emptyMap() ) @@ -1342,10 +1346,23 @@ object VideoDownloadManager { launch(Dispatchers.IO) { while (true) { if (!isActive) return@launch + + var isTooFarAhead = false fileMutex.withLock { if (metadata.type == DownloadType.IsStopped || metadata.type == DownloadType.IsFailed ) return@launch + + // Limit RAM usage by throttling if too much data is downloaded but not yet written to disk + // 50MB limit + if (metadata.bytesDownloaded - metadata.bytesWritten > 50_000_000) { + isTooFarAhead = true + } + } + + if (isTooFarAhead) { + delay(500) + continue } // mutex just in case, we never want this to fail due to multithreading @@ -1734,6 +1751,10 @@ object VideoDownloadManager { companion object { private fun displayNotification(context: Context, id: Int, notification: Notification) { safe { + if (context.checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) + != PackageManager.PERMISSION_GRANTED + ) return@safe + NotificationManagerCompat.from(context) .notify(DOWNLOAD_NOTIFICATION_TAG, id, notification) } @@ -2005,6 +2026,8 @@ object VideoDownloadManager { linkLoadingJob = ioSafe { generator.generateLinks( + offset = 0, + isCasting = false, clearCache = false, sourceTypes = LOADTYPE_INAPP_DOWNLOAD, callback = { diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/videoskip/AnimeSkip.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/videoskip/AnimeSkip.kt new file mode 100644 index 00000000000..f9254576bb5 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/videoskip/AnimeSkip.kt @@ -0,0 +1,370 @@ +package com.lagradost.cloudstream3.utils.videoskip + +import com.fasterxml.jackson.annotation.JsonProperty +import com.lagradost.cloudstream3.AnimeLoadResponse +import com.lagradost.cloudstream3.ErrorLoadingException +import com.lagradost.cloudstream3.LoadResponse +import com.lagradost.cloudstream3.TvSeriesLoadResponse +import com.lagradost.cloudstream3.TvType +import com.lagradost.cloudstream3.app +import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.animeSkipApi +import com.lagradost.cloudstream3.syncproviders.AuthAPI +import com.lagradost.cloudstream3.syncproviders.AuthLoginRequirement +import com.lagradost.cloudstream3.syncproviders.AuthLoginResponse +import com.lagradost.cloudstream3.syncproviders.AuthToken +import com.lagradost.cloudstream3.syncproviders.AuthUser +import com.lagradost.cloudstream3.syncproviders.PlainAuthRepo +import com.lagradost.cloudstream3.ui.result.ResultEpisode +import com.lagradost.cloudstream3.utils.AppUtils.parseJson +import com.lagradost.cloudstream3.utils.AppUtils.toJson +import java.math.BigInteger +import java.util.concurrent.ConcurrentHashMap +import java.security.MessageDigest + +class AnimeSkipAuth : AuthAPI() { + override val name = "AnimeSkip" + override val inAppLoginRequirement: AuthLoginRequirement = + AuthLoginRequirement(password = true, username = true) + override val idPrefix = "anime-skip" + override val hasInApp = true + override val createAccountUrl = "https://anime-skip.com/account" + val baseClientId = "as1JgiMbW4wKfmTLWXS79iTDQFll76pk" + fun md5(input: String): String { + val md = MessageDigest.getInstance("MD5") + return BigInteger(1, md.digest(input.toByteArray())).toString(16).padStart(32, '0') + } + + data class LoginRoot( + @JsonProperty("data") + val data: LoginData, + ) + + data class LoginData( + @JsonProperty("login") + val login: Login, + ) + + data class Login( + @JsonProperty("authToken") + val authToken: String, + @JsonProperty("refreshToken") + val refreshToken: String, + @JsonProperty("account") + val account: Account, + ) + + data class ApiRoot( + @JsonProperty("data") + val data: ApiData, + ) + + data class ApiData( + @JsonProperty("myApiClients") + val myApiClients: List, + ) + + data class MyApiClient( + @JsonProperty("id") + val id: String, + ) + + data class Account( + @JsonProperty("profileUrl") + val profileUrl: String, + @JsonProperty("username") + val username: String, + @JsonProperty("email") + val email: String, + ) + + data class Payload( + @JsonProperty("profileUrl") + val profileUrl: String, + @JsonProperty("username") + val username: String, + @JsonProperty("email") + val email: String, + @JsonProperty("clientId") + val clientId: String, + ) + + override suspend fun user(token: AuthToken?): AuthUser? { + val payload = parseJson(token?.payload ?: return null) + return AuthUser( + name = payload.username, + id = payload.email.hashCode(), + profilePicture = payload.profileUrl + ) + } + + override suspend fun login(form: AuthLoginResponse): AuthToken? { + val hash = md5(form.password ?: return null) + val emailOrUserName = form.email ?: form.username ?: return null + + val loginQuery = """ + { + login(usernameEmail: "$emailOrUserName", passwordHash: "$hash") { + authToken + refreshToken + account { + profileUrl + username + email + } + } + } +""" + val loginRoot = app.post( + "https://api.anime-skip.com/graphql", + json = mapOf("query" to loginQuery), + headers = mapOf( + "Accept" to "*/*", + "content-type" to "application/json", + "X-Client-ID" to baseClientId + ) + ).parsed() + + val authToken = loginRoot.data.login.authToken + val refreshToken = loginRoot.data.login.refreshToken + val account = loginRoot.data.login.account + + val clientQuery = """ + { + myApiClients { + id + } + } + """.trimIndent() + + val apiRoot = app.post( + "https://api.anime-skip.com/graphql", + json = mapOf("query" to clientQuery), + headers = mapOf( + "Accept" to "*/*", + "content-type" to "application/json", + "Authorization" to "Bearer $authToken", + "X-Client-ID" to baseClientId + ) + ).parsed() + + val clientId = apiRoot.data.myApiClients.getOrNull(0)?.id + ?: throw ErrorLoadingException("No API token found") + + val payload = Payload( + profileUrl = account.profileUrl, + username = account.username, + email = account.email, + clientId = clientId, + ) + return AuthToken( + accessToken = authToken, + refreshToken = refreshToken, + payload = payload.toJson() + ) + } +} + +class AnimeSkip : SkipAPI() { + override val name: String = "AniSkip" + override val supportedTypes: Set = setOf(TvType.Anime, TvType.OVA) + + val auth = PlainAuthRepo(animeSkipApi) + //val clientId = "ZGfO0sMF3eCwLYf8yMSCJjlynwNGRXWE" + + companion object { + const val MIN_LENGTH: Int = 4 + + private val strip = Regex("[ :\\-.!]") + + /** Makes names more uniform to make partial matches more still give a result */ + fun stripName(name: String?): String? = + name?.replace(strip, "")?.lowercase() + + private val asciiRegex = Regex("[^a-zA-Z0-9 ]") + + /** Makes names more uniform to make partial matches more still give a result */ + fun asciiName(name: String?): String? = + name?.replace(asciiRegex, "")?.lowercase() + } + + data class Root( + @JsonProperty("data") + val data: Data, + ) + + data class Data( + @JsonProperty("searchShows") + val searchShows: List, + ) + + data class SearchShow( + @JsonProperty("name") + val name: String, + @JsonProperty("originalName") + val originalName: String?, + @JsonProperty("seasonCount") + val seasonCount: Long, + @JsonProperty("episodeCount") + val episodeCount: Long, + @JsonProperty("baseDuration") + val baseDuration: Double, + @JsonProperty("episodes") + val episodes: List, + ) + + data class Episode( + @JsonProperty("number") + val number: String?, + @JsonProperty("absoluteNumber") + val absoluteNumber: String?, + @JsonProperty("season") + val season: String?, + @JsonProperty("timestamps") + val timestamps: List, + ) + + data class Timestamp( + @JsonProperty("at") + val at: Double, + @JsonProperty("type") + val type: Type, + ) + + data class Type( + @JsonProperty("name") + val name: String, + ) + + val cache: ConcurrentHashMap = ConcurrentHashMap() + + override suspend fun stamps( + data: LoadResponse, + episode: ResultEpisode, + episodeDurationMs: Long + ): List? { + val clientId : String = parseJson( + auth.authData()?.token?.payload ?: return null + ).clientId + + when (data) { + is AnimeLoadResponse, is TvSeriesLoadResponse -> { + /** Require episode based anime */ + } + + else -> return null + } + + val query = """{ + searchShows(search: "${data.name}", limit: 1) { + name + originalName + seasonCount + episodeCount + episodes { + number + absoluteNumber + season + baseDuration + timestamps { + at + type { + name + } + } + } + } +}""" + val root = cache[data.name] ?: run { + app.post( + "https://api.anime-skip.com/graphql", + json = mapOf("query" to query), + headers = mapOf( + "Accept" to "*/*", + "content-type" to "application/json", + "X-Client-ID" to clientId + ) + ) + .parsed().data.also { root -> + cache[data.name] = root + } + } + val show = root.searchShows.firstOrNull { show -> + /** Match ascii */ + val ascii1 = asciiName(data.name) + val ascii2 = asciiName(show.name) + if (ascii1 == ascii2 && (ascii1?.length ?: 0) > MIN_LENGTH) { + return@firstOrNull true + } + + if (data !is AnimeLoadResponse) { + return@firstOrNull false + } + + /** Match original name */ + val strip1 = stripName(show.originalName) + val strip2 = stripName(data.japName) + + /** Match english name*/ + val ascii3 = stripName(data.engName) + (strip1 == strip2 && (strip1?.length ?: 0) > MIN_LENGTH) || + (ascii2 == ascii3 && (ascii2?.length ?: 0) > MIN_LENGTH) + } ?: return null + + val showEpisode = when (data) { + is AnimeLoadResponse -> { + val episodeNumber = episode.episode.toString() + /** For anime, match on number */ + show.episodes.firstOrNull { + it.absoluteNumber == episodeNumber + } ?: show.episodes.firstOrNull { + it.number == episodeNumber + } + } + + is TvSeriesLoadResponse -> { + /** For tv-series, match on season + number */ + val seasonNumber = episode.season?.toString() + val episodeNumber = episode.episode.toString() + val episodeIndex = episode.totalEpisodeIndex.toString() + + show.episodes.firstOrNull { + it.season == seasonNumber && it.number == episodeNumber + } ?: show.episodes.firstOrNull { + it.absoluteNumber == episodeIndex + } + } + + else -> null + } ?: return null + + val result = ArrayList() + var pending: SkipStamp? = null + for (stamp in showEpisode.timestamps) { + val startMS = (stamp.at * 1000.0).toLong() + pending?.let { pending -> + result.add(pending.copy(endMs = startMS)) + } + val type = when (stamp.type.name) { + "Intro", "New Intro" -> SkipType.Intro + "Credits" -> SkipType.Credits + "Preview" -> SkipType.Preview + "Recap" -> SkipType.Recap + "Mixed Credits" -> SkipType.MixedEnding + "Filler", "Transition", "Branding", "Canon", "Title Card" -> null + else -> null + } + if (type == null) { + pending = null + continue + } + pending = SkipStamp(type, startMS, 0L) + } + pending?.let { pending -> + result.add(pending.copy(endMs = episodeDurationMs)) + /** Base duration = fucked */ + } + + return result + } +} + diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/videoskip/SkipAPI.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/videoskip/SkipAPI.kt index cd6727a24a4..60cc3ae1e23 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/videoskip/SkipAPI.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/videoskip/SkipAPI.kt @@ -16,7 +16,7 @@ enum class SkipType(@StringRes val res: Int) { Recap(R.string.skip_type_recap), MixedOpening(R.string.skip_type_mixed_op), MixedEnding(R.string.skip_type_mixed_ed), - Credits(R.string.skip_type_creddits), + Credits(R.string.skip_type_credits), Intro(R.string.skip_type_intro), Preview(R.string.skip_type_preview), } @@ -61,7 +61,7 @@ abstract class SkipAPI { } companion object { - private val skipApis: List = listOf(AniSkip(), TheIntroDBSkip(), IntroDbSkip()) + private val skipApis: List = listOf(AniSkip(), TheIntroDBSkip(), IntroDbSkip(), AnimeSkip()) private val cachedStamps = ConcurrentHashMap>() /** Get all video timestamps from an episode */ diff --git a/app/src/main/res/drawable/animeskip.xml b/app/src/main/res/drawable/animeskip.xml new file mode 100644 index 00000000000..8f1bb3105ed --- /dev/null +++ b/app/src/main/res/drawable/animeskip.xml @@ -0,0 +1,36 @@ + + + + + + + + + + + diff --git a/app/src/main/res/layout/player_custom_layout.xml b/app/src/main/res/layout/player_custom_layout.xml index 72024a918d5..407de4a3f1b 100644 --- a/app/src/main/res/layout/player_custom_layout.xml +++ b/app/src/main/res/layout/player_custom_layout.xml @@ -759,7 +759,7 @@ android:scaleType="centerCrop" /> - + + + app:layout_constraintTop_toTopOf="parent"> @@ -39,19 +39,23 @@ android:adjustViewBounds="true" android:scaleType="fitStart" android:visibility="gone" - tools:visibility="visible"/> + tools:visibility="visible" /> + tools:text="Zootopia 2" /> @@ -60,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:shadowColor="@android:color/black" + android:shadowDx="2" + android:shadowDy="2" + android:shadowRadius="4" + 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" /> @@ -352,28 +356,30 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_gravity="end"> + + + tools:visibility="visible" /> + - + + + @@ -1113,34 +1139,34 @@ + android:padding="5dp" + android:visibility="gone"> + 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"> diff --git a/app/src/main/res/layout/trailer_custom_layout.xml b/app/src/main/res/layout/trailer_custom_layout.xml index 52b5c7b2cf8..76231a2d3a0 100644 --- a/app/src/main/res/layout/trailer_custom_layout.xml +++ b/app/src/main/res/layout/trailer_custom_layout.xml @@ -12,6 +12,7 @@ android:layout_width="640dp" android:layout_height="match_parent" android:background="@drawable/bg_player_metadata_scrim_netflix" + android:visibility="gone" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent" app:layout_constraintBottom_toBottomOf="parent"> @@ -732,7 +733,7 @@ android:scaleType="centerCrop" /> - + - - - \ No newline at end of file + diff --git a/app/src/main/res/values-arz/strings.xml b/app/src/main/res/values-arz/strings.xml index 55344e51920..4a4cb755f15 100644 --- a/app/src/main/res/values-arz/strings.xml +++ b/app/src/main/res/values-arz/strings.xml @@ -1,3 +1,3 @@ - \ No newline at end of file + diff --git a/app/src/main/res/values-b+af/strings.xml b/app/src/main/res/values-b+af/strings.xml index 81d7a96aec0..b81848db650 100644 --- a/app/src/main/res/values-b+af/strings.xml +++ b/app/src/main/res/values-b+af/strings.xml @@ -24,8 +24,7 @@ Rand tipe Klaar Afgelaai Kyk verder - Nuwe opdatering gevind! -\n%1$s -> %2$s + Nuwe opdatering gevind! \n%1$s -> %2$s Laai Tale af Soek deur verskaffers te gebruik Gaan terug diff --git a/app/src/main/res/values-b+am/strings.xml b/app/src/main/res/values-b+am/strings.xml index 7fd3274b944..56b71f5a0c0 100644 --- a/app/src/main/res/values-b+am/strings.xml +++ b/app/src/main/res/values-b+am/strings.xml @@ -8,8 +8,7 @@ %1$dሰዓት %2$dደቂቃ ፖስተር የወረዱ - አዲስ ማሻሻያ ተገኝቷል! -\n%1$s -> %2$s + አዲስ ማሻሻያ ተገኝቷል! \n%1$s -> %2$s ተመለስ ተጨማሪ አማራጮች በማየት ላይ diff --git a/app/src/main/res/values-b+apc/strings.xml b/app/src/main/res/values-b+apc/strings.xml index 9bc697acf26..10ba0a88c38 100644 --- a/app/src/main/res/values-b+apc/strings.xml +++ b/app/src/main/res/values-b+apc/strings.xml @@ -29,8 +29,7 @@ فرجي الـLogcat 🐈 +30 كفي حضر - في أپدايت جديدة! -\n%1$s ← %2$s + في أپدايت جديدة! \n%1$s ← %2$s نزل الترجمات مع الڤيديو عوزو المصادر لَ تنبّشو رجاع @@ -96,8 +95,7 @@ لون الكتيبة مخلص عوز قوة ضوّ الشاشة تبع السيستام بدل من تغميئ الڤيديو - فشل ترجيع النسخة الإحتياطية من ملف -\n%s + فشل ترجيع النسخة الإحتياطية من ملف \n%s مشّي المقطع الدعائي مشّي البث المباشر م لقينا ولا حلقة @@ -166,8 +164,7 @@ طفي الترجمة القصة مستعمل - %dد -\nباقي + %dد \nباقي عم ينعرض حاليًا بلايحة النَطر حالة @@ -194,7 +191,7 @@ في مشكلة بجهاز العرض (Renderer error) العِنوان پروكسي \"گِت هَب\" - جودة مشغل الڤيديو + فرجي معلومات مشغل الڤيديو ملصق الترجمة أوڤا نَزِل من مصادر وجودات مختلفة @@ -361,11 +358,7 @@ العشوائي يللي بعده خيال عم نجدِد المثلثلات يللي مشتركينلها - مبين إنو فيه عنصر متل هيدا موجود بال مكتبة عندكن: -\n -\n%s -\n -\nبدكن تزيدو هيدا العنصر بأيّ حال، أو تستبدلوه مع العنصر الموجود، أو تلغو الإجراء؟ + مبين إنو فيه عنصر متل هيدا موجود بال مكتبة عندكن: \n \n%s \n \nبدكن تزيدو هيدا العنصر بأيّ حال، أو تستبدلوه مع العنصر الموجود، أو تلغو الإجراء؟ نزلت %1$d %2$s معرف مش صالح أفّي %s @@ -373,9 +366,7 @@ حطو الأرقام السرية لـ\"%s\" الطريقة القديمة معلى - \"كلود ستريم\" م بتجي مع مصادر ڤيديوات. لازم تنزلو المصادر من ريپويات. -\n -\nفيه معلومات على الـ\"ديسكورد\" تبعنا، أو فيكن تنبشو ع معلومات على الإنترنت. + \"كلود ستريم\" م بتجي مع مصادر ڤيديوات. لازم تنزلو المصادر من ريپويات. \n \nفيه معلومات على الـ\"ديسكورد\" تبعنا، أو فيكن تنبشو ع معلومات على الإنترنت. زبد تتبع 3G/4G… نفَتح %s @@ -388,8 +379,7 @@ دايمًا كتوب ب أحرف كاپيتال، A بدل a مشغل الڤيديو المفضل 4K - بَلَش تنزيل %1$d %2$s -\n… + بَلَش تنزيل %1$d %2$s \n… الوصف شوف الريپويات تبع مجتمع \"كلاود ستريم\" إنت هلّق بال وضع الآمن @@ -419,9 +409,7 @@ أفّى الإعداد فتت ع أكونت \"%s\" تبعك حدود خطية - في مشكلة بطعمير الـUI. هيدي مشكبة كبيرة. پليز بَلِغ عنّا -\n(UI was unable to be created correctly) -\n%s + في مشكلة بطعمير الـUI. هيدي مشكبة كبيرة. پليز بَلِغ عنّا \n(UI was unable to be created correctly) \n%s عَدِل تجَدَد (من الجديد للقديم) TC @@ -477,8 +465,7 @@ SD الإضافات شيل الإعلانات من الترجمة - رفّكن فاضي ☹ -\nفوتو على أكونت فيها رفّ الڤيديوات يللي حضرينها أو زيدو ڤيديوات بال رفّ المحلي. + رفّكن فاضي ☹ \nفوتو على أكونت فيها رفّ الڤيديوات يللي حضرينها أو زيدو ڤيديوات بال رفّ المحلي. اسم الريپو (مش ضروري) الجودات بيانات مش صالحة @@ -505,8 +492,7 @@ رايتينگ (من الواطي للعالي) فتاح من ملف طفي - لقينا ملف الوضع الآمن! -\nمش رح نعوز إضافيات وقت ما ينفَتَح الآپ حتّى ينشال الملف. + لقينا ملف الوضع الآمن! \nمش رح نعوز إضافيات وقت ما ينفَتَح الآپ حتّى ينشال الملف. مش مغير وقت الترجمة مشكلة مصدر @@ -531,7 +517,7 @@ إضافات م قدرنا نفتح %s رايتينگ: %s - تحزير: \"كلاود ستريم 3\" مش مسؤولة عن الإضافات المش رسمية، و م بتدعمن أبدًا! + تحزير: \"كلاود ستريم\" مش مسؤولة عن الإضافات المش رسمية، و م بتدعمن أبدًا! الحالة محي الريپو مشغل الڤيديو @@ -540,10 +526,7 @@ إنتو أصلًا مصوتين كاميرا م لقينا ولا إضافة بال ريپو - مبين إنو في عنصر متل هيدا موجود بالمكتبة عندكن: -\n\"%s\" -\n -\nبدكن تزيدو هيدا العنصر بأيّ حال، أو تستبدلوّ مع العنصر الموجود، أو تلغو الإجراء؟ + مبين إنو في عنصر متل هيدا موجود بالمكتبة عندكن: \n\"%s\" \n \nبدكن تزيدو هيدا العنصر بأيّ حال، أو تستبدلوّ مع العنصر الموجود، أو تلغو الإجراء؟ رايط مش صالح 1000 مللي ثانية إصدار @@ -562,14 +545,8 @@ /%d @string/home_play شيلو من لايحة المحتوى الحاضرينو - الإعتمادات - فيكُن هون تغيرو طريقة ترتيب المصادر. المصدر يللي عنده أولوية أكتر بينحط أعلى بلايحت تنقايت المصدر. إنتو بتنقو الأولوية يإستعمال الأرقام. حطو الرقم الأعلى للمصادر والجودات يللي بتفضلوها. -\n -\nمتلًا: -\nإزا المصدر \"أ\" بتفضلوه، بتعطوه كتير نقات (متلًا 8). -\nإزا الجودة 480 ما بتحبوها، بتعطوها نقات قليلة (متلًا 1). -\n -\nعلامت المصدر وال جودة تبعه بينجمعو مع بعض (8 + 1 = 9). يللي علامته 10 أو أعلى، بينحط تلقائيًا، من دون ما ينعمل لود لكل المصادر! + الإعتمادات + فيكُن هون تغيرو طريقة ترتيب المصادر. المصدر يللي عنده أولوية أكتر بينحط أعلى بلايحت تنقايت المصدر. إنتو بتنقو الأولوية يإستعمال الأرقام. حطو الرقم الأعلى للمصادر والجودات يللي بتفضلوها. \n \nمتلًا: \nإزا المصدر \"أ\" بتفضلوه، بتعطوه كتير نقات (متلًا 8). \nإزا الجودة 480 ما بتحبوها، بتعطوها نقات قليلة (متلًا 1). \n \nعلامت المصدر وال جودة تبعه بينجمعو مع بعض (8 + 1 = 9). يللي علامته 10 أو أعلى، بينحط تلقائيًا، من دون ما ينعمل لود لكل المصادر! حطو الأرقام السرية الحالية صوت حط كبسة لبرم إتجاه الشاشة @@ -589,23 +566,21 @@ رمز/كلمة مرور للمصادقة فتاح التطبيق باستعمال البصمة، آي دي الوج، پِن، النمط، أو الپاسورد. بعد كذا محاولة فاشلة، هيدا الشباك رح يسكر. بكل بساطة، سكر الآپ ورجاع فتحه حتى تجرب بعد مرة. - %s -\nباقي + %s \nباقي المصادقة البيومترية مش مدعومة ع هالجهاز شيله من المفضل اسم وعنوان الريپو نتسخ! فيه ارور بال وصول ل الكليپ-بورد. پليز جرب مرة أخرى. فيه ارور بال نسخ. پليز نسوخ الـLogcat 🐈 وبعته ل المسؤولين عن دعم الآپ. - هلّق نعمل نسخة احتياطية للداتا تبع \"كلود ستريم\". إذا مابق ينفتح ويمشي الآپ، فيك تعمل كلير للداتا تبعه وترَجع الداتا من النسخة الاحتياطية اللي هلّق عملنالك ياها. -\nالاحتمال انو مابق ينفتح الآپ احتمالية زغيرة كتير، بس كل جهاز بيتصرف بشكل مختلف، ونحنا منعتذر إذا سببنا أي إزعاج. + هلّق نعمل نسخة احتياطية للداتا تبع \"كلود ستريم\". إذا مابق ينفتح ويمشي الآپ، فيك تعمل كلير للداتا تبعه وترَجع الداتا من النسخة الاحتياطية اللي هلّق عملنالك ياها. \nالاحتمال انو مابق ينفتح الآپ احتمالية زغيرة كتير، بس كل جهاز بيتصرف بشكل مختلف، ونحنا منعتذر إذا سببنا أي إزعاج. أوكي وقف اپتميزايشن بطارية جهازك بطارية الآپ اصلًا محطوطة ع «غير مقيد» \"Unrestricted\" م قدرنا نفتح معلومات الآپ تبع \"كلود ستريم\". - موسيقى + موسيقى أوديو بوك - الميديا + الميديا ت تضمن عدم انقطاع التنزيلات والنوتيفيكايشنات للبرامج التلفزيونية يللي مشتركلها، الآپ \"كلود ستريم\" بده إذن ليمشي ب الباكگراوند. ازا كبست أوكي، رح يفتح شباك إذن زغير، كبوس «سماح».\n\nملاحظة إنو هيدا الإذن ما بيعني إنو \"كلود ستريم 3\" رح تستنزف البطارية. ومش رح يشتغل الآپ بال باكگراوند إلّا عند الضرورة، متل لمّا تتلقا نوتيفيكايشن أو تنزل ڤيديو من الريپو الاصلي. ريسات رح ينزل ب %s @@ -634,21 +609,13 @@ نقي الإشيا اللي بدك تمحيها موجود لينحضر بلا إنترنت محي الفايلات - متأكد إنو بدك تمحي هيدي الإشيا ل الأبد؟ -\n -\n%s - رح كمان تمحي ل الأبد كل الحلقات اللي ب هيدا المسلسل؟ -\n -\n%s + متأكد إنو بدك تمحي هيدي الإشيا ل الأبد؟ \n \n%s + رح كمان تمحي ل الأبد كل الحلقات اللي ب هيدا المسلسل؟ \n \n%s نقي كل شي شيل التنقاية محي (%1$d | %2$s) - متأكد إنو بدك تمحي هيدي الحلقات من %1$s؟ -\n -\n%2$s - متأكد إنو بدك تمحي كل الحلقات اللي بهيدا المسلسل؟ -\n -\n%s + متأكد إنو بدك تمحي هيدي الحلقات من %1$s؟ \n \n%2$s + متأكد إنو بدك تمحي كل الحلقات اللي بهيدا المسلسل؟ \n \n%s صورة زغيرة مع التقريب وال تبعيد بت حط صورة زغير من الڤيديو إنت و عم بت قرب أو ترجع بال ڤيديو بعد مش معمول لود لولا ترجمة @@ -660,8 +627,8 @@ حدد غير محل هيدا الڤيديو تورينت. هيدا بيعني إنه فيه ينعمله تراك، يعني بينعرف شو عم تحضر.\nإزا م بتعرف شو التورينت، م تستعمله. حجم الحفّة - پودكاست - صوت + پودكاست + صوت أرور، مش مدعوم أرور بال إنكودينگ أفّي اللودينگ تلقائيًا @@ -739,4 +706,33 @@ فوق، عال شمال فوق، بال نُص فوق، عال يمين + ليستة التنزيلات + مافي شي عم يتنزّل هلّق. + قوة ضو إضافية + بت حط فلتر للبرايتنس لمّا تعلي قوة الضو ل أكتر من 100% + extra_brightness_enabled + اقتراحات التنبيش + بت فرجي اقتراحات إنتا و عم بت نَبّش + مساح الاقتراحات + فرجي ميتا-ديتا فوق الڤيديو + فرجي ليستة الممثلين + ڤيديو + معلومات الڤيديو + أولوية المصدر + حدد ترتيب المصادر بال مشغل + اسم المصدر + نزلن كلن + لغين كلن + بدك تنزل الحلقة %s؟ + بدك تلغي كل شي عم يتنَزَّل؟ + + م شي عم يتنزل + شي واحد عم يتنزل + + + مافي شي بعد بده يبلش يتنزل + فيه شي واحد بعد بده يبلش يتنزل + + لايڤ + پريڤيو diff --git a/app/src/main/res/values-b+ar/strings.xml b/app/src/main/res/values-b+ar/strings.xml index 17e809d8d0b..57b2bb62875 100644 --- a/app/src/main/res/values-b+ar/strings.xml +++ b/app/src/main/res/values-b+ar/strings.xml @@ -12,8 +12,7 @@ سرعة (%.2fx) تقييم: %.1f - يوجد تحديث جديد! -\n%1$s -> %2$s + يوجد تحديث جديد! \n%1$s -> %2$s %d دقيقة CloudStream تشغيل بواسطة CloudStream @@ -176,10 +175,8 @@ إستئناف -٣٠ +٣٠ - سوف يتم الحذف نهائيا %s -\nهل أنت متأكد? - %dm -\nمتبقية + سوف يتم الحذف نهائيا %s \nهل أنت متأكد? + %dm \nمتبقية جاري التنفيذ اكتمل الحالة @@ -401,9 +398,7 @@ تم تحميل: %d مُعطل %d غير مُحمل: %d - لا يحتوي CloudStream على مواقع مثبتة افتراضيًا. تحتاج إلى تثبيت المواقع من المستودعات. -\n -\nانضم إلى ديسكورد أو ابحث عبر الإنترنت. + لا يحتوي CloudStream على مواقع مثبتة افتراضيًا. تحتاج إلى تثبيت المواقع من المستودعات. \n \nانضم إلى ديسكورد أو ابحث عبر الإنترنت. عرض مستودعات المجتمع قائمة عامة جميع الترجمات حروف كبيرة @@ -454,7 +449,7 @@ مشغل داخلي لم يتم العثور على التطبيق جميع اللغات - الإعتمادات + الإعتمادات ‌تنزيل تحديث التطبيق… ‏تثبيت تحديث التطبيق… %d دقيقة @@ -493,15 +488,13 @@ التقييم (من الأعلى إلى الأدنى) التقييم (من الأدنى إلى الأعلى) الترتيب الأبجدي (من ي إلى أ) - مكتبتك فارغة :( -\nقم بتسجيل الدخول على حساب مكتبة أو أضف عروضا إلى مكتبتك المحلية. + مكتبتك فارغة :( \nقم بتسجيل الدخول على حساب مكتبة أو أضف عروضا إلى مكتبتك المحلية. محدث (من القديم إلى الجديد) فرز حسب افرز فتح بواسطة المكتبة - تم العثور على ملف الوضع الآمن! -\nلا يتم تحميل أي ملحقات عند بدء التشغيل حتى تتم إزالة الملف. + تم العثور على ملف الوضع الآمن! \nلا يتم تحميل أي ملحقات عند بدء التشغيل حتى تتم إزالة الملف. مدة التقديم عنما يكون المشغل مخفيا مدة التقديم - المشغل مخفي تلفزيون أندرويد @@ -533,13 +526,7 @@ تعديل الملفات التعريفية مساعدة - ‎‎هنا يمكنك تغيير طريقة ترتيب المصادر. إذا كان للفيديو أولوية أعلى ، فسيظهر في الأعلى في اختيار المصدر. يمثل مجموع أولوية المصدر وأولوية الجودة أولوية الفيديو. -\n -\nالمصدر أ: 3 -\nالجودة ب: 7 -\nسيكون لها أولوية فيديو مجمعة تبلغ 10. -\n -\nملاحظة: إذا كان المجموع 10 أو أكثر ، فسيتخطى المشغل التحميل تلقائيًا عند تحميل هذا الرابط! + ‎‎هنا يمكنك تغيير طريقة ترتيب المصادر. إذا كان للفيديو أولوية أعلى ، فسيظهر في الأعلى في اختيار المصدر. يمثل مجموع أولوية المصدر وأولوية الجودة أولوية الفيديو. \n \nالمصدر أ: 3 \nالجودة ب: 7 \nسيكون لها أولوية فيديو مجمعة تبلغ 10. \n \nملاحظة: إذا كان المجموع 10 أو أكثر ، فسيتخطى المشغل التحميل تلقائيًا عند تحميل هذا الرابط! النوعيات خلفية الملف الشخصي تعذر إنشاء واجهة المستخدم بشكل صحيح ، وهذا خطأ كبير ويجب الإبلاغ عنه على الفور %s @@ -552,11 +539,7 @@ تمت إزالة %s من المفضلة المفضلة تمت إضافة %s إلى المفضلة - احتمال وجود تكرارات في مكتبتك. -\n -\n%s -\n -\nهل تريد الاضافة على اي حال مستبدلاً النسخة الموجودة بالفعل, أم تفضل إلغاء العملية؟ + احتمال وجود تكرارات في مكتبتك. \n \n%s \n \nهل تريد الاضافة على اي حال مستبدلاً النسخة الموجودة بالفعل, أم تفضل إلغاء العملية؟ احتمال أن يكون موجود بالفعل قفل الحساب اضافة الى المفضلة @@ -569,9 +552,7 @@ إشترك إزالة من المفضلة اختار حساب - يبدو أن هناك عنصرًا مكررًا موجود بالفعل في مكتبتك: \'%s\'. -\n -\nهل ترغب في إضافة هذا العنصر على أية حال، أو استبدال العنصر الموجود، أو إلغاء الإجراء؟ + يبدو أن هناك عنصرًا مكررًا موجود بالفعل في مكتبتك: \'%s\'. \n \nهل ترغب في إضافة هذا العنصر على أية حال، أو استبدال العنصر الموجود، أو إلغاء الإجراء؟ ادخال ال PIN PIN أدخل ال PIN الحالي @@ -599,8 +580,7 @@ مصادقة كلمة المرور/رقم التعريف الشخصي بعد عدة محاولات فاشلة، سيتم إغلاق المطالبة. ما عليك سوى إعادة تشغيل التطبيق للمحاولة مرة أخرى. لقد تم الآن نسخ بيانات CloudStream احتياطيًا. على الرغم من أن احتمال حدوث ذلك منخفض جدًا، إلا أن جميع الأجهزة يمكن أن تتصرف بشكل مختلف. في الحالات النادرة، التي يتم فيها منعك من الوصول إلى التطبيق، قم بمسح بيانات التطبيق بالكامل واستعادتها من نسخة احتياطية. نحن نأسف جدًا لأي إزعاج ناتج عن هذا. - %s -\nمتبقي + %s \nمتبقي المفضلة إزالة من المفضلة اسم و عنوان المخزن @@ -613,8 +593,8 @@ كتاب صوتي حسناً لضمان عدم انقطاع التنزيلات والإشعارات للعروض التلفزيونية التي اشتركت بها ، يحتاج CloudStream إلى إذن للتشغيل في الخلفية. بالضغط على \"موافق\"، سيظهر لك مربع حوار طلب. يُرجى الضغط على \"السماح\".\n\nيرجى ملاحظة أن هذا الإذن لا يعني أن CS3 سيستنزف بطاريتك. سيعمل في الخلفية فقط عند الضرورة، مثلا عند تلقي الإشعارات أو تنزيل مقاطع الفيديو من الإضافات الرسمية. - موسيقى - الوسائط + موسيقى + الوسائط اعادة تعيين قادم خلال %s سيتم إصدار الحلقة %1$d من الموسم %2$d في @@ -642,21 +622,13 @@ الرجاء تحديد العناصر للحذف تحديد الكل حذف الملفات - هل أنت متأكد أنك تريد حذف الحلقات التالية نهائيًا في %1$s ؟ -\n -\n%2$s - ستقوم أيضًا بحذف جميع الحلقات في السلسلة التالية نهائيًا: -\n -\n%s + هل أنت متأكد أنك تريد حذف الحلقات التالية نهائيًا في %1$s ؟ \n \n%2$s + ستقوم أيضًا بحذف جميع الحلقات في السلسلة التالية نهائيًا: \n \n%s حذف (%1$d | %2$s) متاح للمشاهدة في وضع عدم الاتصال إلغاء تحديد الكل - هل أنت متأكد أنك تريد حذف العناصر التالية نهائيًا ؟ -\n -\n%s - هل أنت متأكد أنك تريد حذف جميع الحلقات في السلسلة التالية نهائيًا ؟ -\n -\n%s + هل أنت متأكد أنك تريد حذف العناصر التالية نهائيًا ؟ \n \n%s + هل أنت متأكد أنك تريد حذف جميع الحلقات في السلسلة التالية نهائيًا ؟ \n \n%s معاينة شريط البحث تمكين معاينة الصورة المصغرة على شريط البحث لم يتم تحميل أي ترجمات بعد @@ -668,8 +640,8 @@ مخصص هذا الفيديو موجود على شبكة تورنت، مما يعني ان الاستخدام يمكن تعقبه.\nتأكد من فهم شبكات التورنت قبل الاستكمال. حجم الحافة - صوت - بودكاست + صوت + بودكاست خطأ في التكويد خطأ في الدعم تحميل أول ترجمة متاحة @@ -749,7 +721,7 @@ اقتراحات البحث عرض اقتراحات البحث أثناء الكتابة مسح الاقتراحات - عرض لوحة البث + عرض لوحة فريق التمثيل تثبيت الإصدار التجريبي تم تثبيت الإصدار التجريبي بالفعل. فشل تثبيت الإصدار التجريبي. diff --git a/app/src/main/res/values-b+ars/strings.xml b/app/src/main/res/values-b+ars/strings.xml index 3104e6a9ab4..cd3830a019a 100644 --- a/app/src/main/res/values-b+ars/strings.xml +++ b/app/src/main/res/values-b+ars/strings.xml @@ -35,8 +35,7 @@ توقف التنزيل خطط للمشاهدة إعادة المشاهدة - !تم العثور على تحديث جديد -\n%1$s -> %2$s + !تم العثور على تحديث جديد \n%1$s -> %2$s %.1f:قدر %dاقل كلاودستريم @@ -157,23 +156,15 @@ تم التحديث (من الجديد إلى القديم) تم التحديث (القديم إلى الجديد) أبجديًا (من الألف إلى الياء) - مكتبتك فارغة :( -\nقم بتسجيل الدخول إلى حساب المكتبة أو قم بإضافة العروض إلى مكتبتك المحلية. - !تم العثور على ملف الوضع الآمن -\n.عدم تحميل أي ملحقات عند بدء التشغيل حتى تتم إزالة الملف + مكتبتك فارغة :( \nقم بتسجيل الدخول إلى حساب المكتبة أو قم بإضافة العروض إلى مكتبتك المحلية. + !تم العثور على ملف الوضع الآمن \n.عدم تحميل أي ملحقات عند بدء التشغيل حتى تتم إزالة الملف ارجع تحديث العروض المشتركة الوضع العادي حرر ملفات تعريفية مساعدة - .هنا يمكنك تغيير كيفية ترتيب المصادر. إذا كان للفيديو أولوية أعلى، فسيظهر في مكان أعلى في تحديد المصدر. مجموع أولوية المصدر وأولوية الجودة هو أولوية الفيديو -\n -\nالمصدر أ: 3 -\nالجودة ب: 7 -\nستكون أولوية الفيديو المدمجة .10 -\n -\n!ملاحظة: إذا كان المجموع 10 أو أكثر، فسيقوم اللاعب تلقائيًا بتخطي التحميل عند تحميل هذا الرابط + .هنا يمكنك تغيير كيفية ترتيب المصادر. إذا كان للفيديو أولوية أعلى، فسيظهر في مكان أعلى في تحديد المصدر. مجموع أولوية المصدر وأولوية الجودة هو أولوية الفيديو \n \nالمصدر أ: 3 \nالجودة ب: 7 \nستكون أولوية الفيديو المدمجة .10 \n \n!ملاحظة: إذا كان المجموع 10 أو أكثر، فسيقوم اللاعب تلقائيًا بتخطي التحميل عند تحميل هذا الرابط لقد صوت بالفعل أبجديًا (ياء إلى ألف) ترتيب حسب @@ -227,8 +218,7 @@ قدم وصف يستمر التشغيل في مشغل مصغر فوق التطبيقات الأخرى - نهائيا %sسيؤدي هذا الى حذف -\nهل أنت متأكد؟ + نهائيا %sسيؤدي هذا الى حذف \nهل أنت متأكد؟ الخط حجم الخط زيل @@ -255,8 +245,7 @@ 🐈عرض لوجكات سجل صور في صور - %d -\nباقي + %d \nباقي مصدر اللاعب مخفي - ابحث عن المبلغ تكرار النسخ الاحتياطي diff --git a/app/src/main/res/values-b+as/strings.xml b/app/src/main/res/values-b+as/strings.xml index eb6ad4aa444..b7efb334148 100644 --- a/app/src/main/res/values-b+as/strings.xml +++ b/app/src/main/res/values-b+as/strings.xml @@ -76,7 +76,7 @@ প্ৰ\'ফাইল %d পিন সন্নিবিষ্ট কৰক একাউণ্ট সম্পাদনা কৰক - মিডিয়া + মিডিয়া ৰিচেট কৰক Cast: %s দেখি আছে @@ -97,7 +97,7 @@ CloudStream আনলক কৰক বায়\'মেট্ৰিক্সৰ সৈতে লক কৰক পাছৱাৰ্ড/পিন প্ৰমাণীকৰণ - সংগীত + সংগীত অডিঅ\' বুক Episode %d will be released in Season %1$d Episode %2$d will be released in @@ -252,12 +252,9 @@ পুনৰ আৰম্ভ কৰক -৩০ +৩০ - এইটো স্থায়ীভাৱে %s ডিলিট কৰিব। -\nআপুনি নিশ্চিত নেকি? - %dm -\nবাকী - %s -\nবাকী + এইটো স্থায়ীভাৱে %s ডিলিট কৰিব। \nআপুনি নিশ্চিত নেকি? + %dm \nবাকী + %s \nবাকী চলমান সম্পূৰ্ণ স্থিতি @@ -456,9 +453,7 @@ %d প্লাগইন আপডেট কৰা হ\'ল নিষ্ক্ৰিয় কৰা: %d ডাউনলোড কৰা নহয়: %d - CloudStreamত কোনো চাইট ডিফল্টভাৱে ইনষ্টল হোৱা নাই। আপুনিয়ে ৰিপ\'জিট\'ৰিবোৰৰ পৰা চাইটসমূহ ইনষ্টল কৰিব লাগিব। -\n -\nআমাৰ Discordত যোগদান কৰক বা অনলাইন বিচাৰক। + CloudStreamত কোনো চাইট ডিফল্টভাৱে ইনষ্টল হোৱা নাই। আপুনিয়ে ৰিপ\'জিট\'ৰিবোৰৰ পৰা চাইটসমূহ ইনষ্টল কৰিব লাগিব। \n \nআমাৰ Discordত যোগদান কৰক বা অনলাইন বিচাৰক। সম্প্ৰদায়ৰ ৰিপ\'জিট\'ৰিসমূহ চাওক সকলো চাবটাইটল মুকলি আখৰত সতর্কতা: CloudStream 3 কোৱা নাই যে তৃতীয় পক্ষৰ বৃদ্ধিসমূহ ব্যৱহাৰ কৰিবলৈ আপুনি সম্পূৰ্ণ দায়িত্ব ল\'ব আৰু কোনো সহায় নাপাব! @@ -493,7 +488,7 @@ মিশ্ৰিত সমাপ্তি মিশ্ৰিত উদ্‌ঘাটনী ইতিহাস পৰিস্কাৰ কৰক - স্বীকৃতি + স্বীকৃতি ভূমিকা ইতিহাস উদ্‌ঘাটনী/সমাপ্তিৰ বাবে এৰি দিয়াৰ পপআপ দেখুৱাওক @@ -523,11 +518,9 @@ বৰ্ণানুক্ৰমিক (A ৰ পৰা Z) পুথিভঁৰালী বাছক ইয়াৰ সহায়ত খুলক - আপোনাৰ পুথিভঁৰালী খালি আছে :( -\nএখন পুথিভঁৰালী একাউণ্টত লগ ইন কৰক বা স্থানীয় পুথিভঁৰালীত শ্ব\'সমূহ যোগ কৰক। + আপোনাৰ পুথিভঁৰালী খালি আছে :( \nএখন পুথিভঁৰালী একাউণ্টত লগ ইন কৰক বা স্থানীয় পুথিভঁৰালীত শ্ব\'সমূহ যোগ কৰক। এই তালিকা খালি। অন্য এটি তালিকালৈ সলনি কৰি চাওক। - নিরাপদ ম\'ড ফাইল পোৱা গৈছে! -\nফাইল আঁতৰোৱা নোহোৱালৈকে কোনো এক্সটেনশ্যন আৰম্ভ নকৰা হৈছে। + নিরাপদ ম\'ড ফাইল পোৱা গৈছে! \nফাইল আঁতৰোৱা নোহোৱালৈকে কোনো এক্সটেনশ্যন আৰম্ভ নকৰা হৈছে। ঘূৰাই দিয়া সদস্যতা গ্ৰহণ কৰা শ্ব\'সমূহ আপডেট কৰিছে %s-ত সদস্যতা গ্ৰহণ কৰা হৈছে @@ -539,13 +532,7 @@ সম্পাদনা কৰক প্ৰ\'ফাইলসমূহ সহায় - ইয়াত আপুনি উৎসসমূহ কেনেকৈ ক্ৰম অনুযায়ী চিহ্নিত কৰিব সেই বিষয়ে সলনি কৰিব পাৰে। যদি এখন ভিডিঅ\'ৰ উচ্চ অগ্ৰাধিকাৰ থাকে তেন্তে সেইটো উৎস নিৰ্বাচনৰ সময়ত ওপৰত দেখা যাব। উৎস অগ্ৰাধিকাৰ আৰু গুণ অগ্ৰাধিকাৰৰ মুঠ যোগফল হৈছে ভিডিঅ\'ৰ অগ্ৰাধিকাৰ। -\n -\nউৎস A: 3 -\nগুণ B: 7 -\nসৰহযোগে ভিডিঅ\'ৰ অগ্ৰাধিকাৰ হ\'ব 10। -\n -\nদ্ৰষ্টব্য: যদি যোগফল 10 বা তাৰ ওপৰত থাকে তেন্তে প্লেয়াৰটো নিজেই লিংক ল\'ড কৰাৰ সময়ত ল\'ডিং এড়াই যাব! + ইয়াত আপুনি উৎসসমূহ কেনেকৈ ক্ৰম অনুযায়ী চিহ্নিত কৰিব সেই বিষয়ে সলনি কৰিব পাৰে। যদি এখন ভিডিঅ\'ৰ উচ্চ অগ্ৰাধিকাৰ থাকে তেন্তে সেইটো উৎস নিৰ্বাচনৰ সময়ত ওপৰত দেখা যাব। উৎস অগ্ৰাধিকাৰ আৰু গুণ অগ্ৰাধিকাৰৰ মুঠ যোগফল হৈছে ভিডিঅ\'ৰ অগ্ৰাধিকাৰ। \n \nউৎস A: 3 \nগুণ B: 7 \nসৰহযোগে ভিডিঅ\'ৰ অগ্ৰাধিকাৰ হ\'ব 10। \n \nদ্ৰষ্টব্য: যদি যোগফল 10 বা তাৰ ওপৰত থাকে তেন্তে প্লেয়াৰটো নিজেই লিংক ল\'ড কৰাৰ সময়ত ল\'ডিং এড়াই যাব! প্ৰ\'ফাইলৰ পটভূমি UI সঠিকভাৱে সৃষ্টি কৰিব পৰা নগ\'ল, ই এটা গুৰুত্বপূৰ্ণ সমস্যা আৰু তাক অবিলম্বে জনোৱা উচিত %s আপুনি ইতিমধ্যে ভোট দিছে @@ -556,14 +543,8 @@ প্ৰিয় তালিকাৰ পৰা আঁতৰ কৰক সম্ভাৱ্য নকল বস্ত্ত পোৱা গৈছে সকলো প্ৰতিস্থাপন কৰক - আপোনাৰ পুথিভঁৰালীতে সম্ভাৱ্য নকল বস্তু পোৱা গৈছে: \'%s.\' -\n -\nআপুনি এই বস্তু যিকোনো হ\'লে যোগ কৰিব বিচাৰেনে, বিদ্যমান বস্তু প্ৰতিস্থাপন কৰিব, বা কাৰ্য বাতিল কৰিব বিচাৰেনে? - আপোনাৰ পুথিভঁৰালীতে সম্ভাৱ্য নকল বস্তুসমূহ পোৱা গৈছে: -\n -\n%s -\n -\nআপুনি এই বস্তু যিকোনো হ\'লে যোগ কৰিব বিচাৰেনে, বিদ্যমানবোৰ প্ৰতিস্থাপন কৰিব, বা কাৰ্য বাতিল কৰিব বিচাৰেনে? + আপোনাৰ পুথিভঁৰালীতে সম্ভাৱ্য নকল বস্তু পোৱা গৈছে: \'%s.\' \n \nআপুনি এই বস্তু যিকোনো হ\'লে যোগ কৰিব বিচাৰেনে, বিদ্যমান বস্তু প্ৰতিস্থাপন কৰিব, বা কাৰ্য বাতিল কৰিব বিচাৰেনে? + আপোনাৰ পুথিভঁৰালীতে সম্ভাৱ্য নকল বস্তুসমূহ পোৱা গৈছে: \n \n%s \n \nআপুনি এই বস্তু যিকোনো হ\'লে যোগ কৰিব বিচাৰেনে, বিদ্যমানবোৰ প্ৰতিস্থাপন কৰিব, বা কাৰ্য বাতিল কৰিব বিচাৰেনে? %s ৰ বাবে পিন সন্নিবিষ্ট কৰক বৰ্তমান পিন সন্নিবিষ্ট কৰক প্ৰফাইল লক কৰক @@ -588,8 +569,7 @@ ডাউনলোড এপ্‌ আৰম্ভণিৰ পিছত নতুন আপডেটৰ সন্ধান কৰক। একেই ডেভেলপাৰৰ দ্বাৰা এনিম এপ্‌ - নতুন আপডেট পোৱা গ’ল! -\n%1$s -> %2$s + নতুন আপডেট পোৱা গ’ল! \n%1$s -> %2$s ফিলাৰ CloudStreamৰে প্লে কৰক সন্ধান @@ -609,18 +589,10 @@ প্ৰয়োগ কৰক ফাইলসমূহ ডিলিট কৰক ডিলিট (%1$d | %2$s) - আপুনি স্থায়ীভাৱে তলত দিয়া আইটেমসমূহ ডিলিট কৰিবলৈ নিশ্চিত নেকি? -\n -\n%s - %1$s ত তলত দিয়া এপিচ’ডসমূহ স্থায়ীভাৱে ডিলিট কৰিব নেকি? -\n -\n%2$s - আপুনি দিয়া ছিৰিজৰ সকলো এপিচ’ড স্থায়ীভাৱে ডিলিট কৰিব: -\n -\n%s - আপুনি দিয়া ছিৰিজৰ সকলো এপিচ’ড স্থায়ীভাৱে ডিলিট কৰিবলৈ নিশ্চিত নেকি? -\n -\n%s + আপুনি স্থায়ীভাৱে তলত দিয়া আইটেমসমূহ ডিলিট কৰিবলৈ নিশ্চিত নেকি? \n \n%s + %1$s ত তলত দিয়া এপিচ’ডসমূহ স্থায়ীভাৱে ডিলিট কৰিব নেকি? \n \n%2$s + আপুনি দিয়া ছিৰিজৰ সকলো এপিচ’ড স্থায়ীভাৱে ডিলিট কৰিব: \n \n%s + আপুনি দিয়া ছিৰিজৰ সকলো এপিচ’ড স্থায়ীভাৱে ডিলিট কৰিবলৈ নিশ্চিত নেকি? \n \n%s মুক্তিৰ তাৰিখ (পুৰণাৰ পৰা নতুন) সতৰ্কবাৰ্তা স্থানীয়ভাৱে প্ৰমাণীকৰণ কৰক @@ -656,8 +628,8 @@ এই ভিডিওটো টৰেন্ট। এই অৰ্থ হৈছে যে আপোনাৰ ভিডিও কাৰ্যকলাপ ট্ৰেক কৰা হ\'ব পাৰে।\nআগবঢ়িবলৈ আগতে টৰেণ্টিং বুজি পোৱা নিশ্চিত কৰক। চুকৰ মাপ প্ৰথম উপলব্ধটো লোড কৰক - অডিঅ\' - প’ডকাষ্ট + অডিঅ\' + প’ডকাষ্ট এনকোডিং ত্ৰুটি সমৰ্থিত ত্ৰুটি গ্ৰহণ কৰা হোৱা নাই টৰেণ্ট diff --git a/app/src/main/res/values-b+az/strings.xml b/app/src/main/res/values-b+az/strings.xml index ffbd9d37d88..b8a1f9e2e41 100644 --- a/app/src/main/res/values-b+az/strings.xml +++ b/app/src/main/res/values-b+az/strings.xml @@ -118,7 +118,7 @@ Torrent Sənədli film Yükləmə xətası, yaddaş icazələrini yoxlayın - Media + Media Kodlama xətası Cizgi filmləri Anime-lər @@ -135,10 +135,10 @@ Canlı yayım +18 məzmun Video - Musiqi + Musiqi Səsli Kitab - Səs - Podkast + Səs + Podkast Mənbə xətası Server xətası Dəstəklənməyən xəta diff --git a/app/src/main/res/values-b+bg/strings.xml b/app/src/main/res/values-b+bg/strings.xml index 096e9f66b3d..4fb7757fd6f 100644 --- a/app/src/main/res/values-b+bg/strings.xml +++ b/app/src/main/res/values-b+bg/strings.xml @@ -16,8 +16,7 @@ Визуализация на фона Скорост (%.2fx) Оценка: %.1f - Намерена е нова актуализация! -\n%1$s -> %2$s + Намерена е нова актуализация! \n%1$s -> %2$s Шаблон %d мин CloudStream @@ -183,10 +182,8 @@ Продължи -30 30 - Това ще изтрие за постоянно %s -\nСигурни ли сте? - %dm -\nостава + Това ще изтрие за постоянно %s \nСигурни ли сте? + %dm \nостава Продължава Завършен Статус @@ -405,9 +402,7 @@ Деактивирано: %d Не е изтеглено: %d Актуализирани %d плъгини - CloudStream няма инсталирани сайтове по подразбиране. Трябва да инсталирате сайтовете от хранилища. -\n -\nПрисъединете се към нашия Дискорд или потърсете онлайн. + CloudStream няма инсталирани сайтове по подразбиране. Трябва да инсталирате сайтовете от хранилища. \n \nПрисъединете се към нашия Дискорд или потърсете онлайн. Вижте хранилищата на общността Публичен списък Всички субтитри с главни букви @@ -451,7 +446,7 @@ Изтегля се актуализация на приложението… Смесено отваряне Смесено затваряне - Кредити + Кредити въведение Изчистване на историята Автоматично инсталиране на всички все още неинсталирани добавки от добавени хранилища. @@ -519,12 +514,10 @@ Профил %d По азбучен ред (A до Z) Отваряне с - Вашата библиотека е празна :( -\nВпишете се в акаунт с библиотеки или добавете сериали в локалната Ви библиотека. + Вашата библиотека е празна :( \nВпишете се в акаунт с библиотеки или добавете сериали в локалната Ви библиотека. Използване Епизод %d е публикуван! - Намерен е файл за безопасен режим! -\nНяма да се зареждат никакви разширения при стартиране, докато файлът не бъде премахнат. + Намерен е файл за безопасен режим! \nНяма да се зареждат никакви разширения при стартиране, докато файлът не бъде премахнат. Вече сте гласували Задаване по подразбиране ПИН трябва да е 4 символа @@ -551,23 +544,11 @@ Скрит играч - сума за търсене Сумата за търсене, използвана, когато играчът е скрит Актуализирано (от ново към старо) - Тук можете да промените начина на подреждане на източниците. Ако даден видеоклип има по-висок приоритет, той ще се показва по-високо в избора на източник. Сборът от приоритета на източника и приоритета на качеството е приоритетът на видеото. -\n -\nИзточник A: 3 -\nКачество B: 7 -\nЩе има комбиниран видео приоритет от 10. -\n -\nЗАБЕЛЕЖКА: Ако сумата е 10 или повече, играчът автоматично ще пропусне зареждането, когато тази връзка се зареди! + Тук можете да промените начина на подреждане на източниците. Ако даден видеоклип има по-висок приоритет, той ще се показва по-високо в избора на източник. Сборът от приоритета на източника и приоритета на качеството е приоритетът на видеото. \n \nИзточник A: 3 \nКачество B: 7 \nЩе има комбиниран видео приоритет от 10. \n \nЗАБЕЛЕЖКА: Ако сумата е 10 или повече, играчът автоматично ще пропусне зареждането, когато тази връзка се зареди! Замени Замени Всички - Изглежда, че потенциално дублиран елемент вече съществува във вашата библиотека: „%s“. -\n -\nИскате ли все пак да добавите този елемент, да замените съществуващия или да отмените действието? - Във вашата библиотека са намерени потенциални дублиращи се елементи: -\n -\n%s -\n -\nИскате ли все пак да добавите този елемент, да замените съществуващите или да отмените действието? + Изглежда, че потенциално дублиран елемент вече съществува във вашата библиотека: „%s“. \n \nИскате ли все пак да добавите този елемент, да замените съществуващия или да отмените действието? + Във вашата библиотека са намерени потенциални дублиращи се елементи: \n \n%s \n \nИскате ли все пак да добавите този елемент, да замените съществуващите или да отмените действието? Заключи Профил Вкарай Сегашен ПИН Управлявай Профили @@ -645,8 +626,8 @@ Започва процесът на актуализация на плъгините! Зареди първия наличен Няма заредени субтитри още - Звук - Подкаст + Звук + Подкаст Автентикация локално Заключване с биометрия Отвори хранилище @@ -654,7 +635,7 @@ PIN кодът е изтекъл ! CloudStream Уики Удостоверяване с парола/PIN - Медия + Медия Аудиокнига Отхвърли Отключете приложението с отпечатък, Face ID, PIN, шаблон или парола. @@ -682,7 +663,7 @@ Посетете %s на вашия смартфон или компютър и въведете горния код Кодът изтича след %1$dm %2$ds Изберете устройство за излъчване - Музика + Музика Любим За да гарантираме непрекъснати изтегляния и известия за абонирани телевизионни шоута, CloudStream се нуждае от разрешение да работи в фонов режим. Като натиснете ДОБРЕ, ще видите диалогов прозорец с искане. Моля, натиснете \"Позволи\".\n\nМоля, имайте предвид, че това разрешение не означава, че CS3 ще изтощава батерията ви. То ще работи във фонов режим само когато е необходимо, като например при получаване на известия или изтегляне на видеоклипове от официални разширения. Изображение на QR код diff --git a/app/src/main/res/values-b+bn/strings.xml b/app/src/main/res/values-b+bn/strings.xml index 87aa8e7eb04..adc1b3f19f8 100644 --- a/app/src/main/res/values-b+bn/strings.xml +++ b/app/src/main/res/values-b+bn/strings.xml @@ -15,8 +15,7 @@ ব্যাকগ্রাউন্ড দেখান গতি (%.2f গুণ) মূল্যায়নঃ %.1f - নতুন আপডেট এসেছে! -\n%1$s -> %2$s + নতুন আপডেট এসেছে! \n%1$s -> %2$s ফিলার %d মিনিট ক্লাউডস্ট্রিম @@ -159,8 +158,7 @@ সিনেমা ডিসকর্ডে যোগ দিন টরেন্টস - এটি স্থায়ীভাবে মুছে ফেলা হবে %s -\nআপনি কি নিশ্চিত? + এটি স্থায়ীভাবে মুছে ফেলা হবে %s \nআপনি কি নিশ্চিত? থামুন -৩০ গিটহাব @@ -184,8 +182,7 @@ ব্যবহৃত লাইব্রেরী আমাদের তৈরি ছোট উপন্যাস পড়ার অ্যাপ্লিকেশন - %d মি -\nবাকি + %d মি \nবাকি অন্যান্য চলমান এশিয়ান নাটক @@ -308,8 +305,7 @@ password123 আসছে %s সময়ের মধ্যে বাতিল করুন - %s -\nঅবশিষ্ট + %s \nঅবশিষ্ট লাইভ স্ট্রিম সোর্স সমস্যা রিমোট সমস্যা diff --git a/app/src/main/res/values-b+cs/strings.xml b/app/src/main/res/values-b+cs/strings.xml index 96110d9c1e1..983a03db7e4 100644 --- a/app/src/main/res/values-b+cs/strings.xml +++ b/app/src/main/res/values-b+cs/strings.xml @@ -16,8 +16,7 @@ Rychlost (%.2fx) Hodnocení: %.1f - Nalezena nová aktualizace! -\n%1$s -> %2$s + Nalezena nová aktualizace! \n%1$s -> %2$s Výplň %d min CloudStream @@ -172,10 +171,8 @@ Pokračovat -30 +30 - Toto nevratně smaže %s -\nJste si jisti? - %dm -\nzbývá + Toto nevratně smaže %s \nJste si jisti? + %dm \nzbývá Probíhající Dokončena Stav @@ -416,9 +413,7 @@ Doplněk stažen 18+ Spuštěno stahování %1$d %2$s… - CloudStream nemá ve výchozím nastavení nainstalované žádné zdroje. Je třeba je nainstalovat z repozitářů. -\n -\nPřipojte se na náš Discord nebo hledejte na internetu. + CloudStream nemá ve výchozím nastavení nainstalované žádné zdroje. Je třeba je nainstalovat z repozitářů. \n \nPřipojte se na náš Discord nebo hledejte na internetu. Zakázáno: %d Aktualizováno %d doplňků Zobrazit informace o pádu @@ -438,7 +433,7 @@ Vymazat historii Všechny jazyky Smíšený úvod - Poděkování + Poděkování Znělka Zobrazit vyskakovací okna pro přeskočení úvodu/konce Stahování aktualizace aplikace… @@ -446,8 +441,7 @@ Nepodařilo se nainstalovat novou verzi aplikace Původní Aplikace bude po ukončení aktualizována - Vaše knihovna je prázdná :( -\nPřihlaste se k účtu v knihovně nebo přidejte pořady do místní knihovny. + Vaše knihovna je prázdná :( \nPřihlaste se k účtu v knihovně nebo přidejte pořady do místní knihovny. Vybrat knihovnu Hodnocení (od nejvyššího) Hodnocení (od nejnižšího) @@ -455,8 +449,7 @@ Seřadit podle Řazení Tento seznam je prázdný. Zkuste přepnout na jiný. - Nalezen soubor bezpečného režimu! -\nDo odebrání souboru nebudeme načítat žádná rozšíření. + Nalezen soubor bezpečného režimu! \nDo odebrání souboru nebudeme načítat žádná rozšíření. Aktualizováno (od nejnovějšího) Aktualizováno (od nejstaršího) Abecedně (od A do Z) @@ -537,13 +530,7 @@ Nápověda Kvality Pozadí profilu - Zde můžete změnit pořadí zdrojů. Pokud má video vyšší prioritu, objeví se ve výběru zdrojů výše. Součet priority zdroje a priority kvality je priorita videa. -\n -\nZdroj A: 3 -\nKvalita B: 7 -\nBudou mít celkovou prioritu videa 10. -\n -\nPOZNÁMKA: Pokud je součet 10 nebo vyšší, přehrávač automaticky přeskočí načítání při načtení daného odkazu! + Zde můžete změnit pořadí zdrojů. Pokud má video vyšší prioritu, objeví se ve výběru zdrojů výše. Součet priority zdroje a priority kvality je priorita videa. \n \nZdroj A: 3 \nKvalita B: 7 \nBudou mít celkovou prioritu videa 10. \n \nPOZNÁMKA: Pokud je součet 10 nebo vyšší, přehrávač automaticky přeskočí načítání při načtení daného odkazu! Nepodařilo se správně vytvořit rozhraní. Toto je VÁŽNÁ CHYBA, kterou je potřeba ihned nahlásit %s Vypnout Výběr režimu pro filtrování stahování doplňků @@ -553,11 +540,7 @@ %s odebráno z oblíbených Oblíbené %s přidáno do oblíbených - Ve vaší knihovně byl nalezen potenciální duplikát: -\n -\n%s -\n -\nChcete přesto přidat tuto položku, nahradit existující nebo zrušit akci? + Ve vaší knihovně byl nalezen potenciální duplikát: \n \n%s \n \nChcete přesto přidat tuto položku, nahradit existující nebo zrušit akci? Frekvence záloh Nalezena potenciální duplicita Zamknout profil @@ -571,9 +554,7 @@ Odebírat Odebrat z oblíbených Vyberte účet - Vypadá to, že ve vaší knihovně již existuje potenciální duplikát: „%s“. -\n -\nChcete přesto přidat tuto položku, nahradit existující nebo zrušit akci? + Vypadá to, že ve vaší knihovně již existuje potenciální duplikát: „%s“. \n \nChcete přesto přidat tuto položku, nahradit existující nebo zrušit akci? Zadejte PIN PIN Zadejte současný PIN @@ -602,8 +583,7 @@ Po několika nezdařilých pokusech se okno zavře. Pro opětovný pokus restartujte aplikaci. Vaše data z aplikace CloudStream byla nyní zálohována. Ačkoli je tato možnost velmi malá, různá zařízení se mohou chovat různě. Ve výjimečném případě, že se vám přístup k aplikaci zablokuje, data aplikace zcela vymažte a obnovte je ze zálohy. Velmi se omlouváme za případné nepříjemnosti z toho plynoucí. Odebrat z oblíbených - %s -\nzbývá + %s \nzbývá Přidat do oblíbených Název a adresa repozitáře Chyba při kopírování, zkopírujte prosím protokol a kontaktujte podporu aplikace. @@ -612,8 +592,8 @@ OK Využití baterie aplikací je již nastaveno na neomezené Nepodařilo se otevřít informace o aplikaci CloudStream. - Hudba - Média + Hudba + Média Zakažte optimalizace baterie Aby bylo zajištěno nepřetržité stahování a upozornění na odebírané seriály, potřebuje aplikace CloudStream povolení ke spuštění na pozadí. Po stisknutí tlačítka OK se vám zobrazí dialog se žádostí. Stiskněte prosím „Povolit“.\n\nUpozorňujeme, že toto oprávnění neznamená, že CS3 bude vybíjet baterii. Na pozadí bude pracovat pouze v případě potřeby, například při přijímání oznámení nebo stahování videí z oficiálních rozšíření. Audiokniha @@ -644,20 +624,12 @@ Zvolte položky k odstranění Dostupné pro sledování offline Vybrat vše - Opravdu chcete trvale odstranit následující položky? -\n -\n%s - Opravdu chcete trvale odstranit následující epizody v %1$s? -\n -\n%2$s - Opravdu chcete trvale odstranit všechny epizody v následujících sériích? -\n -\n%s + Opravdu chcete trvale odstranit následující položky? \n \n%s + Opravdu chcete trvale odstranit následující epizody v %1$s? \n \n%2$s + Opravdu chcete trvale odstranit všechny epizody v následujících sériích? \n \n%s Zrušit výběr všeho Odstranit soubory - Také trvale odstraníte všechny epizody v následujících sériích: -\n -\n%s + Také trvale odstraníte všechny epizody v následujících sériích: \n \n%s Odstranit (%1$d | %2$s) Náhled v liště přehrávače Povolit náhled miniatur na liště přehrávače @@ -670,8 +642,8 @@ Vlastní Toto video je torrent, což znamená, že lze sledovat vaší aktivitu v něm.\nPřed pokračováním se ujistěte, že chápete, co to je torrentování. Velikost okraje - Zvuk - Podcast + Zvuk + Podcast Chyba kódování Nepodporovaná chyba Načíst první dostupné @@ -779,4 +751,7 @@ Priorita zdrojů Rozhodněte, jak mají být řazeny zdroje videí v přehrávači Zobrazit překrytí metadat v přehrávači + Video + Náhled + Živě diff --git a/app/src/main/res/values-b+de/strings.xml b/app/src/main/res/values-b+de/strings.xml index 9a67f9d204b..6ccb9bc69e5 100644 --- a/app/src/main/res/values-b+de/strings.xml +++ b/app/src/main/res/values-b+de/strings.xml @@ -13,7 +13,7 @@ Chromecast-Mirror In App wiedergeben Gemischte Openings - Abspann + Abspann Intro Verlauf löschen Verlauf @@ -27,8 +27,7 @@ Hintergrundbildvorschau Geschwindigkeit (%.2fx) Bewertung: %.1f - Neues Update gefunden! -\n%1$s -> %2$s + Neues Update gefunden! \n%1$s -> %2$s Füller %d Min CloudStream @@ -188,10 +187,8 @@ Fortsetzen -30 +30 - Dadurch wird %s permanent gelöscht -\nBist du dir sicher? - %dm -\nverbleibend + Dadurch wird %s permanent gelöscht \nBist du dir sicher? + %dm \nverbleibend Laufend Abgeschlossen Status @@ -401,9 +398,7 @@ Heruntergeladen: %d Deaktiviert: %d Nicht heruntergeladen: %d - CloudStream hat standardmäßig keine Websites installiert. Websites müssen aus Repositories installiert werden. -\n -\nTrete unserem Discord Server bei oder suche online. + CloudStream hat standardmäßig keine Websites installiert. Websites müssen aus Repositories installiert werden. \n \nTrete unserem Discord Server bei oder suche online. Community-Repositories anzeigen Öffentliche Liste Alle Untertitel in Großbuchstaben @@ -484,11 +479,9 @@ Alphabetisch (Z zu A) Bibliothek auswählen Öffnen mit - Deine Bibliothek ist leer :( -\nMelde dich mit einem Bibliothekskonto an oder füge Titel zu deiner lokalen Bibliothek hinzu. + Deine Bibliothek ist leer :( \nMelde dich mit einem Bibliothekskonto an oder füge Titel zu deiner lokalen Bibliothek hinzu. Diese Liste ist leer. Versuch zu einer anderen Liste zu wechseln. - Datei für den abgesicherten Modus gefunden! -\nBeim Start werden keine Erweiterungen geladen, bis die Datei entfernt wird. + Datei für den abgesicherten Modus gefunden! \nBeim Start werden keine Erweiterungen geladen, bis die Datei entfernt wird. Player ausgeblendet - Betrag zum vor- und zurückspulen Der Betrag, welcher verwendet wird, wenn der Player eingeblendet ist Der Betrag, welcher verwendet wird, wenn der Player ausgeblendet ist @@ -522,13 +515,7 @@ Hilfe Qualitäten Profil-Hintergrund - Hier kannst du verändern, wie die Quellen geordnet werden. Wenn ein Video eine höhere Priorität hat, wird es höher in der Quellenauswahl erscheinen. Die Summe der Quellenpriorität und der Qualitätspriorität ist die Videopriorität. -\n -\nQuelle A: 3 -\nQualität B: 7 -\nWerden eine kombinierte Videopriorität von 10 haben. -\n -\nHINWEIS: Wenn die Summe 10 oder mehr beträgt, überspringt der Player automatisch das Laden, wenn der Link geladen wird! + Hier kannst du verändern, wie die Quellen geordnet werden. Wenn ein Video eine höhere Priorität hat, wird es höher in der Quellenauswahl erscheinen. Die Summe der Quellenpriorität und der Qualitätspriorität ist die Videopriorität. \n \nQuelle A: 3 \nQualität B: 7 \nWerden eine kombinierte Videopriorität von 10 haben. \n \nHINWEIS: Wenn die Summe 10 oder mehr beträgt, überspringt der Player automatisch das Laden, wenn der Link geladen wird! Filtermodus für Plugin-Downloads auswählen Es wurde bereits abgestimmt Keine Plugins im Repository gefunden @@ -560,14 +547,8 @@ Kontoauswahl beim Starten überspringen Konten verwalten Konto bearbeiten - Es wurden potentielle Duplikate in deiner Bibliothek gefunden: -\n -\n%s -\n -\nMöchtest du dieses Element dennoch hinzufügen, das existierende ersetzen oder diese Aktion abbrechen? - Deine Bibliothek enthält möglicherweise schon ein Duplikat dieses Elements: \'%s\' -\n -\nMöchtest du dieses Element dennoch hinzufügen, das existierende ersetzen oder diese Aktion abbrechen? + Es wurden potentielle Duplikate in deiner Bibliothek gefunden: \n \n%s \n \nMöchtest du dieses Element dennoch hinzufügen, das existierende ersetzen oder diese Aktion abbrechen? + Deine Bibliothek enthält möglicherweise schon ein Duplikat dieses Elements: \'%s\' \n \nMöchtest du dieses Element dennoch hinzufügen, das existierende ersetzen oder diese Aktion abbrechen? Links wurden neu geladen Drehen Zeige einen Umschalter für Bildschirmorientierung an @@ -587,8 +568,7 @@ kein Favorit Dieser Bildschirm wurde nach einigen Fehlversuchen geschlossen. Starte die App neu. Ihre CloudStream-Daten wurden gesichert. Obwohl die Wahrscheinlichkeit dieses seltenen Falles sehr gering ist, verhalten sich alle Geräte unterschiedlich. Falls Sie im schlimmsten Fall den Zugriff zur App verlieren, löschen Sie die App-Daten vollständig und stellen Sie die Sicherung wieder her. Jegliche Unannehmlichkeiten, die Ihnen dadurch entstehen, bedauern wir sehr. - %s -\nausstehend + %s \nausstehend Favorit Kopiert! Beim kopieren ist ein Fehler aufgetreten, bitte kopieren sie logical und wenden sich an den Support. @@ -596,9 +576,9 @@ Repository Name und URL OK Akku-Optimierung deaktivieren - Musik + Musik Hörbuch - Medien + Medien Zurücksetzen Akkuverbrauch der App ist bereits auf unbeschränkt eingestellt CloudStreams App-Info kann nicht geöffnet werden. @@ -630,18 +610,10 @@ Vom Beginn an spielen Elemente zum Löschen auswählen Zum Offline-Ansehen verfügbar - Bist du dir sicher, dass du die folgenden Elemente permanent löschen willst? -\n -\n%s - Bist du dir sicher, dass du die folgenden Episoden in %1$s permanent löschen willst? -\n -\n%2$s - Du wirst ebenfalls alle Episoden der folgenden Serien permanent löschen: -\n -\n%s - Bist du dir sicher, dass du alle Episoden der folgenden Serien permanent löschen willst? -\n -\n%s + Bist du dir sicher, dass du die folgenden Elemente permanent löschen willst? \n \n%s + Bist du dir sicher, dass du die folgenden Episoden in %1$s permanent löschen willst? \n \n%2$s + Du wirst ebenfalls alle Episoden der folgenden Serien permanent löschen: \n \n%s + Bist du dir sicher, dass du alle Episoden der folgenden Serien permanent löschen willst? \n \n%s Veröffentlichungsdatum (von neu nach alt) Veröffentlichungsdatum (von alt nach neu) Suchleisten Vorschau @@ -655,7 +627,7 @@ Nicht anzeigen Kantengröße Dieses Video ist ein Torrent, das bedeutet, dass Ihre Videoaktivitäten nachverfolgt werden könen.\nStellen Sie sicher, dass Sie die Grundlagen von Torrents verstehen, bevor Sie fortfahren. - Ton + Ton Episode (Absteigend) Bewertung (Höchste) Bewertung (Niedrigste) @@ -669,7 +641,7 @@ Plugins aktualisieren %d Plugin(s) erfolgreich aktualisiert! Plugins manuell aktualisieren - Podcast + Podcast Nicht unterstützter Fehler Kodierungsfehler Ep %s diff --git a/app/src/main/res/values-b+el/strings.xml b/app/src/main/res/values-b+el/strings.xml index 4b671644bda..a38ee2682d3 100644 --- a/app/src/main/res/values-b+el/strings.xml +++ b/app/src/main/res/values-b+el/strings.xml @@ -110,8 +110,7 @@ Μπανάνα δόθηκε Ταχύτητα (%.2fx) Βαθμολογία: %.1f - Νέα διαθέσιμη ενημέρωση! -\n%1$s -> %2$s + Νέα διαθέσιμη ενημέρωση! \n%1$s -> %2$s Πατήστε δύο φορές στη μέση για παύση Χρήση φωτεινότητας συστήματος Χρήση φωτεινότητας συστήματος στο ενσωματωμένο πρόγραμμα αναπαραγωγής, αντί εφαρμογής προεπιλεγμένου σκούρου επικαλύμματος @@ -149,10 +148,8 @@ Ακύρωση Παύση Συνέχιση - Αυτό θα διαγράψει μόνιμα το %s -\nΕίστε σίγουροι πως θέλετε να προχωρήσετε; - %dm -\nαπομένουν + Αυτό θα διαγράψει μόνιμα το %s \nΕίστε σίγουροι πως θέλετε να προχωρήσετε; + %dm \nαπομένουν Σε εξέλιξη Κατάσταση Έτος @@ -323,9 +320,7 @@ Απενεργοποιήθηκε: %d Δεν κατέβηκε: %d Ενημερώθηκαν %d πρόσθετα - Το CloudStream δεν έχει προεγκατεστημένους ιστότοπους. Πρέπει να εγκαταστήσετε ιστότοπους μέσω ορισμένων αποθετηρίων. -\n -\nΒρείτε μας στο Discord ή ψάξτε στο διαδίκτυο. + Το CloudStream δεν έχει προεγκατεστημένους ιστότοπους. Πρέπει να εγκαταστήσετε ιστότοπους μέσω ορισμένων αποθετηρίων. \n \nΒρείτε μας στο Discord ή ψάξτε στο διαδίκτυο. Προβολή αποθετηρίων κοινότητας Δημόσια λίστα Κεφαλοποίηση υποτίτλων @@ -389,7 +384,7 @@ Web HDR Ανάμεικτοι τίτλοι αρχής - Εύσημα + Εύσημα Εισαγωγή +30 Ολοκληρώθηκε @@ -487,23 +482,15 @@ Αφαίρεση από παρακολουθημένα Περιηγητής Άνοιγμα με - Η βιβλιοθήκη σας είναι άδεια :( -\nΣυνδεθείτε με έναν λογαριασμό βιβλιοθήκης ή προσθέστε σειρές στην τοπική βιβλιοθήκη σας. - Βρέθηκε αρχείο Ασφαλούς Λειτουργίας! -\nΔεν πρόκειται να φορτωθούν extensions κατά το ξεκίνημα μέχρι να διαγραφεί το αρχείο. + Η βιβλιοθήκη σας είναι άδεια :( \nΣυνδεθείτε με έναν λογαριασμό βιβλιοθήκης ή προσθέστε σειρές στην τοπική βιβλιοθήκη σας. + Βρέθηκε αρχείο Ασφαλούς Λειτουργίας! \nΔεν πρόκειται να φορτωθούν extensions κατά το ξεκίνημα μέχρι να διαγραφεί το αρχείο. Αρχείο Καταγραφής Απέτυχε Πέτυχε Εκκίνηση Δε βρέθηκαν επεκτάσεις στο αποθετήριο Δε βρέθηκε αποθετήριο, ελέγξτε την URL και δοκιμάστε VPN - Από εδώ μπορείτε να αλλάξετε τον τρόπο σειράς των πηγών. Εάν ένα βίντεο έχει υψηλότερη προτεραιότητα, θα εμφανίζεται ψηλότερα στην επιλογή πηγής. Το άθροισμα της προτεραιότητας πηγής και της ποιότητας, είναι η προτεραιότητα του βίντεο. -\n -\nΠηγή Α: 3 -\nΠοιότητα Β: 7 -\nΘα έχει συνδυασμένη προτεραιότητα βίντεο 10. -\n -\nΣΗΜΕΙΩΣΗ: Εάν το άθροισμα είναι 10 ή περισσότερο, η συσκευή αναπαραγωγής θα παραλείψει αυτόματα τη φόρτωση όταν φορτωθεί αυτός ο σύνδεσμος! + Από εδώ μπορείτε να αλλάξετε τον τρόπο σειράς των πηγών. Εάν ένα βίντεο έχει υψηλότερη προτεραιότητα, θα εμφανίζεται ψηλότερα στην επιλογή πηγής. Το άθροισμα της προτεραιότητας πηγής και της ποιότητας, είναι η προτεραιότητα του βίντεο. \n \nΠηγή Α: 3 \nΠοιότητα Β: 7 \nΘα έχει συνδυασμένη προτεραιότητα βίντεο 10. \n \nΣΗΜΕΙΩΣΗ: Εάν το άθροισμα είναι 10 ή περισσότερο, η συσκευή αναπαραγωγής θα παραλείψει αυτόματα τη φόρτωση όταν φορτωθεί αυτός ο σύνδεσμος! Δοκιμή παρόχου Προτιμώμενη ποιότητας παρακολούθησης (Δεδομένα τηλεφώνου) Διακομιστής μεσολάβησης GitHub @@ -549,9 +536,7 @@ Εντάξει Απενεργοποιήση της εξοικονόμησης της μπαταρίας Έχετε ήδη ψηφίσει - Φαίνεται πως ένα πιθανό αντίγραφο βρίσκεται στη βιβλιοθήκη σας: \'%s.\' -\n -\nΘα επιθυμούσατε να το προσθέσετε, να το αντικαταστήσετε, ή να ακυρώσετε την ενέργεια; + Φαίνεται πως ένα πιθανό αντίγραφο βρίσκεται στη βιβλιοθήκη σας: \'%s.\' \n \nΘα επιθυμούσατε να το προσθέσετε, να το αντικαταστήσετε, ή να ακυρώσετε την ενέργεια; Εισαγωγή Τρέχον Κωδικού Κλείδωμα Προφίλ Ξεκλείδωμα Cloudstream @@ -578,11 +563,7 @@ Πιθανό αντίγραφο βρέθηκε Προσθήκη Αντικατάσταση - Πιθανά διπλά αρχεία βρέθηκαν στην βιβλιοθήκη: -\n -\n%s -\n -\nΘα επιθυμούσατε να προσθέσετε αυτό το αρχείο ούτως η άλλως, να αντικαταστήσετε τα ήδη υπάρχοντα, ή να ακυρώσετε την ενέργεια? + Πιθανά διπλά αρχεία βρέθηκαν στην βιβλιοθήκη: \n \n%s \n \nΘα επιθυμούσατε να προσθέσετε αυτό το αρχείο ούτως η άλλως, να αντικαταστήσετε τα ήδη υπάρχοντα, ή να ακυρώσετε την ενέργεια? Εισαγωγή Κωδικού για %s Κωδικός Εσφαλμένος Κωδικός. Προσπαθήστε ξανά. @@ -595,8 +576,7 @@ Παράκαμψη απαγόρευσης από raw github URLs χρησιμοποιώντας jsDelivr. Μπορεί να καθυστερήσει τις ενημερώσεις για μερικές μέρες. Εμφάνιση κουμπιού για περιστροφή οθόνης Αγαπημένο - %s -\nαπομένουν + %s \nαπομένουν Βιομετρική αυθεντικοποίηση δεν υποστηρίζεται από τη συσκευή Καστ ταινίας Για να εξασφαλιστούν αδιάκοπες λήψεις και ειδοποιήσεις για αναγραφόμενες τηλεοπτικές εκπομπές, το CloudStream χρειάζεται άδεια για να τρέξει στο παρασκήνιο. Πατώντας OK, θα εμφανιστεί ένας διάλογος αιτήματος. Παρακαλώ πατήστε \\\"Επιτρέπω\\\".\n\nΠαρακαλώ σημειώστε, αυτή η άδεια δεν σημαίνει ότι το CS3 θα αποστραγγίσει την μπαταρία σας. Θα λειτουργεί στο παρασκήνιο μόνο όταν είναι απαραίτητο, όπως κατά τη λήψη ειδοποιήσεων ή τη λήψη βίντεο από επίσημες επεκτάσεις. @@ -604,9 +584,9 @@ Μετά από μερικές αποτυχημένες προσπάθειες, η άμεση θα κλείσει. Απλά επανεκκινήστε την εφαρμογή για να δοκιμάσετε ξανά. Επεξεργασία λογαριασμού Παράλειψη επιλογής λογαριασμού στην εκκίνηση της εφαρμογής - Μουσική + Μουσική Ακουστικό Βιβλίο - Μέσα + Μέσα Επαναφορά Τα δεδομένα σας στο CloudStream έχουν κάνει back up. Αν και η πιθανότητα είναι πολύ χαμηλή, όλες οι συσκευές συμπεριφέρονται διαφορετικά. Στη σπάνια περίπτωση, που απαγορευτεί η πρόσβασή σας από την εφαρμογή, διαγράψτε τα δεδομένα εφαρμογής και επαναφέρετέ τα από ένα ήδη υπάρχον backup. Συγνώμη για οποιαδήποτε ταλαιπωρία. Λογαριασμοί @@ -671,8 +651,8 @@ Άνοιγμα τοπικού βίντεο Κανένα πρόσθετο δεν ενημερώθηκε. Τοποθεσία φακέλου αντιγράφων - Ήχος - Ποντκάστ + Ήχος + Ποντκάστ Σφάλμα κωδικοποίησης Σφάλμα που δεν υποστηρίζεται Φορτώστε το πρώτο διαθέσιμο diff --git a/app/src/main/res/values-b+eo/strings.xml b/app/src/main/res/values-b+eo/strings.xml index ccd18eae3ac..5dea7b24d98 100644 --- a/app/src/main/res/values-b+eo/strings.xml +++ b/app/src/main/res/values-b+eo/strings.xml @@ -83,8 +83,7 @@ %1$dt %2$dh %3$dm %1$dh %2$dm %dm - Nova ĝisdatigo trovita! -\n%1$s -> %2$s + Nova ĝisdatigo trovita! \n%1$s -> %2$s Speciala epizodo CloudStream Elŝuto Komencite diff --git a/app/src/main/res/values-b+es/strings.xml b/app/src/main/res/values-b+es/strings.xml index 5e59477cef5..167de546d75 100644 --- a/app/src/main/res/values-b+es/strings.xml +++ b/app/src/main/res/values-b+es/strings.xml @@ -7,8 +7,8 @@ Descargado %1$d %2$s Borrar repositorio El episodio %d se lanzará en - %1$d h %2$d m - %d m + %1$d h %2$d m + %d m Póster Extensiones Archivo descargado @@ -66,7 +66,7 @@ Final Apertura mixta Resumen - Créditos + Créditos Final mixto Póster del episodio Siguiente episodio @@ -80,8 +80,7 @@ Recargar enlaces /?? /%d - Esto eliminará %s permanentemente -\nEstá seguro? + Esto eliminará %s permanentemente \nEstá seguro? ¿Seguro que quieres salir? Continuar Descarga Código de idioma (es_ES) @@ -104,7 +103,7 @@ Velocidad (%.2f×) Omitir carga %1$s Ep. %2$d - %1$d d %2$d h %3$d m + %1$d d %2$d h %3$d m Reparto: %s Relleno %d min @@ -249,8 +248,7 @@ Continuar -30 +30 - %dm -\nfaltante + %dm \nfaltante En curso Completado Estado @@ -482,10 +480,8 @@ Alfabéticamente (Z a A) Seleccionar biblioteca Abrir con - Tu biblioteca está vacía :( -\nInicia sesión en una cuenta de biblioteca o añade series desde tu biblioteca local. - ¡Se encontró un archivo en modo seguro! -\nNo cargar ninguna extensión al inicio hasta que se elimine el archivo. + Tu biblioteca está vacía :( \nInicia sesión en una cuenta de biblioteca o añade series desde tu biblioteca local. + ¡Se encontró un archivo en modo seguro! \nNo cargar ninguna extensión al inicio hasta que se elimine el archivo. Reproductor visible - buscar cantidad Reproductor oculto - buscar cantidad Android TV @@ -510,13 +506,7 @@ ISP Bypasses Calidad de visualización preferida (Datos móviles) Ayuda - Aquí puedes cambiar el orden de las fuentes. Si un vídeo tiene una prioridad más alta, aparecerá más arriba en la selección de las fuentes. La suma de la prioridad de la fuente y la prioridad de la calidad es la prioridad del vídeo. -\n -\nFuente A: 3 -\nCalidad B: 7 -\nTendrá una prioridad en el vídeo combinada de 10. -\n -\nNOTA: ¡Si la suma es 10 o más el reproductor saltará automáticamente la carga cuando se cargue ese enlace! + Aquí puedes cambiar el orden de las fuentes. Si un vídeo tiene una prioridad más alta, aparecerá más arriba en la selección de las fuentes. La suma de la prioridad de la fuente y la prioridad de la calidad es la prioridad del vídeo. \n \nFuente A: 3 \nCalidad B: 7 \nTendrá una prioridad en el vídeo combinada de 10. \n \nNOTA: ¡Si la suma es 10 o más el reproductor saltará automáticamente la carga cuando se cargue ese enlace! Perfil %d Wifi Editar @@ -536,11 +526,7 @@ %s eliminado de favoritos Favoritos %s añadido a favoritos - Se han encontrado posibles elementos duplicados en su biblioteca: -\n -\n%s -\n -\n¿Desea añadir este elemento de todos modos, sustituir los existentes o cancelar la acción? + Se han encontrado posibles elementos duplicados en su biblioteca: \n \n%s \n \n¿Desea añadir este elemento de todos modos, sustituir los existentes o cancelar la acción? Posible duplicado encontrado Bloquear perfil Añadido a favoritos @@ -583,18 +569,17 @@ Ahora se ha realizado una copia de seguridad de sus datos de CloudStream. Aunque la posibilidad de que esto ocurra es muy baja, todos los dispositivos pueden comportarse de forma diferente. En el raro caso de que no puedas acceder a la aplicación, borra completamente los datos de la aplicación y restaura desde una copia de seguridad. Sentimos mucho las molestias que esto pueda ocasionarte. Favorito Eliminar de favoritos - %s -\nrestante + %s \nrestante Nombre y URL del repositorio ¡Copiado! Error al copiar. Por favor, copie el logcat y comuníquese con el soporte de la aplicación. Error al acceder al portapapeles. Inténtelo de nuevo. De acuerdo Desactivar optimización de batería - Música + Música El uso de la batería de la aplicación está configurado sin restricciones No se puede abrir la información de la aplicación CloudStream. - Media + Media Audiolibro Para garantizar las notificaciones y descargas sin interrupciones de programas de TV suscritos, CloudStream necesita permiso para ejecutarse en segundo plano. Al presionar Aceptar, se abrirá la información de la aplicación. Presione «Permitir».\n\nTenga en cuenta que este permiso no significa que CS3 consumirá la batería. Solo funcionará en segundo plano cuando es necesario, como cuando se reciben notificaciones o se descargan vídeos desde extensiones oficiales. Reset @@ -609,7 +594,7 @@ Imagen del código QR Descartar Abrir repositorio - Visita %s en tu smartphone o ordenador e introduce el código anterior + Visita %s en tu smartphone o equipo e introduce el código anterior ¡El código PIN ya ha caducado! El código caduca en %1$d mín y %2$d s No puedo obtener el código PIN del dispositivo; intente con la autenticación local @@ -621,24 +606,16 @@ Ocultar los nombres de los controles del reproductor Fecha de lanzamiento (antigua a nueva) Fecha de lanzamiento (de nueva a antigua) - ¿Estás seguro de que quieres borrar permanentemente todos los episodios de la serie? -\n -\n%s + ¿Estás seguro de que quieres borrar permanentemente todos los episodios de la serie? \n \n%s Seleccionar elementos para eliminar Disponible para visualizar sin conexión Seleccionar todo Deseleccionar todo Borrar archivos Borrar (%1$d | %2$s) - ¿Seguro que quieres borrar de forma permanente los siguientes elementos? -\n -\n%s - ¿Estás seguro de que deseas eliminar permanentemente los siguientes episodios en %1$s? -\n -\n%2$s - También borrará permanentemente todos los episodios de las siguientes series: -\n -\n%s + ¿Seguro que quieres borrar de forma permanente los siguientes elementos? \n \n%s + ¿Estás seguro de que deseas eliminar permanentemente los siguientes episodios en %1$s? \n \n%2$s + También borrará permanentemente todos los episodios de las siguientes series: \n \n%s Activar la previsualización para las miniaturas en la barra de búsqueda Previsualización de Seekbar Aún no hay subtítulos cargados @@ -650,8 +627,8 @@ Personalizada Este video está en una red de torrents, lo que significa que se puede rastrear su uso.\nAsegúrese de comprender las redes de torrents antes de continuar. Tamaño del borde - Podcast - Audio + Podcast + Audio Error de codificación Error no soportado Cargar primero disponible @@ -695,9 +672,9 @@ Sobreexploración Cambios en el tamaño de los pósteres Tamaño del póster - %1$d h %2$d m %3$d s - %1$d m %2$d s - %1$d s + %1$d h %2$d m %3$d s + %1$d m %2$d s + %1$d s Etiqueta de valoración Mantenga presionado para duplicar la velocidad Sin cuenta @@ -756,4 +733,8 @@ %d descargas encoladas %d descargas encoladas + Mostrar superposición de metadatos del jugador + Vídeo + Vista previa + En Vivo diff --git a/app/src/main/res/values-b+fa/strings.xml b/app/src/main/res/values-b+fa/strings.xml index da6f04d8e60..1018e9a8204 100644 --- a/app/src/main/res/values-b+fa/strings.xml +++ b/app/src/main/res/values-b+fa/strings.xml @@ -113,8 +113,7 @@ در حال تماشا بارگیری‌ها سرعت (%.2f برابر) - بروزرسانی جدید پیدا شد! -\n%1$s -> %2$s + بروزرسانی جدید پیدا شد! \n%1$s -> %2$s پخش فیلم مرورگر پخش قسمت @@ -130,8 +129,7 @@ برای بازنشانی به پیشفرض نگه‌دارید کتابخانه در ادامه - این فرآیند بطور کامل %s را حذف می‌کند -\nآیا از این کار اطمینان دارید؟ + این فرآیند بطور کامل %s را حذف می‌کند \nآیا از این کار اطمینان دارید؟ نام مخزن و نشانی کپی شد! درباره @@ -180,13 +178,11 @@ حذف پرونده نمایش تریلر ها قسمت‌ها - %dد -\nباقی‌مانده + %dد \nباقی‌مانده گیتهاب پنهان کردن ویدیو مشخص شده از نتایج جستجو لغو - %s -\nباقی‌مانده + %s \nباقی‌مانده پیش‌فرض کارتون تورنت @@ -263,7 +259,7 @@ خطای دانلود، دسترسی به حافظه را چک کنید کیفیت کیفیت و عنوان - محتوا + محتوا مقدار جلورفتن پخش‌کننده مخزن پیدا نشد، نشانی را چک کنید و فیلترشکن را امتحان کنید کد زبان (انگلیسی) @@ -320,10 +316,10 @@ انیمه خانگی پخش زنده مثبت ۱۸ سال - موسیقی + موسیقی کتاب صوتی - صدا - پادکست + صدا + پادکست پخش کردن در برنامه دانلود اتوماتیک هیچ آپدیتی پیدا نشد diff --git a/app/src/main/res/values-b+fr/strings.xml b/app/src/main/res/values-b+fr/strings.xml index 1cbee687f54..bf401b20ad0 100644 --- a/app/src/main/res/values-b+fr/strings.xml +++ b/app/src/main/res/values-b+fr/strings.xml @@ -79,8 +79,7 @@ Annuler Pause Reprendre - Cela va supprimer définitivement %s -\nÊtes-vous sûr ? + Cela va supprimer définitivement %s \nÊtes-vous sûr ? En cours Terminé Statut @@ -122,8 +121,7 @@ Mettre à jour Utile pour contourner les bloquages des FAI Emplacement de téléchargement - Nouvelle mise à jour trouvée ! -\n%1$s -> %2$s + Nouvelle mise à jour trouvée ! \n%1$s -> %2$s Épisode spécial Qualité de visionnage préférée (WiFi) Taille de la mémoire cache @@ -137,7 +135,7 @@ Afficher les animés en Anglais (Dub) / sous-titrés Disposition en mode téléphone %1$s Ep %2$d - Note : %.1f + Note : %.1f Zoom Adapter à l\'écran Disposition de l\'application @@ -145,7 +143,7 @@ Langues des extensions Médias préférées Auto - Distribution : %s + Distribution : %s %d min Rechercher sur %s… À re-regarder @@ -291,8 +289,8 @@ Application Light Novel par les mêmes devs Anime app by the same devs Rejoignez le Discord - %1$d h %2$d min - %d min + %1$d h %2$d min + %d min Lire avec CloudStream Lire en direct Fin @@ -302,13 +300,13 @@ Ignorer %s Ouverture Récap - Crédits + Crédits Intro Effacer l\'historique Oui - %1$d j %2$d h %3$d min + %1$d j %2$d h %3$d min Stream - Êtes-vous sûr·e de vouloir quitter ? + Êtes-vous sûr·e de vouloir quitter ? Non Téléchargement de la mise à jour… L\'épisode %d sera publié dans @@ -321,8 +319,7 @@ Nouveau Nom du site ID invalide Installer automatiquement les plugins qui sont dans les repository mais qui n\'ont pas encore été installés. - %dm -\nrestant + %dm \nrestant En direct Autres En direct @@ -363,7 +360,7 @@ NSFW 127.0.0.1 %d / 10 - /?? + / ?? /%d SD UHD @@ -409,14 +406,14 @@ Téléchargé %1$d %2$s Tous les %s déjà téléchargés Télécharger la liste de sites que vous voulez utiliser - Téléchargé : %d + Téléchargé : %d Pistes vidéo Redémarrez l\'application pour voir les changements. Toutes les extensions ont été désactivé à cause d\'un crash pour vous aider à trouver l\'extension causant le problème. Mode sans échec activé Taille Version - Note : %s + Note : %s Description Status Installer l\'extension d\'abord @@ -432,10 +429,10 @@ Nom de dépôt (optionnel) plugin Supprimer le repository - Désactivé : %d - Non téléchargé : %d + Désactivé : %d + Non téléchargé : %d %d plugins mis-à-jour - Avertissement : CloudStream 3 décline toute responsabilité concernant l’utilisation d’extensions tierces et ne fournit aucun support pour celles-ci ! + Avertissement : CloudStream 3 décline toute responsabilité concernant l’utilisation d’extensions tierces et ne fournit aucun support pour celles-ci ! %s (Désactivé) Pistes Pistes audio @@ -448,9 +445,7 @@ Installateur de paquet plugins Cela supprimera également tous les plugins du repository - CloudStream n\'a aucun site installé par défaut. Vous devez installer les sites à partir de dépôts. -\n -\nRejoignez notre Discord ou cherchez en ligne. + CloudStream n\'a aucun site installé par défaut. Vous devez installer les sites à partir de dépôts. \n \nRejoignez notre Discord ou cherchez en ligne. Langage Afficher les popups skip pour les intro / fins Ancienne méthode d\'installation @@ -479,8 +474,7 @@ Note (basse à haute) Note (haut à bas) Alphabétique (A à Z) - Votre bibliothèque est vide :( -\nConnectez-vous à un compte de bibliothèque ou ajoutez des séries à votre bibliothèque locale. + Votre bibliothèque est vide :( \nConnectez-vous à un compte de bibliothèque ou ajoutez des séries à votre bibliothèque locale. Cette liste est vide. Essayez d\'en changer. Android TV Trier par @@ -489,8 +483,7 @@ Ouvrir avec Mis à jour (Nouveau vers ancien) Mis à jour (ancien vers nouveau) - Fichier du mode sans échec trouvé ! -\nAucune extension ne sera chargée au démarrage avant que le fichier ne soit enlevé. + Fichier du mode sans échec trouvé ! \nAucune extension ne sera chargée au démarrage avant que le fichier ne soit enlevé. Arrêter Annuler Enregistrer @@ -515,13 +508,7 @@ Impossible d\'atteindre GitHub. Activation du proxy jsDelivr… Vous avez déjà voté Désactivé - Ici, vous pouvez modifier la façon dont les sources sont ordonnées. Si une vidéo a une priorité plus élevée, elle apparaîtra plus haut dans la sélection de la source. La somme de la priorité source et de la priorité qualité est la priorité vidéo. -\n -\nSource A : 3 -\nQualité B : 7 -\nLa priorité vidéo combinée sera de 10. -\n -\nREMARQUE : Si la somme est de 10 ou plus, le joueur sautera automatiquement le chargement lorsque ce lien est chargé ! + Ici, vous pouvez modifier la façon dont les sources sont ordonnées. Si une vidéo a une priorité plus élevée, elle apparaîtra plus haut dans la sélection de la source. La somme de la priorité source et de la priorité qualité est la priorité vidéo. \n \nSource A : 3 \nQualité B : 7 \nLa priorité vidéo combinée sera de 10. \n \nREMARQUE : Si la somme est de 10 ou plus, le joueur sautera automatiquement le chargement lorsque ce lien est chargé ! Aucun plugin trouvé dans ce dossier Dépôt introuvable, vérifiez l\'URL et essayez avec un VPN Données mobiles @@ -554,20 +541,14 @@ PIN Favoris Connecté en tant que %s - Des doublons potentiels ont été trouvés dans votre bibliothèque : -\n -\n%s -\n -\nSouhaitez-vous ajouter cet élément, remplacer les éléments existants ou annuler l\'action ? + Des doublons potentiels ont été trouvés dans votre bibliothèque : \n \n%s \n \nSouhaitez-vous ajouter cet élément, remplacer les éléments existants ou annuler l\'action ? Saisir le code PIN pour %s Doublon potentiel trouvé Verrouiller le profil Ignorer la sélection de compte au démarrage Se désabonner S\'abonner - Il semble qu\'un élément potentiellement dupliqué existe déjà dans votre bibliothèque : \'%s. -\n -\nSouhaitez-vous ajouter cet élément, remplacer l\'élément existant ou annuler l\'action ? + Il semble qu\'un élément potentiellement dupliqué existe déjà dans votre bibliothèque : \'%s. \n \nSouhaitez-vous ajouter cet élément, remplacer l\'élément existant ou annuler l\'action ? Saisir le code PIN actuel Pivoter Les liens ont été rechargés @@ -580,14 +561,14 @@ Testez toutes les extensions Afficher les recommandations Ce test est destiné uniquement aux développeurs et ne vérifie ni n\'empêche le fonctionnement d\'aucune extension. - Copié ! + Copié ! Nom du dépôt et adresse internet Favori Vos données CloudStream viennent d\'être sauvegardées. Bien que cette éventualité soit très faible, tous les appareils peuvent se comporter différemment. Dans le rare cas où l\'accès à l\'application est bloqué, effacez complètement les données de l\'application et restaurez à partir d\'une sauvegarde. Nous sommes sincèrement désolés pour les désagréments occasionnés par cette situation. Désactiver l\'optimisation de la batterie Impossible d\'ouvrir les informations de l\'application CloudStream. Déverrouiller CloudStream - Musique + Musique %s \nrestants Erreur d\'accès au presse-papiers, veuillez réessayer. OK @@ -600,7 +581,7 @@ Pour garantir des téléchargements ininterrompus et des notifications pour les émissions de télévision auxquelles vous êtes abonné, CloudStream a besoin d\'une autorisation pour fonctionner en arrière-plan. En appuyant sur OK, une boite de dialogue sera affiché. Appuyé sur \'autoriser\'.\n\nVeuillez noter que cette autorisation ne signifie pas que CS3 épuisera votre batterie. Il ne fonctionnera en arrière-plan que lorsque cela sera nécessaire, par exemple lors de la réception de notifications ou du téléchargement de vidéos à partir d\'extensions officielles. L\'utilisation de la batterie de l\'application est déjà réglée sur illimitée Supprimer des favoris - Média + Média Réinitialiser À venir dans %s Verrouillage biométrique @@ -619,10 +600,10 @@ Ouvrir le dépôt Code expire dans %1$dm %2$ds Wiki de CloudStream - Le code PIN est maintenant expiré ! + Le code PIN est maintenant expiré ! Image du code QR Supprimer l\'extension - Êtes-vous sûr de vouloir supprimer définitivement les éléments suivants ? \n \n%s + Êtes-vous sûr de vouloir supprimer définitivement les éléments suivants ? \n \n%s Authentification locale Date de sortie (du plus ancien au plus récent) Date de sortie (du plus récent au plus ancien) @@ -638,9 +619,9 @@ Comptes Cette vidéo est un torrent, ce qui signifie que votre activité vidéo peut être suivie.\nAssurez-vous de comprendre le fonctionnement des torrents avant de continuer. Ignorer - Vous allez également supprimer définitivement tous les épisodes des séries suivantes :\n\n%s - Êtes-vous sûr de vouloir supprimer définitivement tous les épisodes des séries suivantes ?\n\n%s - Êtes-vous sûr de vouloir supprimer définitivement les épisodes suivants dans %1$s ?\n\n%2$s + Vous allez également supprimer définitivement tous les épisodes des séries suivantes :\n\n%s + Êtes-vous sûr de vouloir supprimer définitivement tous les épisodes des séries suivantes ?\n\n%s + Êtes-vous sûr de vouloir supprimer définitivement les épisodes suivants dans %1$s ?\n\n%2$s Recopier l’écran Aucun sous-titre n’a encore été chargé Emplacement du dossier de sauvegarde @@ -654,13 +635,13 @@ Activer les torrents dans Paramètres/Sources/Média préféré Redémarrez l\'application et acceptez la fenêtre contextuelle Stream Torrent pour continuer. Charger le premier disponible - Audio - Podcast + Audio + Podcast Ép %s Note %s Date %s Mettre à jour les plugins - %d plugin(s) mis à jour avec succès ! + %d plugin(s) mis à jour avec succès ! Aucun plugin n\'a été mis à jour. Note (Plus Haute) Mettre à jour les plugins manuellement @@ -671,7 +652,7 @@ La notification du lecteur pour contrôler la lecture en arrière-plan Date (Plus Récent) Note (Plus Basse) - Démarrage du processus de mise à jour du plugin ! + Démarrage du processus de mise à jour du plugin ! Intégré En ligne La reconnaissance vocale n\'est pas disponible @@ -741,8 +722,8 @@ Déterminez comment les sources vidéo seront triées dans le lecteur Télécharger tout Tout annuler - Voulez-vous télécharger l\'épisode %s ? - Vous voulez annuler tous les téléchargements en file d\'attente ? + Voulez-vous télécharger l\'épisode %s ? + Vous voulez annuler tous les téléchargements en file d\'attente ? %d téléchargement actif %d téléchargements actifs @@ -754,4 +735,7 @@ %d téléchargements en attente Afficher les métadata de l\'overlay du lecteur vidéo + Vidéo + Prévisualisation + Direct diff --git a/app/src/main/res/values-b+gl/strings.xml b/app/src/main/res/values-b+gl/strings.xml index 1b8f068e364..e3775b0912b 100644 --- a/app/src/main/res/values-b+gl/strings.xml +++ b/app/src/main/res/values-b+gl/strings.xml @@ -13,8 +13,7 @@ Póster do Episodio Regresar Cambiar provedor - Nova actualización atopada! -\n%1$s -> %2$s + Nova actualización atopada! \n%1$s -> %2$s Recheo %d min Configuración @@ -186,7 +185,7 @@ Está seguro de querer borrar permanentemente os seguintes elementos?\n\n%s Esto borrarase %s permanentemente\nEstás seguro? Debuxo animado - Media + Media %1$s %2$d%3$s Debuxos animados Erro do renderizador @@ -204,7 +203,7 @@ Fonte Erro inesperado do reproductor Capítulo - Música + Música C %dm\nrestantes En curso diff --git a/app/src/main/res/values-b+hi/strings.xml b/app/src/main/res/values-b+hi/strings.xml index d3f25767931..4e013ab3675 100644 --- a/app/src/main/res/values-b+hi/strings.xml +++ b/app/src/main/res/values-b+hi/strings.xml @@ -2,8 +2,7 @@ स्पीड (%.2fx) - नया अपडेट आया है! -\n%1$s -> %2$s + नया अपडेट आया है! \n%1$s -> %2$s होम खोजें डाउनलोडस @@ -87,8 +86,7 @@ रद्द करें रोकें फिर से चलाएं - इससे %s स्थायी रूप से हट जाएगा -\nक्या आपका निर्णय निश्चित है ? + इससे %s स्थायी रूप से हट जाएगा \nक्या आपका निर्णय निश्चित है ? अभी चालू है मुकम्मल हुया स्थिति @@ -153,11 +151,7 @@ %d मिनट क्लाउडस्ट्रीम क्लाउडस्ट्रीम के साथ चलाएं - आपकी लाइब्रेरी में संभावित डुप्लिकेट आइटम पाए गए हैं: -\n -\n%s -\n -\nक्या आप किसी भी तरह इस आइटम को जोड़ना चाहेंगे, मौजूदा आइटम को बदलना चाहेंगे, या कार्रवाई रद्द करना चाहेंगे? + आपकी लाइब्रेरी में संभावित डुप्लिकेट आइटम पाए गए हैं: \n \n%s \n \nक्या आप किसी भी तरह इस आइटम को जोड़ना चाहेंगे, मौजूदा आइटम को बदलना चाहेंगे, या कार्रवाई रद्द करना चाहेंगे? %s के लिए पिन दर्ज करें संभावित डुप्लिकेट मिला अपडेट शुरू हुआ @@ -177,9 +171,7 @@ अकाउंट चुनिये लोडिंग स्किप करे लोडिंग… - ऐसा प्रतीत होता है कि संभावित रूप से डुप्लिकेट आइटम आपकी लाइब्रेरी में पहले से मौजूद है: \'%s.\' -\n -\nक्या आप किसी भी तरह इस आइटम को जोड़ना चाहेंगे, मौजूदा आइटम को बदलना चाहेंगे, या कार्रवाई रद्द करना चाहेंगे? + ऐसा प्रतीत होता है कि संभावित रूप से डुप्लिकेट आइटम आपकी लाइब्रेरी में पहले से मौजूद है: \'%s.\' \n \nक्या आप किसी भी तरह इस आइटम को जोड़ना चाहेंगे, मौजूदा आइटम को बदलना चाहेंगे, या कार्रवाई रद्द करना चाहेंगे? पिन दर्ज करें पिन लिंक पुन्ह खुली @@ -254,7 +246,7 @@ अपने वर्तमान प्रकरण की प्रगति को स्वचालित रूप से समकालीन बनाएं एनीमे के लिए पूरक प्रकरण दिखाएं आंकड़े संगृहीत हो गए - संगीत + संगीत यह वीडियो एक टॉरेंट है, इसका मतलब यह है कि आपकी वीडियो गतिविधि पर नजर रखी जा सकती है।\nजारी रखने से पहले सुनिश्चित करें कि आप टोरेंटिंग को समझते है। रोकने के लिए बीच में दो बार टैप करें देखा हुआ प्रोग्रेस अपडेट करें @@ -309,7 +301,7 @@ उपशीर्षक डाउनलोड करें शीर्षक लाइब्रेरी - मीडिया + मीडिया खोज परिणामों में चयनित वीडियो गुणवत्ता छुपाएं %1$d-%2$d @string/home_play @@ -326,8 +318,8 @@ APK इंस्टॉलर कुछ डिवाइस नए पैकेज इंस्टॉलर को सपोर्ट नहीं करते हैं। यदि अपडेट इंस्टॉल नहीं होते हैं, तो पुराने विकल्प को आज़माएं। डिफ़ॉल्ट मान पर रीसेट करें - ऑडियो - पॉडकास्ट + ऑडियो + पॉडकास्ट रेंडरर त्रुटि एन्कोडिंग त्रुटि एरर क्योंकि सपोर्टेड नहीं diff --git a/app/src/main/res/values-b+hr/strings.xml b/app/src/main/res/values-b+hr/strings.xml index 8b3a6fbf339..7927a15c447 100644 --- a/app/src/main/res/values-b+hr/strings.xml +++ b/app/src/main/res/values-b+hr/strings.xml @@ -19,8 +19,7 @@ Brzina (%.2f×) Ocjena: %.1f - Pronađeno je novo ažuriranje! -\n%1$s -> %2$s + Pronađeno je novo ažuriranje! \n%1$s -> %2$s Umetak %d min CloudStream @@ -186,10 +185,8 @@ Nastavi −30 +30 - Ovo će trajno izbrisati %s -\nJeste li sigurni? - %dmin -\npreostalo + Ovo će trajno izbrisati %s \nJeste li sigurni? + %dmin \npreostalo U tijeku Završeno Status @@ -411,9 +408,7 @@ Preuzeto: %d Onemogućeno: %d Nepreuzeto: %d - CloudStream standardno nema instalirane web stranice. Stranice morate instalirati iz repozitorija. -\n -\nPridružite se našem Discordu ili tražite online. + CloudStream standardno nema instalirane web stranice. Stranice morate instalirati iz repozitorija. \n \nPridružite se našem Discordu ili tražite online. Prikaži repozitorije zajednice Javni popis Koristi velika slova za sve titlove @@ -436,7 +431,7 @@ Jezik HLS playlista Automatski instaliraj dodatke - Zasluge + Zasluge Automatski instaliraj sve neinstalirane dodatke iz dodanih repozitorija. Preferirani video player Interni player @@ -498,11 +493,9 @@ Abecedno (Ž do A) Odaberite biblioteku Otvori sa - Vaša je biblioteka prazna :( -\nPrijavite se na račun biblioteke ili dodajte filmove / serije u svoju lokalnu biblioteku. + Vaša je biblioteka prazna :( \nPrijavite se na račun biblioteke ili dodajte filmove / serije u svoju lokalnu biblioteku. Ova je lista prazna. Pokušajte se prebaciti na jednu drugu listu. - Pronađena je datoteka sigurnog načina rada! -\nProširenja se ne učitavaju tijekom pokretanja dok se datoteka ne ukloni. + Pronađena je datoteka sigurnog načina rada! \nProširenja se ne učitavaju tijekom pokretanja dok se datoteka ne ukloni. Prikazan player – Količina pomicanja Količina pomicanja koja se koristi kada je player vidljiv Player skriven – Količina pomicanja @@ -541,23 +534,13 @@ Onemogući U repozitoriju nisu pronađeni dodaci Repozitorij nije pronađen. Provjeri URL i pokušaj VPN - Ovdje možete promijeniti način na koji su izvori poredani. Ako video ima viši prioritet, pojavit će se više u odabiru izvora. Zbroj prioriteta izvora i prioriteta kvalitete je prioritet videa. -\n -\nIzvor A: 3 -\nKvaliteta B: 7 -\nImat će kombinirani prioritet videa od 10. -\n -\nNAPOMENA: Ako je zbroj 10 ili više, video player će automatski preskočiti učitavanje kada se ta poveznica učita! + Ovdje možete promijeniti način na koji su izvori poredani. Ako video ima viši prioritet, pojavit će se više u odabiru izvora. Zbroj prioriteta izvora i prioriteta kvalitete je prioritet videa. \n \nIzvor A: 3 \nKvaliteta B: 7 \nImat će kombinirani prioritet videa od 10. \n \nNAPOMENA: Ako je zbroj 10 ili više, video player će automatski preskočiti učitavanje kada se ta poveznica učita! Već si glasao/la Učestalost spremanja sigurnosne kopije %s uklonjeno iz favorita Favoriti %s dodano u favorite - Potencijalni duplikati pronađeni su u vašoj biblioteci: -\n -\n%s -\n -\nŽelite li ipak dodati ovu stavku koja zamjenjuje postojeće ili poništiti radnju? + Potencijalni duplikati pronađeni su u vašoj biblioteci: \n \n%s \n \nŽelite li ipak dodati ovu stavku koja zamjenjuje postojeće ili poništiti radnju? Pronađen potencijalni duplikat Zaključaj profil Dodaj u favorite @@ -570,9 +553,7 @@ Pretplata Ukloni iz favorita Odaberite račun - Čini se da potencijalno duplicirana stavka već postoji u vašoj biblioteci: \'%s.\' -\n -\nŽelite li ipak dodati ovu stavku koja zamjenjuje postojeću ili poništiti radnju? + Čini se da potencijalno duplicirana stavka već postoji u vašoj biblioteci: \'%s.\' \n \nŽelite li ipak dodati ovu stavku koja zamjenjuje postojeću ili poništiti radnju? Unesite PIN PIN Unesite trenutni PIN @@ -596,8 +577,7 @@ Ime repozitorija i URL kopirano! Zaključaj s biometrijskim podatcima - %s -\npreostalo + %s \npreostalo Pogreška pri pristupanju međuspremnika. Pokušaj ponovo. Otključaj CloudStream Lozinka/PIN autentifikacija @@ -606,17 +586,17 @@ U redu Deaktiviraj optimizaciju baterije Audio knjiga - Medij + Medij Korištenje baterije aplikacije već je postavljeno na neograničeno Neuspjelo otvaranje podataka CloudStream aplikacije. Favorit Ukloni iz favorita - Glazba + Glazba Obnovi Otključaj aplikaciju pomoću otiska prsta, ID-a lica, PIN-a, uzorka i lozinke. Sljedeća u %s Pogreška pri kopiranju. Kopirajte zapisnik i kontaktirajte podršku aplikacije. - Da biste osigurali neprekinuta preuzimanja i obavijesti za pretplaćene TV emisije, CloudStream treba dopuštenje za rad u pozadini. Pritiskom na \"U redu\" prikazat će vam se dijaloški okvir s zahtjevom. Molimo vas da pritisnete \"Dopusti\". \n\nNapominjemo da ovo dopuštenje ne znači da će CS3 trošiti vašu bateriju. Aplikacija će raditi u pozadini samo kada je to potrebno, primjerice prilikom primanja obavijesti ili preuzimanja videozapisa iz službenih proširenja. + Da biste osigurali neprekinuta preuzimanja i obavijesti za pretplaćene TV emisije, CloudStream treba dopuštenje za rad u pozadini. Pritiskom na \"U redu\" prikazat će vam se dijaloški okvir s zahtjevom. Molimo vas da pritisnete \"Dopusti\". \n\nNapominjemo da ovo dopuštenje ne znači da će CS3 trošiti vašu bateriju. Aplikacija će raditi u pozadini samo kada je to potrebno, primjerice prilikom primanja obavijesti ili preuzimanja videozapisa iz službenih proširenja. Vaši CloudStream podaci su sada spremljeni u sigurnosnu kopiju. Iako je vjerojatnost mala, neki se uređaji mogu ponašati drugačije. Ako izgubite pristup aplikaciji, potpuno izbrišite podatke aplikacije i obnovite ih pomoću sigurnosne kopije. Ispričavamo se zbog mogućih neugodnosti. Sezona %1$d epizoda %2$d izlazi za Cast duplikat @@ -642,22 +622,14 @@ Izbriši dodatak Dostupno za gledanje offline Označi sve - Stvarno želite trajno izbrisati sljedeće stavke? -\n -\n%s - Stvarno želite trajno izbrisati sljedeće epizode u %1$s? -\n -\n%2$s - Trajno ćete izbrisati i sve epizode u sljedećim serijama: -\n -\n%s + Stvarno želite trajno izbrisati sljedeće stavke? \n \n%s + Stvarno želite trajno izbrisati sljedeće epizode u %1$s? \n \n%2$s + Trajno ćete izbrisati i sve epizode u sljedećim serijama: \n \n%s Odaberi stavke za brisanje Odznači sve Izbriši (%1$d | %2$s) Izbriši datoteke - Stvarno želite trajno izbrisati sve epizode u sljedećoj seriji? -\n -\n%s + Stvarno želite trajno izbrisati sve epizode u sljedećoj seriji? \n \n%s Još nije učitan nijedan titl Pretpregled trake za traženje Omogući minijaturu pregleda na traci za pretraživanje @@ -675,8 +647,8 @@ Ovaj je video Torrent, što znači da se tvoja video aktivnost može pratiti.\nInformiraj se o korištenju Torrenta prije nego što nastaviš. Ponovno pokrenite aplikaciju i prihvatite skočni prozor Stream Torrent za nastavak. Učitaj prvi dostupni - Audio - Podcast + Audio + Podcast Aktiviraj torrent u Postavke/Pružatelji usluge/Preferirani mediji Epizoda (Uzlazno) Epizoda (Silazno) @@ -749,7 +721,7 @@ Gore u sredini Gore desno Dodatna svjetlina - Uključi filtar svjetline kada se prekorači 100 % svjetline ekrana + Uključi filtar svjetline kada se prekorači 100 % svjetline ekrana dodatna_svjetlina_uključena Prijedlozi za pretraživanje Prikaži prijedloge za pretraživanje tijekom tipkanja @@ -775,4 +747,8 @@ Želiš li preuzeti epizodu %s? Želiš li otkazati sva preuzimanja u redu čekanja? Prikaži ploču glumačke postave + Video + Pregled + Uživo + Prikaži sloj metapodataka playera diff --git a/app/src/main/res/values-b+hu/strings.xml b/app/src/main/res/values-b+hu/strings.xml index 8bd2ac7ac6c..b753eadb295 100644 --- a/app/src/main/res/values-b+hu/strings.xml +++ b/app/src/main/res/values-b+hu/strings.xml @@ -10,8 +10,7 @@ Szolgáltató Váltás Sebesség (%.2fx) Értékelés: %.1f - Új frissítés található! -\n%1$s -> %2$s + Új frissítés található! \n%1$s -> %2$s %d perc %1$sEp%2$d CloudStream @@ -149,8 +148,7 @@ Ep Nem található epizód Fájl törlése - %dp -\nhátra + %dp \nhátra Időtartam Elérhető Használatban @@ -206,8 +204,7 @@ %1$s %2$d%3$s Nincs évad +30 - Ezzel véglegesen törli a %s -\nBiztosan törli? + Ezzel véglegesen törli a %s \nBiztosan törli? Folyamatban levő Év Webhely @@ -323,8 +320,7 @@ Támogatott Alkalmazásfrissítés letöltése… Frissítve (újabbtól a régebbihez) - Úgy tűnik, a könyvtárad üres :( -\nJelentkezz be egy könyvtár fiókba, vagy adj hozzá műsorokat a helyi könyvtárodhoz. + Úgy tűnik, a könyvtárad üres :( \nJelentkezz be egy könyvtár fiókba, vagy adj hozzá műsorokat a helyi könyvtárodhoz. Úgy tűnik, ez a lista üres, próbálj meg egy másikra váltani. Max 4K @@ -416,9 +412,7 @@ Zárt feliratok eltávolítása a feliratokból 18+ Ez az összes tároló bővítményt is törli - A CloudStream alapértelmezés szerint nem telepített webhelyeket. A webhelyeket a tárolókból kell telepítenie. -\n -\nCsatlakozz a Discord-unkhoz vagy keress online. + A CloudStream alapértelmezés szerint nem telepített webhelyeket. A webhelyeket a tárolókból kell telepítenie. \n \nCsatlakozz a Discord-unkhoz vagy keress online. Verzió Megjelölés megtekintettként Eltávolítás a megnézettek közül @@ -469,11 +463,10 @@ Repó URL Bővítmény betöltve Bővítmény letöltve - Közreműködők + Közreműködők Betűrendben (Z-től az A-ig) Könyvtár kiválasztása - Biztonságos módú fájlba ütköztünk! -\nNem töltődik be semmilyen kiegészítő indításkor, amíg a fájl nem kerül törlésre. + Biztonságos módú fájlba ütköztünk! \nNem töltődik be semmilyen kiegészítő indításkor, amíg a fájl nem kerül törlésre. Normál %s betöltve Beállítás kihagyása @@ -536,18 +529,8 @@ Profilok Eltávolítás kedvencekből Adja meg a jelenlegi PIN-t - Itt beállíthatja hogyan rendezze el a forrásokat. Ha egy videónak nagyobb a prioritása, előbb fog megjelenni a listában. A forrás prioritás és a minőség prioritás együttes értéke adja ki a videó prioritást. -\n -\nForrás A: 3 -\nMinőség B: 7 -\nEzek összértéke egy 10-es videó prioritást eredményez. -\n -\nFIGYELEM: Ha az összeg több mint 10, a lejátszó nem tölt be mást ha már a link betöltésre került! - Potenciálisan dupla elemek a könyvtárjában: -\n -\n%s -\n -\nSzeretné hozzáadni ezt az elemet mindenképpen, ezzel felülírva a jelenlegit, vagy visszavonja a műveletet? + Itt beállíthatja hogyan rendezze el a forrásokat. Ha egy videónak nagyobb a prioritása, előbb fog megjelenni a listában. A forrás prioritás és a minőség prioritás együttes értéke adja ki a videó prioritást. \n \nForrás A: 3 \nMinőség B: 7 \nEzek összértéke egy 10-es videó prioritást eredményez. \n \nFIGYELEM: Ha az összeg több mint 10, a lejátszó nem tölt be mást ha már a link betöltésre került! + Potenciálisan dupla elemek a könyvtárjában: \n \n%s \n \nSzeretné hozzáadni ezt az elemet mindenképpen, ezzel felülírva a jelenlegit, vagy visszavonja a műveletet? Fiók választás kihagyása belépéskor Használjon alapértelmezett fiókot Elforgatás @@ -556,9 +539,7 @@ %s hozzáadva a kedvencekhez %s eltávolítva a kedvencekből Hozzáadás a kedvencekhez - Úgy tűnik egy potenciálisan dupla elem már létezik a könyvtárjában: \'%s.\' -\n -\nMindenképpen hozzá akarja adni ezt az elemet, ezzel felülírva a régit, vagy visszavonja a műveletet? + Úgy tűnik egy potenciálisan dupla elem már létezik a könyvtárjában: \'%s.\' \n \nMindenképpen hozzá akarja adni ezt az elemet, ezzel felülírva a régit, vagy visszavonja a műveletet? Adja meg a PIN-t Profil Zárolása Válasszon egy fiókot @@ -612,11 +593,11 @@ Fájlok törlése Biztosan törölni szeretné az alábbi sorozat összes megjelenését?\n\n%s %s \nmaradék - Zene + Zene Hangoskönyv - Média - Hang - Podcast + Média + Hang + Podcast Kódolási hiba Hiba nem támogatott Törlés (%1$d | %2$s) diff --git a/app/src/main/res/values-b+in/strings.xml b/app/src/main/res/values-b+in/strings.xml index d5bf2d4b012..7d6475f31e7 100644 --- a/app/src/main/res/values-b+in/strings.xml +++ b/app/src/main/res/values-b+in/strings.xml @@ -169,10 +169,8 @@ Lanjutkan -30 +30 - Ini akan secara permanen menghapus %s -\nApakah anda yakin? - %dm -\ntersisa + Ini akan secara permanen menghapus %s \nApakah anda yakin? + %dm \ntersisa Masih Berlanjut Tamat Status @@ -390,9 +388,7 @@ %d plugin diperbarui Lihat repositori komunitas Daftar publik - CloudStream tidak memiliki situs yang terinstal secara default. Anda perlu menginstal situs-situs dari repositori. -\n -\nBergabunglah dengan Discord kami atau cari secara online. + CloudStream tidak memiliki situs yang terinstal secara default. Anda perlu menginstal situs-situs dari repositori. \n \nBergabunglah dengan Discord kami atau cari secara online. URL Repositori atau Kode Pendek Buat Akun Error @@ -467,7 +463,7 @@ Sesi Akhir Sinopsis Sesi akhir ganda - Sesi Kredit + Sesi Kredit Sesi Intro Terlalu banyak teks. Tidak dapat menyalin ke papan klip. Yakin ingin keluar? @@ -487,8 +483,7 @@ Hapus dari tontonan Peramban Pilih pustaka - Yahh daftar pustaka kamu kosong :( -\nMasuk ke akun pustaka atau tambah perlihatkan ke lokal pustaka kamu. + Yahh daftar pustaka kamu kosong :( \nMasuk ke akun pustaka atau tambah perlihatkan ke lokal pustaka kamu. Pustaka Urutkan berdasarkan Urutkan @@ -500,8 +495,7 @@ Abjad (Z ke A) Buka dengan Yahh daftar ini kosong. Coba ganti ke yang lain. - Mode aman file ditemukan! -\nTidak memuat ekstensi pada startup sampai berkas dihapus. + Mode aman file ditemukan! \nTidak memuat ekstensi pada startup sampai berkas dihapus. Sembunyikan Pemutaran - Geser Pemutar terlihat - Geser Geser untuk menghilangkan @@ -527,13 +521,7 @@ Kualitas nonton yang diinginkan (Data Seluler) Data seluler Bantuan - Di sini anda dapat mengubah sumber yang diurutkan. Jika video memiliki prioritas lebih tinggi, video tersebut akan muncul lebih tinggi dalam pemilihan sumber. Jumlah prioritas sumber dan prioritas kualitas adalah prioritas video. -\n -\nSumber A: 3 -\nKualitas B: 7 -\nAkan memiliki prioritas video yang digabung 10. -\n -\nCATATAN: Jika jumlahnya 10 atau lebih, pemain akan melewatkan pemuatan secara otomatis saat tautan dimuat! + Di sini anda dapat mengubah sumber yang diurutkan. Jika video memiliki prioritas lebih tinggi, video tersebut akan muncul lebih tinggi dalam pemilihan sumber. Jumlah prioritas sumber dan prioritas kualitas adalah prioritas video. \n \nSumber A: 3 \nKualitas B: 7 \nAkan memiliki prioritas video yang digabung 10. \n \nCATATAN: Jika jumlahnya 10 atau lebih, pemain akan melewatkan pemuatan secara otomatis saat tautan dimuat! Profil %d Wifi Pengaturan default @@ -593,8 +581,7 @@ Setelah beberapa kali gagal, perintah akan ditutup. Cukup mulai ulang aplikasi untuk mencoba lagi. Batalkan favorit Buka kunci CloudStream - %s -\ntersisa + %s \ntersisa Favorit Kunci dengan Biometrik Nama dan URL repositori @@ -605,9 +592,9 @@ Nonaktifkan optimasi baterai Pemakaian baterai untuk aplikasi ini sudah diatur menjadi tidak dibatasi Gagal membuka info aplikasi CloudStream. - Musik + Musik Buku Audio - Media + Media Untuk memastikan unduhan dan pemberitahuan tanpa gangguan untuk acara TV berlangganan, CloudStream memerlukan izin untuk berjalan di latar belakang. Dengan menekan OK, Anda akan diperlihatkan dialog permintaan. Silakan tekan ‘Izinkan’.\n\nHarap dicatat, izin ini tidak berarti CS3 akan menguras baterai Anda. Aplikasi ini hanya akan beroperasi di latar belakang jika diperlukan, misalnya saat menerima notifikasi atau mengunduh video dari ekstensi resmi. Mengatur ulang Musim %1$d Episode %2$d akan dirilis pada @@ -638,18 +625,10 @@ Pilih Semua Batal Pilih Semua Hapus File - Apakah Anda yakin ingin menghapus item berikut secara permanen? -\n -\n%s - Apakah Anda yakin ingin menghapus episode berikut di %1$s secara permanen? -\n -\n%2$s - Anda juga akan menghapus semua episode dalam seri berikut secara permanen: -\n -\n%s - Apakah Anda yakin ingin menghapus semua episode dalam seri berikut secara permanen? -\n -\n%s + Apakah Anda yakin ingin menghapus item berikut secara permanen? \n \n%s + Apakah Anda yakin ingin menghapus episode berikut di %1$s secara permanen? \n \n%2$s + Anda juga akan menghapus semua episode dalam seri berikut secara permanen: \n \n%s + Apakah Anda yakin ingin menghapus semua episode dalam seri berikut secara permanen? \n \n%s Hapus (%1$d | %2$s) Hapus plugin Tidak bisa mendapatkan kode PIN perangkat, coba autentikasi lokal @@ -662,8 +641,8 @@ Tampilkan dialog sebelum keluar dari aplikasi Jangan Tampilkan Tampilkan - Audio - Podcast + Audio + Podcast Pengkodean error Error yang tidak didukung Muat yang pertama tersedia @@ -765,4 +744,7 @@ Apakah kamu ingin mengunduh episode %s? Apakah kamu ingin membatalkan semua unduhan dalam antrean? Tampilkan overlay metadata pemutar + Live + Video + Pratinjau diff --git a/app/src/main/res/values-b+it/strings.xml b/app/src/main/res/values-b+it/strings.xml index 08a1572d6de..c47a84842ca 100644 --- a/app/src/main/res/values-b+it/strings.xml +++ b/app/src/main/res/values-b+it/strings.xml @@ -6,7 +6,7 @@ L\'episodio %d uscirà in %1$dd %2$dh %3$dm %1$dh %2$dm - %d min + %d min Poster Poster @@ -19,8 +19,7 @@ Velocità (%.2fx) Valutato: %.1f - Nuovo aggiornamento trovato! -\n%1$s -> %2$s + Nuovo aggiornamento trovato! \n%1$s -> %2$s Filler %d min @@ -186,10 +185,8 @@ Riprendi -30 +30 - Stai per eliminare permanentemente %s -\nSei sicuro? - %dm -\nrimanenti + Stai per eliminare permanentemente %s \nSei sicuro? + %dm \nrimanenti In corso Completato Stato @@ -412,9 +409,7 @@ Disabilitato: %d Non scaricato: %d Aggiornati %d plugin - CloudStream non ha siti installati per impostazione predefinita. È necessario installare i siti dai repository. -\n -\nUnisciti al nostro Discord o cerca online. + CloudStream non ha siti installati per impostazione predefinita. È necessario installare i siti dai repository. \n \nUnisciti al nostro Discord o cerca online. Visualizza i repository della comunità Lista pubblica Tutti i sottotitoli in maiuscolo @@ -447,7 +442,7 @@ Riassunto - Crediti + Crediti Cancella cronologia Cronologia @@ -502,15 +497,13 @@ Aggiornato (Da vecchio a nuovo) Alfabetico (A - Z) Alfabetico (Z - A) - La tua libreria è vuota :( -\nAccedi a un account di libreria o aggiungi degli show alla tua libreria locale. + La tua libreria è vuota :( \nAccedi a un account di libreria o aggiungi degli show alla tua libreria locale. Seleziona libreria Apri con Libreria Ordina Questo elenco è vuoto. Prova a passare a un altro. - File \"safe mode\" trovato! -\nAll\'avvio non sarà caricata alcuna estensione finchè il file non verrà rimosso. + File \"safe mode\" trovato! \nAll\'avvio non sarà caricata alcuna estensione finchè il file non verrà rimosso. Intervallo di ricerca utilizzato quando il lettore è nascosto TV Android Intervallo di ricerca utilizzato quando il lettore è visibile @@ -534,13 +527,7 @@ Aggiornando shows a cui sei iscritto L\'episodio %d è stato rilasciato! Qualità di visualizzazione preferita (Dati mobili) - Qui puoi cambiare l\'ordine delle fonti. Se un video ha una priorità maggiore, apparirà più in alto nella selezione delle fonti. La somma tra la priorità della fonte e la priorità della qualità è la priorità video. -\n -\nFonte A: 3 -\nQualità B: 7 -\nAvranno una priorità video combinata di 10. -\n -\nNOTA: se la somma è pari o superiore a 10, il lettore salterà automaticamente il caricamento quando viene caricato quel link! + Qui puoi cambiare l\'ordine delle fonti. Se un video ha una priorità maggiore, apparirà più in alto nella selezione delle fonti. La somma tra la priorità della fonte e la priorità della qualità è la priorità video. \n \nFonte A: 3 \nQualità B: 7 \nAvranno una priorità video combinata di 10. \n \nNOTA: se la somma è pari o superiore a 10, il lettore salterà automaticamente il caricamento quando viene caricato quel link! Profilo %d Wi-Fi Imposta predefinito @@ -560,11 +547,7 @@ %s rimosso dai preferiti Preferiti %s aggiunto ai preferiti - Dei possibili duplicati sono stati trovati nella tua libreria: -\n -\n%s -\n -\nVorresti aggiungere l\'oggetto alla libreria comunque, rimpiazzare l\'esistente, o cancellare l\'azione? + Dei possibili duplicati sono stati trovati nella tua libreria: \n \n%s \n \nVorresti aggiungere l\'oggetto alla libreria comunque, rimpiazzare l\'esistente, o cancellare l\'azione? Frequenza di backup Trovato Possibile Duplicato Aggiungi ai preferiti @@ -577,9 +560,7 @@ Iscriviti Rimuovi dai preferiti Seleziona un account - Sembra che nella tua libreria esista già un elemento potenzialmente duplicato: \'%s.\' -\n -\nDesideri aggiungere comunque questo elemento, sostituire quello esistente o annullare l\'azione? + Sembra che nella tua libreria esista già un elemento potenzialmente duplicato: \'%s.\' \n \nDesideri aggiungere comunque questo elemento, sostituire quello esistente o annullare l\'azione? Inserisci PIN PIN Inserisci PIN corrente @@ -609,8 +590,7 @@ Dopo alcuni tentativi falliti, il prompt si chiuderà. Riavvia semplicemente l\'app per riprovare. È stato eseguito il backup dei tuoi dati CloudStream. Sebbene questa possibilità sia molto bassa, tutti i dispositivi possono comportarsi in modo diverso. Nel raro caso in cui ti venga bloccato l\'accesso all\'app, cancella completamente i dati dell\'app e ripristina da un backup. Siamo molto spiacenti per qualsiasi inconveniente derivanti da questo. Non preferito - %s -\nresiduo + %s \nresiduo Preferito Nome e URL del repository copiato! @@ -619,10 +599,10 @@ OK Disabilita ottimizzazione della batteria Impossibile aprire le informazioni sull\'app CloudStream. - Media + Media Per garantire download e notifiche ininterrotti per le serie TV a cui si è abbonati, CloudStream necessita dell\'autorizzazione per funzionare in background. Premendo OK, verrà mostrata una finestra di dialogo di richiesta. Premi \"Consenti\".\n\nNota che questa autorizzazione non significa che CS3 scaricherà la batteria. Funzionerà in background solo quando necessario, ad esempio quando si ricevono notifiche o si scaricano video da estensioni ufficiali. L\'utilizzo della batteria dell\'app è già impostato su \"Senza restrizioni\" - Musica + Musica Audiolibro Reimposta Prossimamente tra %s @@ -652,20 +632,12 @@ Seleziona tutto Deseleziona tutto Elimina (%1$d | %2$s) - Sei sicuro di voler eliminare definitivamente i seguenti episodi in %1$s? -\n -\n%2$s - Eliminerai definitivamente anche tutti gli episodi delle seguenti serie: -\n -\n%s - Sei sicuro di voler eliminare definitivamente tutti gli episodi delle seguenti serie? -\n -\n%s + Sei sicuro di voler eliminare definitivamente i seguenti episodi in %1$s? \n \n%2$s + Eliminerai definitivamente anche tutti gli episodi delle seguenti serie: \n \n%s + Sei sicuro di voler eliminare definitivamente tutti gli episodi delle seguenti serie? \n \n%s Data di rilascio (dal più vecchio) Elimina file - Sei sicuro di voler eliminare definitivamente i seguenti elementi? -\n -\n%s + Sei sicuro di voler eliminare definitivamente i seguenti elementi? \n \n%s Anteprima barra di avanzamento Abilita miniatura di anteprima sulla barra di avanzamento Nessun sottotitolo caricato @@ -677,8 +649,8 @@ Personalizzato Questo video è un Torrent, il che significa che la tua attività video può essere tracciata.\nAssicurati di aver capito il significato di scaricare tramite Torrent prima di continuare. Dimensione bordo - Audio - Podcast + Audio + Podcast Errore non supportato Errore di codifica Carica il primo disponibile @@ -784,4 +756,7 @@ Priorità sorgente Decidi come le sorgenti video devono essere ordinate nel lettore Mostra sovrapposizione metadati lettore + Video + Anteprima + Live diff --git a/app/src/main/res/values-b+iw/strings.xml b/app/src/main/res/values-b+iw/strings.xml index ef4cb9202ed..3b2d0153a11 100644 --- a/app/src/main/res/values-b+iw/strings.xml +++ b/app/src/main/res/values-b+iw/strings.xml @@ -6,8 +6,7 @@ לשנות ספק מהירות (%.2fx) דירוג: %.1f - נמצא עדכון חדש! -\n%1$s -> %2$s + נמצא עדכון חדש! \n%1$s -> %2$s סינון %d דקות קלאודסטרים @@ -146,10 +145,8 @@ המשך -30 +30 - %dדקות -\nנותרו - ‬פעולה זאת תמחק לצמיתות את %s -\nהאם אתם בטוחים? + %dדקות \nנותרו + ‬פעולה זאת תמחק לצמיתות את %s \nהאם אתם בטוחים? מתמשך משך זמן דירוג @@ -422,13 +419,11 @@ כל %s כבר הורד מחברים שפה - קרדיטים + קרדיטים מיין בחר ספרייה - נראה שהספרייה שלכם ריקה :( -\nהתחברו לחשבון ספריה או הוסיפו סדרות לספרייה המקומית שלכם. - קובץ מצב בטוח נמצא! -\nלא טוען שום תוספות בהפעלה עד להסרת הקובץ. + נראה שהספרייה שלכם ריקה :( \nהתחברו לחשבון ספריה או הוסיפו סדרות לספרייה המקומית שלכם. + קובץ מצב בטוח נמצא! \nלא טוען שום תוספות בהפעלה עד להסרת הקובץ. לא ניתן להתקין את הגרסה החדשה של האפליקציה הורדת אצווה תוסף @@ -444,11 +439,7 @@ הורד: %d מוגבל: %d לא מורד: %d - לקלאודסטרים אין אתרים מותקנים כברירת מחדל. עליכם להתקין את האתרים ממאגרים. -\n -\nבגלל הסרת DMCA על ידי Sky UK LImited 🤮 אנחנו לא יכולים לקשר את אתר המאגרים באפליקציה. -\n -\nתצטרפו לדיסקורד שלנו או תחפשו באינטרנט. + לקלאודסטרים אין אתרים מותקנים כברירת מחדל. עליכם להתקין את האתרים ממאגרים. \n \nבגלל הסרת DMCA על ידי Sky UK LImited 🤮 אנחנו לא יכולים לקשר את אתר המאגרים באפליקציה. \n \nתצטרפו לדיסקורד שלנו או תחפשו באינטרנט. הצג מאגרים קהילתיים רשימה ציבורית לשים את הכתוביות באותיות רישיות @@ -530,13 +521,7 @@ קביעה כברירת מחדל עבר מעקף ספק אינטרנט - כאן ניתן לשנות את סדר המקורות. אם לוידיאו יש עדיפות גבוהה יותר, הוא יופיע גבוה יותר בבחירת המקור. סכום עדיפות המקור ועדיפות האיכות היא עדיפות הוידיאו. -\n -\nמקור א: 3 -\nאיכות ב: 7 -\nיגרמו לעדיפות הסרטון להיות 10. -\n -\nשימו לב: אם הסכום הוא 10 או יותר, הנגן ידלג על טעינת הסרטון כאשר הלינק נטען! + כאן ניתן לשנות את סדר המקורות. אם לוידיאו יש עדיפות גבוהה יותר, הוא יופיע גבוה יותר בבחירת המקור. סכום עדיפות המקור ועדיפות האיכות היא עדיפות הוידיאו. \n \nמקור א: 3 \nאיכות ב: 7 \nיגרמו לעדיפות הסרטון להיות 10. \n \nשימו לב: אם הסכום הוא 10 או יותר, הנגן ידלג על טעינת הסרטון כאשר הלינק נטען! עונה %1$d פרק %2$d תשודר ב: %1$d שעות %2$d דקות %3$d שניות %1$d דקות %2$d שניות diff --git a/app/src/main/res/values-b+ja/strings.xml b/app/src/main/res/values-b+ja/strings.xml index 0b66ca8b2ec..3de9a971a74 100644 --- a/app/src/main/res/values-b+ja/strings.xml +++ b/app/src/main/res/values-b+ja/strings.xml @@ -62,8 +62,7 @@ ローディング… ブラウザで開く シーズン - 残り -\n%d分 + 残り \n%d分 再生エピソード ダウンロード済 バックアップ @@ -82,8 +81,7 @@ 次のランダム 戻り 評価: %.1f - 新しいアップデートを発見! -\n%1$s -> %2$s + 新しいアップデートを発見! \n%1$s -> %2$s %d分 %sを検索… ソース @@ -271,7 +269,7 @@ hello@world.com 無効化 概要 - 音楽 + 音楽 中央で2回タップして一時停止 %s で再生 アカウントを切り替え @@ -297,7 +295,7 @@ リポジトリ名とURL コピーされました! オーディオブック - メディア + メディア プレイヤー情報を表示 プレイヤーに速度オプションを追加 削除する項目を選択 @@ -469,7 +467,7 @@ 無効: %d 優先ビデオプレーヤー %s をスキップ - クレジット + クレジット アプリのバッテリー使用はすでに無制限に設定されています 並べ替え 元に戻す @@ -651,8 +649,8 @@ 更新されたプラグインはありません。 プレーヤー通知 バックグラウンドから再生を制御するためのプレーヤー通知 - オーディオ - ポッドキャスト + オーディオ + ポッドキャスト プレーヤー非表示時 - シーク量 エンコーディングエラー サポートされていないエラー diff --git a/app/src/main/res/values-b+kn/strings.xml b/app/src/main/res/values-b+kn/strings.xml index 22a45b906b2..ba6da787cb8 100644 --- a/app/src/main/res/values-b+kn/strings.xml +++ b/app/src/main/res/values-b+kn/strings.xml @@ -83,8 +83,7 @@ ಶೇರ್ ಫೈಲ್ ಅಳಿಸಿ ಹೆಚ್ಚಿನ ಮಾಹಿತಿ - ಹೊಸ ಅಪ್ಡೇಟ್ ಬಂದಿದೆ -\n%1$s-%2$s + ಹೊಸ ಅಪ್ಡೇಟ್ ಬಂದಿದೆ \n%1$s-%2$s ಲೋಡಿಂಗ್… ಡೌನ್‌ಲೋಡ್ ಭಾಷೆಗಳನ್ನು ಮಾಡಿ ಲೈವ್‌ಸ್ಟ್ರೀಮ್ ಪ್ಲೇ ಮಾಡಿ diff --git a/app/src/main/res/values-b+ko/strings.xml b/app/src/main/res/values-b+ko/strings.xml index 256fc26e9f5..14d327372ff 100644 --- a/app/src/main/res/values-b+ko/strings.xml +++ b/app/src/main/res/values-b+ko/strings.xml @@ -11,8 +11,7 @@ 배경 미리보기 속도 (%.2fx) 평점: %.1f - 새로운 업데이트! -\n%1$s -> %2$s + 새로운 업데이트! \n%1$s -> %2$s %d분 CloudStream CloudStream으로 재생 @@ -28,7 +27,7 @@ 공유 브라우저로 열기 브라우저 - 로딩 건너뛰기 + 로딩 스킵 로딩중… 시청 보류 @@ -124,7 +123,7 @@ 어두운 오버레이 대신 앱 플레이어의 시스템 밝기를 사용합니다 시청 진행 상황 업데이트 현재 에피소드 진행 상황을 자동으로 동기화합니다 - 백업에서 데이터 복원 + 데이터 복원 데이터 백업 파일에서 데이터를 복원하지 못했습니다 %s 저장된 데이터 @@ -161,10 +160,8 @@ 평점 +30 - %s가 영구 삭제됩니다 -\n정말 삭제하시겠습니까? - %d분 -\n남음 + %s이(가) 영구적으로 삭제됩니다 \n정말 삭제하시겠습니까? + %d분 \n남음 사이트 시간 개요 @@ -184,7 +181,7 @@ %s로 재생 자동 다운로드 다운로드 소스 목록 - 링크 새로고침 + 링크 초기화 자막 다운로드 화질 라벨 더빙 라벨 @@ -195,7 +192,7 @@ 크기 조정 소스 오프닝 스킵 - 이 업데이트 건너뛰기 + 다음에 업데이트 선호하는 화질 (WiFi) 선호하는 화질 (모바일 데이터) 플레이어 내 표시 정보 @@ -297,11 +294,7 @@ 저장소 삭제 사용하려는 사이트 목록 다운로드 다운로드됨: %d - CloudStream에는 기본적으로 설치된 사이트가 없습니다. 저장소에서 사이트를 설치해야 합니다. -\n -\nSky UK Limited의 무분별한 DMCA 게시 중단으로 인해 앱에서 저장소 사이트를 연결 할 수 없습니다. -\n -\nDiscord에 가입하거나 온라인으로 검색해 보세요. + CloudStream에는 기본적으로 설치된 사이트가 없습니다. 저장소에서 사이트를 설치해야 합니다. \n \nDiscord에 가입하거나 온라인으로 검색해 보세요. 커뮤니티 저장소 보기 공개 목록 자막 대문자화 표시 @@ -322,7 +315,7 @@ 충돌 정보 보기 언어 에피소드 %d 공개! - PIP 모드 + Picture-in-picture 모드 플레이어 크기 조정 버튼 미니플레이어를 통해 다른 앱 상단에서 계속 재생합니다 레터박스 제거 @@ -331,7 +324,7 @@ 백업 파일을 성공적으로 로드하였습니다 정보 고급 검색 - 설정 프로세스 다시 실행 + 설정 프로세스 재실행 APK 인스톨러 Github 소스 오류 @@ -438,16 +431,16 @@ 먼저 확장 프로그램을 설치하세요 앱을 찾을 수 없음 모든 언어 - 건너뛰기 %s + %s 스킵 오프닝 엔딩 혼합 엔딩 혼합 오프닝 - 크레딧 - 소개 + 크레딧 + 인트로 기록 삭제 기록 - 오프닝/엔딩 시 건너뛰기 팝업 표시 + 오프닝/엔딩 시 스킵 팝업 표시 텍스트가 너무 많습니다. 클립보드에 저장할 수 없습니다. 시청에서 삭제 정말 종료하시겠습니까? @@ -466,10 +459,8 @@ 알파벳순 (A에서 Z) 알파벳순 (Z에서 A) 다음으로 열기 - 라이브러리가 비어 있습니다 :( -\n라이브러리 계정으로 로그인하거나 로컬 라이브러리에 프로그램을 추가하세요. - 안전 모드 파일을 찾았습니다! -\n파일이 제거될 때까지 시작 시 확장 프로그램을 로드하지 않습니다. + 라이브러리가 비어 있습니다 :( \n라이브러리 계정으로 로그인하거나 로컬 라이브러리에 프로그램을 추가하세요. + 안전 모드 파일을 찾았습니다! \n파일이 제거될 때까지 시작 시 확장 프로그램을 로드하지 않습니다. HLS 재생목록 내부 플레이어 선호하는 동영상 플레이어 @@ -485,7 +476,7 @@ @string/home_play 플롯을 찾을 수 없음 설명을 찾을 수 없음 - Logcat 🐈 표시 + 로그캣 🐈 보기 애니메이션용 필러 에피소드 표시 통과 계속 @@ -517,11 +508,11 @@ 보안 계정 리포지토리에서 플러그인을 찾을 수 없습니다 - 복사됨! + 복사 완료! 레포지토리 이름 및 URL 본 테스트는 개발자만을 대상으로 하며, 확장자의 작업을 확인하거나 거부하지 않습니다. CloudStream 위키 - 링크 새로고침 완료 + 링크 초기화 완료 백업 빈도 즐겨찾기 QR 이미지 @@ -561,7 +552,7 @@ 즐겨찾기 해제 잠금 해제 생체 인식으로 잠금 - 음악 + 음악 오디오책 자동 회전 모바일 데이터 @@ -572,17 +563,11 @@ 기본값 설정 구독 사용 - 당신의 라이브러리에 이미 잠재적으로 중복된 항목이 존재합니다: \'%s\'. -\n -\n이 항목을 그래도 추가하시겠습니까, 기존 항목을 교체하시겠습니까, 아니면 작업을 취소하시겠습니까? + 당신의 라이브러리에 이미 잠재적으로 중복된 항목이 존재합니다: \'%s\'. \n \n이 항목을 그래도 추가하시겠습니까, 기존 항목을 교체하시겠습니까, 아니면 작업을 취소하시겠습니까? 전부 대체 추가 즐겨찾기에서 %s 제거 - 당신의 라이브러리에 잠재적으로 중복된 항목이 발견되었습니다: -\n -\n%s -\n -\n이 항목을 그래도 추가하시겠습니까, 기존 항목을 교체하시겠습니까, 아니면 작업을 취소하시겠습니까? + 당신의 라이브러리에 잠재적으로 중복된 항목이 발견되었습니다: \n \n%s \n \n이 항목을 그래도 추가하시겠습니까, 기존 항목을 교체하시겠습니까, 아니면 작업을 취소하시겠습니까? 계정 선택 기본 계정 사용 회전 @@ -591,7 +576,7 @@ 프로필 잠금 잘못된 PIN입니다. 다시 시도하세요. 계정 편집 - 미디어 + 미디어 비밀번호/PIN 인증 이 장치에서는 생체 인식이 지원되지 않습니다 지문, 얼굴 ID, PIN, 패턴 또는 비밀번호로 앱을 잠급니다. @@ -599,7 +584,7 @@ 재설정 플러그인 다운로드를 필터링할 모드 선택 CloudStream 데이터 백업이 완료되었습니다. 드문 경우지만, 기기에 따라 앱 접속이 안 되는 오류가 발생할 수 있습니다. 만약 앱이 열리지 않는다면, 앱 데이터를 완전히 삭제(초기화)한 후 이 백업 파일로 복구해 주시기 바랍니다. 이용에 불편을 드려 대단히 죄송합니다. - 스마트폰이나 컴퓨터에서 %s를 방문하여 위의 코드를 입력하세요 + 스마트폰이나 컴퓨터에서 %s 위의 코드를 입력하세요 구독 중인 TV 쇼의 알림을 받고 다운로드를 끊김 없이 완료하려면, CloudStream의 백그라운드 실행 권한이 필요합니다. \'확인\'을 누른 후 나타나는 요청 창에서 \'허용\'을 선택해 주세요.\n\n참고로, 이 권한을 허용한다고 해서 배터리가 계속 소모되는 것은 아닙니다. 알림을 받거나 공식 확장 프로그램에서 영상을 다운로드할 때처럼 꼭 필요한 상황에서만 백그라운드 작업을 수행합니다. 여기서 소스의 순서를 변경할 수 있습니다. 비디오의 우선 순위가 높은 경우에는 소스 선택 화면에 더 높게 나타납니다. 소스 우선 순위와 품질 우선 순위의 합이 비디오 우선 순위입니다. \n \n참고 A: 3 \n품질 B: 7 \n총 비디오 우선 순위는 10입니다. \n \n참고: 합이 10 이상이면 해당 링크가 로드되면 플레이어는 자동으로 로드를 건너뜁니다! 시즌 %1$d 에피소드 %2$d 공개 예정 @@ -608,8 +593,7 @@ 추천목록 보기 플레이어에 속도 옵션을 추가합니다 %s 후 공개 예정 - %s -\n남음 + %s \n남음 잠재적 중복 발견 %s의 PIN 입력 즐겨찾기에서 제거 @@ -627,24 +611,16 @@ 로컬 비디오 열기 파일 삭제 삭제 (%1$d | %2$s) - 다음 항목을 영구적으로 삭제 하시겠습니까?? -\n -\n%s - 다음 에피소드를 영구적으로 삭제 하시겠습니까? %1$s? -\n -\n%2$s - 또한 다음 시리즈의 모든 에피소드를 영구적으로 삭제합니다: -\n -\n%s - 다음 시리즈의 모든 에피소드를 영구적으로 삭제 하시겠습니까?? -\n -\n%s + 다음 항목을 영구적으로 삭제 하시겠습니까? \n \n%s + 다음 에피소드를 영구적으로 삭제 하시겠습니까? %1$s? \n \n%2$s + 또한 다음 시리즈의 모든 에피소드를 영구적으로 삭제합니다: \n \n%s + 다음 시리즈의 모든 에피소드를 영구적으로 삭제 하시겠습니까? \n \n%s 공개일 (최신순) 공개일 (오래된순) 플레이어 내 버튼명 숨기기 이 동영상은 토렌트이므로 시청 활동이 추적될 수 있습니다.\n계속하기 전에 토렌트에 대해 충분히 이해했는지 확인하세요. - 오디오 - 팟캐스트 + 오디오 + 팟캐스트 시작하기 … 인코딩 오류 미지원 오류 @@ -754,4 +730,7 @@ 새로고침 최대 밝기 확장 활성화 플레이어에 메타데이터 오버레이 표시 + 비디오 + 프리뷰 + 라이브 diff --git a/app/src/main/res/values-b+lt/strings.xml b/app/src/main/res/values-b+lt/strings.xml index cb2d816f382..db7e994f087 100644 --- a/app/src/main/res/values-b+lt/strings.xml +++ b/app/src/main/res/values-b+lt/strings.xml @@ -31,8 +31,7 @@ +30 Atsiuntimas baigtas Tęsti žiūrėjimą - Rastas atnaujinimas! -\n%1$s -> %2$s + Rastas atnaujinimas! \n%1$s -> %2$s Atsisiųsti kalbas Ieškoti naudojant tiekėjus Grįžti atgal @@ -88,8 +87,7 @@ Pratęsti siuntimą Azijietiškos dramos Serija - Jūsų biblioteka tuščia :( -\nPrisijunkite prie bibliotekos paskyros arba pridėkite laidas prie vietinės bibliotekos. + Jūsų biblioteka tuščia :( \nPrisijunkite prie bibliotekos paskyros arba pridėkite laidas prie vietinės bibliotekos. Pradėti sekančia seriją, kai dabartinė baigsis Teksto spalva Užbaigta @@ -181,11 +179,7 @@ 127.0.0.1 Atsiųsta %1$d %2$s Praleisti %s - Pagal numatytuosius nustatymus „CloudStream“ neturi įdiegtų svetainių. Turite įdiegti svetaines iš saugyklų. -\n -\nDėl beprotiško DMCA reikalavimų, kurios atliko Sky UK Limited 🤮, negalime susieti saugyklos svetainės programoje. -\n -\nPrisijunkite prie mūsų Discord arba ieškokite internete. + Pagal numatytuosius nustatymus „CloudStream“ neturi įdiegtų svetainių. Turite įdiegti svetaines iš saugyklų. \n \nDėl beprotiško DMCA reikalavimų, kurios atliko Sky UK Limited 🤮, negalime susieti saugyklos svetainės programoje. \n \nPrisijunkite prie mūsų Discord arba ieškokite internete. Mobilūs duomenys šaunusPrisijungimoVardas Autoriai diff --git a/app/src/main/res/values-b+lv/strings.xml b/app/src/main/res/values-b+lv/strings.xml index 89003317a51..9ab29cd4364 100644 --- a/app/src/main/res/values-b+lv/strings.xml +++ b/app/src/main/res/values-b+lv/strings.xml @@ -12,8 +12,7 @@ Apskatīt background Ātrums (%.2fx) Lidzīgi: %.1f - Jauns atjauninājums atrasts! -\n%1$s -> %2$s + Jauns atjauninājums atrasts! \n%1$s -> %2$s %d galvenais CloudStream Atskaņo ar cloudstream @@ -193,10 +192,8 @@ Atsākt -30 +30 - Šis pilnibā dzesīs %s -\nEsat parliecināts? - %dm -\natlikušas + Šis pilnibā dzesīs %s \nEsat parliecināts? + %dm \natlikušas Pabeigts Statuss Gads @@ -436,7 +433,7 @@ Kopsavilkums Jauktas beigas Jauktais sākums - Kredīts + Kredīts Notīrīt vēsturi Vēsture Rādīt izlaižamos uznirstošos logus atvēršanai/beigšanai @@ -458,8 +455,7 @@ Alfabētiskā secībā (Z līdz A) Atlasiet Bibliotēka Atvērt ar - Šķiet, ka jūsu bibliotēka ir tukša :( -\nPiesakieties bibliotēkas kontā vai pievienojiet pārraides savai vietējai bibliotēkai. + Šķiet, ka jūsu bibliotēka ir tukša :( \nPiesakieties bibliotēkas kontā vai pievienojiet pārraides savai vietējai bibliotēkai. Atgriest Anulēts %s abonements %d sērija izlaista! @@ -538,14 +534,14 @@ Pievienot iecienītajiem nokopēts! Labi - Mūzika + Mūzika Atiestatīt Drošība Konti Brīdinājums Rādīt - Audio - Raidieraksts + Audio + Raidieraksts Iegultie No interneta Nosaukums diff --git a/app/src/main/res/values-b+mk/strings.xml b/app/src/main/res/values-b+mk/strings.xml index 6998c49dbec..ea467833e9e 100644 --- a/app/src/main/res/values-b+mk/strings.xml +++ b/app/src/main/res/values-b+mk/strings.xml @@ -126,8 +126,7 @@ Откажи Паузирај Продолжи - Ова трајно ќе го избрише %s -\nДали си сигурен? + Ова трајно ќе го избрише %s \nДали си сигурен? Во тек Изгледанo Статус @@ -260,7 +259,7 @@ Подреди Внатрешен плеер Резолуција - Кредити + Кредити Пребарај %s… Приклучокот е избришан Статус @@ -417,8 +416,7 @@ Почна да презема %1$d %2$s… Автоматски ажурирања на приклучоци -30 - %dm -\nпреостанува + %dm \nпреостанува Видео кеш на дискот https://example.com/example.mp4 Готово @@ -567,7 +565,7 @@ Неомилен Омилен Заклучување со биометрика - Музика + Музика Известување за нова епизода Пребарај во други екстензии Прикажи препораки @@ -585,10 +583,9 @@ Отклучи CloudStream Биометриската автентикација не е поддржана на овој уред По неколку неуспешни обиди, известувањето ќе се затвори. Едноставно вклучи ја апликацијата повторно за да се обидеш повторно. - Медиуми + Медиуми Претстои во %s - %s -\nпреостанати + %s \nпреостанати За да се обезбедат непрекинати преземања и известувања за претплатените ТВ серии, CloudStream треба дозвола за работа во позадина. Со притискање на „ОК“, ќе ви биде прикажан дијалог за барање дозвола. Ве молиме, притиснете „Дозволи“.\n\nИмајте предвид дека оваа дозвола не значи дека CS3 ќе ја троши вашата батерија. Ќе работи во позадина само кога е потребно, како на пример при примање известувања или преземање видеа од официјални екстензии. Грешка при пристапот до таблата со исечоци, обиди се повторно. Грешка при копирање, молам копирај го логот и контактирај ја поддршката на апликацијата. @@ -633,8 +630,8 @@ CloudStream Wiki Дали си сигурен дека сакаш трајно да ги избришеш следните епизоди во %1$s?\n\n%2$s Кодот истекува за %1$d минута/и и %2$d секунда/и - Аудио - Поткаст + Аудио + Поткаст Грешка при кодирање на преводот Неподдржана грешка Вчитај прво достапно diff --git a/app/src/main/res/values-b+ml/strings.xml b/app/src/main/res/values-b+ml/strings.xml index d1c9409a399..c2b25c5ee77 100644 --- a/app/src/main/res/values-b+ml/strings.xml +++ b/app/src/main/res/values-b+ml/strings.xml @@ -3,8 +3,7 @@ വേഗം (%.2fx) റേറ്റിംഗ്: %.1f - പുതിയ അപ്ഡേറ്റ്! -\n%1$s -> %2$s + പുതിയ അപ്ഡേറ്റ്! \n%1$s -> %2$s ക്ലൗഡ് സ്ട്രീം ഹോം തിരയുക @@ -115,8 +114,7 @@ റദ്ദാക്കുക നിർത്തുക തുടരുക - സ്ഥിരമായി %sനെ ഡിലീറ്റ് ചെയ്യുക -\nഉറപ്പാണോ? + സ്ഥിരമായി %sനെ ഡിലീറ്റ് ചെയ്യുക \nഉറപ്പാണോ? തുടരുന്നു പൂർത്തിയായി അവസ്ഥ @@ -192,9 +190,7 @@ %s ൽ ഫോൻ്റ്‌സ് വെച്ചു കൊണ്ട് ഇംപോർട്ട് ചെയ്യുക പ്രശ്‌നമുണ്ടാക്കുന്ന ഒന്ന് കണ്ടെത്താൻ നിങ്ങളെ സഹായിക്കുന്നതിന് ഒരു ക്രാഷ് കാരണം എല്ലാ വിപുലീകരണങ്ങളും ഓഫാക്കി. പൊതു പട്ടിക - CloudStream-ന് സ്ഥിരസ്ഥിതിയായി സൈറ്റുകളൊന്നും ഇൻസ്റ്റാൾ ചെയ്തിട്ടില്ല. നിങ്ങൾ റിപ്പോസിറ്ററികളിൽ നിന്ന് സൈറ്റുകൾ ഇൻസ്റ്റാൾ ചെയ്യേണ്ടതുണ്ട്. -\n -\nഞങ്ങളുടെ ഡിസ്കോർഡിൽ ചേരുക അല്ലെങ്കിൽ ഓൺലൈനിൽ തിരയുക. + CloudStream-ന് സ്ഥിരസ്ഥിതിയായി സൈറ്റുകളൊന്നും ഇൻസ്റ്റാൾ ചെയ്തിട്ടില്ല. നിങ്ങൾ റിപ്പോസിറ്ററികളിൽ നിന്ന് സൈറ്റുകൾ ഇൻസ്റ്റാൾ ചെയ്യേണ്ടതുണ്ട്. \n \nഞങ്ങളുടെ ഡിസ്കോർഡിൽ ചേരുക അല്ലെങ്കിൽ ഓൺലൈനിൽ തിരയുക. പകർത്തുക എല്ലാ സബ്‌ടൈറ്റിലുകളും വലിയക്ഷരമാക്കുക റെൻഡറർ പിശക് @@ -261,8 +257,7 @@ ഈ ലിസ്റ്റ് ശൂന്യമാണ്. മറ്റൊന്നിലേക്ക് മാറാൻ ശ്രമിക്കുക. ചരിത്രം മായ്ക്കുക ലോഗ്കാറ്റ് കാണിക്കുക 🐈 - നിങ്ങളുടെ ലൈബ്രറി ശൂന്യമാണ് :( -\nഒരു ലൈബ്രറി അക്കൗണ്ടിൽ ലോഗിൻ ചെയ്യുക അല്ലെങ്കിൽ നിങ്ങളുടെ പ്രാദേശിക ലൈബ്രറിയിലേക്ക് ഷോകൾ ചേർക്കുക. + നിങ്ങളുടെ ലൈബ്രറി ശൂന്യമാണ് :( \nഒരു ലൈബ്രറി അക്കൗണ്ടിൽ ലോഗിൻ ചെയ്യുക അല്ലെങ്കിൽ നിങ്ങളുടെ പ്രാദേശിക ലൈബ്രറിയിലേക്ക് ഷോകൾ ചേർക്കുക. വീഡിയോ റിപ്പോസിറ്ററി നാമവും URL ഉം പകർത്തി! diff --git a/app/src/main/res/values-b+ms/strings.xml b/app/src/main/res/values-b+ms/strings.xml index 83492a5fff3..ab0d0a54f97 100644 --- a/app/src/main/res/values-b+ms/strings.xml +++ b/app/src/main/res/values-b+ms/strings.xml @@ -5,7 +5,7 @@ Sejarah Kosongkan sejarah Pengenalan - Kredit + Kredit Pembukaan bercampur Penamat Pembukaan @@ -56,8 +56,7 @@ Tutup Pratonton Resensi:%.1f - Kemas kini baru dijumpai! -\n%1$s -> %2$s + Kemas kini baru dijumpai! \n%1$s -> %2$s %d min Main dari mula Musim %1$d Episod %2$d akan dikeluarkan di @@ -285,7 +284,7 @@ /%d Gunakan ini sekiranya sari kata yang dipaparkan awal sebanyak %d HDR - Media + Media Susun atur TV TV Bersiri Tidak dapat papar %s @@ -390,7 +389,7 @@ Berhenti Anda telah mengundi Alih keluar dari Kegemaran - Musik + Musik Pilih benda untuk dibuang Buang sempadan hitam Akaun dan Sekuriti @@ -532,6 +531,6 @@ Torrent Dokumentari Siaran Langsung - Audio - Podcast + Audio + Podcast diff --git a/app/src/main/res/values-b+mt/strings.xml b/app/src/main/res/values-b+mt/strings.xml index ea859ee293a..8f8bf6cd71e 100644 --- a/app/src/main/res/values-b+mt/strings.xml +++ b/app/src/main/res/values-b+mt/strings.xml @@ -18,8 +18,7 @@ Ibdel Il-fornitur veloċità (%.2fx) Klassifikazzjoni: %.1f - Aġġornament ġdid misjub! -\n%1$s -> %2$s + Aġġornament ġdid misjub! \n%1$s -> %2$s %d min CloudStream Ara bil-CloudStream diff --git a/app/src/main/res/values-b+my/strings.xml b/app/src/main/res/values-b+my/strings.xml index 4a7a50aa73d..d360d095d85 100644 --- a/app/src/main/res/values-b+my/strings.xml +++ b/app/src/main/res/values-b+my/strings.xml @@ -11,8 +11,7 @@ နောက်သို့ နောက်ခံပုံရိပ်ကို အကြိုကြည့်ရန် အဆင့်: %.1f - အပ်ဒိတ်အသစ်! -\n%1$s -> %2$s + အပ်ဒိတ်အသစ်! \n%1$s -> %2$s စစ်ထုတ်မှု %d မိနစ် CloudStream @@ -111,10 +110,8 @@ အပိုင်း အပိုင်းများ %1$d-%2$d - ဒါကအပြီးဖျက်ခြင်းဖြစ်ပါသည် %s -\nသင်သေချာပါသလား။ - %dမိနစ် -\nကျန်ရိှသည် + ဒါကအပြီးဖျက်ခြင်းဖြစ်ပါသည် %s \nသင်သေချာပါသလား။ + %dမိနစ် \nကျန်ရိှသည် ထုတ်လွှင့်နေဆဲ ထုတ်လွှင့်မှုပြီးဆုံး အခြေအနေ @@ -336,17 +333,13 @@ အစမှပြန်စ ရောထားသောအဆုံးပိုင်း ရောထားသောအစပိုင်း - ခရက်ဒစ်များ + ခရက်ဒစ်များ အစ သေချာသည် သမားရိုးကျ ထည့်သွင်းသူ ထွက်ချိန်တွင် အက်ပ်ကို အပ်ဒိတ်လုပ်ပါမည် - CloudStream တွင် မူရင်းအတိုင်း ထည့်သွင်းထားသည့်ဆိုက်များ မရှိပါ။ ရီပိုစစ်ထရီများ မှ ဆိုဒ် များကို ထည့်သွင်းရန်လိုအပ်သည်။ -\n -\nSky UK Limited မှ ဦးနှောက်မဲ့ DMCA ကို ဖယ်ရှားလိုက်ခြင်းကြောင့် 🤮 ကျွန်ုပ်တို့သည် ရီပိုစစ်ထရီဆိုဒ်ကို အက်ပ်တွင် ချိတ်ဆက်၍မရပါ။ -\n -\nကျွန်ုပ်တို့၏ Discord တွင်ပါဝင်ပါ သို့မဟုတ် အွန်လိုင်းတွင်ရှာဖွေပါ။ + CloudStream တွင် မူရင်းအတိုင်း ထည့်သွင်းထားသည့်ဆိုက်များ မရှိပါ။ ရီပိုစစ်ထရီများ မှ ဆိုဒ် များကို ထည့်သွင်းရန်လိုအပ်သည်။ \n \nSky UK Limited မှ ဦးနှောက်မဲ့ DMCA ကို ဖယ်ရှားလိုက်ခြင်းကြောင့် 🤮 ကျွန်ုပ်တို့သည် ရီပိုစစ်ထရီဆိုဒ်ကို အက်ပ်တွင် ချိတ်ဆက်၍မရပါ။ \n \nကျွန်ုပ်တို့၏ Discord တွင်ပါဝင်ပါ သို့မဟုတ် အွန်လိုင်းတွင်ရှာဖွေပါ။ အခြားသူများ၏ရီပိုစစ်ထရီများကိုရှာဖွေမည် အသံများ အသံဖိုင်များ @@ -422,8 +415,7 @@ အက်ပ်အပ်ဒိတ်အားဒေါင်းလုဒ်လုပ်နေသည်… အက်ပ်အပ်ဒိတ်အားသွင်းနေသည်… အပ်ဒိတ်ဖြစ်မှု (အသစ် မှ အဟောင်း) - သင့်လိုက်ဘရီသည် ဗလာဖြစ်နေသည် :( -\nအကောင့်ဝင်ပါ သို့မဟုတ် သင့်ဖုန်းလိုက်ဘရီတွင် ကြည့်စရာများထည့်ပါ။ + သင့်လိုက်ဘရီသည် ဗလာဖြစ်နေသည် :( \nအကောင့်ဝင်ပါ သို့မဟုတ် သင့်ဖုန်းလိုက်ဘရီတွင် ကြည့်စရာများထည့်ပါ။ သုံးရန် တည်းဖြတ်ရန် အရည်အသွေးများ @@ -516,8 +508,7 @@ စာရင်းသွင်းပြီး %s စာရင်းသွင်းမှုပယ်ဖျက်ပြီး %s ဤစာရင်းသည် ဗလာဖြစ်နေသည်။ အခြားတစ်ခုသို့ ပြောင်းကြည့်ပါ။ - Safe mode ဖိုင်ကို တွေ့ရှိခဲ့သည်။ -\nဖိုင်ကိုမဖယ်ရှားမချင်း စတင်ဖွင့်စတွင် မည်သည့် extension များကိုမျှ မတင်ပါ။ + Safe mode ဖိုင်ကို တွေ့ရှိခဲ့သည်။ \nဖိုင်ကိုမဖယ်ရှားမချင်း စတင်ဖွင့်စတွင် မည်သည့် extension များကိုမျှ မတင်ပါ။ အပိုင်းသစ် %d ထွက်ပြီ ပရိုဖိုင် %d ဝိုင်ဖိုင် @@ -525,13 +516,7 @@ ပုံသေထားရန် ပရိုဖိုင်များ အကူအညီ - ဤနေရာတွင် သင်သည် အရင်းအမြစ်များကို မည်ကဲ့သို့ အစီအစဥ်ချမည်ကို ပြောင်းလဲနိုင်သည်။ ဗီဒီယိုတစ်ခုတွင် ပိုမိုဦးစားပေးပါက ရင်းမြစ်ရွေးချယ်မှုတွင် ပိုမိုမြင့်မားလာမည်ဖြစ်သည်။ အရင်းအမြစ် ဦးစားပေးနှင့် အရည်အသွေး ဦးစားပေး၏ ပေါင်းစုသည် ဗီဒီယို ဦးစားပေးဖြစ်သည်။ -\n -\nအရင်းအမြစ် A: 3 -\nအရည်အသွေး B: 7 -\nပေါင်းစပ်ဗီဒီယို ဦးစားပေး 10 ခု ရှိပါမည်။ -\n -\nမှတ်ချက်- ပေါင်းလဒ်သည် 10 သို့မဟုတ် ထို့ထက်ပိုပါက ထိုလင့်ခ်ကို တင်သည့်အခါ ဗီဒီယိုဖွင့်စက်သည် အလိုအလျောက် ဒေါင်းလုဒ်ကို ကျော်သွားမည်ဖြစ်သည် + ဤနေရာတွင် သင်သည် အရင်းအမြစ်များကို မည်ကဲ့သို့ အစီအစဥ်ချမည်ကို ပြောင်းလဲနိုင်သည်။ ဗီဒီယိုတစ်ခုတွင် ပိုမိုဦးစားပေးပါက ရင်းမြစ်ရွေးချယ်မှုတွင် ပိုမိုမြင့်မားလာမည်ဖြစ်သည်။ အရင်းအမြစ် ဦးစားပေးနှင့် အရည်အသွေး ဦးစားပေး၏ ပေါင်းစုသည် ဗီဒီယို ဦးစားပေးဖြစ်သည်။ \n \nအရင်းအမြစ် A: 3 \nအရည်အသွေး B: 7 \nပေါင်းစပ်ဗီဒီယို ဦးစားပေး 10 ခု ရှိပါမည်။ \n \nမှတ်ချက်- ပေါင်းလဒ်သည် 10 သို့မဟုတ် ထို့ထက်ပိုပါက ထိုလင့်ခ်ကို တင်သည့်အခါ ဗီဒီယိုဖွင့်စက်သည် အလိုအလျောက် ဒေါင်းလုဒ်ကို ကျော်သွားမည်ဖြစ်သည် ပရိုဖိုင်နောက်ခံ UI ကို မှန်ကန်စွာ ဖန်တီး၍မရပါ၊ ၎င်းသည် အဓိက ချို့ယွင်းချက်တစ်ခုဖြစ်ပြီး ချက်ချင်းသတင်းပို့သင့်သည်။ %s သင်နဂိုတည်းကသတ်မှတ်ပြီး diff --git a/app/src/main/res/values-b+ne/strings.xml b/app/src/main/res/values-b+ne/strings.xml index 8a432a5058d..49e5a93506e 100644 --- a/app/src/main/res/values-b+ne/strings.xml +++ b/app/src/main/res/values-b+ne/strings.xml @@ -15,8 +15,7 @@ मुख्य पोस्टर %1$s Ep %2$d अभिनेता:%s - नयाँ अपडेट भेटियो! -\n%1$s -> %2$s + नयाँ अपडेट भेटियो! \n%1$s -> %2$s फिलर %d मिनेट क्लाउडस्ट्रीम diff --git a/app/src/main/res/values-b+nl/strings.xml b/app/src/main/res/values-b+nl/strings.xml index 30b8b2def99..ace3aa0d429 100644 --- a/app/src/main/res/values-b+nl/strings.xml +++ b/app/src/main/res/values-b+nl/strings.xml @@ -19,8 +19,7 @@ Snelheid (%.2fx) Beoordeeld: %.1fAls - Nieuwe update gevonden! -\n%1$s -> %2$s + Nieuwe update gevonden! \n%1$s -> %2$s Filler %d min CloudStream @@ -182,10 +181,8 @@ Hervatten -30 +30 - Dit wordt zeker permanent verwijderd %s -\nWeet u het zeker? - %dm -\nremaining + Dit wordt zeker permanent verwijderd %s \nWeet u het zeker? + %dm \nremaining Voortdurende Voltooid Status @@ -484,8 +481,7 @@ Verwijderen uit bekeken App wordt bijgewerkt bij afsluiten Gesorteerd - Je bibliotheek is leeg :( -\nLog in op een bibliotheekaccount of voeg voorstellingen toe aan uw lokale bibliotheek. + Je bibliotheek is leeg :( \nLog in op een bibliotheekaccount of voeg voorstellingen toe aan uw lokale bibliotheek. Uitgeschakeld: %d Stop Niet gedownload: %d @@ -507,24 +503,19 @@ %s ( Uitgeschakeld) Herstart de app om veranderingen te zien. Gedownload: %d - Veilige mode bestand gevonden! -\nGeen extensies laden bij het opstarten totdat het bestand is verwijderd. + Veilige mode bestand gevonden! \nGeen extensies laden bij het opstarten totdat het bestand is verwijderd. Nee Beoordeling ( Hoog naar Laag) Veilige mode aan Herstart Beschrijving - Waardering + Waardering Wis geschiedenis Ingeschreven Wis repository Uitgeschreven bij %s Terugkeren - CloudStream heeft standaard geen sites geïnstalleerd. U moet de sites uit repositories installeren. -\n -\nVanwege een hersenloze DMCA verwijdering door Sky UK Limited 🤮 kunnen we de repository site niet linken in de app. -\n -\nWord lid van onze Discord of zoek online. + CloudStream heeft standaard geen sites geïnstalleerd. U moet de sites uit repositories installeren. \n \nVanwege een hersenloze DMCA verwijdering door Sky UK Limited 🤮 kunnen we de repository site niet linken in de app. \n \nWord lid van onze Discord of zoek online. Audiosporen Gesorteerd op Wifi @@ -536,13 +527,7 @@ Kwaliteiten Profiel achtergrond Gebruik - Hier kan je de volgorde van de bronnen veranderen. Als een video een hogere prioriteit heeft zal het hoger in de bronnenlijst staan. De som van de prioriteit van de bron en de prioriteit van de kwaliteit is de prioriteit van de video. -\n -\nBron A: 3 -\nKwaliteit B: 7 -\nHeeft een totale prioriteit van de video van 10. -\n -\nNOTITIE: Als de som 10 of hoger is zal de speler automatisch het laden overslaan wanneer die link is geladen! + Hier kan je de volgorde van de bronnen veranderen. Als een video een hogere prioriteit heeft zal het hoger in de bronnenlijst staan. De som van de prioriteit van de bron en de prioriteit van de kwaliteit is de prioriteit van de video. \n \nBron A: 3 \nKwaliteit B: 7 \nHeeft een totale prioriteit van de video van 10. \n \nNOTITIE: Als de som 10 of hoger is zal de speler automatisch het laden overslaan wanneer die link is geladen! Profiel %d Repository niet gevonden, controleer de URL en probeer een VPN Geen plug-ins gevonden in de repository @@ -554,11 +539,7 @@ Favorieten %s toegevoegd aan favorieten Aangemeld als %s - Er zijn mogelijk dubbele items gevonden in uw bibliotheek: -\n -\n%s -\n -\nWilt u dit item toch toevoegen, de bestaande vervangen of de actie annuleren? + Er zijn mogelijk dubbele items gevonden in uw bibliotheek: \n \n%s \n \nWilt u dit item toch toevoegen, de bestaande vervangen of de actie annuleren? Voer PIN in voor %s Backupfrequentie Mogelijk Duplicaat Gevonden @@ -577,9 +558,7 @@ Abonneer Verwijder uit favorieten Selecteer een Account - Het lijkt erop dat er al een mogelijk duplicaat bestaat in uw bibliotheek: \'%s.\' -\n -\nWilt u dit item toch toevoegen, het bestaande item vervangen of de actie annuleren? + Het lijkt erop dat er al een mogelijk duplicaat bestaat in uw bibliotheek: \'%s.\' \n \nWilt u dit item toch toevoegen, het bestaande item vervangen of de actie annuleren? PIN invoeren PIN Huidige PIN invoeren @@ -616,11 +595,11 @@ Hiermee worden ook alle afleveringen van de volgende series permanent verwijderd:\n\n%s Weet je zeker dat je alle afleveringen van de volgende series permanent wil verwijderen?\n\n%s %s\nresterend - Muziek + Muziek Luisterboek - Media - Audio - Podcast + Media + Audio + Podcast Coderingsfout Fout: wordt niet ondersteund Beveiliging diff --git a/app/src/main/res/values-b+nn/strings.xml b/app/src/main/res/values-b+nn/strings.xml index 245bf66186c..014e2b0a9c9 100644 --- a/app/src/main/res/values-b+nn/strings.xml +++ b/app/src/main/res/values-b+nn/strings.xml @@ -15,8 +15,7 @@ Fyllstoff Forhandsvis bakgrunnsbilete Vurdert: %.1f - Ny oppdatering tilgjengeleg! -\n%1$s -> %2$s + Ny oppdatering tilgjengeleg! \n%1$s -> %2$s %d minutt Miniatyrbilete Episode %d vil bli sleppt om @@ -115,10 +114,8 @@ Gjenoppta -30 +30 - Dette vil slette %s permanent. -\nEr du sikker på dette? - %dm -\ngjenstår + Dette vil slette %s permanent. \nEr du sikker på dette? + %dm \ngjenstår Pågåande Fullført År diff --git a/app/src/main/res/values-b+no/strings.xml b/app/src/main/res/values-b+no/strings.xml index 374b033c6af..7d309a2f86e 100644 --- a/app/src/main/res/values-b+no/strings.xml +++ b/app/src/main/res/values-b+no/strings.xml @@ -12,8 +12,7 @@ Avspillingshastighet (%.2fx) Vurdert: %.1f - Ny oppdatering funnet! -\n%1$s -> %2$s + Ny oppdatering funnet! \n%1$s -> %2$s CloudStream Hjem Søk @@ -135,8 +134,7 @@ Avbryt Stopp Gjenoppta - Dette vil slette %s -\nEr du sikker? + Dette vil slette %s \nEr du sikker? Pågående Fullført Posisjon @@ -353,8 +351,7 @@ %1$d-%2$d Offentlig liste programtillegg - %dm -\nigjen + %dm \nigjen Videooppløsning Synkroniser undertekster Undertekstforsinkelse @@ -373,15 +370,11 @@ Ugyldig nettadresse Knippe-nedlasting Slett pakkebrønn - CloudStream har ingen sider installert som forvalg. Du må installere sidene fra pakkebrønner. -\n -\nSom følge av en hjernedød DMCA-forespørsel fra Sky UK Limited 🤮 kan vi ikke lenke til pakkebrønnssiden i programmet. -\n -\nTa del i vår Discord, eller søk på nett. + CloudStream har ingen sider installert som forvalg. Du må installere sidene fra pakkebrønner. \n \nSom følge av en hjernedød DMCA-forespørsel fra Sky UK Limited 🤮 kan vi ikke lenke til pakkebrønnssiden i programmet. \n \nTa del i vår Discord, eller søk på nett. Bruk dette hvis undertekster vises %d ms for sent Programtillegg innlastet Lydspor - Rulletekst + Rulletekst Introduksjon Lagringstilgang mangler. Prøv igjen. Vis trailere @@ -512,10 +505,8 @@ ISP-omgåelser Denne listen er tom. Prøv å bytte til en annen. Sorter - Fant fil for trygt modus. -\nLaster ikke inn noen utvidelser ved oppstart til filen er fjernet. - Biblioteket ditt er tomt :( -\nLogg inn på en bibliotekkonto eller legg til programmer i ditt lokale bibliotek. + Fant fil for trygt modus. \nLaster ikke inn noen utvidelser ved oppstart til filen er fjernet. + Biblioteket ditt er tomt :( \nLogg inn på en bibliotekkonto eller legg til programmer i ditt lokale bibliotek. Rediger Profiler Favoritter diff --git a/app/src/main/res/values-b+or/strings.xml b/app/src/main/res/values-b+or/strings.xml index 8c9379f5bab..ce7f74290be 100644 --- a/app/src/main/res/values-b+or/strings.xml +++ b/app/src/main/res/values-b+or/strings.xml @@ -61,7 +61,7 @@ ସବୁ ଭାଷା ମିଶ୍ରିତ ପ୍ରାନ୍ତ ମିଶ୍ରିତ ଆଦ୍ୟ - ଶ୍ରେୟ + ଶ୍ରେୟ ଉପକ୍ରମ ଏହି ଭାଷାଗୁଡ଼ିକରେ ଵିଡ଼ିଓ ଦେଖନ୍ତୁ ସଂସ୍କରଣ @@ -84,8 +84,7 @@ ବ୍ୟାକଅପ୍ ଆଣ୍ଡ୍ରଏଡ୍ ଟିଵି ଅଙ୍ଗଭଙ୍ଗୀ - ନୂଆ ଅଦ୍ୟତନ ମିଳିଲା! -\n%1$s -> %2$s + ନୂଆ ଅଦ୍ୟତନ ମିଳିଲା! \n%1$s -> %2$s ଅଵଧି ଆପ୍ ବ୍ୟାକଅପ୍ ଫାଇଲ୍ ଧାରଣ ହେଲା diff --git a/app/src/main/res/values-b+pl/strings.xml b/app/src/main/res/values-b+pl/strings.xml index c8126f2fe42..35367a1b2a3 100644 --- a/app/src/main/res/values-b+pl/strings.xml +++ b/app/src/main/res/values-b+pl/strings.xml @@ -2,8 +2,7 @@ Prędkość (%.2fx) Ocena: %.1f - Znaleziono nową aktualizację! -\n%1$s -> %2$s + Znaleziono nową aktualizację! \n%1$s -> %2$s Filler %d min %1$s Odc. %2$d @@ -177,10 +176,8 @@ Odtwórz -30 +30 - Spowoduje to trwałe usunięcie %s -\nCzy jesteś pewien? - %dm -\npozostało + Spowoduje to trwałe usunięcie %s \nCzy jesteś pewien? + %dm \npozostało Bieżący Zakończone Status @@ -246,7 +243,7 @@ Aktualizacja Domyślna jakość (WiFi) Maksymalna liczba znaków w tytule odtwarzacza - Pokaż informacje o odtwarzaczu + Pokaż informacje o odtwarzaczu Rozmiar bufora wideo Długość bufora wideo Pamięć podręczna wideo na dysku @@ -385,9 +382,7 @@ Wyłączono: %d Nie pobrano: %d Zaaktualizowano %d rozszerzeń - CloudStream nie ma domyślnie zainstalowanych żadnych witryn. Musisz zainstalować witryny z repozytoriów. -\n -\nDołącz do naszego Discorda lub poszukaj online. + CloudStream nie ma domyślnie zainstalowanych żadnych witryn. Musisz zainstalować witryny z repozytoriów. \n \nDołącz do naszego Discorda lub poszukaj online. Zobacz repozytoria społeczności Publiczna lista Wszystkie napisy wielką literą @@ -452,7 +447,7 @@ Opening Ending Mixed opening - Napisy końcowe + Napisy końcowe Intro Mixed ending Pokaż wyskakujące okienka pomijania dla niektórych segmentów @@ -487,11 +482,9 @@ Alfabetycznie (od Z do A) Wybierz bibliotekę Biblioteka - Twoja biblioteka jest pusta :( -\nZaloguj się na swoje konto lub dodaj programy do swojej lokalnej biblioteki. + Twoja biblioteka jest pusta :( \nZaloguj się na swoje konto lub dodaj programy do swojej lokalnej biblioteki. Ta lista jest pusta. Spróbuj przełączyć się na inną. - Znaleziono plik trybu bezpiecznego. -\nRozszerzenia nie zostaną wczytane, dopóki plik nie zostanie usunięty. + Znaleziono plik trybu bezpiecznego. \nRozszerzenia nie zostaną wczytane, dopóki plik nie zostanie usunięty. Używana ilość przewijania, gdy widoczny jest odtwarzacz Ukryty odtwarzacz - ilość przewijania Android TV @@ -515,13 +508,7 @@ Obchodzi blokadę surowych adresów URL GitHuba za pomocą jsDelivr. Może powodować opóźnienie aktualizacji o kilka dni. Nie udało się połączyć z GitHubem. Włączono serwer pośredniczący jsDelivr… Domyślna jakość (dane mobilne) - W tym miejscu można zmienić kolejność źródeł. Jeśli wideo ma wyższy priorytet, pojawi się wyżej w wyborze źródła. Priorytet wideo jest sumą priorytetu źródła i priorytetu jakości. -\n -\nŹródło A: 3 -\nJakość B: 7 -\nŁączny priorytet wideo będzie wynosił 10. -\n -\nUWAGA: Jeśli suma wynosi 10 lub więcej, odtwarzacz automatycznie pominie ładowanie po załadowaniu tego łącza! + W tym miejscu można zmienić kolejność źródeł. Jeśli wideo ma wyższy priorytet, pojawi się wyżej w wyborze źródła. Priorytet wideo jest sumą priorytetu źródła i priorytetu jakości. \n \nŹródło A: 3 \nJakość B: 7 \nŁączny priorytet wideo będzie wynosił 10. \n \nUWAGA: Jeśli suma wynosi 10 lub więcej, odtwarzacz automatycznie pominie ładowanie po załadowaniu tego łącza! Profil %d Wi-Fi Dane mobilne @@ -541,11 +528,7 @@ Usunięto %s z ulubionych Ulubione Dodano %s do ulubionych - W swojej bibliotece znaleziono potencjalne duplikaty: -\n -\n%s -\n -\nCzy chcesz dodać ten element, zastąpić istniejące, czy anulować operację? + W swojej bibliotece znaleziono potencjalne duplikaty: \n \n%s \n \nCzy chcesz dodać ten element, zastąpić istniejące, czy anulować operację? Wprowadź PIN dla %s Częstotliwość tworzenia kopii zapasowych Znaleziono potencjalny duplikat @@ -562,9 +545,7 @@ Zasubskrybuj Usuń z ulubionych Wybierz konto - Wygląda się, że potencjalny duplikat już znajduje się w bibliotece: \'%s\'. \' -\n -\nCzy chciałbyś dodać ten element, zastąpić istniejący, czy anulować akcję? + Wygląda się, że potencjalny duplikat już znajduje się w bibliotece: \'%s\'. \' \n \nCzy chciałbyś dodać ten element, zastąpić istniejący, czy anulować akcję? Wprowadź PIN PIN Linki załadowane ponownie @@ -590,8 +571,7 @@ Odblokuj aplikację za pomocą odcisku palca, identyfikatora twarzy, kodu PIN, wzoru i hasła. Kopia zapasowa Twoich danych CloudStream została teraz utworzona. Chociaż prawdopodobieństwo tego jest bardzo niskie, wszystkie urządzenia mogą zachowywać się inaczej. W rzadkich przypadkach, gdy dostęp do aplikacji zostanie zablokowany, należy całkowicie wyczyścić dane aplikacji i przywrócić je z kopii zapasowej. Bardzo nam przykro z powodu wszelkich niedogodności z tym związanych. Usuń z ulubionych - %s -\npozostało + %s \npozostało Dodaj do ulubionych Nazwa repozytorium i adres URL Błąd dostępu do schowka. Spróbuj ponownie. @@ -599,10 +579,10 @@ Błąd podczas kopiowania. Skopiuj logcat i skontaktuj się z pomocą techniczną aplikacji. Wyłącz optymalizację baterii Nie można otworzyć informacji o aplikacji CloudStream. - Muzyka + Muzyka Audiobook OK - Multimedia + Multimedia Użycie baterii przez aplikację jest już ustawione na nieograniczone Aby zapewnić nieprzerwane pobieranie i powiadomienia o subskrybowanych programach, CloudStream potrzebuje pozwolenia na działanie w tle. Po naciśnięciu OK wyświetli się okno dialogowe. Naciśnij „Zezwól”.\n\nPamiętaj, że to pozwolenie nie oznacza, że CS3 będzie zużywać baterię. Będzie działać w tle tylko wtedy, gdy będzie to konieczne, na przykład podczas odbierania powiadomień lub pobierania filmów z oficjalnych rozszerzeń. Resetuj @@ -633,19 +613,11 @@ Zaznacz wszystkie Zaznacz elementy do usunięcia Odznacz wszystkie - Czy na pewno chcesz na stałe usunąć następujące elementy? -\n -\n%s - Usuniesz na stale wszystkie odcinki następującego serialu: -\n -\n%s - Czy na pewno chcesz na stałe usunąć wszystkie odcinki następującego serialu? -\n -\n%s + Czy na pewno chcesz na stałe usunąć następujące elementy? \n \n%s + Usuniesz na stale wszystkie odcinki następującego serialu: \n \n%s + Czy na pewno chcesz na stałe usunąć wszystkie odcinki następującego serialu? \n \n%s Usuń pliki - Czy na pewno chcesz na stałe usunąć następujące odcinki %1$s? -\n -\n%2$s + Czy na pewno chcesz na stałe usunąć następujące odcinki %1$s? \n \n%2$s Usuń (%1$d | %2$s) Podgląd paska przewijania Włącz podgląd miniatury na pasku wyszukiwania @@ -658,8 +630,8 @@ Własna Ten film jest torrentem, co oznacza, że Twoja aktywność wideo może być śledzona.\nUpewnij się, że rozumiesz czym są torrenty, zanim przejdziesz dalej. Rozmiar krawędzi - Podcast - Audio + Podcast + Audio Nieobsługiwany błąd Błąd kodowania Wczytaj pierwsze dostępne @@ -721,7 +693,7 @@ Przeładuj dostawcę Odtwarzaj inne źródło" Nazwa - Rozdzielczość i nazwa + Rozdzielczość i nazwa Dolne lewe Wyrównanie napisów Dolne środkowe @@ -742,16 +714,16 @@ Wyczyść sugestie Pokaż panel obsady Nazwa źródła - Informacje o multimediach + Informacje o multimediach Dodatkowa jasność Włącz filtr jasności, gdy jasność wyświetlacza przekroczy 100% Włączono dodatkową jasność Kolejka pobierania - Obecnie nie ma żadnych plików do pobrania w kolejce. + Obecnie nie ma żadnych plików do pobrania w kolejce. Pobierz wszystkie Anuluj wszystkie Czy chcesz pobrać odcinek %s? - Czy chcesz anulować wszystkie pliki do pobrania z kolejki? + Czy chcesz anulować wszystkie pliki do pobrania z kolejki? %d aktywne pobieranie %d aktywne pobierania @@ -759,12 +731,15 @@ %d aktywnych pobierań - %d pobieranie w kolejce - %d pobierania w kolejce - %d pobierań w kolejce - %d pobierań w kolejce + %d pobieranie w kolejce + %d pobierania w kolejce + %d pobierań w kolejce + %d pobierań w kolejce Priorytet źródła - Zdecyduj, jak mają być sortowane źródła wideo w odtwarzaczu + Zdecyduj, jak mają być sortowane źródła wideo w odtwarzaczu Pokaż nakładkę metadanych odtwarzacza + Wideo + Zapowiedź + Na żywo diff --git a/app/src/main/res/values-b+pt+BR/strings.xml b/app/src/main/res/values-b+pt+BR/strings.xml index 58d59870892..00e3a62298a 100644 --- a/app/src/main/res/values-b+pt+BR/strings.xml +++ b/app/src/main/res/values-b+pt+BR/strings.xml @@ -180,8 +180,7 @@ Continuar -30 +30 - Isso apagará %s permanentemente -\nVocê tem certeza? + Isso apagará %s permanentemente \nVocê tem certeza? %dm\nrestantes Em andamento Concluído @@ -396,9 +395,7 @@ Transferido: %d Desativado: %d Não transferido: %d - CloudStream não tem fontes instaladas por padrão. Você precisa instalar um site de repositórios. -\n -\nEntre no nosso Discord ou pesquise online. + CloudStream não tem fontes instaladas por padrão. Você precisa instalar um site de repositórios. \n \nEntre no nosso Discord ou pesquise online. Ver repositórios da comunidade Lista pública Todas as legendas em maiúsculas @@ -441,8 +438,7 @@ Abrir com Selecionar Biblioteca Passou nos testes - Sua biblioteca está vazia :0 -\nEntre numa conta de biblioteca ou adicione Midias para sua biblioteca local. + Sua biblioteca está vazia :0 \nEntre numa conta de biblioteca ou adicione Midias para sua biblioteca local. Qualidade preferida de reprodução (Dados Móveis) Legado Biblioteca @@ -460,15 +456,8 @@ Alfabética(Z => A) Qualidade Perfil de plano de fundo - Aqui você pode alterar como as fontes são ordenadas. Se um vídeo tiver uma prioridade mais alta, aparecerá mais alto na seleção da fonte. A soma da prioridade da fonte e da prioridade da qualidade é a prioridade do vídeo. -\n -\nFonte A: 3 -\nQualidade B: 7 -\nTerá uma prioridade de vídeo combinada de 10. -\n -\nNOTA: Se a soma for 10 ou mais, o Player pulará automaticamente o carregamento quando o link for carregado! - Arquivo de modo de segurança encontrado! -\nNão carregar nenhuma extensão na inicialização até que o arquivo seja removido. + Aqui você pode alterar como as fontes são ordenadas. Se um vídeo tiver uma prioridade mais alta, aparecerá mais alto na seleção da fonte. A soma da prioridade da fonte e da prioridade da qualidade é a prioridade do vídeo. \n \nFonte A: 3 \nQualidade B: 7 \nTerá uma prioridade de vídeo combinada de 10. \n \nNOTA: Se a soma for 10 ou mais, o Player pulará automaticamente o carregamento quando o link for carregado! + Arquivo de modo de segurança encontrado! \nNão carregar nenhuma extensão na inicialização até que o arquivo seja removido. Inscrito em %s Episódio %d lançado! Selecionar padrão @@ -510,7 +499,7 @@ Versão Autores Instale a extensão primeiro - Créditos + Créditos Historico Limpar historico Tem Muito texto. Não é possível salvar no clipboard. @@ -553,11 +542,7 @@ Duplicata em potencial encontrada Adicionar Substituir - Possíveis itens duplicados foram encontrados em sua biblioteca: -\n -\n %s -\n -\nGostaria de adicionar este item mesmo assim, substituir os existentes ou cancelar a ação? + Possíveis itens duplicados foram encontrados em sua biblioteca: \n \n %s \n \nGostaria de adicionar este item mesmo assim, substituir os existentes ou cancelar a ação? Insira o PIN Insira o PIN para %s Insira o PIN atual @@ -576,9 +561,7 @@ Links recarregados Frequência de backup Substitua tudo - Parece que já existe um item potencialmente duplicado na sua biblioteca: \'%s.\' -\n -\nGostaria de adicionar este item mesmo assim, substituir o existente ou cancelar a ação? + Parece que já existe um item potencialmente duplicado na sua biblioteca: \'%s.\' \n \nGostaria de adicionar este item mesmo assim, substituir o existente ou cancelar a ação? Inscrever-se Cancelar inscrição Usar conta padrão @@ -599,8 +582,7 @@ A autenticação biométrica não é compatível com este dispositivo Desbloquear o aplicativo com impressão digital, ID facial, PIN, padrão e senha. Após algumas tentativas fracassadas, o prompt será fechado. Basta reiniciar o aplicativo para tentar novamente. - %s -\nrestante(s) + %s \nrestante(s) Favorito Não favorito copiado! @@ -612,9 +594,9 @@ Desativar otimização de bateria O uso da bateria do app já está definido como irrestrito Não foi possível abrir as informações do aplicativo CloudStream. - Música + Música Áudio-livro - Mídia + Mídia Redefinir Próximo em %s %2$dº episódio da %1$dª temporada estreia em @@ -663,8 +645,8 @@ Erro de codificação Erro, formato não suportado Carregar primeiro disponível - Áudio - Podcast + Áudio + Podcast Decodificação de software A decodificação por software permite que o reprodutor reproduza arquivos de vídeo não suportados pelo seu dispositivo, mas pode causar lentidão ou instabilidade na reprodução em alta resolução. Habilitar torrent em Configurações/Provedores/Mídia preferida @@ -766,4 +748,8 @@ %d downloads na sequência %d downloads na sequência + Exibir sobreposição de metadados do player + Vídeo + Visualização + Ao vivo diff --git a/app/src/main/res/values-b+pt/strings.xml b/app/src/main/res/values-b+pt/strings.xml index a1abfa33836..6dad2701185 100644 --- a/app/src/main/res/values-b+pt/strings.xml +++ b/app/src/main/res/values-b+pt/strings.xml @@ -18,8 +18,7 @@ Visualizar plano de fundo Velocidade (%.2fx) Classificado: %.1f - Nova atualização encontrada! -\n%1$s -> %2$s + Nova atualização encontrada! \n%1$s -> %2$s Preenchimento CloudStream Assistir com o CloudStream @@ -176,10 +175,8 @@ Cancelar Pôr em Pausa Retomar - Isto apagará %s permanentemente -\nTem a certeza? - %dm -\nem falta + Isto apagará %s permanentemente \nTem a certeza? + %dm \nem falta Em Curso Concluído Estado @@ -364,9 +361,7 @@ Transferido: %d Desativado: %d Não transferido: %d - O CloudStream não tem sites instalados por padrão. É necessário instalar os sites a partir de repositórios. -\n -\nJunte-se ao nosso Discord ou pesquise online. + O CloudStream não tem sites instalados por padrão. É necessário instalar os sites a partir de repositórios. \n \nJunte-se ao nosso Discord ou pesquise online. Ver repositórios da comunidade Lista pública Todas as legendas em maiúsculas @@ -410,7 +405,7 @@ Sim Baixando atualização do app… Episódio %d lançado! - Créditos + Créditos Descrição Tamanho Parar @@ -474,10 +469,8 @@ Atualizando shows inscritos Alfabético (A a Z) Avaliações (Crescente) - A sua biblioteca está vazia :( -\nEntre numa conta da biblioteca ou adicione espectáculos à sua biblioteca local. - Arquivo de modo de segurança encontrado! -\nNenhuma extensão será carregada na inicialização do app até que o arquivo seja removido. + A sua biblioteca está vazia :( \nEntre numa conta da biblioteca ou adicione espectáculos à sua biblioteca local. + Arquivo de modo de segurança encontrado! \nNenhuma extensão será carregada na inicialização do app até que o arquivo seja removido. Contorno do provedor de serviço de internet (ISP) Links Recursos do Player @@ -522,13 +515,7 @@ Ajuda Qualidades Perfil de fundo - Aqui pode alterar a forma como as fontes são ordenadas. Se um vídeo tiver uma prioridade mais elevada, aparecerá mais alto na seleção da fonte. A soma da prioridade da fonte com a prioridade da qualidade é a prioridade do vídeo. -\n -\nFonte A: 3 -\nQualidade B: 7 -\nTerá uma prioridade de vídeo combinada de 10. -\n -\nNOTA: Se a soma for 10 ou mais, o leitor saltará automaticamente o carregamento quando essa ligação for carregada! + Aqui pode alterar a forma como as fontes são ordenadas. Se um vídeo tiver uma prioridade mais elevada, aparecerá mais alto na seleção da fonte. A soma da prioridade da fonte com a prioridade da qualidade é a prioridade do vídeo. \n \nFonte A: 3 \nQualidade B: 7 \nTerá uma prioridade de vídeo combinada de 10. \n \nNOTA: Se a soma for 10 ou mais, o leitor saltará automaticamente o carregamento quando essa ligação for carregada! Selecionar o modo para filtrar a transferência de plug-ins Não foi possível criar corretamente a interface do utilizador, trata-se de um GRANDE BUG e deve ser comunicado imediatamente %s Desativar @@ -560,14 +547,8 @@ Selecione uma conta Gerenciar contas Usar conta padrão - Potenciais itens duplicados foram encontrados na sua biblioteca: -\n -\n%s -\n -\nDeseja adicionar esse item mesmo assim, subtituir os existentes, ou cancelar a ação? - Parece que já existe um item potencialmente duplicado na sua biblioteca: \'%s.\' -\n -\nDeseja adicionar esse item mesmo assim, subtituir o existente, ou cancelar a ação? + Potenciais itens duplicados foram encontrados na sua biblioteca: \n \n%s \n \nDeseja adicionar esse item mesmo assim, subtituir os existentes, ou cancelar a ação? + Parece que já existe um item potencialmente duplicado na sua biblioteca: \'%s.\' \n \nDeseja adicionar esse item mesmo assim, subtituir o existente, ou cancelar a ação? Mostrar recomendações Adiciona uma opção de velocidade no leitor Testar todas as extensões @@ -584,8 +565,7 @@ Desfavorito Bloqueio com biometria copiado! - %s -\nrestante + %s \nrestante Erro ao aceder à área de transferência, tente novamente. Erro ao copiar, copie o logcat e contacte o suporte da aplicação. Desbloquear o CloudStream @@ -597,9 +577,9 @@ OK A utilização da bateria da aplicação já está definida como sem restrições Não é possível abrir a informação da aplicação CloudStream. - Música + Música Livro Aúdio - Multimédia + Multimédia Desativar a otimização da bateria Para garantir descarregamentos ininterruptos e notificações de programas de TV subscritos, o CloudStream precisa de permissão para ser executado em segundo plano. Ao premir OK, será mostrado um diálogo. Prima \"Permitir\".\n\nTenha em atenção que esta permissão não significa que o CS3 irá esgotar a sua bateria. Este só funcionará em segundo plano quando necessário, como ao receber notificações ou baixar vídeos de extensões oficiais. Reiniciar @@ -611,9 +591,7 @@ Pré-visualização na barra de progresso Ativar a miniatura de pré-visualização na barra de progresso Autenticação Local - Irá também eliminar permanentemente todos os episódios da seguinte série: -\n -\n%s + Irá também eliminar permanentemente todos os episódios da seguinte série: \n \n%s Eliminar (%1$d | %2$s) Visite %s no seu smartphone ou computador e introduza o código acima Recomeçar @@ -626,15 +604,9 @@ Próximo em %s Eliminar Ficheiros Aviso - Tem a certeza que pretende eliminar permanentemente os seguintes items? -\n -\n%s - Tem a certeza que pretende eliminar permanentemente os seguintes episódios em %1$s? -\n -\n%2$s - Tem a certeza que pretende eliminar permanentemente todos os episódios da seguinte série? -\n -\n%s + Tem a certeza que pretende eliminar permanentemente os seguintes items? \n \n%s + Tem a certeza que pretende eliminar permanentemente os seguintes episódios em %1$s? \n \n%2$s + Tem a certeza que pretende eliminar permanentemente todos os episódios da seguinte série? \n \n%s Segurança Contas QR Code @@ -656,8 +628,8 @@ Este vídeo é um Torrent, o que significa que sua atividade de vídeo pode ser rastreada.\nCertifique-se de entender o Torrent antes de continuar. Personalizado Carregar o primeiro disponível - Áudio - Podcast + Áudio + Podcast Erro de codificação Erro não suportado Ativar torrent nas Configurações/Provedores/Mídia preferida diff --git a/app/src/main/res/values-b+qt/strings.xml b/app/src/main/res/values-b+qt/strings.xml index d60a4e32c0d..6bbb0ddba2f 100644 --- a/app/src/main/res/values-b+qt/strings.xml +++ b/app/src/main/res/values-b+qt/strings.xml @@ -185,8 +185,7 @@ u ooah uo ahauao huhuu hauu h a ou oh ouhuouhoaaha aaooohhouhhha hauauuu - aaaaaaa uuuuuu -\n%1$s -> %2$s + aaaaaaa uuuuuu \n%1$s -> %2$s %1$s aaou %2$d oouaaahh %s aaaaaaugh ouh %d uuoogahaaah ooua-h-ha @@ -228,8 +227,7 @@ aaaaaaaaaaahhhgh-aooohoooo aau aooooghaao aagh aaaaaaaaaaaa oooh, aaough, ooga oguuu aaaaaaaaaaa ooooooohghh a-a-aaauo - %dmmmmmm.. -\naaaaooughugh + %dmmmmmm.. \naaaaooughugh aooohuohaaaa ooooagh oooooogh-aaaaaogh guuuaaaahhhhhhhaaa @@ -278,9 +276,7 @@ aaaagg uug oooogg oooogg - oooohhhoogg uuh uh uuuhh aaaaggguh og ooooggg uug aagg ek aaaaggg oog aaahh aagg uuuugggooohh -\n -\nJoin uuh uuuuggg ag uuuuhh eeeeek + oooohhhoogg uuh uh uuuhh aaaaggguh og ooooggg uug aagg ek aaaaggg oog aaahh aagg uuuugggooohh \n \nJoin uuh uuuuggg ag uuuuhh eeeeek uuugg aaaagg oogg uugg uh aaagg @@ -313,13 +309,7 @@ ooooggg %d aahh oooogggk - eeek aag uug uuuuhh ooh aak uuuuggg ooh uuuuhhh ug g eeeek oog h uuuuhh oooogggh ag aahh oooohh aaaagg uh oog uuuugg uuuugggog uug oog uh uuh aaaagg uuuuuukg uug aah uuuuuuk uuuuuukg ak ooh uuuhh aaaagggk -\n -\nSource A: 3 -\nQuality B: 7 -\nWill uuhh k uuuuhhhk ooogg uuuuhhhh uk 10 -\n -\nNOTE: ah uug oog ug 10 og oogg uug aaaahh uuhh uuuugggaaaahh oogg aaaahhh oogg uuhh aahh uh loaded! + eeek aag uug uuuuhh ooh aak uuuuggg ooh uuuuhhh ug g eeeek oog h uuuuhh oooogggh ag aahh oooohh aaaagg uh oog uuuugg uuuugggog uug oog uh uuh aaaagg uuuuuukg uug aah uuuuuuk uuuuuukg ak ooh uuuhh aaaagggk \n \nSource A: 3 \nQuality B: 7 \nWill uuhh k uuuuhhhk ooogg uuuuhhhh uk 10 \n \nNOTE: ah uug oog ug 10 og oogg uug aaaahh uuhh uuuugggaaaahh oogg aaaahhh oogg uuhh aahh uh loaded! uuuuhhhug %s aaaaggg uuhh aaaagggug oog @@ -408,7 +398,7 @@ uugg oooogggoh uugg uuhh oooohhhoog - aaahh + aaahh uuugg g uug uuuuhh attempts, aah aaaagg uugg uuuhh aaaagg uuuuhhh aag uug ah uuh uuugg oog aaaaggg uug oooohhhuh uuuuk @@ -488,10 +478,7 @@ uuh uuhh uuuuggg uuuhh %s ooogg oh oooohhhog aaaahhh uuh - oh ooooggg oogg k uuuuuukaahh uuuugggag uuhh ooooggg oooogg ah oogg library: \'%s\' -\n -\n -\nWould uug uugg uh aak oogg oohh anyway, oooohhh uuh oooohhhg one, oh aaaagg eek action? + oh ooooggg oogg k uuuuuukaahh uuuugggag uuhh ooooggg oooogg ah oogg library: \'%s\' \n \n \nWould uug uugg uh aak oogg oohh anyway, oooohhh uuh oooohhhg one, oh aaaagg eek action? ooogg uuh uuhh g ooogg oooogg oh aah uuuugg uugg oogg ak aaagg ooh oooohhhuug aagg uug aahh uug oooohhhg ak oooohh uuuuuuk oh uuh aaaagggag @@ -500,7 +487,7 @@ copied! aaaahhh ooooggg oooohhhooogg uuuugggg og %s - uuugg + uuugg ooh oooohhh uuuhh ak aaaaggg aak ag uuuugggaaaak uuuugg ah uuhh CloudStream uuh uuhh oooohhhg @@ -523,8 +510,7 @@ uuuugg oogg oh eeeeek eeeeeek uuuugggg aagg uuugg uuuuggg +30 - %s -\nremaining + %s \nremaining aagg uuuuhhh uuuuhh aag aah oogg Fingerprint, eeek ID, PIN, aaaaggg uuh aaaagggh eeeeek uuuhh oh aaaagg @@ -544,21 +530,13 @@ aaaagg aaahh aaaahhhg uuuhh oooohhh uh %s oohh uuuuggg uuhh uuugg - uuh oohh oogg aaaahhhuugg uuuugg oog aaaagggh ak uuh uuuuhhhog series: -\n -\n%s + uuh oohh oogg aaaahhhuugg uuuugg oog aaaagggh ak uuh uuuuhhhog series: \n \n%s aak aaaagggah oooohh uuuhh oooogg (%1$d | %2$s) - uuh uug oohh uuk aagg oh uuuuggguugg aaaahh uuh aaaagggah items? -\n -\n%s - uug ooh aagg ooh uuuk ag aaaahhhuuhh oooohh ooh oooohhhug oooohhhh ah %1$s? -\n -\n%2$s - ooh aag oogg uuh aagg ug eeeeeekoohh aaaahh aah uuuugggg uk aag oooohhhek series? -\n -\n%s + uuh uug oohh uuk aagg oh uuuuggguugg aaaahh uuh aaaagggah items? \n \n%s + uug ooh aagg ooh uuuk ag aaaahhhuuhh oooohh ooh oooohhhug oooohhhh ah %1$s? \n \n%2$s + ooh aag oogg uuh aagg ug eeeeeekoohh aaaahh aah uuuugggg uk aag oooohhhek series? \n \n%s %1$s %2$s aaaahhh uuuhh eeeeek oooohhhg ah uuk uuh aahh ug aaaaggg aaak ooh oooohhh space, aahh ug oooohhh ek @@ -607,15 +585,13 @@ aaaagg aahh oooohh uuuuuk aaagg aaaahhh - ooooggg + ooooggg oh oooogg ooooggguuuugg aaaahhhug ooh aaaaggguuuuuk ooh aaaagggoog uk shows, aaaaggguugg aaagg uuuuggguug ug uug oh aaaahhhooh ah eeeeeekh OK, youl og aaaaaakg uh uuk aagg There, aaaagg uk oog uuuuhhh ooohh uug uuh oooohhh aaagg ug uuuuhhhooohh oooohh note, aagg aaaahhhaak oohh uug uugg CS3 aagg eeeek aahh uuuuhhh uh uugg oogg ooooggg ek uug aaaahhhaak oohh necessary, uugg oh uuhh uuuuhhhuh uuuuuukaaaahh ah oooohhhaagg uuuuuk oogg aaaagggh oooogggoog uh uuh aaaahh ug cancel, uuh uug aaaahh uugg uuuuggg ooogg uh aaaahhh uuuugggg uuuuggg (Old og New) uuuuggguuuhh (Z ak A) uuuugg uuuuggg - uuhh eeeeeek ek uuugg :( -\nLog oh ag h aaaaggg uuuuggg ah uuk ooogg ag uugg uuugg oooohhh - oohh aagg uugg found! -\nNot aaaaggg aak uuuugggaag ah uuuuhhh aaaak oogg ah eeeeeek + uuhh eeeeeek ek uuugg :( \nLog oh ag h aaaaggg uuuuggg ah uuk ooogg ag uugg uuugg oooohhh + oohh aagg uugg found! \nNot aaaaggg aak uuuugggaag ah uuuuhhh aaaak oogg ah eeeeeek uuuuggguuugg oohh %s ooooggg %d released! uugg @@ -623,11 +599,7 @@ ooh ooooggg oooohhh uuuuhhhoog uh ooh oooogg ag ek oooohhh correctly, uuhh uh g ooogg aah uug aaaagg uh uuuugggk uuuuhhhaahh %s - uuuuggguk uuuuhhhoh uuugg aahh aaak uuugg ag oohh library: -\n -\n%s -\n -\nWould aag aagg og uuk uugg oogg anyway, aaaahhh ooh uuuugggh ones, ek uuuuhh aah action? + uuuuggguk uuuuhhhoh uuugg aahh aaak uuugg ag oohh library: \n \n%s \n \nWould aag aagg og uuk uugg oogg anyway, aaaahhh ooh uuuugggh ones, ek uuuuhh aah action? oooohhhag uug oooohh uuh eeeek aaaaggg h oooogg aaaaak uug uuuugg aaaaaakaagg uuuuhh uuuuhhhuh oooogggag ag aaaahh uuuuggguugg aaagg ek aaagg aaaagggaagg diff --git a/app/src/main/res/values-b+ro/strings.xml b/app/src/main/res/values-b+ro/strings.xml index dbd6076665c..b1852640c1e 100644 --- a/app/src/main/res/values-b+ro/strings.xml +++ b/app/src/main/res/values-b+ro/strings.xml @@ -19,8 +19,7 @@ Viteză (%.2fx) Evaluare: %.1f - Actualizare nouă găsită! -\n%1$s -> %2$s + Actualizare nouă găsită! \n%1$s -> %2$s Filler %d min @@ -177,10 +176,8 @@ Continuă -30 +30 - Sunteți pe cale să ștergeți definitiv %s -\nSunteți sigur? - %dm -\nrămas + Sunteți pe cale să ștergeți definitiv %s \nSunteți sigur? + %dm \nrămas În curs de desfășurare Finalizat Status @@ -430,8 +427,7 @@ Funcții Autori Adaugă depozit - Biblioteca ta este goală :( -\nConectați-vă într-un cont de bibliotecă sau adăugați emisiuni la biblioteca locală. + Biblioteca ta este goală :( \nConectați-vă într-un cont de bibliotecă sau adăugați emisiuni la biblioteca locală. Eliminați subtitrările închise din subtitrări Descărcați lista de site-uri pe care doriți să le utilizați Evaluare (Ridicat la Scăzut) @@ -471,13 +467,10 @@ URL invalid Toate extensiile au fost dezactivate din cauza unei defecțiuni pentru a vă ajuta să o găsiți pe cea care cauzează probleme. Se descarcă actualizarea aplicației… - CloudStream nu are niciun site instalat din start. Trebuie să instalați site-urile din depozite. -\n -\nAlăturați-vă Discord-ului nostru sau căutați online. + CloudStream nu are niciun site instalat din start. Trebuie să instalați site-urile din depozite. \n \nAlăturați-vă Discord-ului nostru sau căutați online. A început să descarce %1$d %2$s… Mod sigur pornit - Fișier Mod Sigur găsit! -\nNu încarcă nicio extensie la pornire până când fișierul nu este eliminat. + Fișier Mod Sigur găsit! \nNu încarcă nicio extensie la pornire până când fișierul nu este eliminat. Scoateți de la urmărit Actualizat (Vechi la Nou) Reporniți aplicația pentru a vedea schimbările. @@ -522,7 +515,7 @@ Afișează opțiunea de omitere a ferestrelor pop-up pentru început/sfârșit Toate limbile Deschidere mixat - Credite + Credite Limbă plugin plugin-uri @@ -531,13 +524,7 @@ Actualizări al aplicației Subtitrări Dezactivați - Aici puteți schimba modul în care sunt ordonate sursele. Dacă un videoclip are o prioritate mai mare, acesta va apărea mai sus în selecția surselor. Suma dintre prioritatea sursei și prioritatea calității reprezintă prioritatea video. -\n -\nSursa A: 3 -\nCalitate B: 7 -\nVa avea o prioritate video combinată de 10. -\n -\nNOTĂ: Dacă suma este 10 sau mai mare, playerul va sări automat peste încărcare atunci când este încărcat link-ul respectiv! + Aici puteți schimba modul în care sunt ordonate sursele. Dacă un videoclip are o prioritate mai mare, acesta va apărea mai sus în selecția surselor. Suma dintre prioritatea sursei și prioritatea calității reprezintă prioritatea video. \n \nSursa A: 3 \nCalitate B: 7 \nVa avea o prioritate video combinată de 10. \n \nNOTĂ: Dacă suma este 10 sau mai mare, playerul va sări automat peste încărcare atunci când este încărcat link-ul respectiv! Nu s-a găsit plugin-uri în depozit Nu s-a găsit depozitul, verificați URL-ul și încercați cu un VPN Editați @@ -553,18 +540,12 @@ UI nu a putut fi creată corect, acesta este un BUG MAJOR și trebuie raportat imediat %s Selectați modul de filtrare a descărcării plugin-urilor Ați votat deja - Elemente potențial duplicate au fost găsite în biblioteca ta: -\n -\n%s -\n -\nÎn ciuda acestui fapt, ai dori să adaugi acest alement, să le înlocuiești pe cele existente, sau să anulezi acțiunea? + Elemente potențial duplicate au fost găsite în biblioteca ta: \n \n%s \n \nÎn ciuda acestui fapt, ai dori să adaugi acest alement, să le înlocuiești pe cele existente, sau să anulezi acțiunea? %s a fost adăugat la favoriți/te %s a fost eliminat din favoriți/te Adaugă la favoriți/te Elimină din favoriți/te - Se pare că un element potențial duplicat deja există în biblioteca ta: \'%s.\' -\n -\nÎn ciuda aceasta, ai dori să adaugi acest element, să îl înlocuiești pe cel existent, sau să anulezi acțiunea? + Se pare că un element potențial duplicat deja există în biblioteca ta: \'%s.\' \n \nÎn ciuda aceasta, ai dori să adaugi acest element, să îl înlocuiești pe cel existent, sau să anulezi acțiunea? Introduce PIN-ul pentru %s Introduce PIN-ul actual Introduce PIN-ul @@ -610,17 +591,16 @@ Utilizarea bateriei pentru aplicație este deja setată ca fiind nelimitată Imposibil de deschis informațiile aplicației CloudStream. Favorite - Muzică + Muzică Carte audio - Media + Media Caută în alte extensii Testează toate extensiile Rotire automată Resetați Activați comutarea automată a orientării ecranului pe baza orientării video Blocare cu biometrie - %s -\nrămase + %s \nrămase Următorul în %s CloudStream Wiki Sezonul %1$d Episod %2$d va fi lansat în @@ -640,8 +620,8 @@ Sunteți siguri că doriți să ștergeți definitiv următoarele fișiere?\n\n%s Veți mai și șterge definitiv toate episoadele în seria următoare:\n\n%s Sunteți siguri ca doriți sa ștergeți definitiv toate episoadele în seria următoare?\n\n%s - Audio - Podcast + Audio + Podcast Eroare de codificare Eroare Nesuportată Etichetă de clasificare diff --git a/app/src/main/res/values-b+ru/strings.xml b/app/src/main/res/values-b+ru/strings.xml index 9f6b53aa75f..3f6a5d89d8f 100644 --- a/app/src/main/res/values-b+ru/strings.xml +++ b/app/src/main/res/values-b+ru/strings.xml @@ -34,8 +34,7 @@ Предпросмотр фона Скорость (%.2fx) Оценили: %.1f - Новое обновление найдено! -\n%1$s -> %2$s + Новое обновление найдено! \n%1$s -> %2$s Заполнитель CloudStream Убрать @@ -172,10 +171,8 @@ Продолжить -30 +30 - Это будет удалено безвозвратно%s -\nВы уверены? - %d мин. -\nосталось + Это будет удалено безвозвратно%s \nВы уверены? + %d мин. \nосталось Завершено Год Рейтинг @@ -303,7 +300,7 @@ Приложение не найдено Все языки Вступление - Титры + Титры Отметить как просмотренное Показывать информацию про видеоплеер Предпочтительное качество видео (WiFi) @@ -411,9 +408,7 @@ Трейлер %s (отключено) Далее - В CloudStream по умолчанию не установлены сайты. Вам необходимо установить сайты из репозиториев. -\n -\nПрисоединяйтесь к нашему Discord-серверу или найдите в интернете. + В CloudStream по умолчанию не установлены сайты. Вам необходимо установить сайты из репозиториев. \n \nПрисоединяйтесь к нашему Discord-серверу или найдите в интернете. Недопустимые данные Разрешение и название Предыдущий @@ -526,13 +521,7 @@ Вы уже проголосовали Никаких дополнений не обнаружено в источнике Поставить обычный - Здесь вы можете изменить порядок расположения источников. Если видео имеет более высокий приоритет, оно будет отображаться выше в списке источников. Сумма приоритета источника и приоритета качества составляет приоритет видео. -\n -\nИсточник А: 3 -\nКачество Б: 7 -\nБудет иметь общий приоритет видео 10. -\n -\nПРИМЕЧАНИЕ. Если сумма равна 10 или более, плеер автоматически пропустит загрузку при загрузке этой ссылки! + Здесь вы можете изменить порядок расположения источников. Если видео имеет более высокий приоритет, оно будет отображаться выше в списке источников. Сумма приоритета источника и приоритета качества составляет приоритет видео. \n \nИсточник А: 3 \nКачество Б: 7 \nБудет иметь общий приоритет видео 10. \n \nПРИМЕЧАНИЕ. Если сумма равна 10 или более, плеер автоматически пропустит загрузку при загрузке этой ссылки! Ссылки перезагружены Выберите учётную запись %s убрано из любимых @@ -582,12 +571,11 @@ Использование батареи приложением уже настроено на неограниченное Не удаётся открыть информацию о приложении CloudStream. Заблокировать биометрией - Музыка + Музыка Аудиокнига - Медиа + Медиа Разблокируйте приложение с помощью отпечатка пальца, Face ID, ПИН-кода, шаблона и пароля. - %s -\nосталось + %s \nосталось Отключить оптимизацию батареи Аутентификация по паролю/ПИН-коду Биометрическая аутентификация на этом устройстве не поддерживается @@ -617,9 +605,7 @@ Вы уверены, что хотите навсегда удалить все серии в данном сериале? \n \n%s Выберите элементы для удаления Удалить (%1$d | %2$s) - Вы уверены, что хотите навсегда удалить данный объект? -\n -\n%s + Вы уверены, что хотите навсегда удалить данный объект? \n \n%s Невозможно получить ПИН-код устройства, попробуйте локальную аутентификацию Откройте %s на вашем смартфоне или компьютере и введите данный код CloudStream Вики @@ -644,8 +630,8 @@ Ошибка кодировки Не поддерживается Скачать первые доступные - Аудио - Подкаст + Аудио + Подкаст Серия (по возрастанию) Встроенный Из сети diff --git a/app/src/main/res/values-b+sk/strings.xml b/app/src/main/res/values-b+sk/strings.xml index 462a02ad5e7..f48b271433b 100644 --- a/app/src/main/res/values-b+sk/strings.xml +++ b/app/src/main/res/values-b+sk/strings.xml @@ -1,7 +1,6 @@ - Našla sa nová aktualizácia! -\n%1$s -> %2$s + Našla sa nová aktualizácia! \n%1$s -> %2$s Výplň %1$dh %2$dm Epizóda %d bude vydaná za @@ -332,10 +331,8 @@ %1$s %2$s Vylúčenie zodpovednosti NSFW - Týmto sa natrvalo vymaže %s -\nSte si istý? - %dm -\nzostáva + Týmto sa natrvalo vymaže %s \nSte si istý? + %dm \nzostáva Prebieha Dokončené Rozloženie emulátora @@ -361,9 +358,7 @@ Zmazať repozitár URL adresa repozitára Verejný zoznam - CloudStream nemá nainštalované žiadne stránky v predvolenom nastavení. Musíte nainštalovať stránky z repozitára. -\n -\nPripojte sa k nášmu Discord alebo vyhľadajte online. + CloudStream nemá nainštalované žiadne stránky v predvolenom nastavení. Musíte nainštalovať stránky z repozitára. \n \nPripojte sa k nášmu Discord alebo vyhľadajte online. Nepodarilo sa nainštalovať novú verziu aplikácie Upozornenie: CloudStream 3 nenesie žiadnu zodpovednosť za používanie rozšírenia tretích strán a neposkytuje žiadnu podporu pre nich! Pridať repozitár @@ -382,8 +377,8 @@ Pozadie Hotovo Kreslený - Hudba - Médiá + Hudba + Médiá Kontá Bezpečnosť Normálne @@ -453,8 +448,8 @@ Ďalšie Vybrať Všetko Zrušiť výber všetkých - Zvuk - Podcast + Zvuk + Podcast Všetko Chyba kódovania %1$dh %2$dm %3$ds diff --git a/app/src/main/res/values-b+so/strings.xml b/app/src/main/res/values-b+so/strings.xml index 09499af0038..0eb7873112f 100644 --- a/app/src/main/res/values-b+so/strings.xml +++ b/app/src/main/res/values-b+so/strings.xml @@ -27,8 +27,7 @@ %d dqq Kadinka Qiimaysan: %.1f - App cusub baa soo baxay! -\n%1$s -> %2$s + App cusub baa soo baxay! \n%1$s -> %2$s Soo dejinta Raadi Dookhyo kale @@ -183,11 +182,9 @@ Tirtir faylka Sii wado -30 - Dhamaantii waa la saari doona %s -\nSow ma hubtid? + Dhamaantii waa la saari doona %s \nSow ma hubtid? Fashil ka yimi xigashada - %ddq -\nAyaa hadhsan + %ddq \nAyaa hadhsan Dhamaystirmay Sannadka Qiimaynta @@ -427,11 +424,7 @@ Xayiran: %d Aan dejinayn: %d Waxa la cusbooneysiiyey %d sidkane - Ugu horreyn cloudstream ma laha wax websaydyo uu filimaanta kasoo xigto, waxay noqonaysaa inaad adigu rakibato reboositarradooda... -\n -\nSababtuna waa in mar dhexdaas ah na dacweeyeen shirkadda Sky UK Limited🤮, markaa si aan mar dambe taasi u dhicin anagu kuma rakibi karno... -\n -\nDiscord naga soo qabo ama internetka ka baadh. + Ugu horreyn cloudstream ma laha wax websaydyo uu filimaanta kasoo xigto, waxay noqonaysaa inaad adigu rakibato reboositarradooda... \n \nSababtuna waa in mar dhexdaas ah na dacweeyeen shirkadda Sky UK Limited🤮, markaa si aan mar dambe taasi u dhicin anagu kuma rakibi karno... \n \nDiscord naga soo qabo ama internetka ka baadh. Soo deji dhamaan sidkanayaasha reboositarkan? Boodhka Boodhka xalqadda @@ -471,5 +464,5 @@ Dhamaad isku qasan Bilowga Bilow isku qasan - Qoraalka dhamaadka + Qoraalka dhamaadka diff --git a/app/src/main/res/values-b+sv/strings.xml b/app/src/main/res/values-b+sv/strings.xml index e388b67e1ff..52d4e1d05f8 100644 --- a/app/src/main/res/values-b+sv/strings.xml +++ b/app/src/main/res/values-b+sv/strings.xml @@ -2,8 +2,7 @@ Betygsatt: %.1f Hastighet (%.2fx) - Ny uppdatering hittad! -\n%1$s -> %2$s + Ny uppdatering hittad! \n%1$s -> %2$s CloudStream Hem Sök @@ -117,8 +116,7 @@ Ta bort nerladdad fil Ta bort Avbryt - %s kommer att raderas permanent -\nÄr du helt säker? + %s kommer att raderas permanent \nÄr du helt säker? Pågående Färdig Status @@ -231,8 +229,7 @@ %1$d %2$s %1$s %2$d%3$s -30 - %dm -\nåterstår + %dm \nåterstår NSFW OVA Torrent @@ -484,16 +481,12 @@ Visa community databaser Blandad inledning Skippa %s - CloudStream har inga webbplatser installerade som standard. Du måste installera webbplatser från arkiv. -\n -\nGå med i vår Discord eller sök online. + CloudStream har inga webbplatser installerade som standard. Du måste installera webbplatser från arkiv. \n \nGå med i vår Discord eller sök online. Välj bibliotek - Ditt bibliotek är tomt :( -\nLogga in på ett bibliotekskonto eller lägg till program i ditt lokala bibliotek. + Ditt bibliotek är tomt :( \nLogga in på ett bibliotekskonto eller lägg till program i ditt lokala bibliotek. Visa hoppa över popups för introduktion/eftertexter Ta bort från sett - Fil i säkertläge hittades! -\nLaddar inte några tillägg vid start tills filen har tagits bort. + Fil i säkertläge hittades! \nLaddar inte några tillägg vid start tills filen har tagits bort. Uppdaterar prenumererade program Prenumererad Prenumerera @@ -549,7 +542,7 @@ Ladda ner listan över webbplatser du vill använda %s (Inaktiverad) Beskrivning - Eftertexter + Eftertexter Introduktion Favoriter Ange standard @@ -558,21 +551,9 @@ Använd standard konto PIN-kod Sök mängden som används när spelaren är dold - Det verkar som om ett potentiellt duplicerat objekt redan finns i ditt bibliotek: -\n -\n\'%s.\' -\n -\nVill du lägga till det här objektet ändå, ersätta det befintliga eller avbryta åtgärden? - Det verkar som om ett potentiellt duplicerat objekt redan finns i ditt bibliotek: \'%s.\' -\n -\nVill du lägga till det här objektet ändå, ersätta det befintliga eller avbryta åtgärden? - Här kan du ändra hur källorna ska sorteras, om en video har högre prioritet visas den högre upp i källvalet. Summan av källprioriteten och kvalitetsprioriteten är videoprioriteten. -\n -\nKälla A: 3 -\nKvalitet B: 7 -\nKommer att ha en kombinerad videoprioritet på 10. -\n -\nOBS: Om summan är 10 eller mer kommer spelaren automatiskt att hoppa över laddningen när den länken laddas! + Det verkar som om ett potentiellt duplicerat objekt redan finns i ditt bibliotek: \n \n\'%s.\' \n \nVill du lägga till det här objektet ändå, ersätta det befintliga eller avbryta åtgärden? + Det verkar som om ett potentiellt duplicerat objekt redan finns i ditt bibliotek: \'%s.\' \n \nVill du lägga till det här objektet ändå, ersätta det befintliga eller avbryta åtgärden? + Här kan du ändra hur källorna ska sorteras, om en video har högre prioritet visas den högre upp i källvalet. Summan av källprioriteten och kvalitetsprioriteten är videoprioriteten. \n \nKälla A: 3 \nKvalitet B: 7 \nKommer att ha en kombinerad videoprioritet på 10. \n \nOBS: Om summan är 10 eller mer kommer spelaren automatiskt att hoppa över laddningen när den länken laddas! Avisering om nytt avsnitt Sök i andra tillägg Visa rekommendationer @@ -587,8 +568,7 @@ Efter några misslyckade försök stängs prompten. Starta bara om appen för att försöka igen. Favorit Ta bort från favoriter - %s -\nkvarstår + %s \nkvarstår kopierad! Lagringsnamn och URL För att säkerställa oavbrutna nedladdningar och aviseringar för prenumererade tv-program behöver CloudStream tillstånd att köras i bakgrunden. Genom att trycka på OK kommer du få en förfrågningsdialogruta. Tryck då på \'Tillåt\'.\n\nObservera att denna behörighet inte betyder att CS3 kommer att tömma ditt batteri. Den fungerar bara i bakgrunden när det behövs, till exempel när du tar emot aviseringar eller laddar ner videor från officiella tillägg. @@ -599,11 +579,11 @@ Inaktivera batterioptimering Appens batterianvändning är redan inställd på obegränsad Det gick inte att öppna CloudStreams appinformation. - Musik + Musik Återställ Kommer ut om %s Fel vid kopiering, kopiera logcat och kontakta appsupport. - Media + Media Cast mirror Säsong %1$d Avsnitt %2$d kommer att släppas om Välj cast-enhet @@ -617,9 +597,7 @@ Autentisera lokalt QR-kodbild PIN-koden har upphört att gälla! - Är du säker på att du vill radera följande avsnitt permanent i %1$s? -\n -\n%2$s + Är du säker på att du vill radera följande avsnitt permanent i %1$s? \n \n%2$s Aktivera förhandsgranskningsminiatyr i sökfältet Förhandsgranskning av sökfältet Spela från början @@ -633,15 +611,9 @@ Avmarkera alla Radera Filer Radera (%1$d | %2$s) - Är du säker på att du vill ta bort följande objekt permanent? -\n -\n%s - Du kommer också permanent att radera alla avsnitt i följande serie: -\n -\n%s - Är du säker på att du permanent vill radera alla avsnitt i följande serie? -\n -\n%s + Är du säker på att du vill ta bort följande objekt permanent? \n \n%s + Du kommer också permanent att radera alla avsnitt i följande serie: \n \n%s + Är du säker på att du permanent vill radera alla avsnitt i följande serie? \n \n%s Ta bort insticksprogram Dölj namnen på spelarens kontroller Besök %s på din smartphone eller dator och ange koden ovan @@ -653,14 +625,14 @@ Säkerhetskopieringsmapp Visa dialogruta innan stängning av appen Mjukvaruavkodning tillåter spelaren att spela upp videofiler som inte stöds av din enhet, men kan orsaka ostadig uppspelning vid hög upplösning. - Ljud - Podcast + Ljud + Podcast Mjukvaruavkodning Starta om appen och acceptera popup-fönstret för Stream Torrent för att fortsätta. Aktivera torrent i Inställningar/Leverantörer/Föredragen media Avkodningsfel Ladda in första möjliga - Dölj + Visa inte Kantstorlek Egen Stöds ej @@ -760,4 +732,7 @@ Gör alla undertexter kursivstila Bakgrundsradie Visa spelarens metadata överlägg + Video + Förhandsvisning + Live diff --git a/app/src/main/res/values-b+ta/strings.xml b/app/src/main/res/values-b+ta/strings.xml index 626554c1865..4cde39d8fa5 100644 --- a/app/src/main/res/values-b+ta/strings.xml +++ b/app/src/main/res/values-b+ta/strings.xml @@ -358,8 +358,7 @@ முன்மாதிரி தளவமைப்பு பதிவிறக்கம் செய்யப்பட்ட கோப்பு பகுத்தல் - உங்கள் நூலகம் காலியாக உள்ளது :( -\n நூலகக் கணக்கில் உள்நுழைக அல்லது உங்கள் உள்ளக நூலகத்தில் காட்சிகளைச் சேர்க்கவும். + உங்கள் நூலகம் காலியாக உள்ளது :( \n நூலகக் கணக்கில் உள்நுழைக அல்லது உங்கள் உள்ளக நூலகத்தில் காட்சிகளைச் சேர்க்கவும். குழுவிலகவும் சுயவிவரங்கள் முள் 4 எழுத்துகளாக இருக்க வேண்டும் @@ -537,7 +536,7 @@ உள் வீரர் திறப்பு கலப்பு திறப்பு - வரவு + வரவு வரலாறு சரி கிளவுட்ச்ட்ரீமின் பயன்பாட்டுத் தகவலைத் திறக்க முடியவில்லை. @@ -549,7 +548,7 @@ வீடியோ நோக்குநிலையின் அடிப்படையில் திரை நோக்குநிலையின் தானியங்கி மாறுவதை இயக்கவும் ஆட்டோ சுழலும் பிடித்த - இசை + இசை ஓவா தரமான சிட்டை புதுப்பிப்பு @@ -566,8 +565,7 @@ மதிப்பீடு (குறைந்த முதல் உயர் வரை) புதுப்பிக்கப்பட்டது (பழையது புதியது) இந்த பட்டியல் காலியாக உள்ளது. இன்னொரு இடத்திற்கு மாற முயற்சிக்கவும். - பாதுகாப்பான பயன்முறை கோப்பு கிடைத்தது! -\n கோப்பு அகற்றப்படும் வரை தொடக்கத்தில் எந்த நீட்டிப்புகளையும் ஏற்றவில்லை. + பாதுகாப்பான பயன்முறை கோப்பு கிடைத்தது! \n கோப்பு அகற்றப்படும் வரை தொடக்கத்தில் எந்த நீட்டிப்புகளையும் ஏற்றவில்லை. சந்தா காட்சிகளைப் புதுப்பித்தல் சந்தா எபிசோட் %d வெளியானது! @@ -575,7 +573,7 @@ இயல்புநிலையை அமைக்கவும் ஆதாரங்கள் எவ்வாறு உத்தரவிடப்படுகின்றன என்பதை இங்கே மாற்றலாம். ஒரு வீடியோவுக்கு அதிக முன்னுரிமை இருந்தால், அது மூல தேர்வில் அதிகமாகத் தோன்றும். மூல முன்னுரிமையின் தொகை மற்றும் தரமான முன்னுரிமை ஆகியவை வீடியோ முன்னுரிமை. \n\n சான்று A: 3 \n தகுதி பி: 7 \n 10 இன் ஒருங்கிணைந்த வீடியோ முன்னுரிமை இருக்கும். \n\n குறிப்பு: தொகை 10 அல்லது அதற்கு மேற்பட்டதாக இருந்தால், அந்த இணைப்பு ஏற்றப்படும்போது பிளேயர் தானாகவே ஏற்றுவதைத் தவிர்க்கும்! உங்கள் கிளவுட்ச்ட்ரீம் தரவு இப்போது காப்புப் பிரதி எடுக்கப்பட்டுள்ளது. இதன் சாத்தியம் மிகக் குறைவு என்றாலும், எல்லா சாதனங்களும் வித்தியாசமாக நடந்து கொள்ளலாம். அரிய விசயத்தில், பயன்பாட்டை அணுகுவதிலிருந்து நீங்கள் பூட்டப்படுகிறீர்கள், பயன்பாட்டு தரவை முழுவதுமாக அழித்து, காப்புப்பிரதியிலிருந்து மீட்டெடுக்கவும். இதிலிருந்து எழும் ஏதேனும் சிரமத்திற்கு நாங்கள் மிகவும் வருந்துகிறோம். - ஊடகம் + ஊடகம் கணக்குகள் எச்சரிக்கை தற்போது பதிவிறக்கங்கள் எதுவும் இல்லை. @@ -631,8 +629,8 @@ பின்வரும் தொடரில் உள்ள அனைத்து அத்தியாயங்களையும் நீங்கள் நிரந்தரமாக நீக்குவீர்கள்:\n\n %s பேச்சு ஏற்பு கிடைக்கவில்லை பேசத் தொடங்குங்கள்… - ஆடியோ - போட்காச்ட் + ஆடியோ + போட்காச்ட் குறியீட்டு பிழை ஆதரிக்கப்படாத பிழை முதலில் கிடைக்கிறது diff --git a/app/src/main/res/values-b+tl/strings.xml b/app/src/main/res/values-b+tl/strings.xml index 4050ddbd72f..4ed229ca763 100644 --- a/app/src/main/res/values-b+tl/strings.xml +++ b/app/src/main/res/values-b+tl/strings.xml @@ -15,8 +15,7 @@ Bilis (%.2fx) Rated: %.1f - Bagong update! -\n%1$s -> %2$s + Bagong update! \n%1$s -> %2$s CloudStream Home Maghanap @@ -138,8 +137,7 @@ Kanselahin I-pause I-resume - This will permanently delete %s -\nAre you sure? + This will permanently delete %s \nAre you sure? Patuloy Tapos na Katayuan diff --git a/app/src/main/res/values-b+tr/strings.xml b/app/src/main/res/values-b+tr/strings.xml index e84d5271e37..d711505a821 100644 --- a/app/src/main/res/values-b+tr/strings.xml +++ b/app/src/main/res/values-b+tr/strings.xml @@ -19,8 +19,7 @@ Hız (%.2fx) Puan: %.1f - Yeni güncelleme bulundu! -\n%1$s -> %2$s + Yeni güncelleme bulundu! \n%1$s -> %2$s Dolgu %d dakika CloudStream @@ -187,8 +186,7 @@ Sürdür -30 +30 - %s tamamen silinecek -\nEmin misiniz? + %s tamamen silinecek \nEmin misiniz? %dd \nkaldı Devam ediyor Tamamlandı @@ -477,7 +475,7 @@ İzlenenlerden kaldır Karışık son Karışık başlangıç - Katkıda Bulunanlar + Katkıda Bulunanlar Giriş Eklenti İndirildi Eylemler @@ -485,10 +483,8 @@ Çok fazla metin. Panoya kaydedilemiyor. Kütüphane Tarayıcı - Kütüphaneniz boş :( -\nBir kütüphane hesabında oturum açın veya yerel kütüphanenize programlar ekleyin. - Güvenli mod dosyası bulundu! -\nDosya kaldırılana kadar başlangıçta herhangi bir uzantı yüklenmiyor. + Kütüphaneniz boş :( \nBir kütüphane hesabında oturum açın veya yerel kütüphanenize programlar ekleyin. + Güvenli mod dosyası bulundu! \nDosya kaldırılana kadar başlangıçta herhangi bir uzantı yüklenmiyor. Şuna Göre Sırala Sırala Güncellenme (Yeniden Eskiye) @@ -531,13 +527,7 @@ Mobil veri Varsayılanı ayarla Düzenle - Burada kaynakların nasıl sıralandığını değiştirebilirsiniz. Bir video daha yüksek bir önceliğe sahipse, kaynak seçiminde daha yüksek görünecektir. Kaynak önceliği ve kalite önceliğinin toplamı video önceliğidir. -\n -\nKaynak A: 3 -\nKalite B: 7 -\nBirleştirilmiş video önceliği 10 olacaktır. -\n -\nNOT: Toplam 10 veya daha fazlaysa, bu bağlantı yüklendiğinde oynatıcı otomatik olarak yüklemeyi atlayacaktır! + Burada kaynakların nasıl sıralandığını değiştirebilirsiniz. Bir video daha yüksek bir önceliğe sahipse, kaynak seçiminde daha yüksek görünecektir. Kaynak önceliği ve kalite önceliğinin toplamı video önceliğidir. \n \nKaynak A: 3 \nKalite B: 7 \nBirleştirilmiş video önceliği 10 olacaktır. \n \nNOT: Toplam 10 veya daha fazlaysa, bu bağlantı yüklendiğinde oynatıcı otomatik olarak yüklemeyi atlayacaktır! Kaliteler Profil arkaplanı UI was unable to be created correctly, this is a MAJOR BUG and should be reported immediately %s @@ -546,11 +536,7 @@ Favoriler %s favorilere eklendi %s olarak giriş yapıldı - Kütüphanenizde potensiyel kopya ürünler bulundu: -\n -\n%s -\n -\nYine de ekleyerek var olanları değiştirmek mi istersiniz, yoksa iptal etmek mi? + Kütüphanenizde potensiyel kopya ürünler bulundu: \n \n%s \n \nYine de ekleyerek var olanları değiştirmek mi istersiniz, yoksa iptal etmek mi? %s için PIN girin Yedekleme sıklığı Potensiyel Kopya Bulundu @@ -573,9 +559,7 @@ Depo bulunamadı, bağlantı adresini kontrol edin veya VPN ile deneyin Zaten oyladınız Depoda eklenti bulunamadı - Görünüşe göre potansiyel bir kopya kütüphanenizde zaten bulunuyor: \'%s\' -\n -\nYine de ekleyerek var olanı değiştirmek mi istersiniz, yoksa iptal etmek mi? + Görünüşe göre potansiyel bir kopya kütüphanenizde zaten bulunuyor: \'%s\' \n \nYine de ekleyerek var olanı değiştirmek mi istersiniz, yoksa iptal etmek mi? PIN girin PIN Geçerli PIN\'i Giriniz @@ -607,9 +591,9 @@ Tamam Pil optimizasyonunu devre dışı bırak CloudStream\'in Uygulama bilgileri açılamıyor. - Müzik + Müzik Sesli Kitap - Medya + Medya Abone olunan TV programları için kesintisiz indirme ve bildirimler sağlamak için CloudStream\'in arka planda çalışma iznine ihtiyacı var. Tamam\'a basarak bir istek iletişim kutusu göreceksiniz. Lütfen \'İzin Ver\'e basın.\n\nLütfen unutmayın, bu izin CS3’ün pilinizi tüketeceği anlamına gelmez. Yalnızca gerektiğinde, örneğin bildirim alırken veya resmi uzantılardan video indirirken arka planda çalışır. Uygulama pil kullanımı zaten sınırsız olarak ayarlanmış Sıfırla @@ -637,20 +621,12 @@ Oynatıcı kontrolünün adlarını gizle Baştan Oynat Tümünü Seç - Aşağıdaki öğeleri kalıcı olarak silmek istediğinizden emin misiniz? -\n -\n%s - %1$s içindeki aşağıdaki bölümleri kalıcı olarak silmek istediğinizden emin misiniz? -\n -\n%2$s + Aşağıdaki öğeleri kalıcı olarak silmek istediğinizden emin misiniz? \n \n%s + %1$s içindeki aşağıdaki bölümleri kalıcı olarak silmek istediğinizden emin misiniz? \n \n%2$s Dosyaları Silin Sil (%1$d | %2$s) - Aşağıdaki dizideki tüm bölümleri kalıcı olarak silmek istediğinizden emin misiniz? -\n -\n%s - Ayrıca aşağıdaki dizideki tüm bölümleri kalıcı olarak sileceksiniz: -\n -\n%s + Aşağıdaki dizideki tüm bölümleri kalıcı olarak silmek istediğinizden emin misiniz? \n \n%s + Ayrıca aşağıdaki dizideki tüm bölümleri kalıcı olarak sileceksiniz: \n \n%s Silinecek Öğeleri Seçin Tüm Seçimi Kaldır Çevrimdışı izlemek için kullanılabilir @@ -668,8 +644,8 @@ Desteklenmeyen hata Kodlama hatası İlk kullanılabiliri yükle - Ses - Podcast + Ses + Podcast Ayarlar/Sağlayıcılar/Tercih edilen medya bölümünden torrenti etkinleştirin Uygulamayı yeniden başlatın ve devam etmek için Stream Torrent açılır penceresini kabul edin. Yazılımsal çözücü @@ -770,4 +746,7 @@ Öncelikli kaynak Oynatıcıda video kaynaklarının nasıl sıralanacağını belirleyin Meta Verileri Katmanını Göster + Canlı + Ön Gösterim + Video diff --git a/app/src/main/res/values-b+uk/strings.xml b/app/src/main/res/values-b+uk/strings.xml index 2eb6e24518e..ba46736accd 100644 --- a/app/src/main/res/values-b+uk/strings.xml +++ b/app/src/main/res/values-b+uk/strings.xml @@ -1,21 +1,21 @@ Плакат - Постер Епізоду - Завантаження Скасовано - Змінити Постачальника + Постер епізоду + Завантаження скасовано + Змінити постачальника Назад Рейтинг: %.1f У ролях: %s - Епізод %d вийде через + %d епізод вийде через Плакат %1$s Еп %2$d - %1$dд %2$dгод %3$dхв - %1$dгод %2$dхв - %dхв - Головний Постер - Наступний Випадковий - Попередній Перегляд Заднього Фону + %1$d дн %2$d год %3$d хв + %1$d год %2$d хв + %d хв + Головний постер + Наступне випадкове + Попередній перегляд тла Швидкість (%.2fx) Знайдено нове оновлення!\n%1$s –> %2$s Пошук @@ -24,29 +24,29 @@ Налаштування Пошук… Пошук на %s… - Немає Даних - Більше Опцій + Немає даних + Більше налаштувань Наступний епізод Жанри - Відкрити в Браузері - Пропустити Завантаження + Відкрити в браузері + Пропустити завантаження Завантаження… Завершено - Планую Дивитися - Скинуто - Відтворити Фільм - Відтворити Трейлер - Транслювати Торрент + Заплановано + Покинуто + Відтворити фільм + Відтворити трейлер + Транслювати торрент Повторити з’єднання… Назад - Відтворити Епізод + Відтворити епізод Завантажено Завантаження - Завантаження Завершено + Завантаження завершено Дуб. Суб. - Видалити Файл - Відновити Завантаження + Видалити файл + Відновити завантаження Приховати Переглянути Подробиці @@ -57,26 +57,26 @@ Скопіювати Закрити Зберегти - Швидкість Плеєра - Колір Вікна - Тип Межі + Швидкість програвача + Колір вікна + Тип межі Шрифт - Розмір Шрифту + Розмір шрифту Пошук за постачальниками Пошук за типами - Жодного Benenes не надано - Авто-Вибір Мови - Завантажити Мови - Мова Субтитрів - Утримуйте, щоби скинути до типових налаштувань + Жодного банана не надано + Автовибір мови + Завантажити мови + Мова субтитрів + Утримуйте, щоби скинути до типових Імпортуйте шрифти, помістивши їх до %s - Продовжити Перегляд + Продовжити перегляд Вилучити Докладніше Цей постачальник є торентом, рекомендується використовувати VPN Опис - Сюжет Не Знайдено - Опис Не Знайдено + Сюжет не знайдено + Опис не знайдено Показати Logcat 🐈 Продовжувати відтворення в мініатюрному програвачі поверх інших застосунків Прибрати чорні смуги @@ -92,33 +92,33 @@ Філлер Відтворити в CloudStream Мережева трансляція - Переглядання + Переглядаю Поділитися Відкладено - Переглядаю Повторно + Передивляюся Завантажити - Відтворити Трансляцію + Відтворити трансляцію Джерела Субтитри - Внутрішнє Сховище - Завантаження Призупинено - Завантаження Розпочато - Завантаження не Вдалося - Оновлення Розпочато - Помилка Завантаження Посилань - Призупинити Завантаження - Переглянути Файл + Внутрішнє сховище + Завантаження призупинено + Завантаження розпочато + Не вдалося завантажити + Оновлення розпочато + Помилка завантаження посилань + Призупинити завантаження + Переглянути файл Докладніше - Фільтрувати Закладки + Фільтрувати закладки Очистити - Налаштування Субтитрів - Колір Тла - Висота Субтитрів - Колір Тексту - Колір Обведення + Налаштування субтитрів + Колір тла + Висота субтитрів + Колір тексту + Колір обведення Автовідтворення наступного епізоду Проведіть збоку в бік, щоби керувати часом відтворення у відео - %d Benenes надано розробникам + Дано %d бананів розробникам Кнопка зміни розміру програвача @string/home_play Для коректної роботи цього постачальника може знадобитися VPN @@ -138,12 +138,12 @@ Дані збережено Помилка резервного копіювання %s Пошук - Облікові Записи та Безпека - Оновлення та Резервне Копіювання + Облікові записи та безпека + Оновлення та резервне копіювання Подробиці - Розширений Пошук + Розширений пошук Показувати результати пошуку, розділені за постачальниками - Показувати наповнювачі для аніме + Показувати філлери для аніме Показувати трейлери Приховати вибрану якість відео у результатах пошуку Автозавантаження розширень @@ -153,25 +153,25 @@ Github Застосунок для ранобе від тих самих розробників Застосунок для аніме від тих самих розробників - Дати benene розробникам - Мова Застосунку + Дати бананів розробникам + Мова застосунку Цей постачальник не має підтримування Chromecast - Посилань Не Знайдено - Переглянути Епізод + Посилань не знайдено + Переглянути епізод Скинути до типових значень - Немає Сезону + Немає сезону Епізоди %1$d %2$s С Е - Видалити Файл + Видалити файл Видалити Скасувати Відновити -30 Це назавжди видалить %s\nВи впевнені? - %dхв\nзалишилося - Триває + %d хв\nзалишилося + Виходить Завершено Рейтинг Тривалість @@ -184,16 +184,16 @@ Телесеріали Мультфільми Аніме - OVA - Азіатські Драми - Прямі Трансляції + ОВА + Азіатські драми + Прямі трансляції Інші Серіал Мультфільм Аніме - Документальний Фільм - Азіатська Драма - Пряма Трансляція + Документалка + Азіатська драма + Пряма трансляція Відео Помилка джерела Віддалена помилка @@ -203,15 +203,15 @@ Переглянути в %s Автозавантаження Завантажити дзеркало - Перевірити Наявність Оновлень + Перевірити наявність оновлень Забл./Розбл. Пропустити ОП Не показувати знову Оновити - Бажана якість перегляду (WiFi) + Бажана якість перегляду (Wi-Fi) Заголовок Перемкнути елементи інтерфейсу на плакаті - Оновлення Не Знайдено + Оновлення не знайдено Натисніть двічі праворуч або ліворуч, щоби перемотати вперед або назад Використовувати системну яскравість у програвачі замість темного накладання Завантажено файл резервної копії @@ -222,10 +222,10 @@ Автооновлення розширень Автоматично встановлювати всі розширення, які ще не встановлено, з доданих сховищ. Автоматично перевіряти нові оновлення після запуску застосунку. - Покликання скопійовано до буфера обміну - Деякі пристрої не підтримують новий інсталятор пакетів. Спробуйте старий варіант, якщо оновлення не встановлюються. + Посилання скопійовано до буфера обміну + Деякі пристрої не підтримують новий встановлювач пакетів. Спробуйте старий варіант, якщо оновлення не встановлюються. Приєднуйтеся до Discord - Дано benene + Дано банани Рік +30 %1$s %2$d%3$s @@ -240,21 +240,21 @@ Стислий зміст Фільми Перезавантажити посилання - Документальні Фільми + Документалки NSFW Фільм - OVA + ОВА Торент Мітка якості NSFW Несподівана помилка програвача Помилка завантаження, перевірте дозвіл на зберігання - Chromecast епізод + Епізод через Chromecast Мітка субтитрів Джерело Завантажити субтитри Мітка дубляжу - Пропустити це Оновлення + Пропустити це оновлення Усе На весь екран Розтягнути @@ -275,9 +275,9 @@ Макет застосунку Бажані медіа Автоматично - Телевізійна Обгортка - Телефона обгортка - Емуляторна обгортка + Макет телевізора + Макет телефона + Макет емулятора Основний колір Тема застосунку Розташування назви плаката @@ -296,7 +296,7 @@ %d / 10 /%d %s автентифіковано - Не вдалося увійти в %s + Не вдалося ввійти в %s Нічого Звичайний Мін. @@ -319,9 +319,9 @@ HDR SDR Web - Зображення Плаката + Зображення плаката Програвач - Роздільна здатність та заголовок + Роздільність та заголовок Недійсний ID Недійсна URL-адреса Резервне копіювання @@ -341,15 +341,15 @@ DNS через HTTPS Шлях завантаження Додайте двійника наявного сайту, з іншою URL-адресою - Показувати Дубльоване/З Субтитрами Аніме + Показувати аніме з дубляжем / із субтитрами Застереження Розширення Дії 127.0.0.1 Макет Кодування субтитрів - Увімкнути NSFW вміст на підтримуваних Розширеннях - Обгортка + Увімкнути NSFW у підтримуваних розширеннях + Макет Постачальники https://example.com %1$s %2$s @@ -375,7 +375,7 @@ Фільтрувати за бажаною мовою медіа 4K Заголовок - Роздільна здатність + Роздільність Помилка Трейлер Додатково @@ -388,7 +388,7 @@ TS TC Вилучати роздуття субтитрів - Referer (необов’язково) + Джерело переходу (необов’язково) Далі Переглядайте відео на цих мовах Пропустити налаштування @@ -396,7 +396,7 @@ Готово Розширення Додати репозиторій - Назва репозиторію (Опціонально) + Назва репозиторію (необов’язково) URL-адреса репозиторію або короткий код Розширення завантажено Розширення завантажено @@ -432,10 +432,10 @@ Список відтворення HLS Вбудований програвач Ендінґ - Коротке повторення + Підсумок Пропустити %s Змішаний ендінґ - Подяки + Подяки Опенінґ Вступ Очистити історію @@ -449,10 +449,10 @@ Установлення оновлення застосунку… Не вдалося встановити нову версію застосунку Застарілий - Встановлювач Пакунків + Встановлювач пакунків Застосунок буде оновлено після виходу Це також призведе до видалення всіх розширень сховища - Усі Мови + Усі мови Назад Змініть вигляд застосунку відповідно до вашого пристрою Розширення видалено @@ -467,26 +467,26 @@ Застосунок не знайдено Змішаний опенінґ Вилучити з переглянутого - Оновленням (від Старого до Нового) - Оновленням (від Нового до Старого) + Оновленням (від старого до нового) + Оновленням (від нового до старого) Бібліотека Сортувати - Рейтингом (від Високого до Низького) + Рейтингом (від високого до низького) Сортувати за Алфавітом (від А до Я) - Рейтингом (від Низького до Високого) + Рейтингом (від низького до високого) Ваша бібліотека порожня :(\nУвійдіть в обліковий запис бібліотеки або додайте щось до вашої локальної бібліотеки. Алфавітом (від Я до А) - Оберіть Бібліотеку + Оберіть бібліотеку Відкрити з Браузер Цей список порожній. Спробуйте перейти до іншого. Файл безпечного режиму знайдено!\nРозширення не завантажуватимуться під час запуску, доки файл не буде видалено. Android TV - Прогрвач Приховано – Крок Перемотування - Програвач Показано – Крок Перемотування + Прогрвач приховано: крок перемотування + Програвач показано: крок перемотування Крок перемотування, який використовується, коли програвач видимий - Крок перемотування, який використовується, коли плеєр прихований + Крок перемотування, який використовується, коли програвач прихований Провалено Пройдено Перезапустити @@ -500,10 +500,10 @@ Ви відписалися від %s Епізод %d випущено! Повернути - GitHub Проксі + Проксі GitHub Не вдалось отримати доступ до GitHub. Увімкнення проксі-сервера jsDelivr… Обходи ISP - Обхід блокування чистих gitHub URLs за допомогою jsDelivr. Можлива затримка оновлень на кілька днів. + Обхід блокування прямих посилань GitHub за допомогою jsDelivr. Може спричинити затримку оновлень на кілька днів. Бажана якість перегляду (мобільні дані) Встановити типові Профілі @@ -527,7 +527,7 @@ Вподобані %s додано до вподобаних У вашій бібліотеці виявлено можливі дублікати:\n\n%s\n\nУсе одно хочете додати цей елемент, замінити наявні чи скасувати дію? - Знайдено Можливий Дублікат + Знайдено можливий дублікат Заблокувати профіль Додати до обраного Замінити все @@ -538,32 +538,32 @@ Додати Підписатися Вилучити з обраного - Оберіть Обліковий Запис + Оберіть обліковий запис Схоже, що у вашій бібліотеці вже є можливий дублікат: \'%s.\'\n\nУсе одно хочете додати цей елемент, замінити наявний чи скасувати дію? Уведіть PIN-код PIN-код Уведіть поточний PIN-код Увійшли як %s Уведіть PIN-код для %s - Використовувати Типовий Обліковий Запис + Використовувати типовий обліковий запис Пропускати вибір облікового запису під час запуску - Керувати Обліковими Записами + Керувати обліковими записами Редагувати обліковий запис Показувати кнопку перемикання орієнтації екрана Обернути - Посилання Перезавантажені + Посилання перезавантажені Автообертання Увімкнути автоматичну зміну орієнтації екрана відповідно до відео Додати налаштування швидкості до програвача - Перевірити всі Розширення + Перевірити всі розширення Пошук в інших розширеннях Показати рекомендації - Ця Перевірка лише для розробників і не підтверджує або заперечує роботу жодного розширення. + Ця перевірка лише для розробників і не підтверджує або заперечує роботу жодного розширення. Сповіщення про новий епізод - Автентифікація за Паролем/PIN-кодом + Автентифікація за паролем / PIN-кодом Розблокуйте CloudStream - Біометричне Блокування - Розблоковуйте застосунок за допомогою відбитка пальця, Face ID, PIN-коду, Графічного Ключа або Пароля. + Біометричне блокування + Розблоковуйте застосунок за допомогою відбитка пальця, Face ID, PIN-коду, графічного ключа або пароля. Щойно було виконано резервне копіювання даних CloudStream. Хоча ймовірність цього вкрай мала, усі пристрої можуть поводитися по-різному. У рідкісних випадках, якщо ви втратите доступ до застосунку, повністю очистіть дані застосунку та відновіть їх із резервної копії. Просимо вибачення за будь-які незручності, що можуть виникнути. Біометрична автентифікація не підтримується на цьому пристрої Після кількох невдалих спроб вікно запиту зникне. Перезапустіть застосунок, щоби спробувати ще раз. @@ -572,19 +572,19 @@ Додати до вподобаного скопійовано! Назва репозиторію та URL - Помилка копіювання, Будь-ласка скопіюйте logcat та зверніться до служби підтримки застосунку. - Помилка доступу до буфера обміну, Будь-ласка спробуйте ще раз. - OK + Помилка копіювання, скопіюйте logcat та зверніться до служби підтримки застосунку. + Помилка доступу до буфера обміну, спробуйте ще раз. + Гаразд Вимкнути оптимізацію батареї - Щоби забезпечити безперервне завантаження та сповіщення про підписані телепередачі, CloudStream потребує дозволу на роботу у фоновому режимі. Натиснувши OK, ви побачите діалогове вікно запиту. Натисніть \'Дозволити\'.\n\nЗверніть увагу, що цей дозвіл не означає, що CS3 розряджатиме ваш акумулятор. Він працюватиме у фоновому режимі лише за необхідності, наприклад, під час отримання сповіщень або завантаження відео з офіційних розширень. + Щоби забезпечити безперервне завантаження та сповіщення про підписані телепередачі, CloudStream потребує дозволу на роботу у фоновому режимі. Натиснувши «Гаразд», ви побачите діалогове вікно запиту. Натисніть «Дозволити».\n\nЗверніть увагу, що цей дозвіл не означає, що CS3 розряджатиме ваш акумулятор. Він працюватиме у фоновому режимі лише за необхідності, наприклад, під час отримання сповіщень або завантаження відео з офіційних розширень. Споживання батареї застосунком уже змінено на необмежене Не вдається відкрити подробиці про застосунок CloudStream. Аудіокнига - Музика - Медіа + Музика + Медіа Скинути Наступний через %s - Сезон %1$d Епізод %2$d вийде через + %1$d сезон %2$d епізод вийде через Оберіть пристрій для трансляції Трансляція через дзеркало CloudStream Wiki @@ -592,60 +592,60 @@ Облікові записи Зображення QR-коду Відкрити сховище - Відвідайте %s на своєму смартфоні або комп\'ютері та введіть вищевказаний код - Не вдається отримати PIN-код пристрою, спробуйте локальну аутентифікацію - PIN-код зараз закінчився ! - Термін дії коду закінчується через %1$dхв %2$dс - Локальна Аутентифікація + Відвідайте %s на своєму смартфоні або комп’ютері та введіть вищевказаний код + Не вдається отримати PIN-код пристрою, спробуйте локальну автентифікацію + Термін дії PIN-коду закінчився! + Термін дії коду закінчується через %1$d хв %2$d с + Локальна автентифікація Відхилити - Відтворити з Початку + Відтворити з початку Попередження Видалити розширення Наразі завантажень немає. Приховати назви елементів керування в програвачі Відкрити локальне відео - Датою випуску (від Нових до Старих) - Датою випуску (від Старих до Нових) - Оберіть Елементи для Видалення - Обрати Все - Зняти Вибір Всіх + Датою випуску (від нових до старих) + Датою випуску (від старих до нових) + Оберіть елементи для видалення + Обрати все + Зняти виділення Видалити (%1$d | %2$s) Ви впевнені, що хочете назавжди видалити такі епізоди в %1$s?\n\n%2$s Ви також назавжди видалите всі епізоди в такому серіалі:\n\n%s - Доступно для перегляду в оффлайн режимі - Видалити Файли + Доступно для перегляду поза мережею + Видалити файли Ви впевнені, що хочете назавжди видалити такі елементи?\n\n%s Ви впевнені, що хочете назавжди видалити всі епізоди в такому серіалі?\n\n%s Попередній перегляд на шкалі перегляду Увімкнути мініатюру попереднього перегляду на шкалі перегляду Субтитри ще не завантажено Підтвердіть перед виходом - Відобразити + Показати Показувати діалог перед виходом із застосунку - Не відображати + Не показувати Розташування теки для резервних копій Власний - Це відео – Торрент, це означає, що ваша відео діяльність може відстежуватися.\nПереконайтеся, що розумієте, що таке Торрент, перед тим як продовжити. + Це відео – торрент, це означає, що ваша відеоактивність може відстежуватися.\nПереконайтеся, що розумієте, що таке торрент, перед тим як продовжити. Розмір обведення - Аудіо - Подкаст + Аудіо + Подкаст Непідтримувана помилка Помилка кодування Завантажити перший доступний - Увімкніть торент в Налаштування/Постачальники/Бажані медіа - Перезапустіть застосунок та прийміть спливне вікно Stream Torrent, щоби продовжити. + Увімкніть торент в Налаштування - > Постачальники - > Бажані медіа + Перезапустіть застосунок та прийміть спливне вікно «Транслювати торрент», щоби продовжити. Програмне декодування Програмне декодування дозволяє плеєру відтворювати відеофайли, які не підтримуються вашим пристроєм, але може спричинити затримки або нестабільне відтворення у високій роздільній здатності. - Датою виходу (Найновіша) - Епізодом (За Зростанням) - Рейтингом (Найнижчий) + Датою виходу (найновіша) + Епізодом (за зростанням) + Рейтингом (найнижчий) Рейтинг %s - Епізодом (за Спаданням) - Рейтингом (Найвищий) - Датою виходу (Найстаріша) + Епізодом (за спаданням) + Рейтингом (найвищий) + Датою виходу (найстаріша) Еп. %s Дата %s - Оновити Розширення + Оновити розширення Успішно оновлено %d розширення(-ь)! Оновити розширення вручну Починається оновлення розширень! @@ -653,10 +653,10 @@ Сповіщення програвача для керування відтворенням у фоновому режимі Сповіщення програвача Розпізнавання мовлення недоступне - Почніть Говорити… + Почніть говорити… Вбудовані Мережеві - Радіус Тла + Радіус тла Зробити всі субтитри потовщеним Зробити всі субтитри курсивом Гучність перевищила 100% @@ -664,8 +664,8 @@ Одночасних з’єднань Змінює межі екрана Обрізання зображення - Перейти до Завантажень - Немає підключення до Інтернету.\n\nБудь ласка, підключіться до Інтернету та спробуйте ще раз або перегляньте завантажені відео офлайн. + Перейти до завантажень + Немає з’єднання з мережею. \n\nПерепід’єднайтеся до мережі та спробуйте ще раз або перегляньте завантажені відео локально. Скільки різних елементів можна завантажити паралельно Паралельних завантажень Скільки одночасних з’єднань може використовувати кожне завантаження @@ -674,46 +674,46 @@ Завжди запитувати Змінювати швидкість при утриманні Утримуйте, щоб отримати 2-кратну швидкість - %1$dгод %2$dхв %3$dс - %1$dхв %2$dс - %1$dс + %1$d год %2$d хв %3$d с + %1$d хв %2$d с + %1$d с Мітка рейтингу Немає облікового запису - Редагувати Зображення Профілю - Введіть URL-адресу Зображення Профілю - URL-адресу Не Знайдено - Недійсна URL-адреса або Зображення - Зображення Успішно Оновлено + Редагувати зображення профілю + Введіть URL-адресу зображення профілю + URL-адресу не знайдено + Недійсна URL-адреса або зображення + Зображення успішно оновлено Позначити як переглянуте до цього епізоду Вилучити переглянуті до цього епізоду Перезавантажено - Перезавантажити Постачальника Послуг + Перезавантажити постачальника Переглянути в дзеркалі" Назва - Роздільна здатність та назва - Вирівнювання Субтитрів + Роздільність та назва + Вирівнювання субтитрів Внизу ліворуч Внизу по центру Внизу праворуч - Середній лівий - Середній центр - Середній правий + Посередині зліва + Посередині в центрі + Посередині справа Угорі ліворуч - Верхній центр + Угорі в центрі Угорі праворуч - Відтворити Повні Серії - Встановити перед-релізну версію - Перед-релізна версія вже встановлена. - Не вдалося встановити перед-релізну версію. - Текст Епізоду - Пропозиції Пошуку + Відтворити весь серіал + Встановити передрелізну версію + Передрелізна версія вже встановлена. + Не вдалося встановити передрелізну версію. + Текст епізоду + Пропозиції пошуку Показувати підказки пошуку під час введення тексту - Очистити Пропозиції + Очистити пропозиції Додаткова яскравість Увімкнути фільтр яскравості при перевищенні 100% яскравості дисплея extra_brightness_enabled Показати панель трансляції - Інформація Про Медіа + Інформація про медіа Назва джерела Черга завантаження Наразі немає завантажень у черзі. @@ -735,5 +735,8 @@ Пріоритетне джерело Виберіть спосіб сортування джерел відео у програвачі - Показувати Накладання Метаданих Програвача + Показувати накладання метаданих програвача + Відео + Передперегляд + Наживо diff --git a/app/src/main/res/values-b+ur/strings.xml b/app/src/main/res/values-b+ur/strings.xml index 5f6d8aa1473..94728e2f817 100644 --- a/app/src/main/res/values-b+ur/strings.xml +++ b/app/src/main/res/values-b+ur/strings.xml @@ -10,8 +10,7 @@ ذریعہ تبدیل کریں پس منظر کا دیکھنا درجہ بندی: %.1f - نیا update آگیا ہے! -\n%1$s -> %2$s + نیا update آگیا ہے! \n%1$s -> %2$s بھرنے والا %d منٹ %1$d دن %2$d گھنٹے %3$d منٹ @@ -189,10 +188,8 @@ از سر نو شروع کریں -30 +30 - یہ مستقل طور پر حذف ہوجائے گا %s -\nتمھيں يقين ہے? - %dm -\nباقی + یہ مستقل طور پر حذف ہوجائے گا %s \nتمھيں يقين ہے? + %dm \nباقی احوال مکمل حالت @@ -346,7 +343,7 @@ %d / 10 اٹھایا اگر سب ٹائٹلز %d ms بہت جلد دکھائے جائیں تو اسے استعمال کریں - کریڈٹس + کریڈٹس اضافی مرکزی ترتیب @@ -441,8 +438,7 @@ پلیئردکھایا گیا - Seek Amount پلیئر کے نظر آنے پر استعمال کی جانے والی Seek Amount پلیئر کے چھپنے پر استعمال ہونے والی seek amount - سیف موڈ فائل مل گئی! -\nفائل کو ہٹانے تک اسٹارٹ اپ پر کوئی ایکسٹینشن لوڈ نہیں کرنا۔ + سیف موڈ فائل مل گئی! \nفائل کو ہٹانے تک اسٹارٹ اپ پر کوئی ایکسٹینشن لوڈ نہیں کرنا۔ شروع کریں ناکام کامیاب ہو گیا @@ -454,8 +450,7 @@ آئی ایس پی بائی پاسز %s کو سبسکرائب کیا Bypass blocking of raw github URLs using jsDelivr. اپ ڈیٹس میں کچھ دنوں کی تاخیر ہو سکتی ہے - آپ کی لائبریری خالی ہے:( -\nلائبریری اکاؤنٹ میں لاگ ان کریں یا اپنی مقامی لائبریری میں شوز شامل کریں۔ + آپ کی لائبریری خالی ہے:( \nلائبریری اکاؤنٹ میں لاگ ان کریں یا اپنی مقامی لائبریری میں شوز شامل کریں۔ غلط URL براؤزر ویب @@ -504,9 +499,7 @@ سب ٹائٹلز سے بند کیپشنز کو ہٹا دیں اپنے آلے کے مطابق ایپ کی شکل تبدیل کریں اگلے - CloudStream میں بذریعہ ڈیفالٹ کوئی سائٹ انسٹال نہیں ہے۔ آپ کو ریپوزٹری سے سائٹس انسٹال کرنے کی ضرورت ہے۔ -\n -\nہمارے Discord میں شامل ہوں یا آن لائن تلاش کریں۔ + CloudStream میں بذریعہ ڈیفالٹ کوئی سائٹ انسٹال نہیں ہے۔ آپ کو ریپوزٹری سے سائٹس انسٹال کرنے کی ضرورت ہے۔ \n \nہمارے Discord میں شامل ہوں یا آن لائن تلاش کریں۔ تمام ایکسٹینشنز کریش کی وجہ سے آف کر دی گئیں تاکہ آپ کو پریشانی کا باعث تلاش کرنے میں مدد مل سکے۔ پہلے ایکسٹینشن انسٹال کریں بہت زیادہ متن۔ کلپ بورڈ میں محفوظ کرنے سے قاصر۔ @@ -528,13 +521,7 @@ آپ نے پہلے ہی ووٹ دیا ہے مخزن میں کوئی پلگ انز نہیں ملا ترجیحی تعین کریں - یہاں آپ تبدیلی کرسکتے ہیں کہ سورسز کو کس طرح کی ترتیب دی جائے۔ اگر ایک ویڈیو کی زیادہ پرائیورٹی ہوتی ہے تو یہ سورس کی انتخاب میں زیادہ اوپر آئے گی۔ سورس کی پرائیورٹی اور کوالٹی کی پرائیورٹی کا مجموعہ ویڈیو کی پرائیورٹی ہوتی ہے۔ -\n -\nسورس A: 3 -\nکوالٹی B: 7 -\nاس کا مجموعی ویڈیو پرائیورٹی 10 ہوتی ہے۔ -\n -\nنوٹ: اگر مجموعہ 10 یا اس سے زیادہ ہو تو پلیر وہ لنک لوڈ کرنے کو خود بخود چھوڑ دے گا! + یہاں آپ تبدیلی کرسکتے ہیں کہ سورسز کو کس طرح کی ترتیب دی جائے۔ اگر ایک ویڈیو کی زیادہ پرائیورٹی ہوتی ہے تو یہ سورس کی انتخاب میں زیادہ اوپر آئے گی۔ سورس کی پرائیورٹی اور کوالٹی کی پرائیورٹی کا مجموعہ ویڈیو کی پرائیورٹی ہوتی ہے۔ \n \nسورس A: 3 \nکوالٹی B: 7 \nاس کا مجموعی ویڈیو پرائیورٹی 10 ہوتی ہے۔ \n \nنوٹ: اگر مجموعہ 10 یا اس سے زیادہ ہو تو پلیر وہ لنک لوڈ کرنے کو خود بخود چھوڑ دے گا! پسندیدہ %s کو پسندیدہ میں شامل کیا گیا %s کو پسندیدہ سے ختم کیا گیا @@ -561,14 +548,8 @@ جمع کریں سب کو بدل دیں بدل دیں - آپ کی لائبریری میں ممکنہ ڈپلیکیٹ آئٹمز مل گئے ہیں: -\n -\n%s -\n -\nکیا آپ بہرحال اس آئٹم کو شامل کرنا چاہیں گے، موجودہ کو تبدیل کرنا چاہیں گے، یا کارروائی کو منسوخ کریں گے؟ - ایسا معلوم ہوتا ہے کہ آپ کی لائبریری میں ممکنہ طور پر ڈپلیکیٹ آئٹم پہلے سے موجود ہے: \'%s۔\' -\n -\nکیا آپ بہرحال اس آئٹم کو شامل کرنا چاہیں گے، موجودہ کو تبدیل کرنا چاہیں گے، یا کارروائی کو منسوخ کریں گے؟ + آپ کی لائبریری میں ممکنہ ڈپلیکیٹ آئٹمز مل گئے ہیں: \n \n%s \n \nکیا آپ بہرحال اس آئٹم کو شامل کرنا چاہیں گے، موجودہ کو تبدیل کرنا چاہیں گے، یا کارروائی کو منسوخ کریں گے؟ + ایسا معلوم ہوتا ہے کہ آپ کی لائبریری میں ممکنہ طور پر ڈپلیکیٹ آئٹم پہلے سے موجود ہے: \'%s۔\' \n \nکیا آپ بہرحال اس آئٹم کو شامل کرنا چاہیں گے، موجودہ کو تبدیل کرنا چاہیں گے، یا کارروائی کو منسوخ کریں گے؟ پروفائل لاک کریں آغاز پر اکاؤنٹ کا انتخاب چھوڑ دیں ویڈیو واقفیت کی بنیاد پر اسکرین کی سمت بندی کی خودکار سوئچنگ کو فعال کریں @@ -576,8 +557,7 @@ کاپی کر لیا! ذخیرہ کا نام اور URL %s میں آنے والا ہے - %s -\nباقی + %s \nباقی کلپ بورڈ تک رسائی میں خرابی، براہ کرم دوبارہ کوشش کریں۔ کاپی کرنے میں خرابی، براہ کرم logcat کاپی کریں اور ایپ سپورٹ سے رابطہ کریں۔ سبسکرائب شدہ ٹی وی شوز کے لیے بلاتعطل ڈاؤن لوڈز اور اطلاعات کو یقینی بنانے کے لیے، CloudStream کو پس منظر میں چلنے کی اجازت درکار ہے۔ OK دبانے سے، آپ کو App info پر بھیج دیا جائے گا۔ وہاں اسکرول کریں 𝘼𝙥𝙥 𝙗𝙖𝙩𝙩𝙚𝙧𝙮 𝙪𝙨𝙖𝙜𝙚 اور battery usage کو 𝙐𝙣𝙧𝙚𝙨𝙩𝙧𝙞𝙘𝙩𝙚𝙙 کردیں، اس اجازت کا مطلب یہ نہیں ہے کہ CS3 آپ کی بیٹری ختم کردے گا۔ یہ صرف ضرورت پڑنے پر پس منظر میں کام کرے گا، جیسے کہ جب اطلاعات موصول ہوں یا آفیشل ایکسٹینشنز سے ویڈیوز ڈاؤن لوڈ کریں۔ اگر آپ منسوخ کرنے کا انتخاب کرتے ہیں، تو آپ اس ترتیب کو بعد میں 𝙂𝙚𝙣𝙚𝙧𝙖𝙡 𝙎𝙚𝙩𝙩𝙞𝙣𝙜𝙨 میں ایڈجسٹ کر سکتے ہیں۔ @@ -591,9 +571,9 @@ Battery Optimization کو غیر فعال کریں اپپ battery usage پہلے سے ہی unrestricted ہے پسندیدہ کریں - موسیقی + موسیقی آڈیو بک - میڈیا + میڈیا انلاک CloudStream بایومیٹرکس کے ساتھ لاک کریں بایومیٹرک تصدیق اس ڈیوائس پر سپپورٹڈ نہیں ہے @@ -617,7 +597,7 @@ لوکل ویڈیو کھولیں فائلز حذف کریں انتباہ - آواز + آواز تاریخ %s اپنے اسمارٹ فون یا کمپیوٹر پر یہ %s وزٹ کریں اور مندرجہ بالا کوڈ ڈالیں diff --git a/app/src/main/res/values-b+vi/strings.xml b/app/src/main/res/values-b+vi/strings.xml index a51a3551db0..842b97080eb 100644 --- a/app/src/main/res/values-b+vi/strings.xml +++ b/app/src/main/res/values-b+vi/strings.xml @@ -14,22 +14,21 @@ Poster tập phim Poster chính Tập tiếp theo ngẫu nhiên - Quay lại + Thoát Thay đổi Nguồn phim Xem trước hình nền Tốc độ (%.2fx) Đánh giá: %.1f - Đã tìm thấy bản cập nhật mới! -\n%1$s -> %2$s + Đã tìm thấy bản cập nhật mới! \n%1$s -> %2$s Bộ lọc %d phút CloudStream Phát bằng CloudStream - Trang Chủ - Tìm Kiếm + Trang chủ + Tìm kiếm Tải xuống - Cài Đặt + Cài đặt Tìm kiếm… Tìm kiếm %s… Không có dữ liệu @@ -47,9 +46,9 @@ Xem sau Xem lại Phát - Phát Livestream + Phát Trực tiếp Phát Torrent - Nguồn phim + Nguồn Phụ đề Thử kết nối lại… Quay lại @@ -58,7 +57,7 @@ Tải xuống Đã tải xuống Đang tải xuống - Đã tạm dừng tải xuống + Tải xuống đã tạm dừng Tải xuống đã bắt đầu Tải xuống thất bại Tải xuống đã hủy @@ -88,7 +87,7 @@ Tốc độ phát Cài đặt phụ đề Màu chữ - Màu viền chữ + Màu viền Màu nền Màu cửa sổ Kiểu viền @@ -102,7 +101,7 @@ Tự động chọn ngôn ngữ Ngôn ngữ tải xuống Ngôn ngữ phụ đề - Nhấn giữ để đặt lại về mặc định + Nhấn giữ để đặt lại mặc định Thêm phông chữ tại %s Tiếp tục xem Xóa @@ -129,10 +128,10 @@ Vuốt lên hoặc xuống cạnh trái hoặc phải để điều chỉnh độ sáng hoặc âm lượng Tự động phát tập tiếp theo Phát tập tiếp theo sau khi hết tập hiện tại - Nhấn 2 lần để tua - Nhấn 2 lần để tạm dừng + Nhấn hai lần để tua + Nhấn hai lần để tạm dừng Thời lượng tua (Giây) - Nhấn 2 lần vào cạnh trái hoặc phải để tua về trước hoặc sau + Nhấn hai lần vào cạnh trái hoặc phải để tua về trước hoặc sau Nhấn vào giữa hai lần để tạm dừng Sử dụng độ sáng hệ thống Dùng độ sáng hệ thống thay cho lớp phủ tối trong trình phát ứng dụng @@ -186,8 +185,7 @@ Tiếp tục -30 +30 - %s sẽ bị xoá vĩnh viễn -\nBạn có chắc chắn muốn xóa? + %s sẽ bị xoá vĩnh viễn \nBạn có chắc chắn không? %d phút\ncòn lại Đang chiếu Hoàn thành @@ -230,14 +228,14 @@ Lỗi nguồn phim Lỗi nguồn từ xa Lỗi kết xuất - Đã có lỗi xảy ra. Vui lòng thử lại sau + Lỗi trình phát bất ngờ Lỗi tải xuống. Hãy kiểm tra quyền truy cập bộ nhớ - Phát tập phim bằng Chromecast - Phản chiếu màn hình bằng Chromecast + Truyền tập phim + Truyền nguồn thay thế Phát bằng ứng dụng Phát bằng %s Tự động tải xuống - Nguồn tải dự phòng + Tải xuống nguồn thay thế Tải lại các liên kết Tải xuống phụ đề Nhãn chất lượng phim @@ -250,7 +248,7 @@ Khóa Thu phóng Nguồn - Bỏ qua giới thiệu + Bỏ qua Giới thiệu Không hiện lại Bỏ qua bản cập nhật này Cập nhật @@ -295,7 +293,7 @@ Đặt tên phim dưới poster Mật khẩu - Tài khoản + Tên người dùng Email 127.0.0.1 Tên trang mới @@ -315,10 +313,10 @@ %d / 10 /?? /%d - Đã xác thực %s - Không thể xác thực %s + %s đã xác thực + Không thể đăng nhập tại %s - Mặc định + Không có Bình thường Tất cả Tối đa @@ -339,7 +337,7 @@ https://en.wikipedia.org/w/index.php?title=Pangram&oldid=225849300 https://en.wikipedia.org/wiki/The_quick_brown_fox_jumps_over_the_lazy_dog --> - Bạch kim rất quý nên sẽ dùng để lắp vô xương + Phụ đề của bạn sẽ trông tương tự như thế này Được đề xuất Đã tải %s Chọn từ tệp @@ -347,7 +345,7 @@ Tệp đã tải xuống Vai chính Vai phụ - Lý lịch + Diễn viên quần chúng Nguồn Ngẫu nhiên Sắp có… @@ -391,9 +389,9 @@ Bạn muốn xem gì Hoàn tất Tiện ích mở rộng - Thêm kho tiện ích - Tên kho tiện ích (Tùy chọn) - URL kho tiện ích hoặc Mã ngắn + Thêm kho nguồn phim + Tên kho nguồn phim (Tùy chọn) + URL kho nguồn phim hoặc Mã ngắn Tiện ích mở rộng đã tải Tiện ích mở rộng đã xoá Không tải được %s @@ -404,23 +402,21 @@ Tải xuống hàng loạt tiện ích mở rộng tiện ích mở rộng - Việc này sẽ xóa tất cả tiện ích mở rộng trong kho tiện ích - Xoá kho tiện ích + Việc này sẽ xóa tất cả tiện ích mở rộng trong kho nguồn phim + Xoá kho nguồn phim Tải xuống danh sách các trang web bạn muốn sử dụng Đã tải xuống: %d Đã vô hiệu: %d Chưa tải xuống: %d - CloudStream không có sẵn trang web nào. Bạn cần cài đặt các trang web từ kho lưu trữ. -\n -\nHãy tham gia Discord của chúng tôi hoặc tìm kiếm trực tuyến. - Xem kho lưu trữ của cộng đồng + CloudStream không có sẵn trang web nào. Bạn cần cài đặt các trang web từ kho lưu trữ. \n \nHãy tham gia Discord của chúng tôi hoặc tìm kiếm trực tuyến. + Xem các kho nguồn phim của cộng đồng Danh sách công khai In hoa toàn bộ phụ đề Cảnh báo: CloudStream không chịu trách nhiệm về các tiện ích mở rộng bên thứ ba và không cung cấp bất kỳ sự hỗ trợ nào! - %s (Đã vô hiệu hoá) - Âm thanh & Độ phân giải + %s (Đã vô hiệu) + Âm thanh & video Âm thanh - Độ phân giải video + Video Khởi động lại ứng dụng để thấy câc thay đổi. Chế độ an toàn được bật Tất cả tiện ích mở rộng đã được tắt do ứng dụng bị ngừng bất thường để giúp bạn tìm ra vấn đề gây lỗi. @@ -430,11 +426,11 @@ Tự động tải xuống tiện ích mở rộng Làm lại tiến trình thiết lập Trình cài đặt APK - Một số thiết bị không hỗ trợ trình cài đặt gói mới. Hãy thử chọn chế độ tương thích cũ nếu các bản cập nhật không cài đặt. + Một số thiết bị không hỗ trợ trình cài đặt gói mới. Hãy thử chọn chế độ tương thích cũ nếu các bản cập nhật không thể cài đặt. %1$s %2$d%3$s Phát Trailer - Tự động cài đặt tất cả tiện ích mở rộng chưa được cài đặt từ những kho tiện ích đã thêm. - Bắt đầu cập nhật + Tự động cài đặt tất cả tiện ích mở rộng chưa được cài đặt từ những kho nguồn phim đã thêm. + Cập nhật đã bắt đầu Liên kết Danh sách HLS Trình phát ưu tiên @@ -470,7 +466,7 @@ Điểm lại nội dung Kết thúc hỗn hợp Mở đầu hỗn hợp - Danh đề + Danh đề Giới thiệu Xoá lịch sử Hiện các popup bỏ qua cho mở đầu/kết thúc @@ -488,8 +484,7 @@ Chế độ tương thích cũ Đã cập nhật (Mới đến Cũ) Đã cập nhật (Cũ đến Mới) - Thư viện của bạn đang trống :( -\nĐăng nhập vào tài khoản thư viện hoặc thêm phim vào thư viện cục bộ. + Thư viện của bạn đang trống :( \nĐăng nhập vào tài khoản thư viện hoặc thêm phim vào thư viện cục bộ. Mở bằng Siêu dữ liệu không được cung cấp bởi trang web, video sẽ không tải được nếu nó không tồn tại trên trang web. Trình cài đặt gói @@ -517,8 +512,7 @@ Dừng Bỏ chặn nhà mạng Đã bỏ đăng ký %s - Tìm thấy tệp Safe mode! -\nKhông tải bất cứ tiện ích mở rộng nào khi khởi động cho đến khi loại bỏ tệp. + Tìm thấy tệp Safe mode! \nKhông tải bất cứ tiện ích mở rộng nào khi khởi động cho đến khi loại bỏ tệp. Hoàn tác Đang cập nhật các phim đã đăng kí Bỏ chặn các URL gốc của GitHub bằng jsDelivr. Có thể làm cập nhật bị trễ vài ngày. @@ -535,28 +529,18 @@ Hồ sơ Trợ giúp Nền hồ sơ - Tại đây bạn có thể thay đổi cách sắp xếp các nguồn. Nếu video có mức độ ưu tiên cao hơn thì video đó sẽ xuất hiện cao hơn trong lựa chọn nguồn. Tổng ưu tiên nguồn và ưu tiên chất lượng là ưu tiên video. -\n -\nNguồn A: 3 -\nChất lượng B: 7 -\nSẽ có mức độ ưu tiên video kết hợp là 10. -\n -\nLƯU Ý: Nếu tổng là 10 hoặc nhiều hơn, trình phát sẽ tự động bỏ tải khi liên kết đó được tải! + Tại đây bạn có thể thay đổi cách sắp xếp các nguồn. Nếu video có mức độ ưu tiên cao hơn thì video đó sẽ xuất hiện cao hơn trong lựa chọn nguồn. Tổng ưu tiên nguồn và ưu tiên chất lượng là ưu tiên video. \n \nNguồn A: 3 \nChất lượng B: 7 \nSẽ có mức độ ưu tiên video kết hợp là 10. \n \nLƯU Ý: Nếu tổng là 10 hoặc nhiều hơn, trình phát sẽ tự động bỏ tải khi liên kết đó được tải! Chất lượng Bạn đã bình chọn - Vô hiệu hoá - Không tìm thấy kho tiện ích, hãy kiểm tra URL và thử lại với VPN + Tắt + Không tìm thấy kho nguồn phim, hãy kiểm tra URL và thử lại với VPN Không tìm thấy tiện ích mở rộng Không thể khởi tạo UI, đây là một LỖI LỚN và cần được báo cáo ngay lập tức tới %s Chọn chế độ lọc tiện ích mở rộng tải xuống %s đã xóa khỏi mục yêu thích Yêu thích %s đã thêm vào mục yêu thích - Các mục có thể trùng lặp đã được tìm thấy trong thư viện của bạn: -\n -\n%s -\n -\nBạn vẫn muốn thêm mục này, thay thế những mục hiện có hay hủy hành động? + Các mục có thể trùng lặp đã được tìm thấy trong thư viện của bạn: \n \n%s \n \nBạn vẫn muốn thêm mục này, thay thế những mục hiện có hay hủy hành động? Tần suất sao lưu Đã tìm thấy bản sao tiềm năng Khóa hồ sơ @@ -569,10 +553,8 @@ Thêm vào Đăng ký Xóa khỏi mục yêu thích - Ai đang xem - Có vẻ như một mục có khả năng trùng lặp đã tồn tại trong thư viện của bạn: \'%s.\' -\n -\nBạn vẫn muốn thêm mục này, thay thế mục hiện có hay hủy hành động? + Chọn một Hồ sơ + Có vẻ như một mục có khả năng trùng lặp đã tồn tại trong thư viện của bạn: \'%s.\' \n \nBạn vẫn muốn thêm mục này, thay thế mục hiện có hay hủy hành động? Nhập mã PIN PIN Nhập mã PIN hiện tại @@ -583,7 +565,7 @@ Quản lý hồ sơ Chỉnh sửa hồ sơ Tải lại liên kết - Tìm kiếm tiện ích mở rộng khác + Tìm kiếm trong tiện ích mở rộng khác Hiển thị đề xuất Kiểm tra tất cả Tiện ích mở rộng Xoay @@ -601,7 +583,7 @@ Không thể mở thông tin ứng dụng CloudStream. Bỏ yêu thích Mở khóa Cloudstream - Nhạc + Nhạc Sách nói Khóa bằng sinh trắc học %s\ncòn lại @@ -612,18 +594,18 @@ Sau vài lần thử thất bại, hộp thoại sẽ tự đóng. Chỉ cần khởi động lại ứng dụng để thử lại. Bài kiểm tra này chỉ dành cho các nhà phát triển và không xác nhận hay phủ nhận việc hoạt động của bất kỳ tiện ích mở rộng nào. Chế độ tiêu thụ pin của ứng dụng đã được đặt ở mức không giới hạn - Phương tiện - Tên và URL kho tiện ích + Phương tiện + Tên và URL kho nguồn phim Đặt lại Để đảm bảo quá trình tải xuống và thông báo cho các chương trình truyền hình đã đăng ký không bị gián đoạn, CloudStream cần có quyền chạy ở chế độ nền. Bằng cách nhấn OK, một hộp thoại yêu cầu sẽ hiển thị. Vui lòng nhấn \"Cho phép\".\n\nXin lưu ý, quyền này không có nghĩa là CS3 sẽ làm hao pin của bạn. Nó sẽ chỉ hoạt động ở chế độ nền khi cần thiết, chẳng hạn như khi nhận được thông báo hoặc tải xuống video từ các tiện ích mở rộng chính thức. Mùa %1$d Tập %2$d sẽ được phát hành vào - Sắp tới sau %s + Sắp ra mắt sau %s Chọn thiết bị truyền Bảo mật Tài khoản Mã QR Bỏ qua - Mở kho tiện ích + Mở kho nguồn phim CloudStream Wiki Truy cập %s trên điện thoại hoặc máy tính và nhập mã bên trên Mã PIN đã hết hạn! @@ -631,7 +613,7 @@ Không lấy được mã PIN, vui lòng thử xác thực cục bộ Không có tải xuống nào. Xác thực cục bộ - Phản chiếu màn hình + Truyền nguồn thay thế Phát từ đầu Mở video có sẵn Cảnh báo @@ -642,16 +624,10 @@ Bỏ chọn tất cả Xoá các tệp Xoá (%1$d | %2$s) - Bạn có chắc chắn muốn xóa vĩnh viễn các mục sau không? -\n -\n%s - Bạn có chắc chắn muốn xóa vĩnh viễn các tập trong %1$s? -\n -\n%2$s + Bạn có chắc chắn muốn xóa vĩnh viễn các mục sau không? \n \n%s + Bạn có chắc chắn muốn xóa vĩnh viễn các tập trong %1$s? \n \n%2$s Bạn cũng sẽ xóa vĩnh viễn tất cả các tập trong loạt phim: \n \n%s - Bạn có chắc chắn muốn xóa vĩnh viễn tất cả các tập trong loạt phim này không? -\n -\n%s + Bạn có chắc chắn muốn xóa vĩnh viễn tất cả các tập trong loạt phim này không? \n \n%s Xóa tiện ích mở rộng Ngày phát hành (Cũ đến mới) Ẩn tên các nút điều khiển trình phát @@ -669,11 +645,11 @@ Lỗi mã hóa Giải mã phần mềm cho phép phát các tệp video không được thiết bị của bạn hỗ trợ, nhưng có thể gây ra phản hồi chậm hoặc phát lại không ổn định ở độ phân giải cao. Bộ giải mã ứng dụng - Khởi động lại ứng dụng và chấp nhận cửa sổ Stream Torrent để tiếp tục. + Khởi động lại ứng dụng và chấp nhận cửa sổ bật lên của Stream Torrent để tiếp tục. Kích hoạt torrent trong Cài đặt/Nguồn phim/Thể loại ưu tiên Tải phụ đề đầu tiên có sẵn - Âm thanh - Podcast + Âm thanh + Podcast Lỗi không được hỗ trợ Xếp hạng (Thấp nhất) Tập (Tăng dần) @@ -709,7 +685,7 @@ Đến mục Tải xuống Không có kết nối Internet. \n\nVui lòng kết nối Internet rồi thử lại, hoặc xem các nội dung đã tải xuống khi đang ngoại tuyến. Thay đổi khung hiển thị màn hình - Vượt khung + Quét chồng lấn Thay đổi kích thước poster Kích thước poster Tăng tốc độ phát khi nhấn giữ @@ -724,12 +700,12 @@ URL hoặc ảnh không hợp lệ Đã cập nhật ảnh thành công Đánh dấu là đã xem đến tập này - Xóa những tập đã xem đến tập này + Xóa đã xem đến tập này Đã tải lại Tải lại nguồn phim Tên Độ phân giải và tên - Phản chiếu màn hình" + Phát nguồn thay thế" Căn chỉnh phụ đề Dưới trái Dưới giữa @@ -749,7 +725,7 @@ Cài đặt bản phát hành trước thất bại. Tập Bật bộ lọc độ sáng khi độ sáng màn hình vượt quá 100% - Hiển thị bảng diễn viên + Hiển thị bảng dàn diễn viên Thông tin video Độ sáng bổ sung Tên nguồn @@ -769,4 +745,7 @@ Đã bật độ sáng bổ sung Hiển thị lớp phủ siêu dữ liệu trình phát + Trực tiếp + Video + Xem trước diff --git a/app/src/main/res/values-b+zh+TW/strings.xml b/app/src/main/res/values-b+zh+TW/strings.xml index 78ba573100a..cfd8adf05ca 100644 --- a/app/src/main/res/values-b+zh+TW/strings.xml +++ b/app/src/main/res/values-b+zh+TW/strings.xml @@ -19,8 +19,7 @@ 速度(%.2fx) 評分:%.1f - 發現新版本! -\n%1$s -> %2$s + 發現新版本! \n%1$s -> %2$s 填充 %d 分鐘 CloudStream @@ -187,10 +186,8 @@ 繼續 -30 +30 - 這將永遠刪除 %s -\n你確定嗎? - 剩下 -\n%d 分鐘 + 這將永遠刪除 %s \n你確定嗎? + 剩下 \n%d 分鐘 連載中 已完結 狀態 @@ -413,9 +410,7 @@ 已停用:%d 未下載:%d 已更新 %d 外掛程式 - CloudStream 預設沒有安裝網站。你需要從儲存庫安裝網站。 -\n -\n加入我們的 Discord 或在網路上搜尋。 + CloudStream 預設沒有安裝網站。你需要從儲存庫安裝網站。 \n \n加入我們的 Discord 或在網路上搜尋。 查看 公開清單 字幕全大寫 @@ -448,7 +443,7 @@ 前情回顧 混合片尾 混合片頭 - 致謝名單 + 致謝名單 介紹 清除歷史紀錄 歷史紀錄 @@ -503,8 +498,7 @@ 按字母順序(A 到 Z) 按字母順序(Z 到 A) 選擇媒體庫 - 找到安全模式檔案! -\n在刪除此檔案之前,將不會在啟動時載入任何擴充功能。 + 找到安全模式檔案! \n在刪除此檔案之前,將不會在啟動時載入任何擴充功能。 日誌 失敗 通過 @@ -522,8 +516,7 @@ 還原 無法存取 GitHub。 正在開啟 jsDelivr proxy… 使用 jsDelivr 繞過直接使用 GitHub 網址時的存取封鎖。 可能導致更新延遲數天。 - 您的媒體庫是空的 :( -\n登入媒體庫帳號或將節目新增到您本機的媒體庫。 + 您的媒體庫是空的 :( \n登入媒體庫帳號或將節目新增到您本機的媒體庫。 您的媒體庫是空的。可嘗試以不同的帳號登入。 正在更新訂閱節目 備份頻率 @@ -531,11 +524,7 @@ 我的最愛 %s 已加入我的最愛 以 %s 的身分登入 - 您的媒體庫中似乎有多個重覆的項目: -\n -\n%s -\n -\n您要強制加入、取代已有項目、還是取消操作? + 您的媒體庫中似乎有多個重覆的項目: \n \n%s \n \n您要強制加入、取代已有項目、還是取消操作? 輸入 %s 的 PIN 碼 行動數據 找到可能重覆的項目 @@ -567,19 +556,11 @@ 找不到資源庫,請檢查網址與 VPN 設定 您已完成投票 在資源庫中找不到外掛程式 - 您的媒體庫中似乎有重覆的項目:「%s」。 -\n -\n您要強制加入、取代已有項目、還是取消操作? + 您的媒體庫中似乎有重覆的項目:「%s」。 \n \n您要強制加入、取代已有項目、還是取消操作? 設回預設 輸入 PIN 碼 PIN 碼 - 您可在此調整來源的排序方式。具有愈小的優先值的影片,在來源選擇中顯示得愈前面。來源優先值與品質優先值的加總就是影片優先值。 -\n例如: -\n來源 A:3 -\n品質 B: 7 -\n則該來源的影片優先值為 10。 -\n -\n注意:如果加總達到 10 或更高,則載入該連結時播放器將自動跳過載入! + 您可在此調整來源的排序方式。具有愈小的優先值的影片,在來源選擇中顯示得愈前面。來源優先值與品質優先值的加總就是影片優先值。 \n例如: \n來源 A:3 \n品質 B: 7 \n則該來源的影片優先值為 10。 \n \n注意:如果加總達到 10 或更高,則載入該連結時播放器將自動跳過載入! 輸入目前的 PIN 碼 顯示切換畫面方向的按鈕 選擇篩選外掛程式下載的模式 @@ -594,17 +575,16 @@ 使用生物辨識技術鎖定 應用程式電池使用已設定為無限制 解除鎖定 CloudStream - 媒體 + 媒體 重置 顯示推薦 在播放器中新增速度選項 即將在 %s 推出 - %s -\n剩餘 + %s \n剩餘 測試所有擴充功能 停用電池優化 有聲書 - 音樂 + 音樂 第 %1$d 季第 %2$d 集即將發佈於 在其他擴充功能中搜尋 新集數通知 @@ -661,10 +641,10 @@ 尚未載入字幕 此影片是 Torrent,這意味著你的影片活動可以被追蹤。\n在繼續之前,請確保你瞭解 Torrenting。 subs_edge_size - 音樂 + 音樂 編碼錯誤 因為不支援造成錯誤 - 播客 + 播客 軟體解碼 重開程式並「同意」線上播放 Torrent 視窗。 載入第一個可用的 @@ -764,4 +744,7 @@ 軟體解碼使程式可以播放裝置不支援的影片,但可能導致播放高解析的影片時的延遲或不穩定。 音量已超過 100% 顯示播放器元資料遮罩層 + 影片 + 預覽 + 播放中 diff --git a/app/src/main/res/values-b+zh/strings.xml b/app/src/main/res/values-b+zh/strings.xml index bc7c2ca0e30..56ec8f43eb8 100644 --- a/app/src/main/res/values-b+zh/strings.xml +++ b/app/src/main/res/values-b+zh/strings.xml @@ -19,8 +19,7 @@ 速度(%.2fx) 评分:%.1f - 发现新版本! -\n%1$s -> %2$s + 发现新版本! \n%1$s -> %2$s 填充 %d 分钟 CloudStream @@ -188,10 +187,8 @@ 继续 -30 +30 - 这将永久删除 %s -\n您确定吗? - %d 分钟 -\n剩余 + 这将永久删除 %s \n您确定吗? + %d 分钟 \n剩余 连载中 已完结 状态 @@ -414,9 +411,7 @@ 已禁用:%d 未下载:%d 已更新 %d 插件 - CloudStream 默认不安装片源。您需要从仓库中安装片源。 -\n -\n加入我们的 Discord 或在网上搜索。 + CloudStream 默认不安装片源。您需要从仓库中安装片源。 \n \n加入我们的 Discord 或在网上搜索。 查看社区仓库 公开列表 字幕全大写 @@ -449,7 +444,7 @@ 前情回顾 混合片尾 混合片头 - 致谢名单 + 致谢名单 介绍 清除历史记录 历史记录 @@ -486,8 +481,7 @@ 应用退出后将会更新 插件已下载 从已观看中移除 - 发现安全模式文件! -\n启动时不加载任何扩展,直到文件被删除。 + 发现安全模式文件! \n启动时不加载任何扩展,直到文件被删除。 浏览器 排序方式 @@ -500,8 +494,7 @@ 字母排序(从 Z 到 A) 选择库 打开方式 - 您的库是空的 :( -\n登录库账户或添加节目到您的本地库。 + 您的库是空的 :( \n登录库账户或添加节目到您的本地库。 此列表是空的,请尝试切换到另一个。 播放器显示 - 快进快退秒数 播放器可见时使用的快进快退秒数 @@ -534,13 +527,7 @@ 配置文件 帮助 移动流量 - 在这里,您可以更改源的排序方式。如果视频具有更高的优先级,它将在源选择中显示得更高。源优先级和质量优先级的总和就是视频优先级。 -\n -\n来源 A:3 -\n质量 B: 7 -\n组合视频优先级为 10。 -\n -\n注意:如果总和为 10 或更多,则加载该链接时播放器将自动跳过加载! + 在这里,您可以更改源的排序方式。如果视频具有更高的优先级,它将在源选择中显示得更高。源优先级和质量优先级的总和就是视频优先级。 \n \n来源 A:3 \n质量 B: 7 \n组合视频优先级为 10。 \n \n注意:如果总和为 10 或更多,则加载该链接时播放器将自动跳过加载! 质量 个人资料背景 PIN @@ -570,14 +557,8 @@ 您已投票 %s已添加到收藏夹 %s已从收藏夹中删除 - 您的资料库中似乎已经存在一个可能相同的项目:\'%s.\' -\n -\n您想添加该项目、替换现有项目还是取消操作? - 在您的资料库中发现了潜在的重复项目: -\n -\n%s -\n -\n您想添加此项目、替换现有项目还是取消操作? + 您的资料库中似乎已经存在一个可能相同的项目:\'%s.\' \n \n您想添加该项目、替换现有项目还是取消操作? + 在您的资料库中发现了潜在的重复项目: \n \n%s \n \n您想添加此项目、替换现有项目还是取消操作? 确认PIN 输入来自 %s 的 PIN 码 锁定个人资料 @@ -597,16 +578,15 @@ 解锁 CloudStream 使用生物识别技术锁定 密码或 PIN 验证 - %s -\n剩余 + %s \n剩余 测试所有扩展 已复制! 访问剪贴板出错,请重试。 应用程序电池使用量已设置为不受限制 有声书 - 媒体 + 媒体 禁用电池最佳化 - 音乐 + 音乐 无法打开 CloudStream 的应用程序信息。 使用指纹、面部 ID、PIN 码、图案和密码解锁应用程序。 此测试仅适用于开发人员,不会验证或否认任何扩展的工作。 @@ -644,18 +624,10 @@ 全不选 删除文件 删除 (%1$d | %2$s) - 您确定要永久删除以下项目吗? -\n -\n%s - 您确定要永久删除 %1$s中的下述剧集吗? -\n -\n%2$s - 您还将永久删除下述系列中的所有剧集: -\n -\n%s - 您确定要永久删除下述系列的所有剧集吗? -\n -\n%s + 您确定要永久删除以下项目吗? \n \n%s + 您确定要永久删除 %1$s中的下述剧集吗? \n \n%2$s + 您还将永久删除下述系列中的所有剧集: \n \n%s + 您确定要永久删除下述系列的所有剧集吗? \n \n%s 发布日期(从新至旧) 无法获取设备 PIN 码,尝试本地身份验证 PIN 码现已过期! @@ -669,8 +641,8 @@ 这是个 Torrent 的视频,这意味着您的视频活动可以被追踪。\n请确认您了解 Torrenting 后,再继续。 备份文件夹位置 边缘大小 - 音频 - 播客 + 音频 + 播客 编码错误 不受支持的错误 加载第一个可用的字幕 @@ -772,4 +744,7 @@ 确定在播放器中如何排列视频源的顺序 已启用额外亮度 显示播放器元数据遮罩层 + 视频 + 预览 + 播放中 diff --git a/app/src/main/res/values-be/strings.xml b/app/src/main/res/values-be/strings.xml index ee2c24972b8..3d9200faf8c 100644 --- a/app/src/main/res/values-be/strings.xml +++ b/app/src/main/res/values-be/strings.xml @@ -34,7 +34,7 @@ Шукаць па %s… Распазнаванне маўлення недаступна Пачніце размаўляць… - Няма даных + Няма дадзеных Больш параметраў Наступная серыя Жанры @@ -128,7 +128,7 @@ \@string/home_play Для карэктнай працы гэтага пастаўшчыка можа спатрэбіцца VPN Гэты пастаўшчык — Torrent, рэкамендуецца VPN - Вэб-сайт не пастаўляе метаданых, загрузіць відэа не ўдасца, калі на сайце яго няма. + Вэб-сайт не пастаўляе метададзеных, загрузіць відэа не ўдасца, калі на сайце яго няма. Апісанне Сюжэту не знойдзена Апісання не знойдзена @@ -159,12 +159,12 @@ Выкарыстоўваць сістэмную яркасць у прайгравальніку замест цёмнага накладання Абнаўляць працэс прагляду Аўтаматычна сінхранізаваць прагрэс бягучай серыі - Аднавіць даныя з рэзервовай копіі - Рэзервовае капіраванне даных + Аднавіць дадзеныя з рэзервовай копіі + Рэзервовае капіраванне дадзеных Частата рэзервовага капіравання Загружаны файл рэзервовай копіі - Не ўдалося аднавіць даныя з файла %s - Даныя захаваны + Не ўдалося аднавіць дадзеныя з файла %s + Дадзеныя захаваны Няма дазволу да сховішча. Паспрабуйце ізноў. Памылка пры рэзервовым капіраванні %s Пошук @@ -247,7 +247,7 @@ Сціслы агляд у чарзе Субцітраў няма - Прадвызначанае + Прадвызначаны Свабодна Ужыта Праграма @@ -273,11 +273,11 @@ Прамая трансляцыя NSFW Відэа - Музыка + Музыка Аўдыякніга - Медыя - Аўдыя - Падкаст + Медыя + Аўдыя + Падкаст Памылка крыніцы Памылка аддаленага элемента Памылка паказу @@ -315,7 +315,7 @@ Прапусціць гэта абнаўленне Абнавіць Прыярытэтная якасць прагляду (WiFi) - Прыярытэтная якасць прагляду (Мабільная перадача даных) + Прыярытэтная якасць прагляду (Мабільная перадача дадзеных) Максімальная колькасць сімвалаў у загалоўку праглядальніка Паказваць інфармацыю ў прайгравальніку Памер буфера відэа @@ -458,7 +458,7 @@ Раздзяляльнасць Звесткі пра медыя Памылковы ID - Памылковыя даныя + Памылковыя дадзеныя Памылковы URL-адрэс Памылка Прыбраць схаваныя цітры з субцітраў @@ -480,7 +480,7 @@ Назва рэпазіторыя (неабавязкова) URL-адрас рэпазіторыя або кароткі код Убудова загружана - Убудава спампавана + Убудова спампавана Убудова выдалена Не ўдалося загрузіць %s 18+ @@ -499,7 +499,7 @@ Спампавана: %d Выключана: %d Не спампавана: %d - Абноўлена %d плагіна(ў) + Абноўлена %d убудоў Прадвызначана на CloudStream няма ўсталяваных вэб-сайтаў. Вам трэба ўсталяваць вэб-сайты з рэпазіторыяў. \n \nДалучыцеся да нашага сервера Discord або пашукайце ў сетцы. Праглядзець рэпазіторыі ад супольнасці Публічны спіс @@ -537,7 +537,7 @@ Зводка Змешанае заканчэнне Змешаны опенінг - Удзельнікі + Удзельнікі Застаўка Ачысціць гісторыю Гісторыя @@ -595,7 +595,7 @@ Адпісацца Профіль %d Wi-Fi - Мабільная перадача даных + Мабільная перадача дадзеных Выбраць як прадвызначаны Выкарыстоўваць Рэдагаваць @@ -643,7 +643,7 @@ Праверка сапраўднасці біяметрыяй не падтрымліваецца на гэтай прыладзе Разблакіруйце праграму адбіткам пальца, Face ID, PIN-кодам, узорам разблакіроўкі або паролем. Праз некалькі няўдалых спроб акно з запытам закрыецца. Проста перазапусціце праграму, каб паўтарыць спробу. - Вашы даныя CloudStream былі зарэзерваваныя. Нягледзячы на тое, што магчымасць вельмі маленькая, усе прылады могуць паводзіць сябе па-рознаму. У рэдкасным выпадку, калі вы страціце доступ да праграмы, поўнасцю ачысціце даныя і аднавіце іх праз рэзервовую копію. Выбачайце за любую нязручнасць, якая можа з гэтага атрымацца. + Вашы дадзеныя CloudStream былібылі толькі што зарэзерваваныя. Нягледзячы на тое, што магчымасць вельмі маленькая, усе прылады могуць паводзіць сябе па-рознаму. У рэдкасным выпадку, калі вы страціце доступ да праграмы, поўнасцю ачысціце даныя і аднавіце іх праз рэзервовую копію. Выбачайце за любую нязручнасць, якая можа з гэтага атрымацца. Скінуць CloudStream-Вікі Наведайце %s на вашым смартфоне або камп\'ютары і ўвядзіце код вышэй @@ -735,5 +735,8 @@ %d спампоўванняў у чарзе %d спампоўванняў у чарзе - Паказваць накладанне з метаданымі ў прайгравальніку + Паказваць накладанне з метададзенымі ў прайгравальніку + Відэа + Перадпрагляд + Ужывую diff --git a/app/src/main/res/values-ca/strings.xml b/app/src/main/res/values-ca/strings.xml index 10f61e40e8c..495eb5e3c0b 100644 --- a/app/src/main/res/values-ca/strings.xml +++ b/app/src/main/res/values-ca/strings.xml @@ -136,9 +136,9 @@ Continua la reproducció en una finestra en miniatura per sobre de altres aplicacions Botó de canvi de mida del reproductor Audiollibre - Mitjà - Àudio - Pòdcast + Mitjà + Àudio + Pòdcast Error a l\'origen Error remot Error de renderitzat diff --git a/app/src/main/res/values-sq/strings.xml b/app/src/main/res/values-sq/strings.xml index 977fd597a32..a13f87bfab3 100644 --- a/app/src/main/res/values-sq/strings.xml +++ b/app/src/main/res/values-sq/strings.xml @@ -75,7 +75,7 @@ E disponueshme për shikim offline Zgjidh të gjitha Çzgjidh të gjitha - Update-i filloi + Përditësimi filloi Transmetim në rrjet Hap video lokale Gabim gjatë ngarkimit të lidhjeve @@ -83,7 +83,7 @@ Memorie e brendshme Dublim Titra - Fshi Skedarin + Fshi skedarin Luaj skedarin Rifillo shkarkimin Ndalo shkarkimin @@ -224,8 +224,8 @@ E Nuk u gjet asnjë episod Fshij - Fshij skedarin - Fshij skedarët + Fshi skedarin + Fshi skedarët Fshij (%1$d | %2$s) Anulo Ndalo @@ -279,11 +279,11 @@ Transmetim live NSFW Video - Muzikë + Muzikë Libër audio - Media - Audio - Podkast + Media + Audio + Podkast Gabim në burim Gabim në distancë Gabim në renderer @@ -309,7 +309,7 @@ Teksti i episodit Zgjidh elementët e ndërfaqes mbi poster Nuk u gjet asnjë përditësim - Kontrollo për përditësim + Kontrollo për përditësime Çelës Ndrysho madhësinë Burimi @@ -494,4 +494,245 @@ Të gjitha %s janë shkarkuar tashmë Asnjë shtesë nuk u gjet në repository Repository nuk u gjet, verifiko URL-në ose provoje me VPN + Të gjitha shtesat u ndaluan për shkak të një gabimi për të ndihmuar në gjetjen e shkaktarit. + Lista publike + CloudStream nuk vjen me faqe të instaluara. Duhet ti shtosh faqet nga repository-t.\n\nNa u bashko në Discord ose kërko në internet. + Autorët + shtesë + Titrat vetëm me shkronja të mëdha + Luajtësi i preferuar i videove + Përshkrimi + Shiko repository-t e komunitetit + Modaliteti i sigurt është aktive + Të pa shkarkuara: %d + Instalo shtesën para + Rifillo aplikacionin për të parë ndryshimet. + Çaktivizuar: %d + Pauzë + Gjurmë + Shkarkimi në grup përfundoi + Kjo do të heqë të gjitha shtesat + Gjuha + Rifillo + Fshi shtesën + Madhësia + Shiko të dhënat e gabimit + Të suportuara + Shkarkuar: %d + Gjurmë video-je + U përditësuan %d shtesa + Lista e HLS + shtesa + Vlerësimi: %s + Paralajmërim: CloudStream nuk mban asnjë përgjegjësi për përdorimin e shtesave të palëve të treta dhe nuk ofron asnjë mbështetje për to! + Statusi + Fshi repository-n + Versioni + %s (Çaktivizuar) + Shkarko listën e faqeve që dëshiron të përdorësh + Gjurmë audio-je + Video + Luajtësi i integruar + Pyet gjithmonë + Zgjidh pajisjen për transmetim + Aplikacioni nuk u gjet + Të gjitha gjuhët + Kalo %s + Hapjen + Mbylljen + Përmbledhjen + Mbylljen e përzier + Hapjen e përzier + Kreditet + Parashikimin + Intron + Pastro historikun + Historia + Shfaq butonat për kapërcimin e hyrjes/mbylljes + Teksti është shumë i gjatë. Nuk mund të ruhet në Clipboard. + Gabim gjatë aksesimit të Clipboard-it. Ju lutem provoni përsëri. + Kopjimi dështoi. Kopjo logcat-in dhe kontakto support-in e aplikacionit. + Shënoje si e përfunduar + Hiqe nga të shikuarat + Jeni i sigurt që dëshironi të dilni? + Po + Jo + Në rregull + Largo + Hap repository-n + Çaktivizo kursimin e baterisë + Për të siguruar shkarkime të pandërprera dhe njoftime për serialet e abonuar, CloudStream ka nevojë për leje për të funksionuar në sfond. Duke shtypur OK, do t’ju shfaqet një dialog kërkese. Ju lutem shtypni “Lejo”.\n\nJu lutemi vini re: kjo leje nuk do të thotë që CS3 do të harxhojë baterinë tuaj. Do të funksionojë në sfond vetëm kur është e nevojshme, si p.sh. kur merr njoftime ose shkarkon video nga shtesat zyrtare. + Përdorimi i baterisë është i vendosur si i pakufizuar + Informacioni i aplikacionit CloudStream nuk mund të hapet. + Po shkarkohet përditësimi i aplikacionit… + Po instalohet përditësimi i aplikacionit… + Instalimi i versionit të ri dështoi + Version i vjetër + Instaluesi i paketës + Aplikacioni do të përditësohet sapo të mbyllet + Rendit nga + Rendit + Vlerësimi (nga më i larti te më i ulëti) + Vlerësimi (nga më i ulëti te më i larti) + Përditësuar (nga më i riu te më i vjetri) + Përditësuar (nga më i vjetri te më i riu) + Alfabetike (A–Z) + Alfabetike (Z–A) + Episodet (rritëse) + Episodet (zbritëse) + Vlerësimi (më i larti) + Vlerësimi (më i ulëti) + Data e publikimit (më e fundit) + Data e publikimit (më e hershmja) + Ep %s + Vlerësimi %s + Data %s + Hape me + Kjo listë është bosh. Provo një tjetër. + U gjet skedari i modalitetit të sigurt!\nNuk do të ngarkohen asnjë shtesë gjatë nisjes derisa skedari të hiqet. + Riktheje në gjendjen e mëparshme + Duke përditësuar serialet që ndiqni + I abonuar + I abonuar në %s + U çabonove nga %s + Episodi %d u publikua! + Abonohu + Çabonohu + Profili %d + Wi-Fi + Mobile data + Vendose si parazgjedhje + Përdor + Ndrysho + Prioriteti i burimit + Vendos si duhet të renditen burimet e videos në luajtës + Profilet + Ndihmë + Këtu mund të ndryshoni si renditen burimet. Nëse një video ka prioritet më të lartë, do të shfaqet më lart në përzgjedhjen e burimeve. Shuma e prioritetit të burimit dhe prioritetit të cilësisë është prioriteti i videos.\n\nBurimi A: 3\nCilësia B: 7\nDo të ketë një prioritet të kombinuar video-je prej 10.\n\nSHËNIM: Nëse shuma është 10 ose më shumë, luajtësi do të kapërcejë automatikisht ngarkimin kur të hapet ai link! + Cilësitë + Sfondi i profilit + UI nuk mundi të krijohej siç duhet, ky është një PROBLEM SERIOZ dhe duhet raportuar menjëherë %s + Ju keni votuar + Të preferuarat + %s u shtua te të preferuarat + %s u hoq nga të preferuarat + Shto te të preferuarat + Hiqe nga të preferuarat + U gjet dublikatë e mundshme + Shto + Zëvendëso + Zëvendëso të gjitha + Duket se një artikull i mundshëm i dyfishuar tashmë ekziston në bibliotekën tuaj: ‘%s’.\n\nDëshironi ta shtoni gjithsesi, ta zëvendësoni atë ekzistuesin, apo ta anuloni veprimin? + Janë gjetur artikuj të mundshëm të dyfishtë në bibliotekën tuaj:\n\n%s\n\nDëshironi ta shtoni gjithsesi, të zëvendësoni ato ekzistuesit, apo të anuloni veprimin? + Fut PIN-in + Fut PIN-in për %s + Fut PIN-in aktual + Kyç profilin + PIN + PIN-i i gabuar. Ju lutem provoni përsëri. + PIN-i duhet të ketë 4 karaktere + Zgjidh një Llogari + Asnjë llogari + Menaxho Llogaritë + Ndrysho llogarinë + I identifikuar si %s + Kapërce përzgjedhjen e llogarisë gjatë nisjes + Përdor llogarinë e paracaktuar + Rrotullo + Shfaq një buton për ndryshimin e orientimit të ekranit + Aktivizo rrotullimin automatik të ekranit sipas orientimit të videos + Rrotullim automatik + I preferuar + Hiq nga të preferuarat + Hap CloudStream-in + Kyç me biometrikë + Verifikim me fjalëkalim/PIN + Biometrikat nuk janë të disponueshme në këtë pajisje + Hap aplikacionin me gjurmë gishti, Face ID, PIN, Pattern ose fjalëkalim. + Pas disa përpjekjesh të dështuara, rifillo aplikacionin për ta provuar përsëri. + Të dhënat tuaja të CloudStream janë kopjuar tani. Edhe pse mundësia është shumë e ulët, disa pajisje mund të sillen ndryshe. Në raste të rralla, nëse bllokoheni nga hyrja në aplikacion, fshini plotësisht të dhënat e aplikacionit dhe rikthejini ato nga kopja rezervë. Na vjen shumë keq për çdo shqetësim që mund të shkaktohet nga kjo. + Rivendos + CloudStream Wiki + Vizitoni %s në telefonin ose kompjuterin tuaj dhe futni kodin e mësipërm + Nuk u mor kodi PIN i pajisjes, provo verifikim lokal + PIN-i ka skaduar! + Kodi skadon pas %1$dm %2$ds + Data e publikimit (nga më e reja te më e vjetra) + Data e publikimit (nga më e vjetra te më e reja) + Fshih emrat e kontrolleve të luajtësit + Shiriti i kërkimit + Aktivizo miniaturën e pamjes paraprake në shiritin kërkues + Nuk ka titra të ngarkuara ende + Vendndodhja e dosjes së kopjes rezervë + I personalizuar + Konfirmo para daljes + Shfaq dialogun para daljes nga aplikacioni + Shfaqe + Mos e shfaq + Madhësia e konturit + Aktivizo torrent-in në Cilësimet/Ofruesit/Media e preferuar + Rifillo aplikacionin dhe prano dritaren “Stream Torrent” për të vazhduar. + Dekodim me softuer + Dekodimi me softuer i lejon player-it të luajë video që nuk mbështeten nga pajisja juaj, por mund të shkaktojë vonesa ose paqëndrueshmëri në rezolucione të larta. + Volumi është mbi 100% + Rrëshqit lart përsëri për të kaluar mbi 100% + Përditëso shtesat + Përditëso shtesat manualisht + Filloi procesi i përditësimit të shtesave! + %d Shtes(at) u përditësuan me sukses! + Nuk u përditësua asnjë shtesë. + Njoftimet e luajtësit + Njoftimi për kontrollin e luajtjes nga sfondi + I integruar + Online + Bëji të gjitha titrat me shkronja të trasha + Bëji të gjitha titrat me shkronja të pjerrëta + Rrezja e sfondit + Sa elementë të ndryshëm mund të shkarkohen njëkohësisht + Shkarkimet paralele + Lidhje paralele + Numri i lidhjeve paralele për çdo shkarkim + Shko tek Shkarkimet + Nuk ka lidhje interneti.\n\nJu lutemi lidhuni me internetin dhe provojeni përsëri, ose shikoni shkarkimet tuaja offline. + Ndryshon kufijtë e ekranit + Tejskenim + Ndryshon madhësinë e posterave + Madhësia e posterit + Ndërrimi i shpejtësisë me shtypje të gjatë + Mbaj shtypur për shpejtësi 2x + Ndrysho foton e profilit + Fut URL-n e fotos së profilit + Nuk u gjet asnjë URL + URL ose imazh i pavlefshëm + Imazhi u përditësua me sukses + Shëno si të parë deri në këtë episod + Hiq të parët deri në këtë episod + U ringarkuan + Ringarko ofruesin + Emri + Emri i burimit + Rezolucioni dhe emri + Shkarko të gjitha + Anulo të gjitha + Dëshiron të shkarkosh episodin %s? + Dëshiron të anulosh të gjitha shkarkimet në radhë? + Pozicionimi i titrave + Poshtë majtas + Poshtë në mes + Poshtë djathtas + Në mes majtas + Në mes në qendër + Në mes djathtas + Lart majtas + Lart në mes + Lart djathtas + + %d shkarkim aktiv + %d shkarkime aktive + + + %d shkarkim në radhë + %d shkarkime në radhë + + Live diff --git a/app/src/main/res/values/donottranslate-strings.xml b/app/src/main/res/values/donottranslate-strings.xml index 0b7aab4cb7b..6a4c8271341 100644 --- a/app/src/main/res/values/donottranslate-strings.xml +++ b/app/src/main/res/values/donottranslate-strings.xml @@ -100,6 +100,7 @@ kitsu_key opensubtitles_key subdl_key + animeskip_key pref_category_security_key pref_category_gestures_key diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index b7956e9d82a..31cf951cf5f 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -288,11 +288,12 @@ Livestream NSFW Video - Music + Music Audio Book - Media - Audio - Podcast + Media + Audio + Podcast + Video Source error Remote error Renderer error @@ -559,7 +560,7 @@ Recap Mixed ending Mixed opening - Credits + Credits Preview Intro Clear history @@ -781,4 +782,6 @@ %d download queued %d downloads queued + Live + diff --git a/app/src/main/res/xml/settings_account.xml b/app/src/main/res/xml/settings_account.xml index 3b8ce22948b..58009031846 100644 --- a/app/src/main/res/xml/settings_account.xml +++ b/app/src/main/res/xml/settings_account.xml @@ -28,6 +28,9 @@ + () main { - // Useful for debugging - WebView.setWebContentsDebuggingEnabled(true) try { webView = WebView( (getContext() as? Context) @@ -152,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) } } 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 48a709eb4ed..048e7fc0237 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/MainAPI.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/MainAPI.kt index 81eabbe77f8..c590165a1ad 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/MainAPI.kt +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/MainAPI.kt @@ -55,6 +55,13 @@ annotation class Prerelease ) annotation class InternalAPI +@Retention(AnnotationRetention.BINARY) // This is only an IDE hint, and will not be used in the runtime +@RequiresOptIn( + message = "Only use this if you know what you are doing and you need to bypass the SSL certificate checks. Never use this for sensitive network requests such as logins.", + level = RequiresOptIn.Level.WARNING +) +annotation class UnsafeSSL + /** * Defines the constant for the all languages preference, if this is set then it is * the equivalent of all languages being set @@ -1057,6 +1064,8 @@ enum class TvType(value: Int?) { Audio(16), Podcast(17), + @Prerelease + Video(18), } enum class AutoDownloadMode(val value: Int) { @@ -1072,14 +1081,15 @@ enum class AutoDownloadMode(val value: Int) { } /** Extension function of [TvType] to check if the type is Movie. - * @return If the type is AnimeMovie, Live, Movie, Torrent returns true otherwise returns false. + * @return If the type is AnimeMovie, Live, Movie, Torrent, Video returns true otherwise returns false. * */ fun TvType.isMovieType(): Boolean { return when (this) { TvType.AnimeMovie, TvType.Live, TvType.Movie, - TvType.Torrent -> true + TvType.Torrent, + TvType.Video -> true else -> false } @@ -2155,6 +2165,7 @@ fun TvType.getFolderPrefix(): String { TvType.Podcast -> "Podcasts" TvType.Torrent -> "Torrents" TvType.TvSeries -> "TVSeries" + TvType.Video -> "Videos" } } diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/MainActivity.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/MainActivity.kt index 6502cc83166..4b163867de3 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/MainActivity.kt +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/MainActivity.kt @@ -8,8 +8,7 @@ import com.lagradost.nicehttp.ResponseParser import kotlin.reflect.KClass // Short name for requests client to make it nicer to use - -var app = Requests(responseParser = object : ResponseParser { +private val jacksonResponseParser = object : ResponseParser { val mapper: ObjectMapper = jacksonObjectMapper().configure( DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false @@ -30,6 +29,18 @@ var app = Requests(responseParser = object : ResponseParser { override fun writeValueAsString(obj: Any): String { return mapper.writeValueAsString(obj) } -}).apply { +} + +/** The default networking helper. This helper performs SSL checks. + * If you need to make requests to websites with invalid SSL certificates use insecureApp instead. */ +var app = Requests(responseParser = jacksonResponseParser).apply { + defaultHeaders = mapOf("user-agent" to USER_AGENT) +} + +/** Same as the default app networking helper, but this instance ignores SSL certificates. + * This should NEVER be used for sensitive networking operations such as logins. Only use this when required. */ +@Prerelease +@UnsafeSSL +var insecureApp = Requests(responseParser = jacksonResponseParser).apply { defaultHeaders = mapOf("user-agent" to USER_AGENT) } \ No newline at end of file diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Bigwarp.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Bigwarp.kt deleted file mode 100644 index 50a68c62f27..00000000000 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Bigwarp.kt +++ /dev/null @@ -1,51 +0,0 @@ -package com.lagradost.cloudstream3.extractors - -import com.lagradost.cloudstream3.SubtitleFile -import com.lagradost.cloudstream3.app -import com.lagradost.cloudstream3.utils.ExtractorApi -import com.lagradost.cloudstream3.utils.ExtractorLink -import com.lagradost.cloudstream3.utils.Qualities -import com.lagradost.cloudstream3.utils.newExtractorLink - -open class BigwarpIO : ExtractorApi() { - override var name = "Bigwarp" - override var mainUrl = "https://bigwarp.io" - override val requiresReferer = false - - private val sourceRegex = Regex("""file:\s*['"](.*?)['"],label:\s*['"](.*?)['"]""") - private val qualityRegex = Regex("""\d+x(\d+) .*""") - - override suspend fun getUrl( - url: String, - referer: String?, - subtitleCallback: (SubtitleFile) -> Unit, - callback: (ExtractorLink) -> Unit - ) { - val resp = app.get(url).text - - for (sourceMatch in sourceRegex.findAll(resp)) { - val label = sourceMatch.groupValues[2] - - callback.invoke( - newExtractorLink( - name, - "$name ${label.split(" ", limit = 2).getOrNull(1)}", - sourceMatch.groupValues[1], // streams are usually in mp4 format - ) { - this.referer = url - this.quality = - qualityRegex.find(label)?.groupValues?.getOrNull(1)?.toIntOrNull() - ?: Qualities.Unknown.value - } - ) - } - } -} - -class BgwpCC : BigwarpIO() { - override var mainUrl = "https://bgwp.cc" -} - -class BigwarpArt : BigwarpIO() { - override var mainUrl = "https://bigwarp.art" -} diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Fastream.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Fastream.kt index e8f8c49aca0..94ddaf61e0e 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Fastream.kt +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Fastream.kt @@ -1,54 +1,44 @@ package com.lagradost.cloudstream3.extractors +import com.lagradost.cloudstream3.SubtitleFile import com.lagradost.cloudstream3.amap import com.lagradost.cloudstream3.app +import com.lagradost.cloudstream3.extractors.helper.JwPlayerHelper import com.lagradost.cloudstream3.utils.ExtractorApi import com.lagradost.cloudstream3.utils.ExtractorLink -import com.lagradost.cloudstream3.utils.M3u8Helper.Companion.generateM3u8 import com.lagradost.cloudstream3.utils.getAndUnpack -import org.jsoup.nodes.Document +import com.lagradost.cloudstream3.utils.getPacked -open class Fastream: ExtractorApi() { +open class Fastream : ExtractorApi() { override var mainUrl = "https://fastream.to" override var name = "Fastream" override val requiresReferer = false - suspend fun getstream( - response: Document, - sources: ArrayList): Boolean{ - response.select("script").amap { script -> - if (script.data().contains(Regex("eval\\(function\\(p,a,c,k,e,[rd]"))) { - val unpacked = getAndUnpack(script.data()) - //val m3u8regex = Regex("((https:|http:)\\/\\/.*\\.m3u8)") - val newm3u8link = unpacked.substringAfter("file:\"").substringBefore("\"") - //val m3u8link = m3u8regex.find(unpacked)?.value ?: return@forEach - generateM3u8( - name, - newm3u8link, - mainUrl - ).forEach { link -> - sources.add(link) - } - } - } - return true - } - override suspend fun getUrl(url: String, referer: String?): List { - val sources = ArrayList() + override suspend fun getUrl( + url: String, + referer: String?, + subtitleCallback: (SubtitleFile) -> Unit, + callback: (ExtractorLink) -> Unit + ) { val idregex = Regex("emb.html\\?(.*)=") - if (url.contains(Regex("(emb.html.*fastream)"))) { + val response = if (url.contains(Regex("(emb.html.*fastream)"))) { val id = idregex.find(url)?.destructured?.component1() ?: "" - val response = app.post("https://fastream.to/dl", allowRedirects = false, + app.post( + "$mainUrl/dl", allowRedirects = false, data = mapOf( "op" to "embed", "file_code" to id, "auto" to "1" ) ).document - getstream(response, sources) + } else { + app.get(url, referer = url).document + } + response.select("script").amap { script -> + if (getPacked(script.data()) != null) { + val unPacked = getAndUnpack(script.data()) + JwPlayerHelper.extractStreamLinks(unPacked, name, mainUrl, callback, subtitleCallback) + } } - val response = app.get(url, referer = url).document - getstream(response, sources) - return sources } } \ No newline at end of file diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Filegram.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Filegram.kt new file mode 100644 index 00000000000..7756f729087 --- /dev/null +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Filegram.kt @@ -0,0 +1,52 @@ +package com.lagradost.cloudstream3.extractors + +import com.lagradost.cloudstream3.SubtitleFile +import com.lagradost.cloudstream3.USER_AGENT +import com.lagradost.cloudstream3.app +import com.lagradost.cloudstream3.extractors.helper.JwPlayerHelper +import com.lagradost.cloudstream3.utils.ExtractorApi +import com.lagradost.cloudstream3.utils.ExtractorLink +import com.lagradost.cloudstream3.utils.getAndUnpack +import org.jsoup.nodes.Element + +open class Filegram : ExtractorApi() { + override val name = "Filegram" + override val mainUrl = "https://filegram.to" + override val requiresReferer = true + + override suspend fun getUrl( + url: String, + referer: String?, + subtitleCallback: (SubtitleFile) -> Unit, + callback: (ExtractorLink) -> Unit + ) { + val header = mapOf( + "Accept" to "*/*", + "Accept-language" to "en-US,en;q=0.9", + "Origin" to mainUrl, + "Accept-Encoding" to "gzip, deflate, br, zstd", + "Connection" to "keep-alive", + "Sec-Fetch-Dest" to "empty", + "Sec-Fetch-Mode" to "cors", + "Sec-Fetch-Site" to "same-site", + "user-agent" to USER_AGENT, + ) + + val doc = app.get(getEmbedUrl(url), referer = referer).document + val unpackedJs = unpackJs(doc).toString() + + JwPlayerHelper.extractStreamLinks(unpackedJs, name, mainUrl, callback, subtitleCallback, headers = header) + } + + private fun unpackJs(script: Element): String? { + return script.select("script").find { it.data().contains("eval(function(p,a,c,k,e,d)") } + ?.data()?.let { getAndUnpack(it) } + } + + private fun getEmbedUrl(url: String): String { + return if (!url.contains("/embed-")) { + val videoId = url.substringAfter("$mainUrl/") + "$mainUrl/embed-$videoId" + } else url + } +} diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Filemoon.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Filemoon.kt index 6c10a92d935..ad4def1defc 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Filemoon.kt +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Filemoon.kt @@ -3,6 +3,7 @@ package com.lagradost.cloudstream3.extractors import com.lagradost.api.Log import com.lagradost.cloudstream3.SubtitleFile import com.lagradost.cloudstream3.app +import com.lagradost.cloudstream3.extractors.helper.JwPlayerHelper import com.lagradost.cloudstream3.network.WebViewResolver import com.lagradost.cloudstream3.utils.ExtractorApi import com.lagradost.cloudstream3.utils.ExtractorLink @@ -54,18 +55,16 @@ open class FilemoonV2 : ExtractorApi() { ?.data().orEmpty() val unpackedScript = JsUnpacker(fallbackScriptData).unpack() - val videoUrl = unpackedScript?.let { - Regex("""sources:\[\{file:"(.*?)"""").find(it)?.groupValues?.get(1) - } + val linkFound = JwPlayerHelper.extractStreamLinks( + unpackedScript.orEmpty(), + name, + mainUrl, + callback, + subtitleCallback, + defaultHeaders + ) - if (!videoUrl.isNullOrEmpty()) { - M3u8Helper.generateM3u8( - name, - videoUrl, - mainUrl, - headers = defaultHeaders - ).forEach(callback) - } else { + if (!linkFound) { Log.d("FilemoonV2", "No iframe and no video URL found in script fallback.") } return @@ -81,18 +80,15 @@ open class FilemoonV2 : ExtractorApi() { val unpackedScript = JsUnpacker(iframeScriptData).unpack() - val videoUrl = unpackedScript?.let { - Regex("""sources:\[\{file:"(.*?)"""").find(it)?.groupValues?.get(1) - } + val linkFound = JwPlayerHelper.extractStreamLinks( + unpackedScript.orEmpty(), + name, + mainUrl, + callback, + subtitleCallback + ) - if (!videoUrl.isNullOrEmpty()) { - M3u8Helper.generateM3u8( - name, - videoUrl, - mainUrl, - headers = defaultHeaders - ).forEach(callback) - } else { + if (!linkFound) { // Last-resort fallback using WebView interception val resolver = WebViewResolver( interceptUrl = Regex("""(m3u8|master\.txt)"""), diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Filesim.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Filesim.kt index 8c0cbec3252..51e127e3fef 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Filesim.kt +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Filesim.kt @@ -4,6 +4,7 @@ import com.lagradost.cloudstream3.SubtitleFile import com.lagradost.cloudstream3.app import com.lagradost.cloudstream3.utils.* import com.lagradost.api.Log +import com.lagradost.cloudstream3.extractors.helper.JwPlayerHelper import com.lagradost.cloudstream3.network.WebViewResolver class Multimoviesshg : Filesim() { @@ -78,17 +79,9 @@ open class Filesim : ExtractorApi() { pageResponse.document.selectFirst("script:containsData(sources:)")?.data() } - val m3u8Url = scriptData?.let { - Regex("""file:\s*"(.*?m3u8.*?)"""").find(it)?.groupValues?.getOrNull(1) - } + val linkFound = JwPlayerHelper.extractStreamLinks(scriptData.orEmpty(), name, mainUrl, callback, subtitleCallback) - if (!m3u8Url.isNullOrEmpty()) { - M3u8Helper.generateM3u8( - name, - m3u8Url, - mainUrl - ).forEach(callback) - } else { + if (!linkFound) { // Fallback using WebViewResolver val resolver = WebViewResolver( interceptUrl = Regex("""(m3u8|master\.txt)"""), diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/GamoVideo.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/GamoVideo.kt index 7e00dbf9596..85212e6bb5b 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/GamoVideo.kt +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/GamoVideo.kt @@ -1,7 +1,10 @@ package com.lagradost.cloudstream3.extractors +import com.lagradost.cloudstream3.SubtitleFile import com.lagradost.cloudstream3.app -import com.lagradost.cloudstream3.utils.* +import com.lagradost.cloudstream3.extractors.helper.JwPlayerHelper +import com.lagradost.cloudstream3.utils.ExtractorApi +import com.lagradost.cloudstream3.utils.ExtractorLink open class GamoVideo : ExtractorApi() { @@ -11,21 +14,13 @@ open class GamoVideo : ExtractorApi() { override suspend fun getUrl( url: String, - referer: String? - ): List? { - return app.get(url, referer = referer).document.select("script") - .firstOrNull { it.html().contains("sources:") }!!.html().substringAfter("file: \"") - .substringBefore("\",").let { - listOf( - newExtractorLink( - name, - name, - it, - ) { - this.referer = url - this.quality = Qualities.Unknown.value - } - ) - } + referer: String?, + subtitleCallback: (SubtitleFile) -> Unit, + callback: (ExtractorLink) -> Unit + ) { + app.get(url, referer = referer).document.select("script") + .firstOrNull { JwPlayerHelper.canParseJwScript(it.data()) }!!.let { + JwPlayerHelper.extractStreamLinks(it.data(), name, mainUrl, callback, subtitleCallback) + } } } diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Hxfile.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Hxfile.kt index 8a56783b1f6..8f8a0c0cec2 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Hxfile.kt +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Hxfile.kt @@ -1,9 +1,9 @@ package com.lagradost.cloudstream3.extractors -import com.fasterxml.jackson.annotation.JsonProperty +import com.lagradost.cloudstream3.SubtitleFile import com.lagradost.cloudstream3.app +import com.lagradost.cloudstream3.extractors.helper.JwPlayerHelper import com.lagradost.cloudstream3.utils.* -import com.lagradost.cloudstream3.utils.AppUtils.tryParseJson class Neonime7n : Hxfile() { override val name = "Neonime7n" @@ -39,64 +39,22 @@ open class Hxfile : ExtractorApi() { override val requiresReferer = false open val redirect = true - override suspend fun getUrl(url: String, referer: String?): List? { - val sources = mutableListOf() + override suspend fun getUrl( + url: String, + referer: String?, + subtitleCallback: (SubtitleFile) -> Unit, + callback: (ExtractorLink) -> Unit + ) { val document = app.get(url, allowRedirects = redirect, referer = referer).document with(document) { this.select("script").map { script -> - if (script.data().contains("eval(function(p,a,c,k,e,d)")) { - val data = - getAndUnpack(script.data()).substringAfter("sources:[").substringBefore("]") - tryParseJson>("[$data]")?.map { - sources.add( - newExtractorLink( - name, - name, - it.file, - ) { - this.referer = mainUrl - this.quality = when { - url.contains("hxfile.co") -> getQualityFromName( - Regex("\\d\\.(.*?).mp4").find( - document.select("title").text() - )?.groupValues?.get(1).toString() - ) - else -> getQualityFromName(it.label) - } - } - ) - } - } else if (script.data().contains("\"sources\":[")) { - val data = script.data().substringAfter("\"sources\":[").substringBefore("]") - tryParseJson>("[$data]")?.map { - sources.add( - newExtractorLink( - name, - name, - it.file, - ) { - this.referer = mainUrl - this.quality = when { - it.label?.contains("HD") == true -> Qualities.P720.value - it.label?.contains("SD") == true -> Qualities.P480.value - else -> getQualityFromName(it.label) - } - } - ) - } - } - else { - null + if (getPacked(script.data()) != null) { + val data = getAndUnpack(script.data()) + JwPlayerHelper.extractStreamLinks(data, name, mainUrl, callback, subtitleCallback) + } else if (JwPlayerHelper.canParseJwScript(script.data())) { + JwPlayerHelper.extractStreamLinks(script.data(), name, mainUrl, callback, subtitleCallback) } } } - return sources } - - private data class ResponseSource( - @JsonProperty("file") val file: String, - @JsonProperty("type") val type: String?, - @JsonProperty("label") val label: String? - ) - } diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/JWPlayer.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/JWPlayer.kt index e744fdb3977..324640355c4 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/JWPlayer.kt +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/JWPlayer.kt @@ -1,13 +1,10 @@ package com.lagradost.cloudstream3.extractors -import com.fasterxml.jackson.annotation.JsonProperty +import com.lagradost.cloudstream3.SubtitleFile import com.lagradost.cloudstream3.app -import com.lagradost.cloudstream3.utils.AppUtils.tryParseJson +import com.lagradost.cloudstream3.extractors.helper.JwPlayerHelper import com.lagradost.cloudstream3.utils.ExtractorApi import com.lagradost.cloudstream3.utils.ExtractorLink -import com.lagradost.cloudstream3.utils.Qualities -import com.lagradost.cloudstream3.utils.getQualityFromName -import com.lagradost.cloudstream3.utils.newExtractorLink class Meownime : JWPlayer() { override val name = "Meownime" @@ -34,50 +31,36 @@ class DesuOdvip : JWPlayer() { override val mainUrl = "https://desustream.me/odvip/" } +class VidNest : JWPlayer() { + override var name = "Vidnest" + override var mainUrl = "https://vidnest.io" +} + +open class BigwarpIO : JWPlayer() { + override var name = "Bigwarp" + override var mainUrl = "https://bigwarp.io" +} + +class BgwpCC : BigwarpIO() { + override var mainUrl = "https://bgwp.cc" +} + +class BigwarpArt : BigwarpIO() { + override var mainUrl = "https://bigwarp.art" +} + open class JWPlayer : ExtractorApi() { override val name = "JWPlayer" override val mainUrl = "https://www.jwplayer.com" override val requiresReferer = false - override suspend fun getUrl(url: String, referer: String?): List? { - val sources = mutableListOf() - with(app.get(url).document) { - val data = this.select("script").mapNotNull { script -> - if (script.data().contains("sources: [")) { - script.data().substringAfter("sources: [") - .substringBefore("],").replace("'", "\"") - } else if (script.data().contains("otakudesu('")) { - script.data().substringAfter("otakudesu('") - .substringBefore("');") - } else { - null - } - } - - tryParseJson>("$data")?.map { - sources.add( - newExtractorLink( - name, - name, - it.file, - ) { - this.referer = url - this.quality = getQualityFromName( - Regex("(\\d{3,4}p)").find(it.file)?.groupValues?.get( - 1 - ) - ) - } - ) - } - } - return sources + override suspend fun getUrl( + url: String, + referer: String?, + subtitleCallback: (SubtitleFile) -> Unit, + callback: (ExtractorLink) -> Unit + ) { + val script = app.get(url).document.selectFirst("script:containsData(sources:)") ?: return + JwPlayerHelper.extractStreamLinks(script.data(), name, mainUrl, callback, subtitleCallback) } - - private data class ResponseSource( - @JsonProperty("file") val file: String, - @JsonProperty("type") val type: String?, - @JsonProperty("label") val label: String? - ) - } \ No newline at end of file diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Jeniusplay.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Jeniusplay.kt index f64863a9f3a..896228b5110 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Jeniusplay.kt +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Jeniusplay.kt @@ -3,9 +3,12 @@ package com.lagradost.cloudstream3.extractors import com.fasterxml.jackson.annotation.JsonProperty import com.lagradost.cloudstream3.SubtitleFile import com.lagradost.cloudstream3.app -import com.lagradost.cloudstream3.newSubtitleFile -import com.lagradost.cloudstream3.utils.* -import com.lagradost.cloudstream3.utils.AppUtils.tryParseJson +import com.lagradost.cloudstream3.extractors.helper.JwPlayerHelper +import com.lagradost.cloudstream3.utils.ExtractorApi +import com.lagradost.cloudstream3.utils.ExtractorLink +import com.lagradost.cloudstream3.utils.M3u8Helper +import com.lagradost.cloudstream3.utils.getAndUnpack +import com.lagradost.cloudstream3.utils.getPacked open class Jeniusplay : ExtractorApi() { override val name = "Jeniusplay" @@ -34,40 +37,17 @@ open class Jeniusplay : ExtractorApi() { url, ).forEach(callback) - document.select("script").map { script -> - if (script.data().contains("eval(function(p,a,c,k,e,d)")) { - val subData = - getAndUnpack(script.data()).substringAfter("\"tracks\":[").substringBefore("],") - tryParseJson>("[$subData]")?.map { subtitle -> - subtitleCallback.invoke( - newSubtitleFile( - getLanguage(subtitle.label ?: ""), - subtitle.file - ) - ) - } + if (getPacked(script.data()) != null) { + val unpacked = getAndUnpack(script.data()) + JwPlayerHelper.extractStreamLinks(unpacked, name, mainUrl, callback, subtitleCallback) } } } - private fun getLanguage(str: String): String { - return when { - str.contains("indonesia", true) || str - .contains("bahasa", true) -> "Indonesian" - else -> str - } - } - data class ResponseSource( @JsonProperty("hls") val hls: Boolean, @JsonProperty("videoSource") val videoSource: String, @JsonProperty("securedLink") val securedLink: String?, ) - - data class Tracks( - @JsonProperty("kind") val kind: String?, - @JsonProperty("file") val file: String, - @JsonProperty("label") val label: String?, - ) } \ No newline at end of file diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/LuluStream.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/LuluStream.kt index c7b6586065f..dec67959410 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/LuluStream.kt +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/LuluStream.kt @@ -1,12 +1,10 @@ package com.lagradost.cloudstream3.extractors import com.lagradost.cloudstream3.SubtitleFile +import com.lagradost.cloudstream3.app +import com.lagradost.cloudstream3.extractors.helper.JwPlayerHelper import com.lagradost.cloudstream3.utils.ExtractorApi import com.lagradost.cloudstream3.utils.ExtractorLink -import com.lagradost.cloudstream3.app -import com.lagradost.cloudstream3.utils.INFER_TYPE -import com.lagradost.cloudstream3.utils.Qualities -import com.lagradost.cloudstream3.utils.newExtractorLink class Luluvdoo : LuluStream() { @@ -47,18 +45,7 @@ open class LuluStream : ExtractorApi() { ).document post.selectFirst("script:containsData(vplayer)")?.data() ?.let { script -> - Regex("file:\"(.*)\"").find(script)?.groupValues?.get(1)?.let { link -> - callback( - newExtractorLink( - name, - name, - link, - ) { - this.referer = mainUrl - this.quality = Qualities.P1080.value - } - ) - } + JwPlayerHelper.extractStreamLinks(script, name, mainUrl, callback, subtitleCallback) } } -} +} \ No newline at end of file diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Minoplres.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Minoplres.kt deleted file mode 100644 index 702501a1e0c..00000000000 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Minoplres.kt +++ /dev/null @@ -1,38 +0,0 @@ -package com.lagradost.cloudstream3.extractors - -import com.fasterxml.jackson.annotation.JsonProperty -import com.lagradost.cloudstream3.app -import com.lagradost.cloudstream3.utils.AppUtils.tryParseJson -import com.lagradost.cloudstream3.utils.ExtractorApi -import com.lagradost.cloudstream3.utils.ExtractorLink -import com.lagradost.cloudstream3.utils.M3u8Helper - -open class Minoplres : ExtractorApi() { - - override val name = "Minoplres" // formerly SpeedoStream - override val requiresReferer = true - override val mainUrl = "https://minoplres.xyz" // formerly speedostream.bond - private val hostUrl = "https://minoplres.xyz" - - override suspend fun getUrl(url: String, referer: String?): List { - val sources = mutableListOf() - app.get(url, referer = referer).document.select("script").map { script -> - if (script.data().contains("jwplayer(\"vplayer\").setup(")) { - val data = script.data().substringAfter("sources: [") - .substringBefore("],").replace("file", "\"file\"").trim() - tryParseJson(data)?.let { - M3u8Helper.generateM3u8( - name, - it.file, - "$hostUrl/", - ).forEach { m3uData -> sources.add(m3uData) } - } - } - } - return sources - } - - private data class File( - @JsonProperty("file") val file: String, - ) -} diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/MultiQuality.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/MultiQuality.kt deleted file mode 100644 index 802d9ea3af6..00000000000 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/MultiQuality.kt +++ /dev/null @@ -1,63 +0,0 @@ -package com.lagradost.cloudstream3.extractors - -import com.lagradost.cloudstream3.app -import com.lagradost.cloudstream3.utils.ExtractorApi -import com.lagradost.cloudstream3.utils.ExtractorLink -import com.lagradost.cloudstream3.utils.ExtractorLinkType -import com.lagradost.cloudstream3.utils.Qualities -import com.lagradost.cloudstream3.utils.getQualityFromName -import com.lagradost.cloudstream3.utils.newExtractorLink -import java.net.URI - -open class MultiQuality : ExtractorApi() { - override var name = "MultiQuality" - override var mainUrl = "https://anihdplay.com" - private val sourceRegex = Regex("""file:\s*['"](.*?)['"],label:\s*['"](.*?)['"]""") - private val m3u8Regex = Regex(""".*?(\d*).m3u8""") - private val urlRegex = Regex("""(.*?)([^/]+$)""") - override val requiresReferer = false - - override fun getExtractorUrl(id: String): String { - return "$mainUrl/loadserver.php?id=$id" - } - - override suspend fun getUrl(url: String, referer: String?): List { - val extractedLinksList: MutableList = mutableListOf() - with(app.get(url)) { - sourceRegex.findAll(this.text).forEach { sourceMatch -> - val extractedUrl = sourceMatch.groupValues[1] - // Trusting this isn't mp4, may fuck up stuff - if (URI(extractedUrl).path.endsWith(".m3u8")) { - with(app.get(extractedUrl)) { - m3u8Regex.findAll(this.text).forEach { match -> - extractedLinksList.add( - newExtractorLink( - source = name, - name = name, - url = urlRegex.find(this.url)!!.groupValues[1] + match.groupValues[0], - type = ExtractorLinkType.M3U8 - ) { - this.referer = url - this.quality = getQualityFromName(match.groupValues[1]) - } - ) - } - - } - } else if (extractedUrl.endsWith(".mp4")) { - extractedLinksList.add( - newExtractorLink( - name, - "$name ${sourceMatch.groupValues[2]}", - extractedUrl, - ) { - this.referer = url.replace(" ", "%20") - this.quality = Qualities.Unknown.value - } - ) - } - } - return extractedLinksList - } - } -} diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Pelisplus.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Pelisplus.kt deleted file mode 100644 index e2588feb63f..00000000000 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Pelisplus.kt +++ /dev/null @@ -1,101 +0,0 @@ -package com.lagradost.cloudstream3.extractors - -import com.lagradost.cloudstream3.SubtitleFile -import com.lagradost.cloudstream3.amap -import com.lagradost.cloudstream3.app -import com.lagradost.cloudstream3.mvvm.safeAsync -import com.lagradost.cloudstream3.utils.ExtractorLink -import com.lagradost.cloudstream3.utils.INFER_TYPE -import com.lagradost.cloudstream3.utils.extractorApis -import com.lagradost.cloudstream3.utils.getQualityFromName -import com.lagradost.cloudstream3.utils.loadExtractor -import com.lagradost.cloudstream3.utils.newExtractorLink -import org.jsoup.Jsoup - -/** - * overrideMainUrl is necessary for for other vidstream clones like vidembed.cc - * If they diverge it'd be better to make them separate. - * */ -open class Pelisplus(val mainUrl: String) { - val name: String = "Vidstream" - - private fun getExtractorUrl(id: String): String { - return "$mainUrl/play?id=$id" - } - - private fun getDownloadUrl(id: String): String { - return "$mainUrl/download?id=$id" - } - - private val normalApis = arrayListOf(MultiQuality()) - - // https://gogo-stream.com/streaming.php?id=MTE3NDg5 - suspend fun getUrl( - id: String, - isCasting: Boolean = false, - subtitleCallback: (SubtitleFile) -> Unit, - callback: (ExtractorLink) -> Unit - ): Boolean { - try { - normalApis.amap { api -> - val url = api.getExtractorUrl(id) - api.getSafeUrl(url, subtitleCallback = subtitleCallback, callback = callback) - } - val extractorUrl = getExtractorUrl(id) - - /** Stolen from GogoanimeProvider.kt extractor */ - safeAsync { - val link = getDownloadUrl(id) - println("Generated vidstream download link: $link") - val page = app.get(link, referer = extractorUrl) - - val pageDoc = Jsoup.parse(page.text) - val qualityRegex = Regex("(\\d+)P") - - //a[download] - pageDoc.select(".dowload > a").amap { element -> - val href = element.attr("href") - val qual = if (element.text() - .contains("HDP") - ) "1080" else qualityRegex.find(element.text())?.destructured?.component1() - .toString() - - if (!loadExtractor(href, link, subtitleCallback, callback)) { - callback.invoke( - newExtractorLink( - this.name, - name = this.name, - href - ) { - this.referer = page.url - this.quality = getQualityFromName(qual) - } - ) - } - } - } - - with(app.get(extractorUrl)) { - val document = Jsoup.parse(this.text) - val primaryLinks = document.select("ul.list-server-items > li.linkserver") - //val extractedLinksList: MutableList = mutableListOf() - - // All vidstream links passed to extractors - primaryLinks.distinctBy { it.attr("data-video") }.forEach { element -> - val link = element.attr("data-video") - //val name = element.text() - - // Matches vidstream links with extractors - extractorApis.filter { !it.requiresReferer || !isCasting }.amap { api -> - if (link.startsWith(api.mainUrl)) { - api.getSafeUrl(link, extractorUrl, subtitleCallback, callback) - } - } - } - return true - } - } catch (e: Exception) { - return false - } - } -} diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/StreamWishExtractor.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/StreamWishExtractor.kt index db883d6afb5..c721db6b95f 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/StreamWishExtractor.kt +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/StreamWishExtractor.kt @@ -4,6 +4,7 @@ import com.lagradost.api.Log import com.lagradost.cloudstream3.SubtitleFile import com.lagradost.cloudstream3.USER_AGENT import com.lagradost.cloudstream3.app +import com.lagradost.cloudstream3.extractors.helper.JwPlayerHelper import com.lagradost.cloudstream3.utils.ExtractorApi import com.lagradost.cloudstream3.utils.ExtractorLink import com.lagradost.cloudstream3.utils.M3u8Helper @@ -12,7 +13,6 @@ import com.lagradost.cloudstream3.utils.getPacked import com.lagradost.cloudstream3.network.WebViewResolver - class Mwish : StreamWishExtractor() { override val name = "Mwish" override val mainUrl = "https://mwish.pro" @@ -180,18 +180,9 @@ open class StreamWishExtractor : ExtractorApi() { else -> pageResponse.document.selectFirst("script:containsData(sources:)")?.data() } - val directStreamUrl = playerScriptData?.let { - Regex("""file:\s*"(.*?m3u8.*?)"""").find(it)?.groupValues?.getOrNull(1) - } + val linkFound = JwPlayerHelper.extractStreamLinks(playerScriptData.orEmpty(), name, mainUrl, callback, subtitleCallback, headers) - if (!directStreamUrl.isNullOrEmpty()) { - M3u8Helper.generateM3u8( - name, - directStreamUrl, - mainUrl, - headers = headers - ).forEach(callback) - } else { + if (!linkFound) { val webViewM3u8Resolver = WebViewResolver( interceptUrl = Regex("""txt|m3u8"""), additionalUrls = listOf(Regex("""txt|m3u8""")), diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/StreamoUpload.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/StreamoUpload.kt index 7fafe05bec7..b7f618e9553 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/StreamoUpload.kt +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/StreamoUpload.kt @@ -1,42 +1,30 @@ package com.lagradost.cloudstream3.extractors -import com.fasterxml.jackson.annotation.JsonProperty +import com.lagradost.cloudstream3.SubtitleFile import com.lagradost.cloudstream3.app -import com.lagradost.cloudstream3.utils.AppUtils.tryParseJson +import com.lagradost.cloudstream3.extractors.helper.JwPlayerHelper import com.lagradost.cloudstream3.utils.ExtractorApi import com.lagradost.cloudstream3.utils.ExtractorLink import com.lagradost.cloudstream3.utils.getAndUnpack -import com.lagradost.cloudstream3.utils.Qualities -import com.lagradost.cloudstream3.utils.M3u8Helper +import com.lagradost.cloudstream3.utils.getPacked open class StreamoUpload : ExtractorApi() { override val name = "StreamoUpload" override val mainUrl = "https://streamoupload.xyz" override val requiresReferer = true - override suspend fun getUrl(url: String, referer: String?): List { - val sources = mutableListOf() + override suspend fun getUrl( + url: String, + referer: String?, + subtitleCallback: (SubtitleFile) -> Unit, + callback: (ExtractorLink) -> Unit + ) { val response = app.get(url, referer = referer) - val scriptElements = response.document.select("script").map { script -> - if (script.data().contains("eval(function(p,a,c,k,e,d)")) { + response.document.select("script").map { script -> + if (getPacked(script.data()) != null) { val data = getAndUnpack(script.data()) - .substringAfter("sources:[") - .substringBefore("],") - .replace("file", "\"file\"") - .trim() - tryParseJson(data)?.let { - M3u8Helper.generateM3u8( - name, - it.file, - "$mainUrl/", - ).forEach { m3uData -> sources.add(m3uData) } - } + JwPlayerHelper.extractStreamLinks(data, name, mainUrl, callback, subtitleCallback) } } - return sources } - - private data class File( - @JsonProperty("file") val file: String, - ) } diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Supervideo.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Supervideo.kt index e70cae6bdf8..5e47dd2decf 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Supervideo.kt +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Supervideo.kt @@ -1,42 +1,27 @@ package com.lagradost.cloudstream3.extractors -import com.fasterxml.jackson.annotation.JsonProperty -import com.lagradost.cloudstream3.utils.ExtractorLink +import com.lagradost.cloudstream3.SubtitleFile import com.lagradost.cloudstream3.app -import com.lagradost.cloudstream3.utils.* -import com.lagradost.cloudstream3.utils.AppUtils.parseJson - -data class Files( - @JsonProperty("file") val id: String, - @JsonProperty("label") val label: String? = null, -) +import com.lagradost.cloudstream3.extractors.helper.JwPlayerHelper +import com.lagradost.cloudstream3.utils.ExtractorApi +import com.lagradost.cloudstream3.utils.ExtractorLink +import com.lagradost.cloudstream3.utils.JsUnpacker open class Supervideo : ExtractorApi() { override var name = "Supervideo" override var mainUrl = "https://supervideo.cc" override val requiresReferer = false - override suspend fun getUrl(url: String, referer: String?): List? { - val extractedLinksList: MutableList = mutableListOf() + + override suspend fun getUrl( + url: String, + referer: String?, + subtitleCallback: (SubtitleFile) -> Unit, + callback: (ExtractorLink) -> Unit + ) { val response = app.get(url).text val jstounpack = Regex("eval((.|\\n)*?)").find(response)?.groups?.get(1)?.value - val unpacjed = JsUnpacker(jstounpack).unpack() - val extractedUrl = - unpacjed?.let { Regex("""sources:((.|\n)*?)image""").find(it) }?.groups?.get(1)?.value.toString() - .replace("file", """"file"""").replace("label", """"label"""") - .substringBeforeLast(",") - val parsedlinks = parseJson>(extractedUrl) - parsedlinks.forEach { data -> - if (data.label.isNullOrBlank()) { // mp4 links (with labels) are slow. Use only m3u8 link. - M3u8Helper.generateM3u8( - name, - data.id, - url, - headers = mapOf("referer" to url) - ).forEach { link -> - extractedLinksList.add(link) - } - } - } - return extractedLinksList + val unpacked = JsUnpacker(jstounpack).unpack() + + JwPlayerHelper.extractStreamLinks(unpacked.orEmpty(), name, mainUrl, callback, subtitleCallback) } } \ No newline at end of file diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Up4Stream.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Up4Stream.kt index 91150992b3a..b72213e66ea 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Up4Stream.kt +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Up4Stream.kt @@ -1,13 +1,13 @@ package com.lagradost.cloudstream3.extractors import com.lagradost.api.Log +import com.lagradost.cloudstream3.SubtitleFile import com.lagradost.cloudstream3.app +import com.lagradost.cloudstream3.extractors.helper.JwPlayerHelper import com.lagradost.cloudstream3.utils.ExtractorApi import com.lagradost.cloudstream3.utils.ExtractorLink import com.lagradost.cloudstream3.utils.JsUnpacker -import com.lagradost.cloudstream3.utils.Qualities import com.lagradost.cloudstream3.utils.fixUrl -import com.lagradost.cloudstream3.utils.newExtractorLink import kotlinx.coroutines.delay class Up4FunTop : Up4Stream() { @@ -19,12 +19,17 @@ open class Up4Stream : ExtractorApi() { override var mainUrl = "https://up4stream.com" override val requiresReferer = true - override suspend fun getUrl(url: String, referer: String?): List? { + override suspend fun getUrl( + url: String, + referer: String?, + subtitleCallback: (SubtitleFile) -> Unit, + callback: (ExtractorLink) -> Unit + ) { val movieId = url.substringAfterLast("/").substringBefore(".html") // redirect from "wait 5 seconds" page to actual movie page val redirectResponse = app.get(url, cookies = mapOf("id" to movieId)) - val redirectForm = redirectResponse.document.selectFirst("form[method=POST]") ?: return null + val redirectForm = redirectResponse.document.selectFirst("form[method=POST]") ?: return val redirectUrl = fixUrl(redirectForm.attr("action")) val redirectParams = redirectForm.select("input[type=hidden]").associate { input -> input.attr("name") to input.attr("value") @@ -42,19 +47,7 @@ open class Up4Stream : ExtractorApi() { } JsUnpacker(extractedpack).unpack()?.let { unPacked -> - Regex("sources:\\[\\{file:\"(.*?)\"").find(unPacked)?.groupValues?.get(1)?.let { link -> - return listOf( - newExtractorLink( - this.name, - this.name, - link, - ) { - this.referer = referer.orEmpty() - this.quality = Qualities.Unknown.value - } - ) - } + JwPlayerHelper.extractStreamLinks(unPacked, name, mainUrl, callback, subtitleCallback) } - return null } } \ No newline at end of file diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/VidHidePro.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/VidHidePro.kt index 469efc5ec96..849b2b6d96b 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/VidHidePro.kt +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/VidHidePro.kt @@ -3,8 +3,11 @@ package com.lagradost.cloudstream3.extractors import com.lagradost.cloudstream3.SubtitleFile import com.lagradost.cloudstream3.USER_AGENT import com.lagradost.cloudstream3.app -import com.lagradost.cloudstream3.utils.* -import com.lagradost.cloudstream3.utils.M3u8Helper.Companion.generateM3u8 +import com.lagradost.cloudstream3.extractors.helper.JwPlayerHelper +import com.lagradost.cloudstream3.utils.ExtractorApi +import com.lagradost.cloudstream3.utils.ExtractorLink +import com.lagradost.cloudstream3.utils.getAndUnpack +import com.lagradost.cloudstream3.utils.getPacked class Ryderjet: VidHidePro() { override var mainUrl = "https://ryderjet.com" @@ -74,24 +77,12 @@ open class VidHidePro : ExtractorApi() { val response = app.get(getEmbedUrl(url), referer = referer) val script = if (!getPacked(response.text).isNullOrEmpty()) { - var result = getAndUnpack(response.text) - if(result.contains("var links")){ - result = result.substringAfter("var links") - } - result + getAndUnpack(response.text) } else { response.document.selectFirst("script:containsData(sources:)")?.data() } ?: return - // m3u8 urls could be prefixed by 'file:', 'hls2:' or 'hls4:', so we just match ':' - Regex(":\\s*\"(.*?m3u8.*?)\"").findAll(script).forEach { m3u8Match -> - generateM3u8( - name, - fixUrl(m3u8Match.groupValues[1]), - referer = "$mainUrl/", - headers = headers - ).forEach(callback) - } + JwPlayerHelper.extractStreamLinks(script, name, mainUrl, callback, subtitleCallback, headers) } private fun getEmbedUrl(url: String): String { diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/VidNest.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/VidNest.kt deleted file mode 100644 index f9d45ebb8b2..00000000000 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/VidNest.kt +++ /dev/null @@ -1,45 +0,0 @@ -package com.lagradost.cloudstream3.extractors - -import com.lagradost.cloudstream3.app -import com.lagradost.cloudstream3.utils.ExtractorApi -import com.lagradost.cloudstream3.utils.ExtractorLink -import com.lagradost.cloudstream3.utils.M3u8Helper -import com.lagradost.cloudstream3.utils.newExtractorLink - -open class VidNest : ExtractorApi() { - override var name = "VidNest" - override var mainUrl = "https://vidnest.io" - override val requiresReferer = true - - private val sourceRegex = Regex("""sources:[\W\w]*?file:\s*?["'](.*?)["']""") - override suspend fun getUrl(url: String, referer: String?): List { - val extractedLinksList: MutableList = mutableListOf() - with(app.get(url, referer = referer)) { - sourceRegex.findAll(this.text).forEach { sourceMatch -> - val extractedUrl = sourceMatch.groupValues[1] - // Trusting this isn't mp4, may fuck up stuff - if (extractedUrl.contains(".m3u8")) { - M3u8Helper.generateM3u8( - name, - extractedUrl, - url, - headers = mapOf("referer" to this.url) - ).forEach { link -> - extractedLinksList.add(link) - } - } else if (extractedUrl.contains(".mp4")) { - extractedLinksList.add( - newExtractorLink( - source = name, - name = name, - url = extractedUrl, - ) { - this.referer = url.replace(" ", "%20") - } - ) - } - } - return extractedLinksList - } - } -} \ No newline at end of file diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Vidmoly.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Vidmoly.kt index bd259b17583..11927c50752 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Vidmoly.kt +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Vidmoly.kt @@ -1,13 +1,11 @@ package com.lagradost.cloudstream3.extractors -import com.fasterxml.jackson.annotation.JsonProperty import com.lagradost.cloudstream3.SubtitleFile import com.lagradost.cloudstream3.USER_AGENT import com.lagradost.cloudstream3.app -import com.lagradost.cloudstream3.newSubtitleFile -import com.lagradost.cloudstream3.utils.* -import com.lagradost.cloudstream3.utils.AppUtils.tryParseJson -import kotlinx.coroutines.delay +import com.lagradost.cloudstream3.extractors.helper.JwPlayerHelper +import com.lagradost.cloudstream3.utils.ExtractorApi +import com.lagradost.cloudstream3.utils.ExtractorLink class Vidmolyme : Vidmoly() { override val mainUrl = "https://vidmoly.me" @@ -26,20 +24,6 @@ open class Vidmoly : ExtractorApi() { override val mainUrl = "https://vidmoly.net" override val requiresReferer = true - private fun String.addMarks(str: String): String { - return this.replace(Regex("\"?$str\"?"), "\"$str\"") - } - - private data class Source( - @JsonProperty("file") val file: String? = null, - ) - - private data class SubSource( - @JsonProperty("file") val file: String? = null, - @JsonProperty("label") val label: String? = null, - @JsonProperty("kind") val kind: String? = null, - ) - override suspend fun getUrl( url: String, referer: String?, @@ -54,34 +38,13 @@ open class Vidmoly : ExtractorApi() { val newUrl = if (url.contains("/w/")) url.replaceFirst("/w/", "/embed-") + ".html" else url + val script = app.get(newUrl, headers = headers, referer = referer) .document.select("script") .firstOrNull { it.data().contains("sources:") } ?.data() + // Extracts and parses videoData - script?.substringAfter("sources: [") - ?.substringBefore("]") - ?.addMarks("file") - ?.replace("'","\"") - ?.let { videoData -> - tryParseJson(videoData)?.file?.let { m3uLink -> - M3u8Helper.generateM3u8(name, m3uLink, "$mainUrl/") - .forEach(callback) - } - } - // Extracts and parses captions - script?.substringAfter("tracks: [") - ?.substringBefore("]") - ?.addMarks("file")?.addMarks("label")?.addMarks("kind") - ?.replace("'","\"") - ?.let { subData -> - tryParseJson>("[$subData]") - ?.filter { it.kind == "captions" } - ?.forEach { - subtitleCallback( - newSubtitleFile(it.label.toString(), fixUrl(it.file.toString())) - ) - } - } + JwPlayerHelper.extractStreamLinks(script.orEmpty(), name, mainUrl, callback, subtitleCallback) } } diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Vidstream.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Vidstream.kt deleted file mode 100644 index ab228ee3c27..00000000000 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Vidstream.kt +++ /dev/null @@ -1,104 +0,0 @@ -package com.lagradost.cloudstream3.extractors - -import com.lagradost.cloudstream3.SubtitleFile -import com.lagradost.cloudstream3.amap -import com.lagradost.cloudstream3.app -import com.lagradost.cloudstream3.runAllAsync -import com.lagradost.cloudstream3.utils.ExtractorLink -import com.lagradost.cloudstream3.utils.INFER_TYPE -import com.lagradost.cloudstream3.utils.extractorApis -import com.lagradost.cloudstream3.utils.getQualityFromName -import com.lagradost.cloudstream3.utils.loadExtractor -import com.lagradost.cloudstream3.utils.newExtractorLink -import org.jsoup.Jsoup - -/** - * overrideMainUrl is necessary for for other vidstream clones like vidembed.cc - * If they diverge it'd be better to make them separate. - * */ -class Vidstream(val mainUrl: String) { - val name: String = "Vidstream" - - private fun getExtractorUrl(id: String): String { - return "$mainUrl/streaming.php?id=$id" - } - - private fun getDownloadUrl(id: String): String { - return "$mainUrl/download?id=$id" - } - - private val normalApis = arrayListOf(MultiQuality()) - - // https://gogo-stream.com/streaming.php?id=MTE3NDg5 - suspend fun getUrl( - id: String, - isCasting: Boolean = false, - subtitleCallback: (SubtitleFile) -> Unit, - callback: (ExtractorLink) -> Unit, - ): Boolean { - val extractorUrl = getExtractorUrl(id) - runAllAsync( - { - normalApis.amap { api -> - val url = api.getExtractorUrl(id) - api.getSafeUrl( - url, - callback = callback, - subtitleCallback = subtitleCallback - ) - } - }, { - /** Stolen from GogoanimeProvider.kt extractor */ - val link = getDownloadUrl(id) - println("Generated vidstream download link: $link") - val page = app.get(link, referer = extractorUrl) - - val pageDoc = Jsoup.parse(page.text) - val qualityRegex = Regex("(\\d+)P") - - //a[download] - pageDoc.select(".dowload > a").amap { element -> - val href = element.attr("href") - val qual = if (element.text() - .contains("HDP") - ) "1080" else qualityRegex.find(element.text())?.destructured?.component1() - .toString() - - if (!loadExtractor(href, link, subtitleCallback, callback)) { - callback.invoke( - newExtractorLink( - this.name, - name = this.name, - href, - type = INFER_TYPE - ) { - this.referer = page.url - this.quality = getQualityFromName(qual) - } - ) - } - } - }, { - with(app.get(extractorUrl)) { - val document = Jsoup.parse(this.text) - val primaryLinks = document.select("ul.list-server-items > li.linkserver") - //val extractedLinksList: MutableList = mutableListOf() - - // All vidstream links passed to extractors - primaryLinks.distinctBy { it.attr("data-video") }.forEach { element -> - val link = element.attr("data-video") - //val name = element.text() - - // Matches vidstream links with extractors - extractorApis.filter { !it.requiresReferer || !isCasting }.amap { api -> - if (link.startsWith(api.mainUrl)) { - api.getSafeUrl(link, extractorUrl, subtitleCallback, callback) - } - } - } - } - } - ) - return true - } -} \ No newline at end of file diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Voe.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Voe.kt index 860f9b540ce..67eb49c9a55 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Voe.kt +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Voe.kt @@ -45,6 +45,10 @@ class Voe1 : Voe() { override val mainUrl = "https://donaldlineelse.com" } +class Voe2 : Voe() { + override val mainUrl = "https://charlestoughrace.com" +} + open class Voe : ExtractorApi() { override val name = "Voe" override val mainUrl = "https://voe.sx" diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Vtbe.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Vtbe.kt index 37b8ecb239e..2fdd7082a34 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Vtbe.kt +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Vtbe.kt @@ -1,15 +1,11 @@ package com.lagradost.cloudstream3.extractors -import com.fasterxml.jackson.annotation.JsonProperty +import com.lagradost.cloudstream3.SubtitleFile import com.lagradost.cloudstream3.app -import com.lagradost.cloudstream3.utils.* -import com.lagradost.cloudstream3.utils.AppUtils.tryParseJson -import com.lagradost.cloudstream3.utils.JsUnpacker +import com.lagradost.cloudstream3.extractors.helper.JwPlayerHelper import com.lagradost.cloudstream3.utils.ExtractorApi import com.lagradost.cloudstream3.utils.ExtractorLink -import com.lagradost.cloudstream3.utils.Qualities -import com.lagradost.cloudstream3.utils.getQualityFromName -import java.net.URI +import com.lagradost.cloudstream3.utils.JsUnpacker open class Vtbe : ExtractorApi() { @@ -17,23 +13,16 @@ open class Vtbe : ExtractorApi() { override var mainUrl = "https://vtbe.to" override val requiresReferer = true - override suspend fun getUrl(url: String, referer: String?): List? { + override suspend fun getUrl( + url: String, + referer: String?, + subtitleCallback: (SubtitleFile) -> Unit, + callback: (ExtractorLink) -> Unit + ) { val response = app.get(url,referer=mainUrl).document - val extractedpack =response.selectFirst("script:containsData(function(p,a,c,k,e,d))")?.data().toString() + val extractedpack = response.selectFirst("script:containsData(function(p,a,c,k,e,d))")?.data().toString() JsUnpacker(extractedpack).unpack()?.let { unPacked -> - Regex("sources:\\[\\{file:\"(.*?)\"").find(unPacked)?.groupValues?.get(1)?.let { link -> - return listOf( - newExtractorLink( - this.name, - this.name, - link, - ) { - this.referer = referer ?: "" - this.quality = Qualities.Unknown.value - } - ) - } + JwPlayerHelper.extractStreamLinks(unPacked, name, mainUrl, callback, subtitleCallback) } - return null } } diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/helper/JWPlayerHelper.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/helper/JWPlayerHelper.kt new file mode 100644 index 00000000000..43ceb2314cf --- /dev/null +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/helper/JWPlayerHelper.kt @@ -0,0 +1,155 @@ +package com.lagradost.cloudstream3.extractors.helper + +import com.fasterxml.jackson.annotation.JsonProperty +import com.lagradost.api.Log +import com.lagradost.cloudstream3.Prerelease +import com.lagradost.cloudstream3.SubtitleFile +import com.lagradost.cloudstream3.newSubtitleFile +import com.lagradost.cloudstream3.utils.AppUtils.tryParseJson +import com.lagradost.cloudstream3.utils.ExtractorLink +import com.lagradost.cloudstream3.utils.M3u8Helper +import com.lagradost.cloudstream3.utils.newExtractorLink +import kotlin.collections.orEmpty + +@Prerelease +object JwPlayerHelper { + private val sourceRegex = Regex(""""?sources"?:\s*(\[.*?\])""") + private val tracksRegex = Regex(""""?tracks"?:\s*(\[.*?\])""") + private val m3u8Regex = Regex("""[:=]\s*\"([^\"\s]+(\.m3u8|master\.txt)[^\"\s]*)""") + + /** + * Get stream links the "sources" attribute inside a JWPlayer script, e.g. + * + * ```js + *