diff --git a/.github/workflows/build_to_archive.yml b/.github/workflows/build_to_archive.yml index 30bedcc1b..07096014a 100644 --- a/.github/workflows/build_to_archive.yml +++ b/.github/workflows/build_to_archive.yml @@ -9,9 +9,6 @@ on: - '**/wcokey.txt' workflow_dispatch: -permissions: - contents: read - concurrency: group: "Archive-build" cancel-in-progress: true @@ -64,15 +61,13 @@ jobs: cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} - name: Run Gradle - run: ./gradlew assemblePrereleaseRelease + run: ./gradlew assemblePrerelease env: SIGNING_KEY_ALIAS: "key0" SIGNING_KEY_PASSWORD: ${{ steps.fetch_keystore.outputs.key_pwd }} SIGNING_STORE_PASSWORD: ${{ steps.fetch_keystore.outputs.key_pwd }} SIMKL_CLIENT_ID: ${{ secrets.SIMKL_CLIENT_ID }} SIMKL_CLIENT_SECRET: ${{ secrets.SIMKL_CLIENT_SECRET }} - TRAKT_CLIENT_ID: ${{ secrets.TRAKT_CLIENT_ID }} - MDL_API_KEY: ${{ secrets.MDL_API_KEY }} - uses: actions/checkout@v6 with: diff --git a/.github/workflows/generate_dokka.yml b/.github/workflows/generate_dokka.yml index d67b8a519..e3dac3857 100644 --- a/.github/workflows/generate_dokka.yml +++ b/.github/workflows/generate_dokka.yml @@ -6,9 +6,6 @@ on: paths-ignore: - '*.md' -permissions: - contents: read - concurrency: group: "dokka" cancel-in-progress: true @@ -54,6 +51,9 @@ jobs: with: cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} + - name: Set up Android SDK + uses: android-actions/setup-android@v3 + - name: Generate Dokka run: | cd $GITHUB_WORKSPACE/src/ diff --git a/.github/workflows/issue_action.yml b/.github/workflows/issue_action.yml new file mode 100644 index 000000000..4286e6b68 --- /dev/null +++ b/.github/workflows/issue_action.yml @@ -0,0 +1,94 @@ +name: Issue automatic actions + +on: + issues: + types: [opened] + +jobs: + issue-moderator: + runs-on: ubuntu-latest + steps: + - name: Generate access token + id: generate_token + uses: tibdex/github-app-token@v2 + with: + app_id: ${{ secrets.GH_APP_ID }} + private_key: ${{ secrets.GH_APP_KEY }} + + - name: Similarity analysis + id: similarity + uses: actions-cool/issues-similarity-analysis@v1 + with: + token: ${{ steps.generate_token.outputs.token }} + filter-threshold: 0.60 + title-excludes: '' + comment-title: | + ### Your issue looks similar to these issues: + Please close if duplicate. + comment-body: '${index}. ${similarity} #${number}' + + - name: Label if possible duplicate + if: steps.similarity.outputs.similar-issues-found =='true' + uses: actions/github-script@v8 + with: + github-token: ${{ steps.generate_token.outputs.token }} + script: | + github.rest.issues.addLabels({ + issue_number: context.issue.number, + owner: context.repo.owner, + repo: context.repo.repo, + labels: ["possible duplicate"] + }) + + - uses: actions/checkout@v6 + + - name: Automatically close issues that dont follow the issue template + uses: lucasbento/auto-close-issues@v1.0.2 + with: + github-token: ${{ steps.generate_token.outputs.token }} + issue-close-message: | + @${issue.user.login}: hello! :wave: + This issue is being automatically closed because it does not follow the issue template." + closed-issues-label: "invalid" + + - name: Check if issue mentions a provider + id: provider_check + env: + GH_TEXT: "${{ github.event.issue.title }} ${{ github.event.issue.body }}" + run: | + wget --output-document check_issue.py "https://raw.githubusercontent.com/recloudstream/.github/master/.github/check_issue.py" + pip3 install httpx + RES="$(python3 ./check_issue.py)" + echo "name=${RES}" >> $GITHUB_OUTPUT + + - name: Comment if issue mentions a provider + if: steps.provider_check.outputs.name != 'none' + uses: actions-cool/issues-helper@v3 + with: + actions: 'create-comment' + token: ${{ steps.generate_token.outputs.token }} + body: | + Hello ${{ github.event.issue.user.login }}. + Please do not report any provider bugs here. This repository does not contain any providers. Please find the appropriate repository and report your issue there or join the [discord](https://discord.gg/5Hus6fM). + + Found provider name: `${{ steps.provider_check.outputs.name }}` + + - name: Label if mentions provider + if: steps.provider_check.outputs.name != 'none' + uses: actions/github-script@v8 + with: + github-token: ${{ steps.generate_token.outputs.token }} + script: | + github.rest.issues.addLabels({ + issue_number: context.issue.number, + owner: context.repo.owner, + repo: context.repo.repo, + labels: ["possible provider issue"] + }) + + - name: Add eyes reaction to all issues + uses: actions-cool/emoji-helper@v1.0.0 + with: + type: 'issue' + token: ${{ steps.generate_token.outputs.token }} + emoji: 'eyes' diff --git a/.github/workflows/prerelease.yml b/.github/workflows/prerelease.yml index b5b17ba6a..c7dee13eb 100644 --- a/.github/workflows/prerelease.yml +++ b/.github/workflows/prerelease.yml @@ -12,9 +12,6 @@ concurrency: group: "pre-release" cancel-in-progress: true -permissions: - contents: write - jobs: build: runs-on: ubuntu-latest @@ -55,14 +52,13 @@ jobs: cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} - name: Run Gradle - run: ./gradlew assemblePrereleaseRelease androidSourcesJar makeJar + run: ./gradlew assemblePrerelease build androidSourcesJar makeJar env: SIGNING_KEY_ALIAS: "key0" SIGNING_KEY_PASSWORD: ${{ steps.fetch_keystore.outputs.key_pwd }} SIGNING_STORE_PASSWORD: ${{ steps.fetch_keystore.outputs.key_pwd }} SIMKL_CLIENT_ID: ${{ secrets.SIMKL_CLIENT_ID }} SIMKL_CLIENT_SECRET: ${{ secrets.SIMKL_CLIENT_SECRET }} - TRAKT_CLIENT_ID: ${{ secrets.TRAKT_CLIENT_ID }} MDL_API_KEY: ${{ secrets.MDL_API_KEY }} - name: Create pre-release diff --git a/.github/workflows/pull_request.yml b/.github/workflows/pull_request.yml index 8f5c62866..090e7a2ec 100644 --- a/.github/workflows/pull_request.yml +++ b/.github/workflows/pull_request.yml @@ -2,9 +2,6 @@ name: Artifact Build on: [pull_request] -permissions: - contents: read - jobs: build: runs-on: ubuntu-latest @@ -27,10 +24,10 @@ jobs: cache-read-only: false - name: Run Gradle - run: ./gradlew assemblePrereleaseDebug lint check + run: ./gradlew assemblePrereleaseDebug - name: Upload Artifact - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@v6 with: name: pull-request-build path: "app/build/outputs/apk/prerelease/debug/*.apk" diff --git a/.github/workflows/update_locales.yml b/.github/workflows/update_locales.yml index 0a538d5d4..5b170d540 100644 --- a/.github/workflows/update_locales.yml +++ b/.github/workflows/update_locales.yml @@ -11,9 +11,6 @@ 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 02c1f99e8..e69720e15 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -8,89 +8,47 @@ import org.jetbrains.kotlin.gradle.tasks.KotlinJvmCompile plugins { alias(libs.plugins.android.application) alias(libs.plugins.dokka) - alias(libs.plugins.kotlin.serialization) + alias(libs.plugins.kotlin.android) } val javaTarget = JvmTarget.fromTarget(libs.versions.jvmTarget.get()) +val tmpFilePath = System.getProperty("user.home") + "/work/_temp/keystore/" +val prereleaseStoreFile: File? = File(tmpFilePath).listFiles()?.first() -abstract class GenerateGitHashTask : DefaultTask() { +fun getGitCommitHash(): String { + return try { + val headFile = file("${project.rootDir}/.git/HEAD") - @get:InputFile - @get:PathSensitive(PathSensitivity.RELATIVE) - abstract val headFile: RegularFileProperty - - @get:InputDirectory - @get:PathSensitive(PathSensitivity.RELATIVE) - abstract val headsDir: DirectoryProperty - - @get:OutputDirectory - abstract val outputDir: DirectoryProperty - - @TaskAction - fun generate() { - val head = headFile.get().asFile - - val hash = try { - if (head.exists()) { - // Read the commit hash from .git/HEAD - val headContent = head.readText().trim() - if (headContent.startsWith("ref:")) { - val refPath = headContent.substring(5) // e.g., refs/heads/main - val commitFile = File(head.parentFile, refPath) - if (commitFile.exists()) commitFile.readText().trim() else "" - } else headContent // If it's a detached HEAD (commit hash directly) - } else "" // If .git/HEAD doesn't exist - } catch (_: Throwable) { - "" // Just set to an empty string if any exception occurs - }.take(7) // Get the short commit hash - - val outFile = outputDir.file("git-hash.txt").get().asFile - outFile.parentFile.mkdirs() - outFile.writeText(hash) + // Read the commit hash from .git/HEAD + if (headFile.exists()) { + val headContent = headFile.readText().trim() + if (headContent.startsWith("ref:")) { + val refPath = headContent.substring(5) // e.g., refs/heads/main + val commitFile = file("${project.rootDir}/.git/$refPath") + if (commitFile.exists()) commitFile.readText().trim() else "" + } else headContent // If it's a detached HEAD (commit hash directly) + } else { + "" // If .git/HEAD doesn't exist + }.take(7) // Return the short commit hash + } catch (_: Throwable) { + "" // Just return an empty string if any exception occurs } } -val generateGitHash = tasks.register("generateGitHash") { - val gitDir = layout.projectDirectory.dir("../.git") - - headFile.set(gitDir.file("HEAD")) - headsDir.set(gitDir.dir("refs/heads")) - - outputDir.set(layout.buildDirectory.dir("generated/git")) -} - android { @Suppress("UnstableApiUsage") testOptions { unitTests.isReturnDefaultValues = true } - // Looks like google likes to add metadata only they can read https://gitlab.com/IzzyOnDroid/repo/-/work_items/491 - dependenciesInfo { - // Disables dependency metadata when building APKs. - includeInApk = false - // Disables dependency metadata when building Android App Bundles. - includeInBundle = false - } - - androidComponents { - onVariants { variant -> - variant.sources.assets?.addGeneratedSourceDirectory( - generateGitHash, - GenerateGitHashTask::outputDir - ) - } + viewBinding { + enable = true } signingConfigs { - // We just use SIGNING_KEY_ALIAS here since it won't change - // so won't kill the configuration cache. - if (System.getenv("SIGNING_KEY_ALIAS") != null) { + if (prereleaseStoreFile != null) { create("prerelease") { - val tmpFilePath = System.getProperty("user.home") + "/work/_temp/keystore/" - val prereleaseStoreFile: File? = File(tmpFilePath).listFiles()?.first() - - storeFile = prereleaseStoreFile?.let { file(it) } + storeFile = file(prereleaseStoreFile) storePassword = System.getenv("SIGNING_STORE_PASSWORD") keyAlias = System.getenv("SIGNING_KEY_ALIAS") keyPassword = System.getenv("SIGNING_KEY_PASSWORD") @@ -104,8 +62,10 @@ android { applicationId = "com.lagradost.cloudstream3" minSdk = libs.versions.minSdk.get().toInt() targetSdk = libs.versions.targetSdk.get().toInt() - versionCode = libs.versions.versionCode.get().toInt() - versionName = libs.versions.versionName.get() + versionCode = 67 + versionName = "4.6.1" + + resValue("string", "commit_hash", getGitCommitHash()) manifestPlaceholders["target_sdk_version"] = libs.versions.targetSdk.get() @@ -175,28 +135,21 @@ android { } java { - // Use Java 17 toolchain even if a higher JDK runs the build. + // Use Java 17 toolchain even if a higher JDK runs the build. // We still use Java 8 for now which higher JDKs have deprecated. - toolchain { - languageVersion.set(JavaLanguageVersion.of(libs.versions.jdkToolchain.get())) - } + toolchain { + languageVersion.set(JavaLanguageVersion.of(libs.versions.jdkToolchain.get())) + } } lint { + abortOnError = false checkReleaseBuilds = false } buildFeatures { buildConfig = true - viewBinding = true - } - - packaging { - jniLibs { - // Enables legacy JNI packaging to reduce APK size (similar to builds before minSdk 23). - // Note: This may increase app startup time slightly. - useLegacyPackaging = true - } + resValues = true } namespace = "com.lagradost.cloudstream3" @@ -207,22 +160,17 @@ dependencies { testImplementation(libs.junit) testImplementation(libs.json) androidTestImplementation(libs.core) - androidTestImplementation(libs.espresso.core) + implementation(libs.junit.ktx) androidTestImplementation(libs.ext.junit) - androidTestImplementation(libs.instancio.core) - androidTestImplementation(libs.junit.ktx) - androidTestImplementation(libs.kotlin.test) + androidTestImplementation(libs.espresso.core) // 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) - implementation(libs.kotlinx.serialization.json) // JSON Parser // Design & UI implementation(libs.preference.ktx) @@ -239,9 +187,6 @@ 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 @@ -259,15 +204,13 @@ dependencies { // Extensions & Other Libs implementation(libs.jsoup) // HTML Parser implementation(libs.rhino) // Run JavaScript + implementation(libs.fuzzywuzzy) // Library/Ext Searching with Levenshtein Distance implementation(libs.safefile) // To Prevent the URI File Fu*kery coreLibraryDesugaring(libs.desugar.jdk.libs.nio) // NIO Flavor Needed for NewPipeExtractor implementation(libs.conscrypt.android) // To Fix SSL Fu*kery on Android 9 implementation(libs.jackson.module.kotlin) // JSON Parser implementation(libs.zipline) - // Deprecated; will be removed once extensions have time to migrate from using it - implementation("me.xdrop:fuzzywuzzy:1.4.0") - // Torrent Support implementation(libs.torrentserver) @@ -275,7 +218,18 @@ dependencies { implementation(libs.work.runtime.ktx) implementation(libs.nicehttp) // HTTP Lib - implementation(project(":library")) + implementation(project(":library") { + // There does not seem to be a good way of getting the android flavor. + val isDebug = gradle.startParameter.taskRequests.any { task -> + task.args.any { arg -> + arg.contains("debug", true) + } + } + + this.extra.set("isDebug", isDebug) + }) + // Extra brightness video filters + implementation(libs.gpuv) } tasks.register("androidSourcesJar") { @@ -312,22 +266,16 @@ tasks.withType { compilerOptions { jvmTarget.set(javaTarget) jvmDefault.set(JvmDefaultMode.ENABLE) + optIn.add("com.lagradost.cloudstream3.Prerelease") freeCompilerArgs.add("-Xannotation-default-target=param-property") - optIn.addAll( - "com.lagradost.cloudstream3.InternalAPI", - "com.lagradost.cloudstream3.Prerelease", - "kotlin.uuid.ExperimentalUuidApi", - ) } } dokka { moduleName = "App" dokkaSourceSets { - configureEach { - suppress = name != "prereleaseDebug" + main { analysisPlatform = KotlinPlatform.JVM - displayName = "JVM" documentedVisibilities( VisibilityModifier.Public, VisibilityModifier.Protected diff --git a/app/lint.xml b/app/lint.xml index b2f5e8f2b..48cdec04a 100644 --- a/app/lint.xml +++ b/app/lint.xml @@ -5,9 +5,4 @@ - - - - - diff --git a/app/src/androidTest/java/com/lagradost/cloudstream3/SerializationClassTester.kt b/app/src/androidTest/java/com/lagradost/cloudstream3/SerializationClassTester.kt deleted file mode 100644 index 80c7b49b0..000000000 --- a/app/src/androidTest/java/com/lagradost/cloudstream3/SerializationClassTester.kt +++ /dev/null @@ -1,134 +0,0 @@ -package com.lagradost.cloudstream3 - -import androidx.test.ext.junit.runners.AndroidJUnit4 -import androidx.test.platform.app.InstrumentationRegistry -import com.lagradost.cloudstream3.utils.AppUtils.toJson -import dalvik.system.DexFile -import kotlinx.serialization.ExperimentalSerializationApi -import kotlinx.serialization.InternalSerializationApi -import kotlinx.serialization.KSerializer -import kotlinx.serialization.Serializable -import kotlinx.serialization.serializer -import kotlinx.serialization.serializerOrNull -import org.instancio.Instancio -import org.junit.Test -import org.junit.runner.RunWith -import kotlin.reflect.KClass -import kotlin.reflect.jvm.jvmName -import kotlin.test.assertEquals -import kotlin.test.assertNotNull - -@RunWith(AndroidJUnit4::class) -class SerializationClassTester { - // Same as app, or using app reference - val jacksonMapper = mapper - val kotlinxMapper = json - - @Test - fun isIdenticalSerialization() { - val serializableClasses = findSerializableClasses("com.lagradost") - println("Number of serializable classes: ${serializableClasses.size}") - - serializableClasses.forEach { kClass -> - val instance = Instancio.create(kClass.java) - - val jacksonJson = jacksonMapper.writeValueAsString(instance) - val kotlinxJson = serializeWithKotlinx(kClass, instance) - - assertEquals( - jacksonJson, - kotlinxJson, - """ - Serialization mismatch for: - ${kClass.qualifiedName} - - Jackson: - $jacksonJson - - Kotlinx: - $kotlinxJson - - """.trimIndent() - ) - println("Identical serialization for: ${kClass.jvmName}") - } - } - - @OptIn(InternalSerializationApi::class, ExperimentalSerializationApi::class) - @Test - fun isIdenticalDeserialization() { - val serializableClasses = findSerializableClasses("com.lagradost") - println("Number of serializable classes: ${serializableClasses.size}") - - serializableClasses.forEach { kClass -> - val instance = Instancio.create(kClass.java) - // Convert to JSON to get example JSON object - // We prefer jackson here because the app may have many jackson JSON strings in local storage - val originalJson = jacksonMapper.writeValueAsString(instance) - - // Create an object from the JSON using kotlinx - val serializer = - kClass.serializerOrNull() ?: kotlinxMapper.serializersModule.getContextual(kClass) - assertNotNull(serializer, "The class: ${kClass.jvmName} must be serializable!") - val kotlinxDecoded = kotlinxMapper.decodeFromString(serializer, originalJson) - - // Create an object from the JSON using jackson - val mapperDecoded = jacksonMapper.readValue(originalJson, kClass.java) - - - // Deep inspect both object using the mapper toJson function. - // This deep equality check can be performed using other methods, but this just works. - val jacksonJson = mapperDecoded.toJson() - val kotlinxJson = kotlinxDecoded.toJson() - - assertEquals( - jacksonJson, - kotlinxJson, - """ - Serialization mismatch for: - ${kClass.qualifiedName} - - Jackson: - $jacksonJson - - Kotlinx: - $kotlinxJson - - """.trimIndent() - ) - println("Identical deserialization for: ${kClass.jvmName}") - } - } - - // DEX files are the best solution to read all our classes dynamically. - // classgraph could be used instead, but it only gives results on the JVM, not Android. - @Suppress("DEPRECATION") - private fun findSerializableClasses(packageName: String): List> { - val context = InstrumentationRegistry - .getInstrumentation() - .targetContext - - val dexFile = DexFile(context.packageCodePath) - return dexFile.entries() - .toList() - .filter { it.startsWith(packageName) } - .mapNotNull { - runCatching { Class.forName(it).kotlin }.getOrNull() - }.filter { kClass -> - // Not possible to use .hasAnnotation() on newer Android versions. - kClass.java.annotations.any { - it is Serializable - } - } - } - - @OptIn(InternalSerializationApi::class) - @Suppress("UNCHECKED_CAST") - private fun serializeWithKotlinx( - kClass: KClass<*>, - value: Any - ): String { - val serializer = kClass.serializer() as KSerializer - return kotlinxMapper.encodeToString(serializer, value) - } -} diff --git a/app/src/androidTest/java/com/lagradost/cloudstream3/utils/serializers/SerializerTest.kt b/app/src/androidTest/java/com/lagradost/cloudstream3/utils/serializers/SerializerTest.kt deleted file mode 100644 index 15ad532f8..000000000 --- a/app/src/androidTest/java/com/lagradost/cloudstream3/utils/serializers/SerializerTest.kt +++ /dev/null @@ -1,157 +0,0 @@ -package com.lagradost.cloudstream3.utils.serializers - -import android.net.Uri -import com.lagradost.cloudstream3.utils.AppUtils.parseJson -import com.lagradost.cloudstream3.utils.AppUtils.toJson -import kotlinx.serialization.ExperimentalSerializationApi -import kotlinx.serialization.KeepGeneratedSerializer -import kotlinx.serialization.Serializable -import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse -import org.junit.Assert.assertNull -import org.junit.Assert.assertTrue -import org.junit.Test - -@OptIn(ExperimentalSerializationApi::class) -@KeepGeneratedSerializer -@Serializable(with = NonEmptyData.Serializer::class) -data class NonEmptyData( - val title: String = "", - val tags: List = emptyList(), - val meta: Map = emptyMap(), - val name: String = "hello", -) { - object Serializer : NonEmptySerializer(NonEmptyData.generatedSerializer()) -} - -@OptIn(ExperimentalSerializationApi::class) -@KeepGeneratedSerializer -@Serializable(with = WriteOnlyData.Serializer::class) -data class WriteOnlyData( - val fieldA: String = "", - val fieldB: String = "", -) { - object Serializer : WriteOnlySerializer( - WriteOnlyData.generatedSerializer(), - setOf("fieldB"), - ) -} - -@OptIn(ExperimentalSerializationApi::class) -@KeepGeneratedSerializer -@Serializable(with = MultiWriteOnly.Serializer::class) -data class MultiWriteOnly( - val fieldA: String = "", - val fieldB: String = "", - val fieldC: String = "", -) { - object Serializer : WriteOnlySerializer( - MultiWriteOnly.generatedSerializer(), - setOf("fieldB", "fieldC"), - ) -} - -@Serializable -data class UriData( - @Serializable(with = UriSerializer::class) - val uri: Uri = Uri.EMPTY, -) - -class SerializerTest { - - @Test - fun nonEmptySerializerOmitsEmptyStrings() { - val data = NonEmptyData(title = "", name = "hello") - val result = data.toJson() - assertFalse(result.contains("title")) - assertTrue(result.contains("name")) - } - - @Test - fun nonEmptySerializerOmitsEmptyLists() { - val data = NonEmptyData(tags = emptyList(), name = "hello") - val result = data.toJson() - assertFalse(result.contains("tags")) - } - - @Test - fun nonEmptySerializerOmitsEmptyMaps() { - val data = NonEmptyData(meta = emptyMap(), name = "hello") - val result = data.toJson() - assertFalse(result.contains("meta")) - } - - @Test - fun nonEmptySerializerKeepsNonEmptyFields() { - val data = NonEmptyData(title = "hello", tags = listOf("a"), meta = mapOf("k" to "v")) - val result = data.toJson() - assertTrue(result.contains("title")) - assertTrue(result.contains("tags")) - assertTrue(result.contains("meta")) - } - - @Test - fun nonEmptySerializerDoesNotAffectDeserialization() { - val input = """{"title":"hello","tags":["a"],"meta":{"k":"v"},"name":"world"}""" - val result = parseJson(input) - assertEquals("hello", result.title) - assertEquals(listOf("a"), result.tags) - assertEquals(mapOf("k" to "v"), result.meta) - assertEquals("world", result.name) - } - - @Test - fun writeOnlySerializerOmitsFieldOnSerialize() { - val data = WriteOnlyData(fieldA = "hello", fieldB = "secret") - val result = data.toJson() - assertTrue(result.contains("fieldA")) - assertFalse(result.contains("fieldB")) - } - - @Test - fun writeOnlySerializerDeserializesNormally() { - val input = """{"fieldA":"hello","fieldB":"secret"}""" - val result = parseJson(input) - assertEquals("hello", result.fieldA) - assertEquals("secret", result.fieldB) - } - - @Test - fun writeOnlySerializerDeserializesMissingAsDefault() { - val input = """{"fieldA":"hello"}""" - val result = parseJson(input) - assertEquals("hello", result.fieldA) - assertEquals("", result.fieldB) - } - - @Test - fun writeOnlySerializerHandlesMultipleKeys() { - val data = MultiWriteOnly(fieldA = "hello", fieldB = "secret1", fieldC = "secret2") - val result = data.toJson() - assertTrue(result.contains("fieldA")) - assertFalse(result.contains("fieldB")) - assertFalse(result.contains("fieldC")) - } - - @Test - fun uriSerializerSerializesUriToString() { - val data = UriData(uri = Uri.parse("https://example.com/path?query=1")) - val result = data.toJson() - assertTrue(result.contains("https://example.com/path?query=1")) - } - - @Test - fun uriSerializerDeserializesStringToUri() { - val input = """{"uri":"https://example.com/path?query=1"}""" - val result = parseJson(input) - assertEquals(Uri.parse("https://example.com/path?query=1"), result.uri) - } - - @Test - fun uriSerializerRoundtripsCorrectly() { - val data = UriData(uri = Uri.parse("https://example.com/path?query=1")) - val encoded = data.toJson() - val decoded = parseJson(encoded) - assertEquals(data.uri, decoded.uri) - } -} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index ee4c978f2..9e1bc9ac9 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -22,47 +22,6 @@ android:name="android.permission.QUERY_ALL_PACKAGES" tools:ignore="QueryAllPackagesPermission" /> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + android:supportsPictureInPicture="true"> @@ -231,7 +173,7 @@ - + @@ -244,6 +186,21 @@ + + + + + + + + + + + + - - ? = null + /*@Deprecated( message = "AcraApplication is deprecated, use CloudStreamApp instead", replaceWith = ReplaceWith("com.lagradost.cloudstream3.CloudStreamApp.context"), level = DeprecationLevel.WARNING - ) - val context get() = CloudStreamApp.context + )*/ + var context + get() = _context?.get() + internal set(value) { + _context = WeakReference(value) + setContext(WeakReference(value)) + } - @Deprecated( + /*@Deprecated( message = "AcraApplication is deprecated, use CloudStreamApp instead", replaceWith = ReplaceWith("com.lagradost.cloudstream3.CloudStreamApp.removeKeys(folder)"), level = DeprecationLevel.WARNING - ) - fun removeKeys(folder: String): Int? = - CloudStreamApp.removeKeys(folder) + )*/ + fun removeKeys(folder: String): Int? { + return context?.removeKeys(folder) + } - @Deprecated( + /*@Deprecated( message = "AcraApplication is deprecated, use CloudStreamApp instead", replaceWith = ReplaceWith("com.lagradost.cloudstream3.CloudStreamApp.setKey(path, value)"), level = DeprecationLevel.WARNING - ) - fun setKey(path: String, value: T) = - CloudStreamApp.setKey(path, value) + )*/ + fun setKey(path: String, value: T) { + context?.setKey(path, value) + } - @Deprecated( + /*@Deprecated( message = "AcraApplication is deprecated, use CloudStreamApp instead", replaceWith = ReplaceWith("com.lagradost.cloudstream3.CloudStreamApp.setKey(folder, path, value)"), level = DeprecationLevel.WARNING - ) - fun setKey(folder: String, path: String, value: T) = - CloudStreamApp.setKey(folder, path, value) + )*/ + fun setKey(folder: String, path: String, value: T) { + context?.setKey(folder, path, value) + } - @Deprecated( + /*@Deprecated( message = "AcraApplication is deprecated, use CloudStreamApp instead", replaceWith = ReplaceWith("com.lagradost.cloudstream3.CloudStreamApp.getKey(path, defVal)"), level = DeprecationLevel.WARNING - ) - inline fun getKey(path: String, defVal: T?): T? = - CloudStreamApp.getKey(path, defVal) + )*/ + inline fun getKey(path: String, defVal: T?): T? { + return context?.getKey(path, defVal) + } - @Deprecated( + /*@Deprecated( message = "AcraApplication is deprecated, use CloudStreamApp instead", replaceWith = ReplaceWith("com.lagradost.cloudstream3.CloudStreamApp.getKey(path)"), level = DeprecationLevel.WARNING - ) - inline fun getKey(path: String): T? = - CloudStreamApp.getKey(path) + )*/ + inline fun getKey(path: String): T? { + return context?.getKey(path) + } - @Deprecated( + /*@Deprecated( message = "AcraApplication is deprecated, use CloudStreamApp instead", replaceWith = ReplaceWith("com.lagradost.cloudstream3.CloudStreamApp.getKey(folder, path)"), level = DeprecationLevel.WARNING - ) - inline fun getKey(folder: String, path: String): T? = - CloudStreamApp.getKey(folder, path) + )*/ + inline fun getKey(folder: String, path: String): T? { + return context?.getKey(folder, path) + } - @Deprecated( + /*@Deprecated( message = "AcraApplication is deprecated, use CloudStreamApp instead", replaceWith = ReplaceWith("com.lagradost.cloudstream3.CloudStreamApp.getKey(folder, path, defVal)"), level = DeprecationLevel.WARNING - ) - inline fun getKey(folder: String, path: String, defVal: T?): T? = - CloudStreamApp.getKey(folder, path, defVal) + )*/ + inline fun getKey(folder: String, path: String, defVal: T?): T? { + return context?.getKey(folder, path, defVal) + } } } diff --git a/app/src/main/java/com/lagradost/cloudstream3/CloudStreamApp.kt b/app/src/main/java/com/lagradost/cloudstream3/CloudStreamApp.kt index a9cd9c01e..b78327998 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/CloudStreamApp.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/CloudStreamApp.kt @@ -13,7 +13,6 @@ import coil3.ImageLoader import coil3.PlatformContext import coil3.SingletonImageLoader import com.lagradost.api.setContext -import com.lagradost.cloudstream3.BuildConfig import com.lagradost.cloudstream3.mvvm.safe import com.lagradost.cloudstream3.mvvm.safeAsync import com.lagradost.cloudstream3.plugins.PluginManager @@ -21,7 +20,6 @@ import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR import com.lagradost.cloudstream3.ui.settings.Globals.TV import com.lagradost.cloudstream3.ui.settings.Globals.isLayout import com.lagradost.cloudstream3.utils.AppContextUtils.openBrowser -import com.lagradost.cloudstream3.utils.AppDebug import com.lagradost.cloudstream3.utils.Coroutines.runOnMainThread import com.lagradost.cloudstream3.utils.DataStore.getKey import com.lagradost.cloudstream3.utils.DataStore.getKeys @@ -67,6 +65,7 @@ class ExceptionHandler( } } +@Prerelease class CloudStreamApp : Application(), SingletonImageLoader.Factory { override fun onCreate() { @@ -82,13 +81,13 @@ class CloudStreamApp : Application(), SingletonImageLoader.Factory { exceptionHandler = it Thread.setDefaultUncaughtExceptionHandler(it) } - - AppDebug.isDebug = BuildConfig.DEBUG } override fun attachBaseContext(base: Context?) { super.attachBaseContext(base) context = base + // This can be removed without deprecation after next stable + AcraApplication.context = context } override fun newImageLoader(context: PlatformContext): ImageLoader { diff --git a/app/src/main/java/com/lagradost/cloudstream3/CommonActivity.kt b/app/src/main/java/com/lagradost/cloudstream3/CommonActivity.kt index 4ce09bd44..abf56dcbd 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/CommonActivity.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/CommonActivity.kt @@ -9,8 +9,6 @@ import android.content.res.Configuration import android.content.res.Resources import android.Manifest import android.os.Build -import android.os.Handler -import android.os.Looper import android.util.DisplayMetrics import android.util.Log import android.view.Gravity @@ -41,6 +39,7 @@ import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.syncproviders.AccountManager import com.lagradost.cloudstream3.ui.home.HomeChildItemAdapter import com.lagradost.cloudstream3.ui.home.ParentItemAdapter +import com.lagradost.cloudstream3.ui.player.PlayerEventType import com.lagradost.cloudstream3.ui.player.PlayerPipHelper.isPIPPossible import com.lagradost.cloudstream3.ui.player.Torrent import com.lagradost.cloudstream3.ui.result.ActorAdaptor @@ -116,6 +115,7 @@ object CommonActivity { val onColorSelectedEvent = Event>() val onDialogDismissedEvent = Event() + var playerEventListener: ((PlayerEventType) -> Unit)? = null var keyEventListener: ((Pair) -> Boolean)? = null var appliedTheme: Int = 0 var appliedColor: Int = 0 @@ -191,16 +191,6 @@ object CommonActivity { currentToast = toast toast.show() - val handler = Handler(Looper.getMainLooper()) - val ref = WeakReference(toast) - - /* Clean up activity leak */ - handler.postDelayed({ - if (ref.get() == currentToast) { - currentToast = null - } - }, 10_000) - } catch (e: Exception) { logError(e) } @@ -244,8 +234,19 @@ object CommonActivity { fun init(act: Activity) { setActivityInstance(act) ioSafe { Torrent.deleteAllFiles() } + + // Clear all pools to apply the correct theme + for (pool in arrayOf( + PluginAdapter.sharedPool, HomeChildItemAdapter.sharedPool, + ParentItemAdapter.sharedPool, ActorAdaptor.sharedPool, EpisodeAdapter.sharedPool, + SearchAdapter.sharedPool, ImageAdapter.sharedPool + )) { + pool.clear() + } + val componentActivity = activity as? ComponentActivity ?: return + componentActivity.updateLocale() componentActivity.updateTv() AccountManager.initMainAPI() @@ -532,7 +533,87 @@ 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 */ @@ -579,10 +660,8 @@ object CommonActivity { // TODO: Figure out why removing the check for SearchAutoComplete seems // to break focus on TV as it shouldn't need to be used. - // Also handle KEYCODE_ENTER here because some remotes (e.g. LG Magic Remote) - // send KEYCODE_ENTER instead of KEYCODE_DPAD_CENTER when clicking the OK button. @SuppressLint("RestrictedApi") - if ((keyCode == KeyEvent.KEYCODE_DPAD_CENTER || keyCode == KeyEvent.KEYCODE_ENTER) && + if (keyCode == KeyEvent.KEYCODE_DPAD_CENTER && (act.currentFocus is SearchView || act.currentFocus is SearchView.SearchAutoComplete) ) { showInputMethod(act.currentFocus?.findFocus()) @@ -603,4 +682,4 @@ object CommonActivity { } return null } -} +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt b/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt index 90583011d..1caaaa4c6 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt @@ -29,6 +29,7 @@ import androidx.annotation.MainThread import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AppCompatActivity import androidx.cardview.widget.CardView +import androidx.constraintlayout.widget.ConstraintLayout import androidx.core.content.edit import androidx.core.net.toUri import androidx.core.view.children @@ -105,6 +106,7 @@ import com.lagradost.cloudstream3.ui.SyncWatchType import com.lagradost.cloudstream3.ui.WatchType import com.lagradost.cloudstream3.ui.account.AccountHelper.showAccountSelectLinear import com.lagradost.cloudstream3.ui.download.DOWNLOAD_NAVIGATE_TO +import com.lagradost.cloudstream3.ui.home.HomeFragment import com.lagradost.cloudstream3.ui.home.HomeViewModel import com.lagradost.cloudstream3.ui.library.LibraryViewModel import com.lagradost.cloudstream3.ui.player.BasicLink @@ -188,9 +190,11 @@ import java.nio.charset.Charset 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 +import androidx.core.net.toUri +import androidx.tvprovider.media.tv.Channel +import androidx.tvprovider.media.tv.TvContractCompat +import android.content.ComponentName +import android.content.ContentUris class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCallback { companion object { @@ -200,21 +204,6 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa const val ANIMATED_OUTLINE: Boolean = false var lastError: String? = null - /** Update lastError variable based on error file, to check if app crashed. - * Can be called multiple times without changing the lastError variable changing. - **/ - fun setLastError(context: Context) { - if (lastError != null) return - - val errorFile = context.filesDir.resolve("last_error") - if (errorFile.exists() && errorFile.isFile) { - lastError = errorFile.readText(Charset.defaultCharset()) - errorFile.delete() - } else { - lastError = null - } - } - private const val FILE_DELETE_KEY = "FILES_TO_DELETE_KEY" const val API_NAME_EXTRA_KEY = "API_NAME_EXTRA_KEY" @@ -276,6 +265,7 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa * @return true if the str has launched an app task (be it successful or not) * @param isWebview does not handle providers and opening download page if true. Can still add repos and login. * */ + @Suppress("DEPRECATION_ERROR") fun handleAppIntentUrl( activity: FragmentActivity?, str: String?, @@ -362,8 +352,7 @@ 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) { @@ -408,10 +397,13 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa return true } - val matchedApi = apis.filter { str.startsWith(it.mainUrl) }.firstOrNull() - if (matchedApi != null) { - loadResult(str, matchedApi.name, "") - return true + synchronized(apis) { + for (api in apis) { + if (str.startsWith(api.mainUrl)) { + loadResult(str, api.name, "") + return true + } + } } } } @@ -440,7 +432,6 @@ 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) @@ -456,8 +447,7 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa syncViewModel.clear() } - lastPopupJob?.cancel() - lastPopupJob = if (load) { + if (load) { viewModel.load( this, result.url, result.apiName, false, if (getApiDubstatusSettings() .contains(DubStatus.Dubbed) @@ -504,7 +494,6 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa R.id.navigation_downloads, R.id.navigation_settings, R.id.navigation_download_child, - R.id.navigation_download_queue, R.id.navigation_subtitles, R.id.navigation_chrome_subtitles, R.id.navigation_settings_player, @@ -557,11 +546,9 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa navView.isVisible = isNavVisible && !isLandscape() navHostFragment.apply { val marginPx = resources.getDimensionPixelSize(R.dimen.nav_rail_view_width) - layoutParams = - (navHostFragment.layoutParams as ViewGroup.MarginLayoutParams).apply { - marginStart = - if (isNavVisible && isLandscape() && isLayout(TV or EMULATOR)) marginPx else 0 - } + layoutParams = (navHostFragment.layoutParams as ViewGroup.MarginLayoutParams).apply { + marginStart = if (isNavVisible && isLandscape() && isLayout(TV or EMULATOR)) marginPx else 0 + } } /** @@ -570,11 +557,7 @@ 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) -> { navRailView.menu.findItem(R.id.navigation_downloads).isChecked = true navView.menu.findItem(R.id.navigation_downloads).isChecked = true } @@ -806,11 +789,12 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa } } + private val pluginsLock = Mutex() private fun onAllPluginsLoaded(success: Boolean = false) { ioSafe { pluginsLock.withLock { - allProviders.withLock { + synchronized(allProviders) { // Load cloned sites after plugins have been loaded since clones depend on plugins. try { getKey>(USER_PROVIDER_API)?.let { list -> @@ -856,8 +840,6 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa private fun hidePreviewPopupDialog() { bottomPreviewPopup.dismissSafe(this) - lastPopupJob?.cancel() - lastPopupJob = null bottomPreviewPopup = null bottomPreviewBinding = null } @@ -1177,14 +1159,18 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa } } + @Suppress("DEPRECATION_ERROR") override fun onCreate(savedInstanceState: Bundle?) { - app.initClient(this, ignoreSSL = false) - @OptIn(UnsafeSSL::class) - insecureApp.initClient(this, ignoreSSL = true) - + app.initClient(this) val settingsManager = PreferenceManager.getDefaultSharedPreferences(this) - setLastError(this) + val errorFile = filesDir.resolve("last_error") + if (errorFile.exists() && errorFile.isFile) { + lastError = errorFile.readText(Charset.defaultCharset()) + errorFile.delete() + } else { + lastError = null + } val settingsForProvider = SettingsJson() settingsForProvider.enableAdult = @@ -1653,7 +1639,9 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa ioSafe { initAll() // No duplicates (which can happen by registerMainAPI) - apis = allProviders.distinctBy { it } + apis = synchronized(allProviders) { + allProviders.distinctBy { it } + } } // val navView: BottomNavigationView = findViewById(R.id.nav_view) @@ -1961,7 +1949,7 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa if (BuildConfig.DEBUG) { var providersAndroidManifestString = "Current androidmanifest should be:\n" - allProviders.withLock { + synchronized(allProviders) { for (api in allProviders) { providersAndroidManifestString += "() )*/ - - if (video.tvType.isEpisodeBased()) { - video.season?.let { intent.putExtra("introdb_season", it) } - video.episode.let { intent.putExtra("introdb_episode", it) } - } - - val position = getViewPos(video.id)?.position - if (position != null) - putExtra("position", position.toInt()) - } - } - - override fun onResult(activity: Activity, intent: Intent?) { - val position = intent?.getIntExtra("position", -1) ?: -1 - val duration = intent?.getIntExtra("duration", -1) ?: -1 - Log.d("MPV", "Position: $position, Duration: $duration") - updateDurationAndPosition(position.toLong(), duration.toLong()) - } -} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/actions/temp/OnlyPlayer.kt b/app/src/main/java/com/lagradost/cloudstream3/actions/temp/OnlyPlayer.kt deleted file mode 100644 index 348be440a..000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/actions/temp/OnlyPlayer.kt +++ /dev/null @@ -1,44 +0,0 @@ -package com.lagradost.cloudstream3.actions.temp - -import android.app.Activity -import android.content.Context -import android.content.Intent -import android.os.Bundle -import androidx.core.net.toUri -import com.lagradost.cloudstream3.actions.OpenInAppAction -import com.lagradost.cloudstream3.ui.result.LinkLoadingResult -import com.lagradost.cloudstream3.ui.result.ResultEpisode -import com.lagradost.cloudstream3.utils.txt - -/** https://github.com/Kindness-Kismet/only_player/tree/main - * https://github.com/Kindness-Kismet/only_player/blob/main/feature/player/src/main/java/one/only/player/feature/player/PlayerActivity.kt */ -class OnlyPlayer : OpenInAppAction( - txt("Only Player"), - "one.only.player", - intentClass = "one.only.player.feature.player.PlayerActivity" -) { - override val oneSource = true - override suspend fun putExtra( - context: Context, - intent: Intent, - video: ResultEpisode, - result: LinkLoadingResult, - index: Int? - ) { - /** https://github.com/Kindness-Kismet/only_player/blob/d3f55049a2913fa762d31b311146073cc2da46cb/app/src/main/java/one/only/player/navigation/CloudNavGraph.kt#L39 */ - intent.apply { - val link = result.links[index!!] - setData(link.url.toUri()) - - putExtra("headers", Bundle().apply { - for ((key, value) in link.headers) { - putExtra(key, value) - } - }) - } - } - - override fun onResult(activity: Activity, intent: Intent?) { - /* onResult does not get called */ - } -} \ 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 56512377b..d69619b45 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,11 +35,9 @@ 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, @@ -49,7 +47,7 @@ class PlayMirrorAction : VideoClickAction() { offset: Int, isCasting: Boolean ): Boolean { - index?.let { callback(link to null) } + index?.let { callback(result.links[it] to null) } result.subs.forEach { subtitle -> subtitleCallback(subtitle) } return true } @@ -58,7 +56,7 @@ class PlayMirrorAction : VideoClickAction() { activity.navigate( R.id.global_to_navigation_player, GeneratorPlayer.newInstance( - generatorMirror, 0, result.syncData + generatorMirror, 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 482ec05fc..3df5197cd 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/mvvm/Lifecycle.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/mvvm/Lifecycle.kt @@ -1,68 +1,16 @@ 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 ComponentActivity.observe(liveData: LiveData, action: (T) -> Unit) { - observeNullable(liveData) { t -> t?.run(action) } -} - -/** NOTE: Only one observer at a time per value */ -fun ComponentActivity.observeNullable(liveData: LiveData, action: (T?) -> Unit) { +fun LifecycleOwner.observe(liveData: LiveData, action: (t: T) -> Unit) { liveData.removeObservers(this) - liveData.observe(this, action) + liveData.observe(this) { it?.let { t -> action(t) } } } /** NOTE: Only one observer at a time per value */ -fun BaseFragment.observe(liveData: LiveData, action: (T) -> Unit) { - observeNullable(liveData) { t -> t?.run(action) } +fun LifecycleOwner.observeNullable(liveData: LiveData, action: (t: T) -> Unit) { + liveData.removeObservers(this) + liveData.observe(this) { action(it) } } - -/** - * 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 6234297d0..ec486d61d 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/network/RequestsHelper.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/network/RequestsHelper.kt @@ -2,7 +2,6 @@ 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 @@ -16,26 +15,11 @@ 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) @@ -43,11 +27,7 @@ fun buildDefaultClient(context: Context, ignoreSSL: Boolean = false): OkHttpClie val baseClient = OkHttpClient.Builder() .followRedirects(true) .followSslRedirects(true) - .apply { - if (ignoreSSL) { - ignoreAllSSLErrors() - } - } + .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. @@ -72,6 +52,11 @@ fun buildDefaultClient(context: Context, ignoreSSL: Boolean = false): OkHttpClie 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/Plugin.kt b/app/src/main/java/com/lagradost/cloudstream3/plugins/Plugin.kt index e1496db06..efa028d14 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/plugins/Plugin.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/plugins/Plugin.kt @@ -7,6 +7,7 @@ import com.lagradost.cloudstream3.actions.VideoClickAction import com.lagradost.cloudstream3.actions.VideoClickActionHolder import kotlin.Throws + abstract class Plugin : BasePlugin() { /** * Called when your Plugin is loaded @@ -25,7 +26,9 @@ abstract class Plugin : BasePlugin() { fun registerVideoClickAction(element: VideoClickAction) { Log.i(PLUGIN_TAG, "Adding ${element.name} VideoClickAction") element.sourcePlugin = this.filename - VideoClickActionHolder.allVideoClickActions.add(element) + synchronized(VideoClickActionHolder.allVideoClickActions) { + VideoClickActionHolder.allVideoClickActions.add(element) + } } /** @@ -37,4 +40,4 @@ abstract class Plugin : BasePlugin() { * This will add a button in the settings allowing you to add custom settings */ var openSettings: ((context: Context) -> Unit)? = null -} +} \ No newline at end of file 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 debd3f0eb..1b5d2909c 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/plugins/PluginManager.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/plugins/PluginManager.kt @@ -13,7 +13,6 @@ 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 @@ -27,11 +26,9 @@ import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey import com.lagradost.cloudstream3.CloudStreamApp.Companion.removeKey import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey import com.lagradost.cloudstream3.CommonActivity.showToast -import com.lagradost.cloudstream3.InternalAPI import com.lagradost.cloudstream3.MainAPI import com.lagradost.cloudstream3.MainAPI.Companion.settingsForProvider import com.lagradost.cloudstream3.MainActivity.Companion.afterPluginsLoadedEvent -import com.lagradost.cloudstream3.MainActivity.Companion.lastError import com.lagradost.cloudstream3.PROVIDER_STATUS_DOWN import com.lagradost.cloudstream3.PROVIDER_STATUS_OK import com.lagradost.cloudstream3.R @@ -46,7 +43,6 @@ 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 @@ -55,7 +51,7 @@ import com.lagradost.cloudstream3.utils.Coroutines.main import com.lagradost.cloudstream3.utils.ExtractorApi import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute import com.lagradost.cloudstream3.utils.UiText -import com.lagradost.cloudstream3.utils.downloader.DownloadFileManagement.sanitizeFilename +import com.lagradost.cloudstream3.utils.VideoDownloadManager.sanitizeFilename import com.lagradost.cloudstream3.utils.extractorApis import com.lagradost.cloudstream3.utils.txt import dalvik.system.PathClassLoader @@ -80,7 +76,6 @@ data class PluginData( @JsonProperty("filePath") val filePath: String, @JsonProperty("version") val version: Int, ) { - @WorkerThread fun toSitePlugin(): SitePlugin { return SitePlugin( this.filePath, @@ -95,9 +90,7 @@ data class PluginData( null, null, null, - 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 + File(this.filePath).length() ) } } @@ -265,8 +258,12 @@ object PluginManager { * DO NOT USE THIS IN A PLUGIN! It may case an infinite recursive loop lagging or crashing everyone's devices. * If you use it from a plugin, do not expect a stable jvmName, SO DO NOT USE IT! */ - @Suppress("FunctionName") - @InternalAPI + @Suppress("FunctionName", "DEPRECATION_ERROR") + @Deprecated( + "Calling this function from a plugin will lead to crashes, use loadPlugin and unloadPlugin", + replaceWith = ReplaceWith("loadPlugin"), + level = DeprecationLevel.ERROR + ) @Throws suspend fun ___DO_NOT_CALL_FROM_A_PLUGIN_updateAllOnlinePluginsAndLoadThem(activity: Activity) { assertNonRecursiveCallstack() @@ -307,7 +304,6 @@ object PluginManager { downloadPlugin( activity, pluginData.onlineData.second.url, - pluginData.onlineData.second.fileHash, pluginData.savedData.internalName, File(pluginData.savedData.filePath), true @@ -343,8 +339,12 @@ object PluginManager { * DO NOT USE THIS IN A PLUGIN! It may case an infinite recursive loop lagging or crashing everyone's devices. * If you use it from a plugin, do not expect a stable jvmName, SO DO NOT USE IT! */ - @Suppress("FunctionName") - @InternalAPI + @Suppress("FunctionName", "DEPRECATION_ERROR") + @Deprecated( + "Calling this function from a plugin will lead to crashes, use loadPlugin and unloadPlugin", + replaceWith = ReplaceWith("loadPlugin"), + level = DeprecationLevel.ERROR + ) @Throws suspend fun ___DO_NOT_CALL_FROM_A_PLUGIN_downloadNotExistingPluginsAndLoad( activity: Activity, @@ -419,7 +419,6 @@ object PluginManager { downloadPlugin( activity, pluginData.onlineData.second.url, - pluginData.onlineData.second.fileHash, pluginData.savedData.internalName, pluginData.onlineData.first, !pluginData.isDisabled @@ -454,8 +453,12 @@ object PluginManager { * DO NOT USE THIS IN A PLUGIN! It may case an infinite recursive loop lagging or crashing everyone's devices. * If you use it from a plugin, do not expect a stable jvmName, SO DO NOT USE IT! */ - @Suppress("FunctionName") - @InternalAPI + @Suppress("FunctionName", "DEPRECATION_ERROR") + @Deprecated( + "Calling this function from a plugin will lead to crashes, use loadPlugin and unloadPlugin", + replaceWith = ReplaceWith("loadPlugin"), + level = DeprecationLevel.ERROR + ) @Throws suspend fun ___DO_NOT_CALL_FROM_A_PLUGIN_loadAllOnlinePlugins(context: Context) { assertNonRecursiveCallstack() @@ -476,9 +479,13 @@ object PluginManager { * DO NOT USE THIS IN A PLUGIN! It may case an infinite recursive loop lagging or crashing everyone's devices. * If you use it from a plugin, do not expect a stable jvmName, SO DO NOT USE IT! */ - @Suppress("FunctionName") - @InternalAPI + @Suppress("FunctionName", "DEPRECATION_ERROR") @Throws + @Deprecated( + "Calling this function from a plugin will lead to crashes, use loadPlugin and unloadPlugin", + replaceWith = ReplaceWith("loadPlugin"), + level = DeprecationLevel.ERROR + ) suspend fun ___DO_NOT_CALL_FROM_A_PLUGIN_hotReloadAllLocalPlugins(activity: FragmentActivity?) { assertNonRecursiveCallstack() @@ -497,8 +504,12 @@ object PluginManager { * DO NOT USE THIS IN A PLUGIN! It may case an infinite recursive loop lagging or crashing everyone's devices. * If you use it from a plugin, do not expect a stable jvmName, SO DO NOT USE IT! */ - @Suppress("FunctionName") - @InternalAPI + @Suppress("FunctionName", "DEPRECATION_ERROR") + @Deprecated( + "Calling this function from a plugin will lead to crashes, use loadPlugin and unloadPlugin", + replaceWith = ReplaceWith("loadPlugin"), + level = DeprecationLevel.ERROR + ) @Throws suspend fun ___DO_NOT_CALL_FROM_A_PLUGIN_loadAllLocalPlugins(context: Context, forceReload: Boolean) { assertNonRecursiveCallstack() @@ -561,11 +572,6 @@ object PluginManager { afterPluginsLoadedEvent.invoke(forceReload) } - /** @return true if safe mode is enabled in any possible way. */ - fun isSafeMode(): Boolean { - return checkSafeModeFile() || lastError != null - } - /** * This can be used to override any extension loading to fix crashes! * @return true if safe mode file is present @@ -610,7 +616,7 @@ object PluginManager { return false } InputStreamReader(stream).use { reader -> - manifest = parseJson(reader.readText()) + manifest = parseJson(reader, BasePlugin.Manifest::class.java) } } @@ -651,15 +657,9 @@ object PluginManager { context.resources.configuration ) } - synchronized(plugins) { - plugins[filePath] = pluginInstance - } - synchronized(classLoaders) { - classLoaders[loader] = pluginInstance - } - synchronized(urlPlugins) { - urlPlugins[data.url ?: filePath] = pluginInstance - } + plugins[filePath] = pluginInstance + classLoaders[loader] = pluginInstance + urlPlugins[data.url ?: filePath] = pluginInstance if (pluginInstance is Plugin) { pluginInstance.load(context) } else { @@ -695,33 +695,25 @@ object PluginManager { } // remove all registered apis - APIHolder.apis.filter { api -> api.sourcePlugin == plugin.filename }.forEach { - removePluginMapping(it) + synchronized(APIHolder.apis) { + APIHolder.apis.filter { api -> api.sourcePlugin == plugin.filename }.forEach { + removePluginMapping(it) + } + } + synchronized(APIHolder.allProviders) { + APIHolder.allProviders.removeIf { provider: MainAPI -> provider.sourcePlugin == plugin.filename } } - APIHolder.allProviders.withLock { - APIHolder.allProviders.removeAll { provider -> provider.sourcePlugin == plugin.filename } + extractorApis.removeIf { provider: ExtractorApi -> provider.sourcePlugin == plugin.filename } + + synchronized(VideoClickActionHolder.allVideoClickActions) { + VideoClickActionHolder.allVideoClickActions.removeIf { action: VideoClickAction -> action.sourcePlugin == plugin.filename } } - extractorApis.withLock { - extractorApis.removeAll { provider -> provider.sourcePlugin == plugin.filename } - } + classLoaders.values.removeIf { v -> v == plugin } - VideoClickActionHolder.allVideoClickActions.withLock { - VideoClickActionHolder.allVideoClickActions.removeAll { action -> action.sourcePlugin == plugin.filename } - } - - synchronized(classLoaders) { - classLoaders.values.removeIf { v -> v == plugin } - } - - synchronized(plugins) { - plugins.remove(absolutePath) - } - - synchronized(urlPlugins) { - urlPlugins.values.removeIf { v -> v == plugin } - } + plugins.remove(absolutePath) + urlPlugins.values.removeIf { v -> v == plugin } } /** @@ -751,27 +743,25 @@ 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, pluginHash, internalName, file, loadPlugin) + return downloadPlugin(activity, pluginUrl, 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(activity, pluginUrl, file, pluginHash) ?: return false + val newFile = downloadPluginToFile(pluginUrl, file) ?: return false val data = PluginData( internalName, @@ -818,9 +808,13 @@ object PluginManager { * DO NOT USE THIS IN A PLUGIN! It may case an infinite recursive loop lagging or crashing everyone's devices. * If you use it from a plugin, do not expect a stable jvmName, SO DO NOT USE IT! */ - @Suppress("FunctionName") - @InternalAPI + @Suppress("FunctionName", "DEPRECATION_ERROR") @Throws + @Deprecated( + "Calling this function from a plugin will lead to crashes, use loadPlugin and unloadPlugin", + replaceWith = ReplaceWith("loadPlugin"), + level = DeprecationLevel.ERROR + ) suspend fun ___DO_NOT_CALL_FROM_A_PLUGIN_manuallyReloadAndUpdatePlugins(activity: Activity) { assertNonRecursiveCallstack() @@ -859,7 +853,6 @@ object PluginManager { if (downloadPlugin( activity, pluginData.onlineData.second.url, - pluginData.onlineData.second.fileHash, pluginData.savedData.internalName, existingFile, true @@ -958,4 +951,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 07d6aaa37..45ed65611 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/plugins/RepositoryManager.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/plugins/RepositoryManager.kt @@ -1,7 +1,6 @@ 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 @@ -19,12 +18,10 @@ 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.nio.file.AtomicMoveNotSupportedException -import java.nio.file.Files -import java.nio.file.StandardCopyOption -import java.security.MessageDigest -import java.util.concurrent.atomic.AtomicInteger +import java.io.InputStream +import java.io.OutputStream /** * Comes with the app, always available in the app, non removable. @@ -70,7 +67,6 @@ data class SitePlugin( @JsonProperty("iconUrl") val iconUrl: String?, // Automatically generated by the gradle plugin @JsonProperty("fileSize") val fileSize: Long?, - @JsonProperty("fileHash") val fileHash: String?, ) @@ -79,26 +75,7 @@ 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_.-]+)/(.*)$") - - - /** 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) } - } + private val GH_REGEX = Regex("^https://raw.githubusercontent.com/([A-Za-z0-9-]+)/([A-Za-z0-9_.-]+)/(.*)$") /* Convert raw.githubusercontent.com urls to cdn.jsdelivr.net if enabled in settings */ fun convertRawGitUrl(url: String): String { @@ -163,52 +140,21 @@ object RepositoryManager { }.flatten() } - suspend fun downloadPluginToFile( - context: Context, pluginUrl: String, - file: File, - expectedFileHash: String? + file: File ): File? { return safeAsync { - val parentDir = file.parentFile ?: return@safeAsync null - parentDir.mkdirs() + file.mkdirs() - // Prevent corrupting the plugin file if the operation fails - val tempFile = File.createTempFile(file.name, ".tmp", context.cacheDir) + // Overwrite if exists + if (file.exists()) { + file.delete() + } + file.createNewFile() val body = app.get(convertRawGitUrl(pluginUrl)).okhttpResponse.body - - 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 - ) - } - + write(body.byteStream(), file.outputStream()) file } } @@ -256,4 +202,13 @@ 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/plugins/VotingApi.kt b/app/src/main/java/com/lagradost/cloudstream3/plugins/VotingApi.kt index 85a806f0b..930106644 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/plugins/VotingApi.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/plugins/VotingApi.kt @@ -12,76 +12,87 @@ import com.lagradost.cloudstream3.utils.Coroutines.main import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock -object VotingApi { - +object VotingApi { // please do not cheat the votes lol private const val LOGKEY = "VotingApi" - private const val API_DOMAIN = "https://api.countify.xyz" - private fun transformUrl(url: String): String = + private const val API_DOMAIN = "https://counterapi.com/api" + + private fun transformUrl(url: String): String = // dont touch or all votes get reset MessageDigest .getInstance("SHA-256") .digest("${url}#funny-salt".toByteArray()) .fold("") { str, it -> str + "%02x".format(it) } - suspend fun SitePlugin.getVotes(): Int = getVotes(url) - fun SitePlugin.hasVoted(): Boolean = hasVoted(url) - suspend fun SitePlugin.vote(): Int = vote(url) - fun SitePlugin.canVote(): Boolean = canVote(this.url) + suspend fun SitePlugin.getVotes(): Int { + return getVotes(url) + } + fun SitePlugin.hasVoted(): Boolean { + return hasVoted(url) + } + + suspend fun SitePlugin.vote(): Int { + return vote(url) + } + + fun SitePlugin.canVote(): Boolean { + return canVote(this.url) + } + + // Plugin url to Int private val votesCache = mutableMapOf() + private fun getRepository(pluginUrl: String) = pluginUrl + .split("/") + .drop(2) + .take(3) + .joinToString("-") + private suspend fun readVote(pluginUrl: String): Int { - val id = transformUrl(pluginUrl) - val url = "$API_DOMAIN/get-total/$id" - Log.d(LOGKEY, "Requesting GET: $url") - return app.get(url).parsedSafe()?.count ?: 0 + val url = "${API_DOMAIN}/cs-${getRepository(pluginUrl)}/vote/${transformUrl(pluginUrl)}?readOnly=true" + Log.d(LOGKEY, "Requesting: $url") + return app.get(url).parsedSafe()?.value ?: 0 } private suspend fun writeVote(pluginUrl: String): Boolean { - val id = transformUrl(pluginUrl) - val url = "$API_DOMAIN/increment/$id" - Log.d(LOGKEY, "Requesting POST: $url") - return app.post(url, emptyMap()) - .parsedSafe()?.count != null + val url = "${API_DOMAIN}/cs-${getRepository(pluginUrl)}/vote/${transformUrl(pluginUrl)}" + Log.d(LOGKEY, "Requesting: $url") + return app.get(url).parsedSafe()?.value != null } suspend fun getVotes(pluginUrl: String): Int = - votesCache[pluginUrl] ?: readVote(pluginUrl).also { - votesCache[pluginUrl] = it - } + votesCache[pluginUrl] ?: readVote(pluginUrl).also { + votesCache[pluginUrl] = it + } fun hasVoted(pluginUrl: String) = getKey("cs3-votes/${transformUrl(pluginUrl)}") ?: false - fun canVote(pluginUrl: String): Boolean = - PluginManager.urlPlugins.contains(pluginUrl) + fun canVote(pluginUrl: String): Boolean { + return PluginManager.urlPlugins.contains(pluginUrl) + } private val voteLock = Mutex() - suspend fun vote(pluginUrl: String): Int { + // Prevent multiple requests at the same time. voteLock.withLock { if (!canVote(pluginUrl)) { main { - Toast.makeText( - context, - R.string.extension_install_first, - Toast.LENGTH_SHORT - ).show() + Toast.makeText(context, R.string.extension_install_first, Toast.LENGTH_SHORT) + .show() } return getVotes(pluginUrl) } if (hasVoted(pluginUrl)) { main { - Toast.makeText( - context, - R.string.already_voted, - Toast.LENGTH_SHORT - ).show() + Toast.makeText(context, R.string.already_voted, Toast.LENGTH_SHORT) + .show() } return getVotes(pluginUrl) } + if (writeVote(pluginUrl)) { setKey("cs3-votes/${transformUrl(pluginUrl)}", true) votesCache[pluginUrl] = votesCache[pluginUrl]?.plus(1) ?: 1 @@ -91,8 +102,7 @@ object VotingApi { } } - private data class CountifyResult( - val id: String? = null, - val count: Int? = null + private data class Result( + val value: Int? ) -} +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/services/DownloadQueueService.kt b/app/src/main/java/com/lagradost/cloudstream3/services/DownloadQueueService.kt deleted file mode 100644 index e07747a86..000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/services/DownloadQueueService.kt +++ /dev/null @@ -1,279 +0,0 @@ -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 -import android.util.Log -import androidx.core.app.NotificationCompat -import androidx.core.app.NotificationManagerCompat -import androidx.core.app.PendingIntentCompat -import com.lagradost.cloudstream3.CloudStreamApp.Companion.removeKey -import com.lagradost.cloudstream3.MainActivity -import com.lagradost.cloudstream3.MainActivity.Companion.lastError -import com.lagradost.cloudstream3.MainActivity.Companion.setLastError -import com.lagradost.cloudstream3.R -import com.lagradost.cloudstream3.mvvm.debugAssert -import com.lagradost.cloudstream3.mvvm.debugWarning -import com.lagradost.cloudstream3.mvvm.logError -import com.lagradost.cloudstream3.mvvm.safe -import com.lagradost.cloudstream3.plugins.PluginManager -import com.lagradost.cloudstream3.utils.AppContextUtils.createNotificationChannel -import com.lagradost.cloudstream3.utils.Coroutines.ioSafe -import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute -import com.lagradost.cloudstream3.utils.downloader.DownloadQueueManager -import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager -import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager.KEY_RESUME_IN_QUEUE -import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager.KEY_RESUME_PACKAGES -import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager.downloadEvent -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.FlowPreview -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.debounce -import kotlinx.coroutines.flow.takeWhile -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.flow.updateAndGet -import kotlinx.coroutines.withTimeoutOrNull -import kotlin.system.measureTimeMillis -import kotlin.time.Duration.Companion.milliseconds -import kotlin.time.Duration.Companion.seconds - -class DownloadQueueService : Service() { - companion object { - const val TAG = "DownloadQueueService" - const val DOWNLOAD_QUEUE_CHANNEL_ID = "cloudstream3.download.queue" - const val DOWNLOAD_QUEUE_CHANNEL_NAME = "Download queue service" - const val DOWNLOAD_QUEUE_CHANNEL_DESCRIPTION = "App download queue notification." - const val DOWNLOAD_QUEUE_NOTIFICATION_ID = 917194232 // Random unique - @Volatile - var isRunning = false - - fun getIntent( - context: Context, - ): Intent { - return Intent(context, DownloadQueueService::class.java) - } - - private val _downloadInstances: MutableStateFlow> = - MutableStateFlow(emptyList()) - - /** Flow of all active downloads, not queued. May temporarily contain completed or failed EpisodeDownloadInstances. - * Completed or failed instances are automatically removed by the download queue service. - * - */ - val downloadInstances: StateFlow> = - _downloadInstances - - private val totalDownloadFlow = - downloadInstances.combine(DownloadQueueManager.queue) { instances, queue -> - instances to queue - } - .combine(VideoDownloadManager.currentDownloads) { (instances, queue), currentDownloads -> - Triple(instances, queue, currentDownloads) - } - } - - - private val baseNotification by lazy { - val intent = Intent(this, MainActivity::class.java) - val pendingIntent = - PendingIntentCompat.getActivity(this, 0, intent, 0, false) - - val activeDownloads = resources.getQuantityString(R.plurals.downloads_active, 0).format(0) - val activeQueue = resources.getQuantityString(R.plurals.downloads_queued, 0).format(0) - - NotificationCompat.Builder(this, DOWNLOAD_QUEUE_CHANNEL_ID) - .setOngoing(true) // Make it persistent - .setAutoCancel(false) - .setColorized(false) - .setOnlyAlertOnce(true) - .setSilent(true) - .setShowWhen(false) - // If low priority then the notification might not show :( - .setPriority(NotificationCompat.PRIORITY_DEFAULT) - .setColor(this.colorFromAttribute(R.attr.colorPrimary)) - .setContentText(activeDownloads) - .setSubText(activeQueue) - .setContentIntent(pendingIntent) - .setSmallIcon(R.drawable.download_icon_load) - } - - - private fun updateNotification(context: Context, downloads: Int, queued: Int) { - if (context.checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) - != PackageManager.PERMISSION_GRANTED - ) return - - val activeDownloads = - resources.getQuantityString(R.plurals.downloads_active, downloads).format(downloads) - val activeQueue = - resources.getQuantityString(R.plurals.downloads_queued, queued).format(queued) - - val newNotification = baseNotification - .setContentText(activeDownloads) - .setSubText(activeQueue) - .build() - - safe { - NotificationManagerCompat.from(context) - .notify(DOWNLOAD_QUEUE_NOTIFICATION_ID, newNotification) - } - } - - // We always need to listen to events, even before the download is launched. - // Stopping link loading is an event which can trigger before downloading. - val downloadEventListener = { event: Pair -> - when (event.second) { - VideoDownloadManager.DownloadActionType.Stop -> { - removeKey(KEY_RESUME_PACKAGES, event.first.toString()) - removeKey(KEY_RESUME_IN_QUEUE, event.first.toString()) - DownloadQueueManager.cancelDownload(event.first) - } - - else -> {} - } - } - - @OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class) - override fun onCreate() { - isRunning = true - val context: Context = this // To make code more readable - - Log.d(TAG, "Download queue service started.") - this.createNotificationChannel( - DOWNLOAD_QUEUE_CHANNEL_ID, - DOWNLOAD_QUEUE_CHANNEL_NAME, - DOWNLOAD_QUEUE_CHANNEL_DESCRIPTION - ) - if (SDK_INT >= 29) { - startForeground( - DOWNLOAD_QUEUE_NOTIFICATION_ID, - baseNotification.build(), - FOREGROUND_SERVICE_TYPE_DATA_SYNC - ) - } else { - startForeground(DOWNLOAD_QUEUE_NOTIFICATION_ID, baseNotification.build()) - } - - downloadEvent += downloadEventListener - - val queueJob = ioSafe { - // Ensure this is up to date to prevent race conditions with MainActivity launches - setLastError(context) - // Early return, to prevent waiting for plugins in safe mode - if (lastError != null) return@ioSafe - - // Try to ensure all plugins are loaded before starting the downloader. - // To prevent infinite stalls we use a timeout of 15 seconds, it is judged as long enough - val timeout = 15.seconds - val timeTaken = withTimeoutOrNull(timeout) { - measureTimeMillis { - while (!(PluginManager.loadedOnlinePlugins && PluginManager.loadedLocalPlugins)) { - delay(100.milliseconds) - } - } - } - - debugWarning({ timeTaken == null || timeTaken > 3_000 }, { - "Abnormally long downloader startup time of: ${timeTaken ?: timeout.inWholeMilliseconds}ms" - }) - debugAssert({ timeTaken == null }, { "Downloader startup should not time out" }) - - totalDownloadFlow - .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 - // Run as long as there is a queue to process - && (instances.isNotEmpty() || queue.isNotEmpty()) - // Run as long as there are no app crashes - && lastError == null - } - .collect { (_, queue, currentDownloads) -> - // Remove completed or failed - val newInstances = _downloadInstances.updateAndGet { currentInstances -> - currentInstances.filterNot { it.isCompleted || it.isFailed || it.isCancelled } - } - - val maxDownloads = VideoDownloadManager.maxConcurrentDownloads(context) - val currentInstanceCount = newInstances.size - - val newDownloads = minOf( - // Cannot exceed the max downloads - maxOf(0, maxDownloads - currentInstanceCount), - // Cannot start more downloads than the queue size - queue.size - ) - - // Cant start multiple downloads at once. If this is rerun it may start too many downloads. - if (newDownloads > 0) { - _downloadInstances.update { instances -> - val downloadInstance = DownloadQueueManager.popQueue(context) - if (downloadInstance != null) { - downloadInstance.startDownload() - instances + downloadInstance - } else { - instances - } - } - } - - // The downloads actually displayed to the user with a notification - val currentVisualDownloads = - currentDownloads.size + newInstances.count { - currentDownloads.contains(it.downloadQueueWrapper.id) - .not() - } - // Just the queue - val currentVisualQueue = queue.size - - updateNotification(context, currentVisualDownloads, currentVisualQueue) - } - } - - // Stop self regardless of job outcome - queueJob.invokeOnCompletion { throwable -> - if (throwable != null) { - logError(throwable) - } - safe { - stopSelf() - } - } - } - - override fun onDestroy() { - Log.d(TAG, "Download queue service stopped.") - downloadEvent -= downloadEventListener - isRunning = false - super.onDestroy() - } - - override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { - return START_STICKY // We want the service restarted if its killed - } - - override fun onBind(intent: Intent?): IBinder? = null - - override fun onTimeout(reason: Int) { - stopSelf() - Log.e(TAG, "Service stopped due to timeout: $reason") - } - -} diff --git a/app/src/main/java/com/lagradost/cloudstream3/services/SubscriptionWorkManager.kt b/app/src/main/java/com/lagradost/cloudstream3/services/SubscriptionWorkManager.kt index 7134650ed..fc31c1f3e 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/services/SubscriptionWorkManager.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/services/SubscriptionWorkManager.kt @@ -22,7 +22,7 @@ import com.lagradost.cloudstream3.utils.DataStoreHelper import com.lagradost.cloudstream3.utils.DataStoreHelper.getAllSubscriptions import com.lagradost.cloudstream3.utils.DataStoreHelper.getDub import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute -import com.lagradost.cloudstream3.utils.downloader.DownloadUtils.getImageBitmapFromUrl +import com.lagradost.cloudstream3.utils.VideoDownloadManager.getImageBitmapFromUrl import kotlinx.coroutines.withTimeoutOrNull import java.util.concurrent.TimeUnit @@ -97,7 +97,7 @@ class SubscriptionWorkManager(val context: Context, workerParams: WorkerParamete .build() ) } - + @Suppress("DEPRECATION_ERROR") override suspend fun doWork(): Result { try { // println("Update subscriptions!") diff --git a/app/src/main/java/com/lagradost/cloudstream3/services/VideoDownloadService.kt b/app/src/main/java/com/lagradost/cloudstream3/services/VideoDownloadService.kt index d63b18cdc..6151a0edd 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/services/VideoDownloadService.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/services/VideoDownloadService.kt @@ -2,13 +2,12 @@ package com.lagradost.cloudstream3.services import android.app.Service import android.content.Intent import android.os.IBinder -import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager +import com.lagradost.cloudstream3.utils.VideoDownloadManager import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.cancel import kotlinx.coroutines.launch -/** Handle notification actions such as pause/resume downloads */ class VideoDownloadService : Service() { private val downloadScope = CoroutineScope(Dispatchers.Default) @@ -43,3 +42,19 @@ class VideoDownloadService : Service() { super.onDestroy() } } +// override fun onHandleIntent(intent: Intent?) { +// if (intent != null) { +// val id = intent.getIntExtra("id", -1) +// val type = intent.getStringExtra("type") +// if (id != -1 && type != null) { +// val state = when (type) { +// "resume" -> VideoDownloadManager.DownloadActionType.Resume +// "pause" -> VideoDownloadManager.DownloadActionType.Pause +// "stop" -> VideoDownloadManager.DownloadActionType.Stop +// else -> return +// } +// VideoDownloadManager.downloadEvent.invoke(Pair(id, state)) +// } +// } +// } +//} diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/AccountManager.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/AccountManager.kt index 3bc5f2733..93df0fd26 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/AccountManager.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/AccountManager.kt @@ -5,7 +5,6 @@ import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey import com.lagradost.cloudstream3.LoadResponse import com.lagradost.cloudstream3.syncproviders.providers.Addic7ed import com.lagradost.cloudstream3.syncproviders.providers.AniListApi -import com.lagradost.cloudstream3.syncproviders.providers.KitsuApi import com.lagradost.cloudstream3.syncproviders.providers.LocalList import com.lagradost.cloudstream3.syncproviders.providers.MALApi import com.lagradost.cloudstream3.syncproviders.providers.OpenSubtitlesApi @@ -13,14 +12,12 @@ 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 { companion object { const val NONE_ID: Int = -1 val malApi = MALApi() - val kitsuApi = KitsuApi() val aniListApi = AniListApi() val simklApi = SimklApi() val localListApi = LocalList() @@ -29,7 +26,6 @@ abstract class AccountManager { val addic7ed = Addic7ed() val subDlApi = SubDlApi() val subSourceApi = SubSourceApi() - val animeSkipApi = AnimeSkipAuth() var cachedAccounts: MutableMap> var cachedAccountIds: MutableMap @@ -63,14 +59,13 @@ abstract class AccountManager { val allApis = arrayOf( SyncRepo(malApi), - SyncRepo(kitsuApi), SyncRepo(aniListApi), SyncRepo(simklApi), SyncRepo(localListApi), + SubtitleRepo(openSubtitlesApi), SubtitleRepo(addic7ed), - SubtitleRepo(subDlApi), - PlainAuthRepo(animeSkipApi) + SubtitleRepo(subDlApi) ) fun updateAccountIds() { @@ -112,7 +107,6 @@ abstract class AccountManager { // accessing other classes fun initMainAPI() { LoadResponse.malIdPrefix = malApi.idPrefix - LoadResponse.kitsuIdPrefix = kitsuApi.idPrefix LoadResponse.aniListIdPrefix = aniListApi.idPrefix LoadResponse.simklIdPrefix = simklApi.idPrefix } @@ -124,7 +118,6 @@ abstract class AccountManager { ) val syncApis = arrayOf( SyncRepo(malApi), - SyncRepo(kitsuApi), SyncRepo(aniListApi), SyncRepo(simklApi), SyncRepo(localListApi) diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/AuthAPI.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/AuthAPI.kt index 184a9fbcc..0303e03c6 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/AuthAPI.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/AuthAPI.kt @@ -28,7 +28,6 @@ import com.lagradost.cloudstream3.syncproviders.providers.Addic7ed import com.lagradost.cloudstream3.syncproviders.providers.AniListApi import com.lagradost.cloudstream3.syncproviders.providers.LocalList import com.lagradost.cloudstream3.syncproviders.providers.MALApi -import com.lagradost.cloudstream3.syncproviders.providers.KitsuApi import com.lagradost.cloudstream3.syncproviders.providers.OpenSubtitlesApi import com.lagradost.cloudstream3.syncproviders.providers.SimklApi import com.lagradost.cloudstream3.syncproviders.providers.SubDlApi @@ -36,9 +35,11 @@ import com.lagradost.cloudstream3.syncproviders.providers.SubSourceApi import com.lagradost.cloudstream3.ui.SyncWatchType import com.lagradost.cloudstream3.ui.library.ListSorting import com.lagradost.cloudstream3.utils.AppContextUtils.splitQuery +import com.lagradost.cloudstream3.utils.Coroutines.threadSafeListOf import com.lagradost.cloudstream3.utils.DataStoreHelper import com.lagradost.cloudstream3.utils.UiText import com.lagradost.cloudstream3.utils.txt +import me.xdrop.fuzzywuzzy.FuzzySearch import java.net.URL import java.security.SecureRandom import java.util.Date 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 645a19e3a..4ae629ab9 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/AuthRepo.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/AuthRepo.kt @@ -9,9 +9,6 @@ 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/syncproviders/SubtitleRepo.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/SubtitleRepo.kt index 0b8c3e5ae..7a93f96f6 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/SubtitleRepo.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/SubtitleRepo.kt @@ -6,7 +6,7 @@ import com.lagradost.cloudstream3.ErrorLoadingException import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities.SubtitleEntity import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities.SubtitleSearch import com.lagradost.cloudstream3.subtitles.SubtitleResource -import com.lagradost.cloudstream3.utils.Coroutines.atomicListOf +import com.lagradost.cloudstream3.utils.Coroutines.threadSafeListOf /** Stateless safe abstraction of SubtitleAPI */ class SubtitleRepo(override val api: SubtitleAPI) : AuthRepo(api) { @@ -24,30 +24,26 @@ class SubtitleRepo(override val api: SubtitleAPI) : AuthRepo(api) { ) // maybe make this a generic struct? right now there is a lot of boilerplate - private val searchCache = atomicListOf() + private val searchCache = threadSafeListOf() private var searchCacheIndex: Int = 0 - private val resourceCache = atomicListOf() + private val resourceCache = threadSafeListOf() private var resourceCacheIndex: Int = 0 const val CACHE_SIZE = 20 } @WorkerThread suspend fun resource(data: SubtitleEntity): Result = runCatching { - val cached = resourceCache.withLock { - var found: SubtitleResource? = null + synchronized(resourceCache) { for (item in resourceCache) { // 20 min save if (item.query == data && (unixTime - item.unixTime) < 60 * 20) { - found = item.response - break + return@runCatching item.response } } - found } - if (cached != null) return@runCatching cached val returnValue = api.resource(freshAuth(), data) - resourceCache.withLock { + synchronized(resourceCache) { val add = SavedResourceResponse(unixTime, returnValue, data) if (resourceCache.size > CACHE_SIZE) { resourceCache[resourceCacheIndex] = add // rolling cache @@ -62,25 +58,22 @@ class SubtitleRepo(override val api: SubtitleAPI) : AuthRepo(api) { @WorkerThread suspend fun search(query: SubtitleSearch): Result> { return runCatching { - val cached = searchCache.withLock { - var found: List? = null + synchronized(searchCache) { for (item in searchCache) { // 120 min save if (item.query == query && (unixTime - item.unixTime) < 60 * 120) { - found = item.response - break + return@runCatching item.response } } - found } - if (cached != null) return@runCatching cached - val returnValue = api.search(freshAuth(), query) ?: emptyList() + val returnValue = + api.search(freshAuth(), query) ?: emptyList() // only cache valid return values if (returnValue.isNotEmpty()) { val add = SavedSearchResponse(unixTime, returnValue, query) - searchCache.withLock { + synchronized(searchCache) { if (searchCache.size > CACHE_SIZE) { searchCache[searchCacheIndex] = add // rolling cache searchCacheIndex = (searchCacheIndex + 1) % CACHE_SIZE @@ -93,3 +86,4 @@ class SubtitleRepo(override val api: SubtitleAPI) : AuthRepo(api) { } } } + diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/SyncAPI.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/SyncAPI.kt index f30a64748..e5f9aca84 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/SyncAPI.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/SyncAPI.kt @@ -10,8 +10,8 @@ import com.lagradost.cloudstream3.ShowStatus import com.lagradost.cloudstream3.TvType import com.lagradost.cloudstream3.ui.SyncWatchType import com.lagradost.cloudstream3.ui.library.ListSorting -import com.lagradost.cloudstream3.utils.Levenshtein import com.lagradost.cloudstream3.utils.UiText +import me.xdrop.fuzzywuzzy.FuzzySearch import java.util.Date /** @@ -138,7 +138,7 @@ abstract class SyncAPI : AuthAPI() { ListSorting.Query -> if (query != null) { items.sortedBy { - -Levenshtein.partialRatio( + -FuzzySearch.partialRatio( query.lowercase(), it.name.lowercase() ) } @@ -191,4 +191,4 @@ abstract class SyncAPI : AuthAPI() { override var score: Score? = null, val tags: List? = null ) : SearchResponse -} +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/AniListApi.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/AniListApi.kt index 177018e19..7a46b4113 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/AniListApi.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/AniListApi.kt @@ -50,8 +50,7 @@ class AniListApi : SyncAPI() { override suspend fun login(redirectUrl: String, payload: String?): AuthToken? { val sanitizer = splitRedirectUrl(redirectUrl) val token = AuthToken( - accessToken = sanitizer["access_token"] - ?: throw ErrorLoadingException("No access token"), + accessToken = sanitizer["access_token"] ?: throw ErrorLoadingException("No access token"), //refreshToken = sanitizer["refresh_token"], accessTokenLifetime = unixTime + sanitizer["expires_in"]!!.toLong(), ) @@ -84,8 +83,8 @@ class AniListApi : SyncAPI() { return "$mainUrl/anime/$id" } - override suspend fun search(auth: AuthData?, query: String): List? { - val data = searchShows(query) ?: return null + override suspend fun search(auth : AuthData?, query: String): List? { + val data = searchShows(name) ?: return null return data.data?.page?.media?.map { SyncAPI.SyncSearchResult( it.title.romaji ?: return null, @@ -97,7 +96,7 @@ class AniListApi : SyncAPI() { } } - override suspend fun load(auth: AuthData?, id: String): SyncAPI.SyncResult? { + override suspend fun load(auth : AuthData?, id: String): SyncAPI.SyncResult? { val internalId = (Regex("anilist\\.co/anime/(\\d*)").find(id)?.groupValues?.getOrNull(1) ?: id).toIntOrNull() ?: throw ErrorLoadingException("Invalid internalId") val season = getSeason(internalId).data.media @@ -159,7 +158,7 @@ class AniListApi : SyncAPI() { ) } - override suspend fun status(auth: AuthData?, id: String): SyncAPI.AbstractSyncStatus? { + override suspend fun status(auth : AuthData?, id: String): SyncAPI.AbstractSyncStatus? { val internalId = id.toIntOrNull() ?: return null val data = getDataAboutId(auth ?: return null, internalId) ?: return null @@ -460,7 +459,7 @@ class AniListApi : SyncAPI() { } } - private suspend fun getDataAboutId(auth: AuthData, id: Int): AniListTitleHolder? { + private suspend fun getDataAboutId(auth : AuthData, id: Int): AniListTitleHolder? { val q = """query (${'$'}id: Int = $id) { # Define which variables will be used in the query (id) Media (id: ${'$'}id, type: ANIME) { # Insert our variables into the query arguments (id) (type: ANIME is hard-coded in the query) @@ -507,7 +506,7 @@ class AniListApi : SyncAPI() { } - private suspend fun postApi(token: AuthToken, q: String, cache: Boolean = false): String? { + private suspend fun postApi(token : AuthToken, q: String, cache: Boolean = false): String? { return app.post( "https://graphql.anilist.co/", headers = mapOf( @@ -639,7 +638,7 @@ class AniListApi : SyncAPI() { } } - override suspend fun library(auth: AuthData?): SyncAPI.LibraryMetadata? { + override suspend fun library(auth : AuthData?): SyncAPI.LibraryMetadata? { val list = getAniListAnimeListSmart(auth ?: return null)?.groupBy { convertAniListStringToStatus(it.status ?: "").stringRes }?.mapValues { group -> @@ -667,7 +666,7 @@ class AniListApi : SyncAPI() { ) } - private suspend fun getFullAniListList(auth: AuthData): FullAnilistList? { + private suspend fun getFullAniListList(auth : AuthData): FullAnilistList? { val userID = auth.user.id val mediaType = "ANIME" @@ -715,7 +714,7 @@ class AniListApi : SyncAPI() { return text?.toKotlinObject() } - suspend fun toggleLike(auth: AuthData, id: Int): Boolean { + suspend fun toggleLike(auth : AuthData, id: Int): Boolean { val q = """mutation (${'$'}animeId: Int = $id) { ToggleFavourite (animeId: ${'$'}animeId) { anime { @@ -738,7 +737,7 @@ class AniListApi : SyncAPI() { data class MediaListId(@JsonProperty("id") val id: Long? = null) private suspend fun postDataAboutId( - auth: AuthData, + auth : AuthData, id: Int, type: AniListStatusType, score: Score?, @@ -787,7 +786,7 @@ class AniListApi : SyncAPI() { return data != "" } - private suspend fun getUser(token: AuthToken): AniListUser? { + private suspend fun getUser(token : AuthToken): AniListUser? { val q = """ { Viewer { diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/KitsuApi.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/KitsuApi.kt index e15a77c64..724d72163 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/KitsuApi.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/KitsuApi.kt @@ -1,676 +1,8 @@ package com.lagradost.cloudstream3.syncproviders.providers - -import androidx.annotation.StringRes import com.fasterxml.jackson.annotation.JsonProperty -import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey -import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey -import com.lagradost.cloudstream3.R -import com.lagradost.cloudstream3.Score -import com.lagradost.cloudstream3.ShowStatus -import com.lagradost.cloudstream3.TvType import com.lagradost.cloudstream3.app import com.lagradost.cloudstream3.mvvm.logError -import com.lagradost.cloudstream3.syncproviders.AuthData -import com.lagradost.cloudstream3.syncproviders.AuthLoginRequirement -import com.lagradost.cloudstream3.syncproviders.AuthLoginResponse -import com.lagradost.cloudstream3.syncproviders.AuthToken -import com.lagradost.cloudstream3.syncproviders.AuthUser -import com.lagradost.cloudstream3.syncproviders.SyncAPI -import com.lagradost.cloudstream3.syncproviders.SyncIdName -import com.lagradost.cloudstream3.ui.SyncWatchType -import com.lagradost.cloudstream3.ui.library.ListSorting -import com.lagradost.cloudstream3.utils.AppUtils.toJson -import com.lagradost.cloudstream3.utils.txt -import okhttp3.Interceptor -import okhttp3.Request -import okhttp3.RequestBody.Companion.toRequestBody -import okhttp3.Response -import java.text.SimpleDateFormat -import java.time.LocalDate -import java.time.ZoneId -import java.util.Date -import java.util.Locale - -const val KITSU_MAX_SEARCH_LIMIT = 20 - -class KitsuApi: SyncAPI() { - override var name = "Kitsu" - override val idPrefix = "kitsu" - - private val apiUrl = "https://kitsu.io/api/edge" - private val fallbackApiUrl = "https://kitsu.app/api/edge" - private val oauthUrl = "https://kitsu.io/api/oauth" - private val fallbackOauthUrl = "https://kitsu.app/api/oauth" - override val hasInApp = true - override val mainUrl = "https://kitsu.app" - override val icon = R.drawable.kitsu_icon - override val syncIdName = SyncIdName.Kitsu - override val createAccountUrl = mainUrl - - override val supportedWatchTypes = setOf( - SyncWatchType.WATCHING, - SyncWatchType.COMPLETED, - SyncWatchType.PLANTOWATCH, - SyncWatchType.DROPPED, - SyncWatchType.ONHOLD, - SyncWatchType.NONE - ) - - override val inAppLoginRequirement = AuthLoginRequirement( - password = true, - email = true - ) - - private class FallbackInterceptor(private val apiUrl: String, private val fallbackApiUrl: String) : Interceptor { - override fun intercept(chain: Interceptor.Chain): Response { - val request: Request = chain.request() - - try { - - val response = chain.proceed(request); - - if (response.isSuccessful) return response - - response.close() - - } catch (_: Exception) { - } - - val fallbackRequest: Request = request.newBuilder() - .url(request.url.toString().replaceFirst(apiUrl, fallbackApiUrl)) - .build() - - return chain.proceed(fallbackRequest) - - } - } - - private val apiFallbackInterceptor = FallbackInterceptor(apiUrl, fallbackApiUrl) - private val oauthFallbackInterceptor = FallbackInterceptor(oauthUrl, fallbackOauthUrl) - - override suspend fun login(form: AuthLoginResponse): AuthToken? { - val username = form.email ?: return null - val password = form.password ?: return null - - val grantType = "password" - - val token = app.post( - "$oauthUrl/token", - data = mapOf( - "grant_type" to grantType, - "username" to username, - "password" to password - ), - interceptor = oauthFallbackInterceptor - ).parsed() - - return AuthToken( - accessTokenLifetime = unixTime + token.expiresIn.toLong(), - refreshToken = token.refreshToken, - accessToken = token.accessToken, - ) - } - - override suspend fun refreshToken(token: AuthToken): AuthToken { - val res = app.post( - "$oauthUrl/token", - data = mapOf( - "grant_type" to "refresh_token", - "refresh_token" to token.refreshToken!! - ), - interceptor = oauthFallbackInterceptor - ).parsed() - - return AuthToken( - accessToken = res.accessToken, - refreshToken = res.refreshToken, - accessTokenLifetime = unixTime + res.expiresIn.toLong() - ) - } - - override suspend fun user(token: AuthToken?): AuthUser? { - val user = app.get( - "$apiUrl/users?filter[self]=true", - headers = mapOf( - "Authorization" to "Bearer ${token?.accessToken ?: return null}" - ), cacheTime = 0, - interceptor = apiFallbackInterceptor - ).parsed() - - if (user.data.isEmpty()) { - return null - } - - return AuthUser( - id = user.data[0].id.toInt(), - name = user.data[0].attributes.name, - profilePicture = user.data[0].attributes.avatar?.original - ) - } - - override suspend fun search(auth: AuthData?, query: String): List? { - val auth = auth?.token?.accessToken ?: return null - val animeSelectedFields = arrayOf("titles","canonicalTitle","posterImage","episodeCount") - val url = "$apiUrl/anime?filter[text]=$query&page[limit]=$KITSU_MAX_SEARCH_LIMIT&fields[anime]=${animeSelectedFields.joinToString(",")}" - - val res = app.get( - url, headers = mapOf( - "Authorization" to "Bearer $auth", - ), cacheTime = 0, - interceptor = apiFallbackInterceptor - ).parsed() - - return res.data.map { - val attributes = it.attributes - - val title = attributes.canonicalTitle ?: attributes.titles?.enJp ?: attributes.titles?.jaJp ?: "No title" - - SyncSearchResult( - title, - this.name, - it.id, - "$mainUrl/anime/${it.id}/", - attributes.posterImage?.large ?: attributes.posterImage?.medium - ) - } - } - - override suspend fun load(auth : AuthData?, id: String): SyncResult? { - val auth = auth?.token?.accessToken ?: return null - if (id.toIntOrNull() == null) { - return null - } - - data class KitsuResponse( - @field:JsonProperty(value = "data") - val data: KitsuNode, - ) - - val url = - "$apiUrl/anime/$id" - - val anime = app.get( - url, headers = mapOf( - "Authorization" to "Bearer $auth" - ), - interceptor = apiFallbackInterceptor - ).parsed().data.attributes - - return SyncResult( - id = id, - totalEpisodes = anime.episodeCount, - title = anime.canonicalTitle ?: anime.titles?.enJp ?: anime.titles?.jaJp.orEmpty(), - publicScore = Score.from(anime.ratingTwenty, 20), - duration = anime.episodeLength, - synopsis = anime.synopsis, - airStatus = when(anime.status) { - "finished" -> ShowStatus.Completed - "current" -> ShowStatus.Ongoing - else -> null - }, - nextAiring = null, - studio = null, - genres = null, - trailers = null, - startDate = LocalDate.parse(anime.startDate).toEpochDay(), - endDate = LocalDate.parse(anime.endDate).toEpochDay(), - recommendations = null, - nextSeason =null, - prevSeason = null, - actors = null, - ) - - } - - override suspend fun status(auth : AuthData?, id: String): AbstractSyncStatus? { - val accessToken = auth?.token?.accessToken ?: return null - val userId = auth.user.id - - val selectedFields = arrayOf("status","ratingTwenty", "progress") - - val url = - "$apiUrl/library-entries?filter[userId]=$userId&filter[animeId]=$id&fields[libraryEntries]=${selectedFields.joinToString(",")}" - - val anime = app.get( - url, headers = mapOf( - "Authorization" to "Bearer $accessToken" - ), - interceptor = apiFallbackInterceptor - ).parsed().data.firstOrNull()?.attributes - - if (anime == null) { - return SyncStatus( - score = null, - status = SyncWatchType.NONE, - isFavorite = null, - watchedEpisodes = null - ) - } - - return SyncStatus( - score = Score.from(anime.ratingTwenty, 20), - status = SyncWatchType.fromInternalId(kitsuStatusAsString.indexOf(anime.status)), - isFavorite = null, - watchedEpisodes = anime.progress, - ) - } - suspend fun getAnimeIdByTitle(title: String): String? { - - val animeSelectedFields = arrayOf("titles","canonicalTitle") - val url = "$apiUrl/anime?filter[text]=$title&page[limit]=$KITSU_MAX_SEARCH_LIMIT&fields[anime]=${animeSelectedFields.joinToString(",")}" - - val res = app.get(url, interceptor = apiFallbackInterceptor).parsed() - - return res.data.firstOrNull()?.id - - } - - override fun urlToId(url: String): String? = - Regex("""/anime/((.*)/|(.*))""").find(url)?.groupValues?.first() - - override suspend fun updateStatus( - auth : AuthData?, - id: String, - newStatus: AbstractSyncStatus - ): Boolean { - - return setScoreRequest( - auth ?: return false, - id.toIntOrNull() ?: return false, - fromIntToAnimeStatus(newStatus.status), - newStatus.score?.toInt(20), - newStatus.watchedEpisodes - ) - } - - private suspend fun setScoreRequest( - auth : AuthData, - id: Int, - status: KitsuStatusType? = null, - score: Int? = null, - numWatchedEpisodes: Int? = null, - ): Boolean { - - val libraryEntryId = getAnimeLibraryEntryId(auth, id) - - // Exists entry for anime in library - if (libraryEntryId != null) { - - // Delete anime from library - if (status == null || status == KitsuStatusType.None) { - - val res = app.delete( - "$apiUrl/library-entries/$libraryEntryId", - headers = mapOf( - "Authorization" to "Bearer ${auth.token.accessToken}" - ), - interceptor = apiFallbackInterceptor - ) - - - return res.isSuccessful - - } - - return setScoreRequest( - auth, - libraryEntryId, - kitsuStatusAsString[maxOf(0, status.value)], - score, - numWatchedEpisodes - ) - - } - - val data = mapOf( - "data" to mapOf( - "type" to "libraryEntries", - "attributes" to mapOf( - "ratingTwenty" to score, - "progress" to numWatchedEpisodes, - "status" to if (status == null) null else kitsuStatusAsString[maxOf(0, status.value)], - ), - "relationships" to mapOf( - "anime" to mapOf( - "data" to mapOf( - "type" to "anime", - "id" to id.toString() - ) - ), - "user" to mapOf( - "data" to mapOf( - "type" to "users", - "id" to auth.user.id - ) - ) - ) - ) - ) - - val res = app.post( - "$apiUrl/library-entries", - headers = mapOf( - "content-type" to "application/vnd.api+json", - "Authorization" to "Bearer ${auth.token.accessToken}" - ), - requestBody = data.toJson().toRequestBody(), - interceptor = apiFallbackInterceptor - ) - - return res.isSuccessful - - } - - @Suppress("UNCHECKED_CAST") - private suspend fun setScoreRequest( - auth : AuthData, - id: Int, - status: String? = null, - score: Int? = null, - numWatchedEpisodes: Int? = null, - ): Boolean { - val data = mapOf( - "data" to mapOf( - "type" to "libraryEntries", - "id" to id.toString(), - "attributes" to mapOf( - "ratingTwenty" to score, - "progress" to numWatchedEpisodes, - "status" to status - ) - ) - ) - - val res = app.patch( - "$apiUrl/library-entries/$id", - headers = mapOf( - "content-type" to "application/vnd.api+json", - "Authorization" to "Bearer ${auth.token.accessToken}" - ), - requestBody = data.toJson().toRequestBody(), - interceptor = apiFallbackInterceptor - ) - - - return res.isSuccessful - - } - - private suspend fun getAnimeLibraryEntryId(auth: AuthData, id: Int): Int? { - - val userId = auth.user.id - - val res = app.get( - "$apiUrl/library-entries?filter[userId]=$userId&filter[animeId]=$id", - headers = mapOf( - "Authorization" to "Bearer ${auth.token.accessToken}" - ), - interceptor = apiFallbackInterceptor - ).parsed().data.firstOrNull() ?: return null - - return res.id.toInt() - - } - - override suspend fun library(auth : AuthData?): LibraryMetadata? { - val list = getKitsuAnimeListSmart(auth ?: return null)?.groupBy { - convertToStatus(it.attributes.status ?: "").stringRes - }?.mapValues { group -> - group.value.map { it.toLibraryItem() } - } ?: emptyMap() - - // To fill empty lists when Kitsu does not return them - val baseMap = - KitsuStatusType.entries.filter { it.value >= 0 }.associate { - it.stringRes to emptyList() - } - - return LibraryMetadata( - (baseMap + list).map { LibraryList(txt(it.key), it.value) }, - setOf( - ListSorting.AlphabeticalA, - ListSorting.AlphabeticalZ, - ListSorting.UpdatedNew, - ListSorting.UpdatedOld, - ListSorting.ReleaseDateNew, - ListSorting.ReleaseDateOld, - ListSorting.RatingHigh, - ListSorting.RatingLow, - ) - ) - } - - private suspend fun getKitsuAnimeListSmart(auth : AuthData): Array? { - return if (requireLibraryRefresh) { - val list = getKitsuAnimeList(auth.token, auth.user.id) - setKey(KITSU_CACHED_LIST, auth.user.id.toString(), list) - list - } else { - getKey>(KITSU_CACHED_LIST, auth.user.id.toString()) as? Array - } - } - - private suspend fun getKitsuAnimeList(token: AuthToken, userId: Int): Array { - - val animeSelectedFields = arrayOf("titles","canonicalTitle","posterImage","synopsis","startDate","endDate","episodeCount") - val libraryEntriesSelectedFields = arrayOf("progress","ratingTwenty","updatedAt", "status") - val limit = 500 - var url = "$apiUrl/library-entries?filter[userId]=$userId&filter[kind]=anime&include=anime&page[limit]=$limit&page[offset]=0&fields[anime]=${animeSelectedFields.joinToString(",")}&fields[libraryEntries]=${libraryEntriesSelectedFields.joinToString(",")}" - - val fullList = mutableListOf() - - while (true) { - - val data: KitsuResponse = getKitsuAnimeListSlice(token, url) - - data.data.forEachIndexed { index, value -> - value.anime = data.included?.get(index) - } - - fullList.addAll(data.data) - - url = data.links?.next ?: break - } - - - return fullList.toTypedArray() - } - - private suspend fun getKitsuAnimeListSlice(token: AuthToken, url: String): KitsuResponse { - val res = app.get( - url, headers = mapOf( - "Authorization" to "Bearer ${token.accessToken}", - ), - interceptor = apiFallbackInterceptor - ).parsed() - return res - } - - - data class ResponseToken( - @JsonProperty("token_type") val tokenType: String, - @JsonProperty("expires_in") val expiresIn: Int, - @JsonProperty("access_token") val accessToken: String, - @JsonProperty("refresh_token") val refreshToken: String, - ) - - data class KitsuNode( - @JsonProperty("id") val id: String, - @JsonProperty("attributes") val attributes: KitsuNodeAttributes, - /* User list anime node */ - @JsonProperty("relationships") val relationships: KitsuRelationships?, - var anime: KitsuAnimeData? - ) { - fun toLibraryItem(): LibraryItem { - - val animeItem = this.anime - - val numEpisodes = animeItem?.attributes?.episodeCount - - val startDate = animeItem?.attributes?.startDate - - val posterImage = animeItem?.attributes?.posterImage - - val canonicalTitle = animeItem?.attributes?.canonicalTitle - val titles = animeItem?.attributes?.titles - - val animeId = animeItem?.id - - val synopsis: String? = animeItem?.attributes?.synopsis - - return LibraryItem( - canonicalTitle ?: titles?.enJp ?: titles?.jaJp.orEmpty(), - "https://kitsu.app/anime/${animeId}/", - this.id, - this.attributes.progress, - numEpisodes, - Score.from(this.attributes.ratingTwenty, 20), - parseDateLong(this.attributes.updatedAt), - "Kitsu", - TvType.Anime, - posterImage?.large ?: posterImage?.medium, - null, - null, - plot = synopsis, - releaseDate = if (startDate == null) null else try { - Date.from(LocalDate.parse(startDate).atStartOfDay() - .atZone(ZoneId.systemDefault()) - .toInstant()) - } catch (_: RuntimeException) { - null - } - ) - } - - } - - data class KitsuAnimeAttributes( - @JsonProperty("titles") val titles: KitsuTitles?, - @JsonProperty("canonicalTitle") val canonicalTitle: String?, - @JsonProperty("posterImage") val posterImage: KitsuPosterImage?, - @JsonProperty("synopsis") val synopsis: String?, - @JsonProperty("startDate") val startDate: String?, - @JsonProperty("endDate") val endDate: String?, - @JsonProperty("episodeCount") val episodeCount: Int?, - @JsonProperty("episodeLength") val episodeLength: Int?, - ) - - data class KitsuAnimeData( - @JsonProperty("id") val id: String, - @JsonProperty("attributes") val attributes: KitsuAnimeAttributes, - ) - - - data class KitsuNodeAttributes( - /* General attributes */ - @JsonProperty("titles") val titles: KitsuTitles?, - @JsonProperty("canonicalTitle") val canonicalTitle: String?, - @JsonProperty("posterImage") val posterImage: KitsuPosterImage?, - @JsonProperty("synopsis") val synopsis: String?, - @JsonProperty("startDate") val startDate: String?, - @JsonProperty("endDate") val endDate: String?, - @JsonProperty("episodeCount") val episodeCount: Int?, - @JsonProperty("episodeLength") val episodeLength: Int?, - /* User attributes */ - @JsonProperty("name") val name: String?, - @JsonProperty("location") val location: String?, - @JsonProperty("createdAt") val createdAt: String?, - @JsonProperty("avatar") val avatar: KitsuUserAvatar?, - /* User list anime attributes */ - @JsonProperty("progress") val progress: Int?, - @JsonProperty("ratingTwenty") val ratingTwenty: Int?, - @JsonProperty("updatedAt") val updatedAt: String?, - @JsonProperty("status") val status: String?, - ) - - data class KitsuRelationships( - @JsonProperty("anime") val anime: KitsuRelationshipsAnime? - ) - - data class KitsuRelationshipsAnime( - @JsonProperty("links") val links: KitsuLinks? - ) - - data class KitsuPosterImage( - @JsonProperty("large") val large: String?, - @JsonProperty("medium") val medium: String?, - ) - - data class KitsuTitles( - @JsonProperty("en_jp") val enJp: String?, - @JsonProperty("ja_jp") val jaJp: String? - ) - - data class KitsuUserAvatar( - @JsonProperty("original") val original: String? - ) - - data class KitsuLinks( - /* Pagination */ - @JsonProperty("first") val first: String?, - @JsonProperty("next") val next: String?, - @JsonProperty("last") val last: String?, - /* Relationships */ - @JsonProperty("related") val related: String? - ) - - data class KitsuResponse( - @JsonProperty("links") val links: KitsuLinks?, - @JsonProperty("data") val data: List, - /* When requesting related info (User library entry -> anime) */ - @JsonProperty("included") val included: List?, - ) - - - companion object { - - const val KITSU_CACHED_LIST: String = "kitsu_cached_list" - private fun parseDateLong(string: String?): Long? { - return try { - SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.getDefault()).parse( - string ?: return null - )?.time?.div(1000) - } catch (e: Exception) { - null - } - } - - private val kitsuStatusAsString = - arrayOf("current", "completed", "on_hold", "dropped", "planned") - private fun fromIntToAnimeStatus(inp: SyncWatchType): KitsuStatusType { - return when (inp) { - SyncWatchType.NONE -> KitsuStatusType.None - SyncWatchType.WATCHING -> KitsuStatusType.Watching - SyncWatchType.COMPLETED -> KitsuStatusType.Completed - SyncWatchType.ONHOLD -> KitsuStatusType.OnHold - SyncWatchType.DROPPED -> KitsuStatusType.Dropped - SyncWatchType.PLANTOWATCH -> KitsuStatusType.PlanToWatch - SyncWatchType.REWATCHING -> KitsuStatusType.Watching - } - } - - enum class KitsuStatusType(var value: Int, @StringRes val stringRes: Int) { - Watching(0, R.string.type_watching), - Completed(1, R.string.type_completed), - OnHold(2, R.string.type_on_hold), - Dropped(3, R.string.type_dropped), - PlanToWatch(4, R.string.type_plan_to_watch), - None(-1, R.string.type_none) - } - - private fun convertToStatus(string: String): KitsuStatusType { - return when (string) { - "current" -> KitsuStatusType.Watching - "completed" -> KitsuStatusType.Completed - "on_hold" -> KitsuStatusType.OnHold - "dropped" -> KitsuStatusType.Dropped - "planned" -> KitsuStatusType.PlanToWatch - else -> KitsuStatusType.None - } - } - } -} // modified code from from https://github.com/saikou-app/saikou/blob/main/app/src/main/java/ani/saikou/others/Kitsu.kt // GNU General Public License v3.0 https://github.com/saikou-app/saikou/blob/main/LICENSE.md @@ -810,4 +142,4 @@ query { val canonical: String? = null ) } -} +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/MALApi.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/MALApi.kt index c0a80b3c9..ba0195be6 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/MALApi.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/MALApi.kt @@ -98,9 +98,9 @@ class MALApi : SyncAPI() { ) } - override suspend fun search(auth: AuthData?, query: String): List? { + override suspend fun search(auth : AuthData?, query: String): List? { val auth = auth?.token?.accessToken ?: return null - val url = "$apiUrl/v2/anime?q=$query&limit=$MAL_MAX_SEARCH_LIMIT" + val url = "$apiUrl/v2/anime?q=$name&limit=$MAL_MAX_SEARCH_LIMIT" val res = app.get( url, headers = mapOf( "Authorization" to "Bearer $auth", @@ -122,7 +122,7 @@ class MALApi : SyncAPI() { Regex("""/anime/((.*)/|(.*))""").find(url)!!.groupValues.first() override suspend fun updateStatus( - auth: AuthData?, + auth : AuthData?, id: String, newStatus: SyncAPI.AbstractSyncStatus ): Boolean { @@ -225,7 +225,7 @@ class MALApi : SyncAPI() { ) } - override suspend fun load(auth: AuthData?, id: String): SyncAPI.SyncResult? { + override suspend fun load(auth : AuthData?, id: String): SyncAPI.SyncResult? { val auth = auth?.token?.accessToken ?: return null val internalId = id.toIntOrNull() ?: return null val url = @@ -271,7 +271,7 @@ class MALApi : SyncAPI() { } } - override suspend fun status(auth: AuthData?, id: String): SyncAPI.AbstractSyncStatus? { + override suspend fun status(auth : AuthData?, id: String): SyncAPI.AbstractSyncStatus? { val auth = auth?.token?.accessToken ?: return null // https://myanimelist.net/apiconfig/references/api/v2#operation/anime_anime_id_get @@ -477,7 +477,7 @@ class MALApi : SyncAPI() { @JsonProperty("start_time") val startTime: String? ) - override suspend fun library(auth: AuthData?): LibraryMetadata? { + override suspend fun library(auth : AuthData?): LibraryMetadata? { val list = getMalAnimeListSmart(auth ?: return null)?.groupBy { convertToStatus(it.listStatus?.status ?: "").stringRes }?.mapValues { group -> @@ -505,7 +505,7 @@ class MALApi : SyncAPI() { ) } - private suspend fun getMalAnimeListSmart(auth: AuthData): Array? { + private suspend fun getMalAnimeListSmart(auth : AuthData): Array? { return if (requireLibraryRefresh) { val list = getMalAnimeList(auth.token) setKey(MAL_CACHED_LIST, auth.user.id.toString(), list) diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/SimklApi.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/SimklApi.kt index 3110b23ac..c4095e2d8 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/SimklApi.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/SimklApi.kt @@ -16,6 +16,7 @@ import com.lagradost.cloudstream3.Score import com.lagradost.cloudstream3.SimklSyncServices import com.lagradost.cloudstream3.TvType import com.lagradost.cloudstream3.app +import com.lagradost.cloudstream3.mapper import com.lagradost.cloudstream3.mvvm.debugPrint import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.APP_STRING @@ -29,7 +30,6 @@ import com.lagradost.cloudstream3.syncproviders.SyncIdName import com.lagradost.cloudstream3.ui.SyncWatchType import com.lagradost.cloudstream3.ui.library.ListSorting import com.lagradost.cloudstream3.utils.AppUtils.toJson -import com.lagradost.cloudstream3.utils.AppUtils.tryParseJson import com.lagradost.cloudstream3.utils.DataStoreHelper.toYear import com.lagradost.cloudstream3.utils.txt import java.math.BigInteger @@ -117,8 +117,13 @@ class SimklApi : SyncAPI() { * Gets cached object, if object is not fresh returns null and removes it from cache */ inline fun getKey(path: String): T? { + // Required for generic otherwise "LinkedHashMap cannot be cast to MediaObject" + val type = mapper.typeFactory.constructParametricType( + SimklCacheWrapper::class.java, + T::class.java + ) val cache = getKey(SIMKL_CACHE_KEY, path)?.let { - tryParseJson>(it) + mapper.readValue>(it, type) } return if (cache?.isFresh() == true) { @@ -911,7 +916,7 @@ class SimklApi : SyncAPI() { override suspend fun search(auth: AuthData?, query: String): List? { return app.get( - "$mainUrl/search/", params = mapOf("client_id" to CLIENT_ID, "q" to query) + "$mainUrl/search/", params = mapOf("client_id" to CLIENT_ID, "q" to name) ).parsedSafe>()?.mapNotNull { it.toSyncSearchResult() } } diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/APIRepository.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/APIRepository.kt index 8ec082520..93a79689e 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/APIRepository.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/APIRepository.kt @@ -17,7 +17,7 @@ import com.lagradost.cloudstream3.mvvm.Resource import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.mvvm.safeApiCall import com.lagradost.cloudstream3.newSearchResponseList -import com.lagradost.cloudstream3.utils.Coroutines.atomicListOf +import com.lagradost.cloudstream3.utils.Coroutines.threadSafeListOf import com.lagradost.cloudstream3.utils.ExtractorLink import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.async @@ -55,7 +55,7 @@ class APIRepository(val api: MainAPI) { val hash: Pair ) - private val cache = atomicListOf() + private val cache = threadSafeListOf() private var cacheIndex: Int = 0 const val CACHE_SIZE = 20 @@ -66,7 +66,9 @@ class APIRepository(val api: MainAPI) { private fun afterPluginsLoaded(forceReload: Boolean) { if (forceReload) { - cache.clear() + synchronized(cache) { + cache.clear() + } } } @@ -89,25 +91,21 @@ class APIRepository(val api: MainAPI) { val fixedUrl = api.fixUrl(url) val lookingForHash = Pair(api.name, fixedUrl) - val cached = cache.withLock { - var found: LoadResponse? = null + synchronized(cache) { for (item in cache) { // 10 min save if (item.hash == lookingForHash && (unixTime - item.unixTime) < 60 * 10) { - found = item.response - break + return@withTimeout item.response } } - found } - if (cached != null) return@withTimeout cached api.load(fixedUrl)?.also { response -> // Remove all blank tags as early as possible response.tags = response.tags?.filter { it.isNotBlank() } val add = SavedLoadResponse(unixTime, response, lookingForHash) - cache.withLock { + synchronized(cache) { if (cache.size > CACHE_SIZE) { cache[cacheIndex] = add // rolling cache cacheIndex = (cacheIndex + 1) % CACHE_SIZE @@ -217,4 +215,4 @@ class APIRepository(val api: MainAPI) { return false } } -} +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/BaseAdapter.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/BaseAdapter.kt index 4ebb7564c..2bc1af833 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/BaseAdapter.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/BaseAdapter.kt @@ -1,6 +1,5 @@ package com.lagradost.cloudstream3.ui -import android.content.Context import android.view.View import android.view.ViewGroup import android.widget.ImageView @@ -12,7 +11,6 @@ import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView.ViewHolder import androidx.viewbinding.ViewBinding import coil3.dispose -import java.util.WeakHashMap import java.util.concurrent.CopyOnWriteArrayList open class ViewHolderState(val view: ViewBinding) : ViewHolder(view.root) { @@ -24,33 +22,6 @@ abstract class NoStateAdapter( diffCallback: DiffUtil.ItemCallback = BaseDiffCallback() ) : BaseAdapter(0, diffCallback) -/** Creates a new shared pool, using the supplied lambda as a constructor. - * - * The reason for this complicated structure is that a pool should not be shared between contexts - * as it makes coil fuck up, and theming. - * */ -fun newSharedPool(lambda: RecyclerView.RecycledViewPool.() -> Unit = { }): Pair, RecyclerView.RecycledViewPool.() -> Unit> = - WeakHashMap() to lambda - -/** Sets the shared pool of the recyclerview */ -fun RecyclerView.setRecycledViewPool(pool: Pair, RecyclerView.RecycledViewPool.() -> Unit>) { - val ctx = context ?: return - synchronized(pool.first) { - this.setRecycledViewPool(pool.first.getOrPut(ctx) { - RecyclerView.RecycledViewPool().apply(pool.second) - }) - } -} - -/** Clears the shared pool of views */ -fun Pair, RecyclerView.RecycledViewPool.() -> Unit>.clear() { - synchronized(this.first) { - for (pool in this.first.values) { - pool?.clear() - } - } -} - /** * BaseAdapter is a persistent state stored adapter that supports headers and footers. * This should be used for restoring eg scroll or focus related to a view when it is recreated. 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 2aadfb13c..ed273a3ce 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/ControllerActivity.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/ControllerActivity.kt @@ -12,6 +12,9 @@ import android.widget.ImageView import android.widget.LinearLayout import android.widget.ListView import androidx.appcompat.app.AlertDialog +import com.fasterxml.jackson.databind.DeserializationFeature +import com.fasterxml.jackson.databind.json.JsonMapper +import com.fasterxml.jackson.module.kotlin.kotlinModule import com.google.android.gms.cast.MediaLoadOptions import com.google.android.gms.cast.MediaQueueItem import com.google.android.gms.cast.MediaSeekOptions @@ -102,6 +105,9 @@ data class MetadataHolder( class SelectSourceController(val view: ImageView, val activity: ControllerActivity) : UIController() { + private val mapper: JsonMapper = JsonMapper.builder().addModule(kotlinModule()) + .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false).build() + init { view.setImageResource(R.drawable.ic_baseline_playlist_play_24) view.setOnClickListener { @@ -328,7 +334,6 @@ class SelectSourceController(val view: ImageView, val activity: ControllerActivi }, subtitleCallback = { currentSubs.add(it) }, - offset = 0, isCasting = true ) } @@ -443,4 +448,4 @@ class ControllerActivity : ExpandedControllerActivity() { SkipNextEpisodeController(skipOpButton) ) } -} +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/CustomRecyclerViews.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/CustomRecyclerViews.kt index 302358538..78ad2a6bf 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/CustomRecyclerViews.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/CustomRecyclerViews.kt @@ -3,7 +3,6 @@ package com.lagradost.cloudstream3.ui import android.content.Context import android.util.AttributeSet import android.view.View -import androidx.core.content.withStyledAttributes import androidx.core.view.children import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.RecyclerView @@ -155,9 +154,10 @@ class AutofitRecyclerView @JvmOverloads constructor(context: Context, attrs: Att init { if (attrs != null) { - context.withStyledAttributes(attrs, intArrayOf(android.R.attr.columnWidth)) { - columnWidth = getDimensionPixelSize(0, -1) - } + val attrsArray = intArrayOf(android.R.attr.columnWidth) + val array = context.obtainStyledAttributes(attrs, attrsArray) + columnWidth = array.getDimensionPixelSize(0, -1) + array.recycle() } layoutManager = manager diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/MiniControllerFragment.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/MiniControllerFragment.kt index bd8541e6b..b6326eb36 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/MiniControllerFragment.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/MiniControllerFragment.kt @@ -7,12 +7,12 @@ import android.view.View import android.widget.LinearLayout import android.widget.ProgressBar import android.widget.RelativeLayout -import androidx.core.content.withStyledAttributes import com.google.android.gms.cast.framework.media.widget.MiniControllerFragment import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.utils.UIHelper.adjustAlpha import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute import com.lagradost.cloudstream3.utils.UIHelper.toPx +import java.lang.ref.WeakReference class MyMiniControllerFragment : MiniControllerFragment() { @@ -25,15 +25,26 @@ class MyMiniControllerFragment : MiniControllerFragment() { // I KNOW, KINDA SPAGHETTI SOLUTION, BUT IT WORKS override fun onInflate(context: Context, attributeSet: AttributeSet, bundle: Bundle?) { - if (currentColor == 0) { - context.withStyledAttributes(attributeSet, R.styleable.CustomCast, 0, 0) { - if (hasValue(R.styleable.CustomCast_customCastBackgroundColor)) { - currentColor = getColor(R.styleable.CustomCast_customCastBackgroundColor, 0) - } - } - } - super.onInflate(context, attributeSet, bundle) + + // somehow this leaks and I really dont know why, it seams like if you go back to a fragment with this, it leaks???? + if (currentColor == 0) { + WeakReference( + context.obtainStyledAttributes( + attributeSet, + R.styleable.CustomCast + ) + ).apply { + if (get() + ?.hasValue(R.styleable.CustomCast_customCastBackgroundColor) == true + ) { + currentColor = + get() + ?.getColor(R.styleable.CustomCast_customCastBackgroundColor, 0) ?: 0 + } + get()?.recycle() + }.clear() + } } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { 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 ad323c7d1..42f68067b 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 @@ -38,15 +38,15 @@ import com.lagradost.cloudstream3.utils.UIHelper.setNavigationBarColorCompat class AccountSelectActivity : FragmentActivity(), BiometricCallback { - companion object { - var hasLoggedIn: Boolean = false - } - val accountViewModel: AccountViewModel by viewModels() @SuppressLint("NotifyDataSetChanged") override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + loadThemes(this) + + enableEdgeToEdgeCompat() + setNavigationBarColorCompat(R.attr.primaryBlackBackground) // Are we editing and coming from MainActivity? val isEditingFromMainActivity = intent.getBooleanExtra( @@ -54,19 +54,6 @@ class AccountSelectActivity : FragmentActivity(), BiometricCallback { 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 && !isEditingFromMainActivity) { - navigateToMainActivity() - return - } - - loadThemes(this) - - enableEdgeToEdgeCompat() - setNavigationBarColorCompat(R.attr.primaryBlackBackground) - val settingsManager = PreferenceManager.getDefaultSharedPreferences(this) val skipStartup = settingsManager.getBoolean( getString(R.string.skip_startup_account_select_key), false @@ -201,11 +188,8 @@ class AccountSelectActivity : FragmentActivity(), BiometricCallback { askBiometricAuth() } - @SuppressLint("UnsafeIntentLaunch") private fun navigateToMainActivity() { - hasLoggedIn = true - // We want to propagate any intent we get here to MainActivity since this is just an intermediary - openActivity(MainActivity::class.java, baseIntent = intent) + openActivity(MainActivity::class.java) finish() // Finish the account selection activity } @@ -216,4 +200,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/DownloadAdapter.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadAdapter.kt index 1b48143a6..d0740f66a 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadAdapter.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadAdapter.kt @@ -19,7 +19,7 @@ import com.lagradost.cloudstream3.ui.download.button.DownloadStatusTell import com.lagradost.cloudstream3.utils.AppContextUtils.getNameFull import com.lagradost.cloudstream3.utils.DataStoreHelper.getViewPos import com.lagradost.cloudstream3.utils.ImageLoader.loadImage -import com.lagradost.cloudstream3.utils.downloader.DownloadObjects +import com.lagradost.cloudstream3.utils.VideoDownloadHelper const val DOWNLOAD_ACTION_PLAY_FILE = 0 const val DOWNLOAD_ACTION_DELETE_FILE = 1 @@ -27,7 +27,6 @@ const val DOWNLOAD_ACTION_RESUME_DOWNLOAD = 2 const val DOWNLOAD_ACTION_PAUSE_DOWNLOAD = 3 const val DOWNLOAD_ACTION_DOWNLOAD = 4 const val DOWNLOAD_ACTION_LONG_CLICK = 5 -const val DOWNLOAD_ACTION_CANCEL_PENDING = 6 const val DOWNLOAD_ACTION_GO_TO_CHILD = 0 const val DOWNLOAD_ACTION_LOAD_RESULT = 1 @@ -35,22 +34,22 @@ const val DOWNLOAD_ACTION_LOAD_RESULT = 1 sealed class VisualDownloadCached { abstract val currentBytes: Long abstract val totalBytes: Long - abstract val data: DownloadObjects.DownloadCached + abstract val data: VideoDownloadHelper.DownloadCached abstract var isSelected: Boolean data class Child( override val currentBytes: Long, override val totalBytes: Long, - override val data: DownloadObjects.DownloadEpisodeCached, + override val data: VideoDownloadHelper.DownloadEpisodeCached, override var isSelected: Boolean, ) : VisualDownloadCached() data class Header( override val currentBytes: Long, override val totalBytes: Long, - override val data: DownloadObjects.DownloadHeaderCached, + override val data: VideoDownloadHelper.DownloadHeaderCached, override var isSelected: Boolean, - val child: DownloadObjects.DownloadEpisodeCached?, + val child: VideoDownloadHelper.DownloadEpisodeCached?, val currentOngoingDownloads: Int, val totalDownloads: Int, ) : VisualDownloadCached() @@ -58,12 +57,12 @@ sealed class VisualDownloadCached { data class DownloadClickEvent( val action: Int, - val data: DownloadObjects.DownloadEpisodeCached + val data: VideoDownloadHelper.DownloadEpisodeCached ) data class DownloadHeaderClickEvent( val action: Int, - val data: DownloadObjects.DownloadHeaderCached + val data: VideoDownloadHelper.DownloadHeaderCached ) class DownloadAdapter( @@ -171,7 +170,6 @@ class DownloadAdapter( } } - downloadButton.resetView() val status = downloadButton.getStatus(card.child.id, card.currentBytes, card.totalBytes) if (status == DownloadStatusTell.IsDone) { // We do this here instead if we are finished downloading @@ -189,6 +187,7 @@ class DownloadAdapter( } else { // We need to make sure we restore the correct progress // when we refresh data in the adapter. + downloadButton.resetView() val drawable = downloadButton.getDrawableFromStatus(status)?.let { ContextCompat.getDrawable(downloadButton.context, it) } @@ -278,7 +277,6 @@ class DownloadAdapter( } } - downloadButton.resetView() val status = downloadButton.getStatus(data.id, card.currentBytes, card.totalBytes) if (status == DownloadStatusTell.IsDone) { // We do this here instead if we are finished downloading @@ -297,6 +295,7 @@ class DownloadAdapter( } else { // We need to make sure we restore the correct progress // when we refresh data in the adapter. + downloadButton.resetView() val drawable = downloadButton.getDrawableFromStatus(status)?.let { ContextCompat.getDrawable(downloadButton.context, it) } diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadButtonSetup.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadButtonSetup.kt index dae70ebd7..295feffe8 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadButtonSetup.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadButtonSetup.kt @@ -18,9 +18,8 @@ import com.lagradost.cloudstream3.utils.DOWNLOAD_EPISODE_CACHE import com.lagradost.cloudstream3.utils.DOWNLOAD_HEADER_CACHE import com.lagradost.cloudstream3.utils.SnackbarHelper.showSnackbar import com.lagradost.cloudstream3.utils.UIHelper.navigate -import com.lagradost.cloudstream3.utils.downloader.DownloadObjects -import com.lagradost.cloudstream3.utils.downloader.DownloadQueueManager -import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager +import com.lagradost.cloudstream3.utils.VideoDownloadHelper +import com.lagradost.cloudstream3.utils.VideoDownloadManager import kotlinx.coroutines.MainScope object DownloadButtonSetup { @@ -83,7 +82,7 @@ object DownloadButtonSetup { } else { val pkg = VideoDownloadManager.getDownloadResumePackage(ctx, id) if (pkg != null) { - DownloadQueueManager.addToQueue(pkg.toWrapper()) + VideoDownloadManager.downloadFromResumeUsingWorker(ctx, pkg) } else { VideoDownloadManager.downloadEvent.invoke( Pair(click.data.id, VideoDownloadManager.DownloadActionType.Resume) @@ -96,7 +95,7 @@ object DownloadButtonSetup { DOWNLOAD_ACTION_LONG_CLICK -> { activity?.let { act -> val length = - VideoDownloadManager.getDownloadFileInfo( + VideoDownloadManager.getDownloadFileInfoAndUpdateSettings( act, click.data.id )?.fileLength @@ -111,31 +110,24 @@ object DownloadButtonSetup { } } - DOWNLOAD_ACTION_CANCEL_PENDING -> { - DownloadQueueManager.cancelDownload(id) - } - DOWNLOAD_ACTION_PLAY_FILE -> { activity?.let { act -> - val parent = getKey( + val parent = getKey( DOWNLOAD_HEADER_CACHE, click.data.parentId.toString() ) ?: return val episodes = getKeys(DOWNLOAD_EPISODE_CACHE) ?.mapNotNull { - getKey(it) + getKey(it) } ?.filter { it.parentId == click.data.parentId } val items = mutableListOf() - val allRelevantEpisodes = - episodes?.sortedWith(compareBy { - it.season ?: 0 - }.thenBy { it.episode }) + val allRelevantEpisodes = episodes?.sortedWith(compareBy { it.season ?: 0 }.thenBy { it.episode }) allRelevantEpisodes?.forEach { - val keyInfo = getKey( + val keyInfo = getKey( VideoDownloadManager.KEY_DOWNLOAD_INFO, it.id.toString() ) ?: return@forEach @@ -162,8 +154,7 @@ object DownloadButtonSetup { } act.navigate( R.id.global_to_navigation_player, GeneratorPlayer.newInstance( - DownloadFileGenerator(items), - items.indexOfFirst { it.id == click.data.id } + DownloadFileGenerator(items).apply { goto(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 abc432ef9..3bd424640 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadFragment.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadFragment.kt @@ -29,7 +29,6 @@ import com.lagradost.cloudstream3.mvvm.observe import com.lagradost.cloudstream3.mvvm.observeNullable import com.lagradost.cloudstream3.ui.BaseFragment import com.lagradost.cloudstream3.ui.download.DownloadButtonSetup.handleDownloadClick -import com.lagradost.cloudstream3.ui.download.queue.DownloadQueueViewModel import com.lagradost.cloudstream3.ui.player.BasicLink import com.lagradost.cloudstream3.ui.player.GeneratorPlayer import com.lagradost.cloudstream3.ui.player.LinkGenerator @@ -59,7 +58,6 @@ class DownloadFragment : BaseFragment( ) { private val downloadViewModel: DownloadViewModel by activityViewModels() - private val downloadQueueViewModel: DownloadQueueViewModel by activityViewModels() private fun View.setLayoutWidth(weight: Long) { val param = LinearLayout.LayoutParams( @@ -144,17 +142,6 @@ class DownloadFragment : BaseFragment( binding.downloadApp ) } - observe(downloadQueueViewModel.childCards) { cards -> - val size = cards.currentDownloads.size + cards.queue.size - val context = binding.root.context - val baseText = context.getString(R.string.download_queue) - binding.downloadQueueText.text = if (size > 0) { - "$baseText (${cards.currentDownloads.size}/$size)" - } else { - baseText - } - } - observe(downloadViewModel.selectedBytes) { updateDeleteButton(downloadViewModel.selectedItemIds.value?.count() ?: 0, it) } @@ -226,7 +213,7 @@ class DownloadFragment : BaseFragment( setLinearListLayout( isHorizontal = false, nextRight = FOCUS_SELF, - nextDown = R.id.download_queue_button, + nextDown = FOCUS_SELF, ) } @@ -240,10 +227,6 @@ class DownloadFragment : BaseFragment( setOnClickListener { showStreamInputDialog(it.context) } } - downloadQueueButton.setOnClickListener { - activity?.navigate(R.id.action_navigation_global_to_navigation_download_queue) - } - downloadStreamButtonTv.isFocusableInTouchMode = isLayout(TV) downloadAppbar.isFocusableInTouchMode = isLayout(TV) @@ -349,8 +332,7 @@ 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/DownloadViewModel.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadViewModel.kt index 0d35d5670..ee69390ff 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadViewModel.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadViewModel.kt @@ -5,46 +5,30 @@ import android.content.DialogInterface import android.os.Environment import android.os.StatFs import androidx.appcompat.app.AlertDialog -import androidx.core.content.edit import androidx.lifecycle.LiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.lagradost.api.Log import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.isEpisodeBased import com.lagradost.cloudstream3.mvvm.Resource import com.lagradost.cloudstream3.mvvm.launchSafe import com.lagradost.cloudstream3.mvvm.logError -import com.lagradost.cloudstream3.services.DownloadQueueService -import com.lagradost.cloudstream3.services.DownloadQueueService.Companion.downloadInstances import com.lagradost.cloudstream3.utils.AppContextUtils.getNameFull import com.lagradost.cloudstream3.utils.AppContextUtils.setDefaultFocus import com.lagradost.cloudstream3.utils.ConsistentLiveData -import com.lagradost.cloudstream3.utils.Coroutines.ioSafe -import com.lagradost.cloudstream3.utils.Coroutines.ioWork import com.lagradost.cloudstream3.utils.DOWNLOAD_EPISODE_CACHE -import com.lagradost.cloudstream3.utils.DOWNLOAD_EPISODE_CACHE_BACKUP import com.lagradost.cloudstream3.utils.DOWNLOAD_HEADER_CACHE -import com.lagradost.cloudstream3.utils.DOWNLOAD_HEADER_CACHE_BACKUP import com.lagradost.cloudstream3.utils.DataStore.getFolderName import com.lagradost.cloudstream3.utils.DataStore.getKey import com.lagradost.cloudstream3.utils.DataStore.getKeys -import com.lagradost.cloudstream3.utils.DataStore.getSharedPrefs -import com.lagradost.cloudstream3.utils.DataStoreHelper.getAllResumeStateIds -import com.lagradost.cloudstream3.utils.DataStoreHelper.getLastWatched import com.lagradost.cloudstream3.utils.ResourceLiveData -import com.lagradost.cloudstream3.utils.downloader.DownloadObjects -import com.lagradost.cloudstream3.utils.downloader.DownloadQueueManager -import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager.deleteFilesAndUpdateSettings -import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager.getDownloadFileInfo +import com.lagradost.cloudstream3.utils.VideoDownloadHelper +import com.lagradost.cloudstream3.utils.VideoDownloadManager.deleteFilesAndUpdateSettings +import com.lagradost.cloudstream3.utils.VideoDownloadManager.getDownloadFileInfoAndUpdateSettings import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext class DownloadViewModel : ViewModel() { - companion object { - const val TAG = "DownloadViewModel" - } - private val _headerCards = ResourceLiveData>(Resource.Loading()) val headerCards: LiveData>> = _headerCards @@ -127,109 +111,23 @@ class DownloadViewModel : ViewModel() { } - fun removeRedundantEpisodeKeys(context: Context, keys: List>) { - val settingsManager = context.getSharedPrefs() - ioSafe { - settingsManager.edit { - keys.forEach { (parentId, childId) -> - Log.i(TAG, "Removing download episode key: ${parentId}/${childId}") - val oldPath = getFolderName( - getFolderName( - DOWNLOAD_EPISODE_CACHE, - parentId.toString() - ), - childId.toString() - ) - val newPath = getFolderName( - getFolderName( - DOWNLOAD_EPISODE_CACHE_BACKUP, - parentId.toString() - ), - childId.toString() - ) - - val oldPref = settingsManager.getString(oldPath, null) - // Cowardly future backup solution in case the key removal fails in some edge case. - // This and all backup keys may be removed in a future update if the key removal is proven to be robust. - this.putString(newPath, oldPref) - this.remove(oldPath) - } - } - } - } - - fun removeRedundantHeaderKeys( - context: Context, - cached: List, - totalBytesUsedByChild: Map, - totalDownloads: Map - ) { - val settingsManager = context.getSharedPrefs() - ioSafe { - // Do not remove headers used by resume watching - val resumeWatchingIds = - getAllResumeStateIds()?.mapNotNull { id -> - getLastWatched(id)?.parentId - }?.toSet() ?: emptySet() - - settingsManager.edit { - cached.forEach { header -> - val downloads = totalDownloads[header.id] ?: 0 - val bytes = totalBytesUsedByChild[header.id] ?: 0 - - if ( (downloads <= 0 || bytes <= 0) && !resumeWatchingIds.contains(header.id) ) { - Log.i(TAG, "Removing download header key: ${header.id}") - val oldPAth = getFolderName(DOWNLOAD_HEADER_CACHE, header.id.toString()) - val newPath = - getFolderName(DOWNLOAD_HEADER_CACHE_BACKUP, header.id.toString()) - val oldPref = settingsManager.getString(oldPAth, null) - // Cowardly future backup solution in case the key removal fails in some edge case. - // This and all backup keys may be removed in a future update if the key removal is proven to be robust. - this.putString(newPath, oldPref) - this.remove(oldPAth) - } - } - } - } - } - fun updateHeaderList(context: Context) = viewModelScope.launchSafe { // Do not push loading as it interrupts the UI //_headerCards.postValue(Resource.Loading()) - val visual = ioWork { + val visual = withContext(Dispatchers.IO) { val children = context.getKeys(DOWNLOAD_EPISODE_CACHE) - .mapNotNull { context.getKey(it) } + .mapNotNull { context.getKey(it) } .distinctBy { it.id } // Remove duplicates - val isCurrentlyDownloading = - DownloadQueueService.isRunning || downloadInstances.value.isNotEmpty() || DownloadQueueManager.queue.value.isNotEmpty() - - val downloadStats = + val (totalBytesUsedByChild, currentBytesUsedByChild, totalDownloads) = calculateDownloadStats(context, children) val cached = context.getKeys(DOWNLOAD_HEADER_CACHE) - .mapNotNull { context.getKey(it) } - - // Download stats and header keys may change when downloading. - // To prevent the downloader and key removal from colliding, simply do not prune keys when downloading. - if (!isCurrentlyDownloading) { - removeRedundantHeaderKeys( - context, - cached, - downloadStats.totalBytesUsedByChild, - downloadStats.totalDownloads - ) - } - // calculateDownloadStats already performed checks when creating redundantDownloads, no extra check required - removeRedundantEpisodeKeys(context, downloadStats.redundantDownloads) + .mapNotNull { context.getKey(it) } createVisualDownloadList( - context, - cached, - downloadStats.totalBytesUsedByChild, - downloadStats.currentBytesUsedByChild, - downloadStats.totalDownloads + context, cached, totalBytesUsedByChild, currentBytesUsedByChild, totalDownloads ) } @@ -261,38 +159,20 @@ class DownloadViewModel : ViewModel() { })) } - private data class DownloadStats( - val totalBytesUsedByChild: Map, - val currentBytesUsedByChild: Map, - val totalDownloads: Map, - /** Parent ID to child ID. Keys to be removed. */ - val redundantDownloads: List> - ) - private fun calculateDownloadStats( context: Context, - children: List - ): DownloadStats { + children: List + ): Triple, Map, Map> { // parentId : bytes val totalBytesUsedByChild = mutableMapOf() // parentId : bytes val currentBytesUsedByChild = mutableMapOf() // parentId : downloadsCount val totalDownloads = mutableMapOf() - val redundantDownloads = mutableListOf>() children.forEach { child -> - val childFile = getDownloadFileInfo(context, child.id) - - if (childFile == null) { - // It may not be a redundant child if something is currently downloading. - // DOWNLOAD_EPISODE_CACHE gets created before KEY_DOWNLOAD_INFO in the downloader - // leading to valid situations where getDownloadFileInfo is null, but we do not want to remove DOWNLOAD_EPISODE_CACHE - if (!DownloadQueueService.isRunning && downloadInstances.value.isEmpty() && DownloadQueueManager.queue.value.isEmpty()) { - redundantDownloads.add(child.parentId to child.id) - } - return@forEach - } + val childFile = + getDownloadFileInfoAndUpdateSettings(context, child.id) ?: return@forEach if (childFile.fileLength <= 1) return@forEach val len = childFile.totalBytes @@ -302,17 +182,12 @@ class DownloadViewModel : ViewModel() { currentBytesUsedByChild.merge(child.parentId, flen, Long::plus) totalDownloads.merge(child.parentId, 1, Int::plus) } - return DownloadStats( - totalBytesUsedByChild, - currentBytesUsedByChild, - totalDownloads, - redundantDownloads - ) + return Triple(totalBytesUsedByChild, currentBytesUsedByChild, totalDownloads) } private fun createVisualDownloadList( context: Context, - cached: List, + cached: List, totalBytesUsedByChild: Map, currentBytesUsedByChild: Map, totalDownloads: Map @@ -321,14 +196,11 @@ class DownloadViewModel : ViewModel() { val downloads = totalDownloads[it.id] ?: 0 val bytes = totalBytesUsedByChild[it.id] ?: 0 val currentBytes = currentBytesUsedByChild[it.id] ?: 0 - - if (bytes <= 0 || downloads <= 0) { - return@mapNotNull null - } + if (bytes <= 0 || downloads <= 0) return@mapNotNull null val isSelected = selectedItemIds.value?.contains(it.id) ?: false val movieEpisode = - if (it.type.isEpisodeBased()) null else context.getKey( + if (it.type.isEpisodeBased()) null else context.getKey( DOWNLOAD_EPISODE_CACHE, getFolderName(it.id.toString(), it.id.toString()) ) @@ -361,10 +233,11 @@ class DownloadViewModel : ViewModel() { val visual = withContext(Dispatchers.IO) { context.getKeys(folder).mapNotNull { key -> - context.getKey(key) + context.getKey(key) }.mapNotNull { val isSelected = selectedItemIds.value?.contains(it.id) ?: false - val info = getDownloadFileInfo(context, it.id) ?: return@mapNotNull null + val info = + getDownloadFileInfoAndUpdateSettings(context, it.id) ?: return@mapNotNull null VisualDownloadCached.Child( currentBytes = info.fileLength, totalBytes = info.totalBytes, @@ -440,7 +313,7 @@ class DownloadViewModel : ViewModel() { if (item.data.type.isEpisodeBased()) { val episodes = context.getKeys(DOWNLOAD_EPISODE_CACHE) .mapNotNull { - context.getKey( + context.getKey( it ) } @@ -464,7 +337,7 @@ class DownloadViewModel : ViewModel() { is VisualDownloadCached.Child -> { ids.add(item.data.id) - val parent = context.getKey( + val parent = context.getKey( DOWNLOAD_HEADER_CACHE, item.data.parentId.toString() ) 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 382a770cd..36a84d9f3 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 @@ -11,7 +11,7 @@ import androidx.core.widget.ContentLoadingProgressBar import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.utils.Coroutines.ioSafe import com.lagradost.cloudstream3.utils.Coroutines.mainWork -import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager +import com.lagradost.cloudstream3.utils.VideoDownloadManager typealias DownloadStatusTell = VideoDownloadManager.DownloadType @@ -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(appContext, id) + val savedData = VideoDownloadManager.getDownloadFileInfoAndUpdateSettings(context, id) + mainWork { if (savedData != null) { val downloadedBytes = savedData.fileLength @@ -87,7 +87,7 @@ abstract class BaseFetchButton(context: Context, attributeSet: AttributeSet) : setProgress(downloadedBytes, totalBytes) applyMetaData(id, downloadedBytes, totalBytes) - } + } else run { resetView() } } } } @@ -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/DownloadButton.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/download/button/DownloadButton.kt index 91c5dd72c..20a444611 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/download/button/DownloadButton.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/download/button/DownloadButton.kt @@ -8,7 +8,7 @@ import androidx.core.view.isVisible import com.google.android.material.button.MaterialButton import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.ui.download.DownloadClickEvent -import com.lagradost.cloudstream3.utils.downloader.DownloadObjects +import com.lagradost.cloudstream3.utils.VideoDownloadHelper class DownloadButton(context: Context, attributeSet: AttributeSet) : PieFetchButton(context, attributeSet) { @@ -18,7 +18,6 @@ class DownloadButton(context: Context, attributeSet: AttributeSet) : super.onAttachedToWindow() progressText = findViewById(R.id.result_movie_download_text_precentage) mainText = findViewById(R.id.result_movie_download_text) - setStatus(null) } override fun setStatus(status: DownloadStatusTell?) { @@ -36,7 +35,7 @@ class DownloadButton(context: Context, attributeSet: AttributeSet) : } override fun setDefaultClickListener( - card: DownloadObjects.DownloadEpisodeCached, + card: VideoDownloadHelper.DownloadEpisodeCached, textView: TextView?, callback: (DownloadClickEvent) -> Unit ) { 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 f6f8a5ff8..3181a1bcd 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 @@ -10,14 +10,11 @@ import android.widget.ImageView import android.widget.TextView import androidx.annotation.MainThread import androidx.core.content.ContextCompat -import androidx.core.content.withStyledAttributes import androidx.core.view.isGone import androidx.core.view.isVisible import com.lagradost.cloudstream3.CloudStreamApp.Companion.removeKey import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.mvvm.logError -import com.lagradost.cloudstream3.services.DownloadQueueService.Companion.downloadInstances -import com.lagradost.cloudstream3.ui.download.DOWNLOAD_ACTION_CANCEL_PENDING import com.lagradost.cloudstream3.ui.download.DOWNLOAD_ACTION_DELETE_FILE import com.lagradost.cloudstream3.ui.download.DOWNLOAD_ACTION_DOWNLOAD import com.lagradost.cloudstream3.ui.download.DOWNLOAD_ACTION_LONG_CLICK @@ -26,10 +23,9 @@ import com.lagradost.cloudstream3.ui.download.DOWNLOAD_ACTION_PLAY_FILE import com.lagradost.cloudstream3.ui.download.DOWNLOAD_ACTION_RESUME_DOWNLOAD import com.lagradost.cloudstream3.ui.download.DownloadClickEvent import com.lagradost.cloudstream3.utils.UIHelper.popupMenuNoIcons -import com.lagradost.cloudstream3.utils.downloader.DownloadObjects -import com.lagradost.cloudstream3.utils.downloader.DownloadQueueManager.queue -import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager -import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager.KEY_RESUME_PACKAGES +import com.lagradost.cloudstream3.utils.VideoDownloadHelper +import com.lagradost.cloudstream3.utils.VideoDownloadManager +import com.lagradost.cloudstream3.utils.VideoDownloadManager.KEY_RESUME_PACKAGES open class PieFetchButton(context: Context, attributeSet: AttributeSet) : BaseFetchButton(context, attributeSet) { @@ -67,7 +63,7 @@ open class PieFetchButton(context: Context, attributeSet: AttributeSet) : open fun onInflate() {} init { - context.withStyledAttributes(attributeSet, R.styleable.PieFetchButton, 0, 0) { + context.obtainStyledAttributes(attributeSet, R.styleable.PieFetchButton, 0, 0).apply { try { inflate( overrideLayout ?: getResourceId( @@ -76,7 +72,6 @@ open class PieFetchButton(context: Context, attributeSet: AttributeSet) : ) ) } catch (e: Exception) { - recycle() // Manually call recycle first to avoid memory leaks Log.e( "PieFetchButton", "Error inflating PieFetchButton, " + "check that you have declared the required aria2c attrs: aria2c_icon_scale aria2c_icon_color aria2c_outline_color aria2c_fill_color" @@ -84,6 +79,11 @@ open class PieFetchButton(context: Context, attributeSet: AttributeSet) : throw e } + + progressBar = findViewById(R.id.progress_downloaded) + progressBarBackground = findViewById(R.id.progress_downloaded_background) + statusView = findViewById(R.id.image_download_status) + animateWaiting = getBoolean( R.styleable.PieFetchButton_download_animate_waiting, true @@ -92,13 +92,16 @@ open class PieFetchButton(context: Context, attributeSet: AttributeSet) : R.styleable.PieFetchButton_download_hide_when_icon, true ) + waitingAnimation = getResourceId( R.styleable.PieFetchButton_download_waiting_animation, R.anim.rotate_around_center_point ) + activeOutline = getResourceId( R.styleable.PieFetchButton_download_outline_active, R.drawable.circle_shape ) + nonActiveOutline = getResourceId( R.styleable.PieFetchButton_download_outline_non_active, R.drawable.circle_shape_dotted @@ -126,29 +129,19 @@ open class PieFetchButton(context: Context, attributeSet: AttributeSet) : ) val fillIndex = getInt(R.styleable.PieFetchButton_download_fill, 0) + progressDrawable = getResourceId( R.styleable.PieFetchButton_download_fill_override, fillArray[fillIndex] ) + + progressBar.progressDrawable = ContextCompat.getDrawable(context, progressDrawable) + + recycle() } - - progressBar = findViewById(R.id.progress_downloaded) - progressBarBackground = findViewById(R.id.progress_downloaded_background) - statusView = findViewById(R.id.image_download_status) - - progressBar.progressDrawable = ContextCompat.getDrawable(context, progressDrawable) - - // resetView() + resetView() onInflate() } - - override fun onAttachedToWindow() { - super.onAttachedToWindow() - // Re-run all animations when the view gets visible. - // Otherwise views may run without animations after recycled - setStatusInternal(currentStatus) - } - private var currentStatus: DownloadStatusTell? = null /*private fun getActivity(): Activity? { var context = context @@ -169,31 +162,16 @@ open class PieFetchButton(context: Context, attributeSet: AttributeSet) : }*/ protected fun setDefaultClickListener( - view: View, textView: TextView?, card: DownloadObjects.DownloadEpisodeCached, + view: View, textView: TextView?, card: VideoDownloadHelper.DownloadEpisodeCached, callback: (DownloadClickEvent) -> Unit ) { this.progressText = textView this.setPersistentId(card.id) view.setOnClickListener { if (isZeroBytes) { - val localQueue = queue.value - val localInstances = downloadInstances.value - val id = card.id - - // If the download is already in queue or active downloads, provide an option to cancel it - if (localQueue.any { q -> q.id == id } || localInstances.any { i -> i.downloadQueueWrapper.id == id }) { - it.popupMenuNoIcons( - arrayListOf( - Pair(DOWNLOAD_ACTION_CANCEL_PENDING, R.string.cancel), - ) - ) { - callback(DownloadClickEvent(itemId, card)) - } - } else { - // Otherwise just start a download instantly - removeKey(KEY_RESUME_PACKAGES, card.id.toString()) - callback(DownloadClickEvent(DOWNLOAD_ACTION_DOWNLOAD, card)) - } + removeKey(KEY_RESUME_PACKAGES, card.id.toString()) + callback(DownloadClickEvent(DOWNLOAD_ACTION_DOWNLOAD, card)) + // callback.invoke(DownloadClickEvent(DOWNLOAD_ACTION_DOWNLOAD, data)) } else { val list = arrayListOf( Pair(DOWNLOAD_ACTION_PLAY_FILE, R.string.popup_play_file), @@ -234,7 +212,7 @@ open class PieFetchButton(context: Context, attributeSet: AttributeSet) : } open fun setDefaultClickListener( - card: DownloadObjects.DownloadEpisodeCached, + card: VideoDownloadHelper.DownloadEpisodeCached, textView: TextView?, callback: (DownloadClickEvent) -> Unit ) { @@ -304,8 +282,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.getMainLooper().isCurrentThread) { + // Runs on the main thread, but also instant if it already is + if (Looper.myLooper() == Looper.getMainLooper()) { try { setStatusInternal(status) } catch (t: Throwable) { diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/download/queue/DownloadQueueAdapter.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/download/queue/DownloadQueueAdapter.kt deleted file mode 100644 index 877fcfea8..000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/download/queue/DownloadQueueAdapter.kt +++ /dev/null @@ -1,274 +0,0 @@ -package com.lagradost.cloudstream3.ui.download.queue - - -import android.view.LayoutInflater -import android.view.ViewGroup -import androidx.core.view.isGone -import androidx.fragment.app.Fragment -import androidx.recyclerview.widget.ItemTouchHelper -import androidx.recyclerview.widget.RecyclerView -import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey -import com.lagradost.cloudstream3.R -import com.lagradost.cloudstream3.databinding.DownloadQueueItemBinding -import com.lagradost.cloudstream3.ui.BaseAdapter -import com.lagradost.cloudstream3.ui.BaseDiffCallback -import com.lagradost.cloudstream3.ui.ViewHolderState -import com.lagradost.cloudstream3.ui.download.DOWNLOAD_ACTION_CANCEL_PENDING -import com.lagradost.cloudstream3.ui.download.DOWNLOAD_ACTION_DELETE_FILE -import com.lagradost.cloudstream3.ui.download.DOWNLOAD_ACTION_PAUSE_DOWNLOAD -import com.lagradost.cloudstream3.ui.download.DOWNLOAD_ACTION_RESUME_DOWNLOAD -import com.lagradost.cloudstream3.ui.download.DownloadButtonSetup.handleDownloadClick -import com.lagradost.cloudstream3.ui.download.DownloadClickEvent -import com.lagradost.cloudstream3.ui.download.queue.DownloadQueueAdapter.Companion.DOWNLOAD_SEPARATOR_TAG -import com.lagradost.cloudstream3.utils.AppContextUtils.getNameFull -import com.lagradost.cloudstream3.utils.DOWNLOAD_EPISODE_CACHE -import com.lagradost.cloudstream3.utils.DataStore.getFolderName -import com.lagradost.cloudstream3.utils.DataStore.getKey -import com.lagradost.cloudstream3.utils.UIHelper.popupMenuNoIcons -import com.lagradost.cloudstream3.utils.downloader.DownloadObjects -import com.lagradost.cloudstream3.utils.downloader.DownloadObjects.DownloadQueueWrapper -import com.lagradost.cloudstream3.utils.downloader.DownloadQueueManager -import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager -import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager.KEY_DOWNLOAD_INFO - -/** An item in the adapter can either be a separator or a real item. - * isCurrentlyDownloading is used to fully update items as opposed to just moving them. */ -class DownloadAdapterItem(val item: DownloadQueueWrapper?) { - val isSeparator = item == null -} - - -class DownloadQueueAdapter(val fragment: Fragment) : BaseAdapter( - diffCallback = BaseDiffCallback( - itemSame = { a, b -> a.item?.id == b.item?.id }, - contentSame = { a, b -> - a.item == b.item - }) -) { - var currentDownloads = 0 - - companion object { - val DOWNLOAD_SEPARATOR_TAG = "DOWNLOAD_SEPARATOR_TAG" - } - - override fun onCreateContent(parent: ViewGroup): ViewHolderState { - val inflater = LayoutInflater.from(parent.context) - val binding = DownloadQueueItemBinding.inflate(inflater, parent, false) - return ViewHolderState(binding) - } - - override fun onBindContent( - holder: ViewHolderState, - item: DownloadAdapterItem, - position: Int - ) { - when (val binding = holder.view) { - is DownloadQueueItemBinding -> { - if (item.item == null) { - holder.itemView.tag = DOWNLOAD_SEPARATOR_TAG - bindSeparator(binding) - } else { - holder.itemView.tag = null - bind(binding, item.item) - } - } - } - } - - fun submitQueue(newQueue: DownloadAdapterQueue) { - val index = newQueue.currentDownloads.size - val current = newQueue.currentDownloads - val queue = newQueue.queue - currentDownloads = current.size - - val newList = - (current + queue).distinctBy { it.id }.map { DownloadAdapterItem(it) }.toMutableList() - .apply { - // Only add the separator if it actually separates something - if (index < this.size) { - add(index, DownloadAdapterItem(null)) - } - } - submitList(newList) - } - - fun bindSeparator(binding: DownloadQueueItemBinding) { - binding.apply { - separatorHolder.isGone = false - downloadChildEpisodeHolder.isGone = true - } - } - - fun bind( - binding: DownloadQueueItemBinding, - queueWrapper: DownloadQueueWrapper, - ) { - val context = binding.root.context - - binding.apply { - separatorHolder.isGone = true - downloadChildEpisodeHolder.isGone = false - - // Only set the child-text if child and parent are not the same - // This prevents setting movie titles twice - if (queueWrapper.id != queueWrapper.parentId) { - val mainName = queueWrapper.downloadItem?.resultName ?: queueWrapper.resumePackage?.item?.ep?.mainName - downloadChildEpisodeTextExtra.text = mainName - } else { - downloadChildEpisodeTextExtra.text = null - } - - downloadChildEpisodeTextExtra.isGone = downloadChildEpisodeTextExtra.text.isNullOrBlank() - - val status = VideoDownloadManager.downloadStatus[queueWrapper.id] - - downloadButton.setOnClickListener { view -> - val episodeCached = - getKey( - DOWNLOAD_EPISODE_CACHE, - getFolderName(queueWrapper.parentId.toString(), queueWrapper.id.toString()) - ) - - val downloadInfo = context.getKey( - KEY_DOWNLOAD_INFO, - queueWrapper.id.toString() - ) - - val isCurrentlyDownloading = queueWrapper.isCurrentlyDownloading() - - val actionList = arrayListOf>() - - if (isCurrentlyDownloading && episodeCached != null) { - // KEY_DOWNLOAD_INFO is used in the file deletion, and is required to exist to delete anything - if (downloadInfo != null) { - actionList.add(Pair(DOWNLOAD_ACTION_DELETE_FILE, R.string.popup_delete_file)) - } else { - actionList.add(Pair(DOWNLOAD_ACTION_CANCEL_PENDING, R.string.cancel)) - } - - val currentStatus = VideoDownloadManager.downloadStatus[queueWrapper.id] - - when (currentStatus) { - VideoDownloadManager.DownloadType.IsDownloading -> { - actionList.add( - Pair( - DOWNLOAD_ACTION_PAUSE_DOWNLOAD, - R.string.popup_pause_download - ) - ) - } - - VideoDownloadManager.DownloadType.IsPaused -> { - actionList.add( - Pair( - DOWNLOAD_ACTION_RESUME_DOWNLOAD, - R.string.popup_resume_download - ) - ) - } - - else -> {} - } - - view.popupMenuNoIcons( - actionList - ) { - handleDownloadClick(DownloadClickEvent(itemId, episodeCached)) - } - } else { - actionList.add(Pair(DOWNLOAD_ACTION_CANCEL_PENDING, R.string.cancel)) - - view.popupMenuNoIcons( - actionList - ) { - when (itemId) { - DOWNLOAD_ACTION_CANCEL_PENDING -> { - DownloadQueueManager.cancelDownload(queueWrapper.id) - } - } - } - } - } - - downloadButton.resetView() - downloadButton.setStatus(status) - downloadButton.setPersistentId(queueWrapper.id) - - downloadChildEpisodeText.apply { - val name = queueWrapper.downloadItem?.episode?.name - ?: queueWrapper.resumePackage?.item?.ep?.name - val episode = - queueWrapper.downloadItem?.episode?.episode - ?: queueWrapper.resumePackage?.item?.ep?.episode - val season = - queueWrapper.downloadItem?.episode?.season - ?: queueWrapper.resumePackage?.item?.ep?.season - text = context.getNameFull(name, episode, season) - isSelected = true // Needed for text repeating - } - } - } -} - - -class DragAndDropTouchHelper(adapter: DownloadQueueAdapter) : - ItemTouchHelper( - DragAndDropTouchHelperCallback(adapter) - ) - -private class DragAndDropTouchHelperCallback(private val adapter: DownloadQueueAdapter) : - ItemTouchHelper.Callback() { - override fun getMovementFlags( - recyclerView: RecyclerView, - viewHolder: RecyclerView.ViewHolder - ): Int { - val item = adapter.getItem(viewHolder.absoluteAdapterPosition) - val isDownloading = item.item?.isCurrentlyDownloading() == true - val dragFlags = if (item.isSeparator || isDownloading) { - 0 - } else { - ItemTouchHelper.UP or ItemTouchHelper.DOWN // Allow drag up/down - } - - val swipeFlags = 0 // Disable swipe functionality - return makeMovementFlags(dragFlags, swipeFlags) - } - - override fun onMove( - recyclerView: RecyclerView, - source: RecyclerView.ViewHolder, - target: RecyclerView.ViewHolder - ): Boolean { - val fromPosition = source.absoluteAdapterPosition - val toPosition = target.absoluteAdapterPosition - val separatorPosition = adapter.currentDownloads - - val toPositionNoSeparator = - if (separatorPosition < toPosition) toPosition - separatorPosition else toPosition - - if (source.itemView.tag == DOWNLOAD_SEPARATOR_TAG) { - return false - } else { - adapter.getItem(fromPosition).item?.let { downloadQueueInfo -> - DownloadQueueManager.reorderItem( - downloadQueueInfo, - toPositionNoSeparator - 1 - ) - } - } - - return true - } - - override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) { - - } - - override fun isLongPressDragEnabled(): Boolean { - return true // Enable drag with long press - } - - override fun isItemViewSwipeEnabled(): Boolean { - return false // Disable swipe by default - } -} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/download/queue/DownloadQueueFragment.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/download/queue/DownloadQueueFragment.kt deleted file mode 100644 index 071d8913d..000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/download/queue/DownloadQueueFragment.kt +++ /dev/null @@ -1,79 +0,0 @@ -package com.lagradost.cloudstream3.ui.download.queue - -import android.view.View -import androidx.appcompat.app.AlertDialog -import androidx.core.view.isGone -import androidx.fragment.app.activityViewModels -import com.lagradost.cloudstream3.R -import com.lagradost.cloudstream3.databinding.FragmentDownloadQueueBinding -import com.lagradost.cloudstream3.mvvm.observe -import com.lagradost.cloudstream3.ui.BaseFragment -import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR -import com.lagradost.cloudstream3.ui.settings.Globals.PHONE -import com.lagradost.cloudstream3.ui.settings.Globals.TV -import com.lagradost.cloudstream3.ui.settings.Globals.isLandscape -import com.lagradost.cloudstream3.ui.settings.Globals.isLayout -import com.lagradost.cloudstream3.utils.UIHelper.fixSystemBarsPadding -import com.lagradost.cloudstream3.utils.UIHelper.setAppBarNoScrollFlagsOnTV -import com.lagradost.cloudstream3.utils.downloader.DownloadQueueManager -import com.lagradost.cloudstream3.utils.txt - - -class DownloadQueueFragment : - BaseFragment(BindingCreator.Inflate(FragmentDownloadQueueBinding::inflate)) { - private val queueViewModel: DownloadQueueViewModel by activityViewModels() - - override fun onBindingCreated(binding: FragmentDownloadQueueBinding) { - val adapter = DownloadQueueAdapter(this@DownloadQueueFragment) - val clearQueueItem = binding.downloadQueueToolbar.menu?.findItem(R.id.cancel_all) - - observe(queueViewModel.childCards) { cards -> - val size = cards.queue.size + cards.currentDownloads.size - val isEmptyQueue = size == 0 - binding.downloadQueueList.isGone = isEmptyQueue - binding.textNoQueue.isGone = !isEmptyQueue - clearQueueItem?.isVisible = !isEmptyQueue - - adapter.submitQueue(cards) - } - - binding.apply { - downloadQueueToolbar.apply { - title = txt(R.string.download_queue).asString(context) - if (isLayout(PHONE or EMULATOR)) { - setNavigationIcon(R.drawable.ic_baseline_arrow_back_24) - setNavigationOnClickListener { - dispatchBackPressed() - } - } - setAppBarNoScrollFlagsOnTV() - clearQueueItem?.setOnMenuItemClickListener { - AlertDialog.Builder(context, R.style.AlertDialogCustom) - .setTitle(R.string.cancel_all) - .setMessage(R.string.cancel_queue_message) - .setPositiveButton(R.string.yes) { _, _ -> - DownloadQueueManager.removeAllFromQueue() - } - .setNegativeButton(R.string.no) { _, _ -> - }.show() - - true - } - } - - downloadQueueList.adapter = adapter - - // Drag and drop - val helper = DragAndDropTouchHelper(adapter) - helper.attachToRecyclerView(downloadQueueList) - } - } - - override fun fixLayout(view: View) { - fixSystemBarsPadding( - view, - padBottom = isLandscape(), - padLeft = isLayout(TV or EMULATOR) - ) - } -} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/download/queue/DownloadQueueViewModel.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/download/queue/DownloadQueueViewModel.kt deleted file mode 100644 index fc384cb4e..000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/download/queue/DownloadQueueViewModel.kt +++ /dev/null @@ -1,43 +0,0 @@ -package com.lagradost.cloudstream3.ui.download.queue - -import androidx.lifecycle.LiveData -import androidx.lifecycle.MutableLiveData -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import com.lagradost.cloudstream3.mvvm.launchSafe -import com.lagradost.cloudstream3.services.DownloadQueueService.Companion.downloadInstances -import com.lagradost.cloudstream3.utils.downloader.DownloadObjects -import com.lagradost.cloudstream3.utils.downloader.DownloadQueueManager -import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.launch - -data class DownloadAdapterQueue( - val currentDownloads: List, - val queue: List, -) - -class DownloadQueueViewModel : ViewModel() { - private val _childCards = MutableLiveData() - val childCards: LiveData = _childCards - private val totalDownloadFlow = - downloadInstances.combine(DownloadQueueManager.queue) { instances, queue -> - val current = instances.map { it.downloadQueueWrapper } - DownloadAdapterQueue(current, queue.toList()) - }.combine(VideoDownloadManager.currentDownloads) { total, _ -> - // We want to update the flow when currentDownloads updates, but we do not care about its value - total - } - - init { - viewModelScope.launch { - totalDownloadFlow.collect { queue -> - updateChildList(queue) - } - } - } - - fun updateChildList(downloads: DownloadAdapterQueue) { - _childCards.postValue(downloads) - } -} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeChildItemAdapter.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeChildItemAdapter.kt index 43f6d19ff..4cd4197df 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeChildItemAdapter.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeChildItemAdapter.kt @@ -5,8 +5,12 @@ import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.widget.FrameLayout +import android.widget.ImageView +import androidx.fragment.app.Fragment import androidx.preference.PreferenceManager +import androidx.recyclerview.widget.RecyclerView import androidx.viewbinding.ViewBinding +import coil3.load import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.SearchResponse import com.lagradost.cloudstream3.databinding.HomeRemoveGridBinding @@ -16,7 +20,6 @@ import com.lagradost.cloudstream3.databinding.HomeResultGridExpandedBinding import com.lagradost.cloudstream3.ui.BaseAdapter import com.lagradost.cloudstream3.ui.BaseDiffCallback import com.lagradost.cloudstream3.ui.ViewHolderState -import com.lagradost.cloudstream3.ui.newSharedPool import com.lagradost.cloudstream3.ui.search.SEARCH_ACTION_LOAD import com.lagradost.cloudstream3.ui.search.SearchClickCallback import com.lagradost.cloudstream3.ui.search.SearchResultBuilder @@ -162,7 +165,7 @@ open class HomeChildItemAdapter( // The vast majority of the lag comes from creating the view // This simply shares the views between all HomeChildItemAdapter val sharedPool = - newSharedPool { setMaxRecycledViews(CONTENT, 20) } + RecyclerView.RecycledViewPool().apply { this.setMaxRecycledViews(CONTENT, 20) } var minPosterSize: Int = 0 var maxPosterSize: Int = 0 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 b68ef5962..6c58fac9a 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 @@ -15,7 +15,6 @@ import android.widget.ImageView import android.widget.ListView import android.widget.TextView import android.widget.Toast -import androidx.activity.ComponentActivity import androidx.appcompat.app.AlertDialog import androidx.core.net.toUri import androidx.core.view.isGone @@ -52,7 +51,6 @@ import com.lagradost.cloudstream3.ui.search.SEARCH_ACTION_LOAD import com.lagradost.cloudstream3.ui.search.SEARCH_ACTION_PLAY_FILE import com.lagradost.cloudstream3.ui.search.SearchAdapter import com.lagradost.cloudstream3.ui.search.SearchHelper.handleSearchClickCallback -import com.lagradost.cloudstream3.ui.setRecycledViewPool import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR import com.lagradost.cloudstream3.ui.settings.Globals.PHONE import com.lagradost.cloudstream3.ui.settings.Globals.TV @@ -66,9 +64,6 @@ import com.lagradost.cloudstream3.utils.AppContextUtils.loadSearchResult import com.lagradost.cloudstream3.utils.AppContextUtils.ownHide import com.lagradost.cloudstream3.utils.AppContextUtils.ownShow import com.lagradost.cloudstream3.utils.AppContextUtils.setDefaultFocus -import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper -import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.attachBackPressedCallback -import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.detachBackPressedCallback import com.lagradost.cloudstream3.utils.Coroutines.ioSafe import com.lagradost.cloudstream3.utils.DataStoreHelper import com.lagradost.cloudstream3.utils.EmptyEvent @@ -90,6 +85,7 @@ class HomeFragment : BaseFragment( // Used for configuration changed events to fix any popups that are not attached to a fragment val configEvent = EmptyEvent() var currentSpan = 1 + val listHomepageItems = mutableListOf() private val errorProfilePics = listOf( R.drawable.monke_benene, @@ -571,7 +567,6 @@ class HomeFragment : BaseFragment( } override fun onDestroyView() { - (activity as? ComponentActivity)?.detachBackPressedCallback("HomeFragment_BackPress") bottomSheetDialog?.ownHide() super.onDestroyView() } @@ -632,9 +627,6 @@ class HomeFragment : BaseFragment( @SuppressLint("SetTextI18n") override fun onBindingCreated(binding: FragmentHomeBinding) { context?.let { HomeChildItemAdapter.updatePosterSize(it) } - (activity as? ComponentActivity)?.attachBackPressedCallback("HomeFragment_BackPress") { - handleTvBackPress(this) - } binding.apply { //homeChangeApiLoading.setOnClickListener(apiChangeClickListener) //homeChangeApiLoading.setOnClickListener(apiChangeClickListener) @@ -650,7 +642,13 @@ class HomeFragment : BaseFragment( activity?.showAccountSelectLinear() } + homeRandom.setOnClickListener { + if (listHomepageItems.isNotEmpty()) { + activity.loadSearchResult(listHomepageItems.random()) + } + } homeMasterAdapter = HomeParentItemAdapterPreview( + fragment = this@HomeFragment, homeViewModel, accountViewModel ) homeMasterRecycler.setRecycledViewPool(ParentItemAdapter.sharedPool) @@ -727,9 +725,8 @@ class HomeFragment : BaseFragment( settingsManager.getBoolean( getString(R.string.random_button_key), false - ) + ) && isLayout(PHONE) binding.homeRandom.visibility = View.GONE - binding.homeRandomButtonTv.visibility = View.GONE } observe(homeViewModel.apiName) { apiName -> @@ -755,28 +752,23 @@ class HomeFragment : BaseFragment( saveHomepageToTV(d) + listHomepageItems.clear() homeLoading.isVisible = false homeLoadingError.isVisible = false homeMasterRecycler.isVisible = true homeLoadingShimmer.stopShimmer() //home_loaded?.isVisible = true if (toggleRandomButton) { - val distinct = d.values - .flatMap { it.list.list } - .distinctBy { it.url } - val hasItems = distinct.isNotEmpty() - val isPhone = isLayout(PHONE) - val randomClickListener = View.OnClickListener { - distinct.randomOrNull()?.let { activity.loadSearchResult(it) } + //Flatten list + val mutableListOfResponse = mutableListOf() + d.values.forEach { dlist -> + mutableListOfResponse.addAll(dlist.list.list) } + listHomepageItems.addAll(mutableListOfResponse.distinctBy { it.url }) - homeRandom.isVisible = isPhone && hasItems - homeRandom.setOnClickListener(randomClickListener) - homeRandomButtonTv.isVisible = !isPhone && hasItems - homeRandomButtonTv.setOnClickListener(randomClickListener) + homeRandom.isVisible = listHomepageItems.isNotEmpty() } else { homeRandom.isGone = true - homeRandomButtonTv.isGone = true } } @@ -892,44 +884,4 @@ class HomeFragment : BaseFragment( } }*/ } - - private fun handleTvBackPress(helper: BackPressedCallbackHelper.CallbackHelper) { - // Only apply custom behavior on TV interface - if (!isLayout(TV)) { - helper.runDefault() - return - } - val currentFocus = activity?.currentFocus ?: run { - helper.runDefault() - return - } - // isInsideRecycle is true when focus is inside home_master_recycler - var parent = currentFocus.parent - var isInsideRecycler = false - while (parent != null) { - if (parent is View && parent.id == R.id.home_master_recycler) { - isInsideRecycler = true - break - } - parent = parent.parent - } - when { - // Case 1: Focus is within plugin content -> Move to plugin selector - isInsideRecycler -> { - binding?.homeMasterRecycler?.scrollToPosition(0) - // Defer focus request until after scroll ends - binding?.homeChangeApi?.post { - binding?.homeChangeApi?.requestFocus() - } - } - // Case 2: Focus is on plugin selector or nearby buttons -> Move to home navigation - currentFocus.id == R.id.home_change_api || - currentFocus.id == R.id.home_preview_reload_provider || - currentFocus.id == R.id.home_preview_search_button -> { - activity?.findViewById(R.id.navigation_home)?.requestFocus() - } - // Case 3: Any other location -> Use default back behavior - else -> helper.runDefault() - } - } } diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeParentItemAdapter.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeParentItemAdapter.kt index 6bdd1bf49..0d08dc898 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeParentItemAdapter.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeParentItemAdapter.kt @@ -6,8 +6,10 @@ import android.os.Parcelable import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import androidx.fragment.app.Fragment import androidx.recyclerview.widget.RecyclerView import androidx.viewbinding.ViewBinding +import com.lagradost.cloudstream3.HomePageList import com.lagradost.cloudstream3.LoadResponse import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.databinding.HomepageParentBinding @@ -15,11 +17,9 @@ import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.ui.BaseAdapter import com.lagradost.cloudstream3.ui.BaseDiffCallback import com.lagradost.cloudstream3.ui.ViewHolderState -import com.lagradost.cloudstream3.ui.newSharedPool import com.lagradost.cloudstream3.ui.result.FOCUS_SELF import com.lagradost.cloudstream3.ui.result.setLinearListLayout import com.lagradost.cloudstream3.ui.search.SearchClickCallback -import com.lagradost.cloudstream3.ui.setRecycledViewPool import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR import com.lagradost.cloudstream3.ui.settings.Globals.PHONE import com.lagradost.cloudstream3.ui.settings.Globals.TV @@ -48,7 +48,7 @@ open class ParentItemAdapter( ) { companion object { val sharedPool = - newSharedPool { setMaxRecycledViews(CONTENT, 4) } + RecyclerView.RecycledViewPool().apply { this.setMaxRecycledViews(CONTENT, 4) } } data class ParentItemHolder(val binding: ViewBinding) : ViewHolderState(binding) { 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 959806e56..26e3477ef 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 @@ -60,9 +60,9 @@ import com.lagradost.cloudstream3.utils.UIHelper.fixPaddingStatusbarMargin import com.lagradost.cloudstream3.utils.UIHelper.fixPaddingStatusbarView import com.lagradost.cloudstream3.utils.UIHelper.populateChips 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( @@ -104,7 +104,7 @@ class HomeParentItemAdapterPreview( ) } - return HeaderViewHolder(binding, viewModel, accountViewModel) + return HeaderViewHolder(binding, viewModel, accountViewModel, fragment) } override fun onBindHeader(holder: ViewHolderState) { @@ -131,6 +131,7 @@ class HomeParentItemAdapterPreview( val binding: ViewBinding, val viewModel: HomeViewModel, accountViewModel: AccountViewModel, + fragment: LifecycleOwner, ) : ViewHolderState(binding) { @@ -542,7 +543,7 @@ class HomeParentItemAdapterPreview( headProfilePicCard?.isGone = isLayout(TV or EMULATOR) alternateHeadProfilePicCard?.isGone = isLayout(TV or EMULATOR) - (headProfilePic ?: alternateHeadProfilePic)?.observe(viewModel.currentAccount) { currentAccount -> + fragment.observe(viewModel.currentAccount) { currentAccount -> headProfilePic?.loadImage(currentAccount?.image) alternateHeadProfilePic?.loadImage(currentAccount?.image) } @@ -773,7 +774,7 @@ class HomeParentItemAdapterPreview( fun onViewAttachedToWindow() { previewViewpager.registerOnPageChangeCallback(previewCallback) - previewViewpager.apply { + binding.root.findViewTreeLifecycleOwner()?.apply { observe(viewModel.preview) { updatePreview(it) } @@ -798,7 +799,7 @@ class HomeParentItemAdapterPreview( } toggleListHolder?.isGone = visible.isEmpty() } - } + } ?: debugException { "Expected findViewTreeLifecycleOwner" } } } } diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeViewModel.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeViewModel.kt index 8d48f5a68..6df5bbbef 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeViewModel.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeViewModel.kt @@ -9,12 +9,12 @@ import com.lagradost.cloudstream3.APIHolder.apis import com.lagradost.cloudstream3.APIHolder.getApiFromNameNull import com.lagradost.cloudstream3.CloudStreamApp.Companion.context import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey -import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey import com.lagradost.cloudstream3.CommonActivity.activity import com.lagradost.cloudstream3.HomePageList import com.lagradost.cloudstream3.LoadResponse import com.lagradost.cloudstream3.MainAPI import com.lagradost.cloudstream3.MainActivity +import com.lagradost.cloudstream3.MainActivity.Companion.lastError import com.lagradost.cloudstream3.SearchResponse import com.lagradost.cloudstream3.amap import com.lagradost.cloudstream3.mvvm.Resource @@ -40,7 +40,6 @@ import com.lagradost.cloudstream3.utils.AppContextUtils.filterSearchResultByFilm import com.lagradost.cloudstream3.utils.AppContextUtils.loadResult import com.lagradost.cloudstream3.utils.Coroutines.ioSafe import com.lagradost.cloudstream3.utils.DOWNLOAD_HEADER_CACHE -import com.lagradost.cloudstream3.utils.DOWNLOAD_HEADER_CACHE_BACKUP import com.lagradost.cloudstream3.utils.DataStoreHelper import com.lagradost.cloudstream3.utils.DataStoreHelper.deleteAllResumeStateIds import com.lagradost.cloudstream3.utils.DataStoreHelper.getAllResumeStateIds @@ -50,12 +49,13 @@ import com.lagradost.cloudstream3.utils.DataStoreHelper.getCurrentAccount import com.lagradost.cloudstream3.utils.DataStoreHelper.getLastWatched import com.lagradost.cloudstream3.utils.DataStoreHelper.getResultWatchState import com.lagradost.cloudstream3.utils.DataStoreHelper.getViewPos -import com.lagradost.cloudstream3.utils.downloader.DownloadObjects +import com.lagradost.cloudstream3.utils.VideoDownloadHelper import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.withContext import java.util.EnumSet import java.util.concurrent.CopyOnWriteArrayList +import kotlin.collections.set class HomeViewModel : ViewModel() { companion object { @@ -67,26 +67,11 @@ class HomeViewModel : ViewModel() { } val resumeWatchingResult = withContext(Dispatchers.IO) { resumeWatching?.mapNotNull { resume -> - val headerCache = getKey( + + val data = getKey( DOWNLOAD_HEADER_CACHE, resume.parentId.toString() - ) - - val data = if (headerCache == null) { - // We store resume watching data in download header cache - // Because downloads automatically pruned outdated download headers we - // removed resume watching data. We should restore the data for affected users. - val oldData = getKey( - DOWNLOAD_HEADER_CACHE_BACKUP, - resume.parentId.toString() - ) ?: return@mapNotNull null - - // Restore data - setKey(DOWNLOAD_HEADER_CACHE, resume.parentId.toString(), oldData) - oldData - } else { - headerCache - } + ) ?: return@mapNotNull null val watchPos = getViewPos(resume.episodeId) @@ -133,7 +118,7 @@ class HomeViewModel : ViewModel() { private var currentShuffledList: List = listOf() private fun autoloadRepo(): APIRepository { - return APIRepository(apis.withLock { apis.first { it.hasMainPage } }) + return APIRepository(synchronized(apis) { apis.first { it.hasMainPage } }) } private val _availableWatchStatusTypes = @@ -516,6 +501,9 @@ class HomeViewModel : ViewModel() { return@ioSafe } + HomeChildItemAdapter.sharedPool.clear() + ParentItemAdapter.sharedPool.clear() + val api = getApiFromNameNull(preferredApiName) if (preferredApiName == noneApi.name) { // just set to random @@ -535,7 +523,7 @@ class HomeViewModel : ViewModel() { } else if (api == null) { // API is not found aka not loaded or removed, post the loading // progress if waiting for plugins, otherwise nothing - if (PluginManager.loadedOnlinePlugins || PluginManager.isSafeMode()) { + if (PluginManager.loadedOnlinePlugins || PluginManager.checkSafeModeFile() || lastError != null) { loadAndCancel(noneApi) } else { _page.postValue(Resource.Loading()) diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/library/LibraryFragment.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/library/LibraryFragment.kt index c5f8fa3d9..c9be2ed5c 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/library/LibraryFragment.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/library/LibraryFragment.kt @@ -80,6 +80,8 @@ class LibraryFragment : BaseFragment( BaseFragment.BindingCreator.Bind(FragmentLibraryBinding::bind) ) { companion object { + + val listLibraryItems = mutableListOf() fun newInstance() = LibraryFragment() /** @@ -102,19 +104,14 @@ class LibraryFragment : BaseFragment( super.onSaveInstanceState(outState) } - private fun updateRandomVisibility(binding: FragmentLibraryBinding) { - if (!toggleRandomButton) { - binding.libraryRandom.isGone = true - binding.libraryRandomButtonTv.isGone = true - return - } + private fun updateRandom(binding: FragmentLibraryBinding) { val position = libraryViewModel.currentPage.value ?: 0 val pages = (libraryViewModel.pages.value as? Resource.Success)?.value ?: return - val hasItems = pages[position].items.isNotEmpty() - val isPhone = isLayout(PHONE) - - binding.libraryRandom.isVisible = isPhone && hasItems - binding.libraryRandomButtonTv.isVisible = !isPhone && hasItems + if (toggleRandomButton) { + listLibraryItems.clear() + listLibraryItems.addAll(pages[position].items) + binding.libraryRandom.isVisible = listLibraryItems.isNotEmpty() + } else binding.libraryRandom.isGone = true } override fun fixLayout(view: View) { @@ -197,9 +194,17 @@ class LibraryFragment : BaseFragment( settingsManager.getBoolean( getString(R.string.random_button_key), false - ) + ) && isLayout(PHONE) binding.libraryRandom.visibility = View.GONE - binding.libraryRandomButtonTv.visibility = View.GONE + } + + binding.libraryRandom.setOnClickListener { + if (listLibraryItems.isNotEmpty()) { + val listLibraryItem = listLibraryItems.random() + libraryViewModel.currentSyncApi?.syncIdName?.let { + loadLibraryItem(it, listLibraryItem.syncId, listLibraryItem) + } + } } /** @@ -210,13 +215,14 @@ class LibraryFragment : BaseFragment( syncId: SyncIdName, apiName: String? = null, ) { - val availableProviders = allProviders.filter { - it.supportedSyncNames.contains(syncId) - }.map { it.name } + - // Add the api if it exists - (APIHolder.getApiFromNameNull(apiName)?.let { listOf(it.name) } - ?: emptyList()) - + val availableProviders = synchronized(allProviders) { + allProviders.filter { + it.supportedSyncNames.contains(syncId) + }.map { it.name } + + // Add the api if it exists + (APIHolder.getApiFromNameNull(apiName)?.let { listOf(it.name) } + ?: emptyList()) + } val baseOptions = listOf( LibraryOpenerType.Default, LibraryOpenerType.None, @@ -381,19 +387,7 @@ class LibraryFragment : BaseFragment( binding.searchBar.setExpanded(true) } - // Set up random button click listener - if (toggleRandomButton) { - val randomClickListener = View.OnClickListener { - val position = libraryViewModel.currentPage.value ?: 0 - val syncIdName = libraryViewModel.currentSyncApi?.syncIdName ?: return@OnClickListener - pages[position].items.randomOrNull()?.let { item -> - loadLibraryItem(syncIdName, item.syncId, item) - } - } - libraryRandom.setOnClickListener(randomClickListener) - libraryRandomButtonTv.setOnClickListener(randomClickListener) - } - updateRandomVisibility(binding) + updateRandom(binding) // Only stop loading after 300ms to hide the fade effect the viewpager produces when updating // Without this there would be a flashing effect: @@ -472,7 +466,7 @@ class LibraryFragment : BaseFragment( } observe(libraryViewModel.currentPage) { position -> - updateRandomVisibility(binding) + updateRandom(binding) val all = binding.viewpager.allViews.toList() .filterIsInstance() 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 e5a460b9a..1fbdd9f4e 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,16 +1,64 @@ 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.viewbinding.ViewBinding +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 com.lagradost.cloudstream3.R -import com.lagradost.cloudstream3.ui.BaseFragment +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.EpisodeSkip +import com.lagradost.cloudstream3.utils.UIHelper +import com.lagradost.cloudstream3.utils.UIHelper.hideSystemUI +import com.lagradost.cloudstream3.utils.UIHelper.popCurrentPage +import java.net.SocketTimeoutException enum class PlayerResize(@StringRes val nameRes: Int) { Fit(R.string.resize_fit), @@ -31,131 +79,677 @@ const val NEXT_WATCH_EPISODE_PERCENTAGE = 90 const val UPDATE_SYNC_PROGRESS_PERCENTAGE = 80 @OptIn(UnstableApi::class) -abstract class AbstractPlayerFragment( - bindingCreator: BindingCreator -) : BaseFragment(bindingCreator), PlayerView.Callbacks { +abstract class AbstractPlayerFragment( + var player: IPlayer = CS3IPlayer() +) : Fragment() { + var resizeMode: Int = 0 + var subView: SubtitleView? = null + protected open var hasPipModeSupport = true - // Stored pre-initialization so subclasses can set them before onBindingCreated. - private var _player: IPlayer = CS3IPlayer() + 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 - /** The shared [PlayerView] host that owns all player state and view references. */ - protected var playerHostView: PlayerView? = null + @LayoutRes + protected open var layout: Int = R.layout.fragment_player - var player: IPlayer - get() = playerHostView?.player ?: _player - set(value) { - _player = value - playerHostView?.player = value + open fun nextEpisode() { + throw NotImplementedError() + } + + open fun prevEpisode() { + throw NotImplementedError() + } + + open fun playerPositionChanged(position: Long, duration: Long) { + throw NotImplementedError() + } + + open fun playerStatusChanged() {} + + open fun playerDimensionsLoaded(width: Int, height: Int) { + throw NotImplementedError() + } + + open fun subtitlesChanged() { + throw NotImplementedError() + } + + open fun embeddedSubtitlesFetched(subtitles: List) { + throw NotImplementedError() + } + + open fun onTracksInfoChanged() { + throw NotImplementedError() + } + + open fun onTimestamp(timestamp: EpisodeSkip.SkipStamp?) { + + } + + open fun onTimestampSkipped(timestamp: EpisodeSkip.SkipStamp) { + + } + + open fun exitedPipMode() { + 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) + } + } + + private fun updateIsPlaying( + wasPlaying: CSPlayerLoading, + isPlaying: CSPlayerLoading + ) { + val isPlayingRightNow = CSPlayerLoading.IsPlaying == isPlaying + val isPausedRightNow = CSPlayerLoading.IsPaused == isPlaying + currentPlayerStatus = isPlaying + + keepScreenOn(!isPausedRightNow) + + val isBuffering = CSPlayerLoading.IsBuffering == isPlaying + 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) + } } - val subView: SubtitleView? get() = playerHostView?.subView - val playerPausePlay: ImageView? get() = playerHostView?.playerPausePlay - - /** The underlying [androidx.media3.ui.PlayerView] widget (named to avoid conflict with our [PlayerView]). */ - val playerView: androidx.media3.ui.PlayerView? - get() = playerHostView?.exoPlayerView - - var currentPlayerStatus: CSPlayerLoading - get() = playerHostView?.currentPlayerStatus ?: CSPlayerLoading.IsBuffering - set(value) { playerHostView?.currentPlayerStatus = value } - - protected var mMediaSession: MediaSession? - get() = playerHostView?.mMediaSession - set(value) { playerHostView?.mMediaSession = value } - - // 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() - } - - override fun prevEpisode() { - throw NotImplementedError() - } - - override fun playerPositionChanged(position: Long, duration: Long) { - throw NotImplementedError() - } - - override fun playerDimensionsLoaded(width: Int, height: Int) { - throw NotImplementedError() - } - - override fun subtitlesChanged() { - throw NotImplementedError() - } - - override fun embeddedSubtitlesFetched(subtitles: List) { - throw NotImplementedError() - } - - override fun onTracksInfoChanged() { - throw NotImplementedError() - } - - override fun exitedPipMode() { - throw NotImplementedError() - } - - override fun hasNextMirror(): Boolean { - throw NotImplementedError() - } - - override fun nextMirror() { - throw NotImplementedError() - } - - /** 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 - - 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() + PlayerPipHelper.updatePIPModeActions( + activity, + isPlaying, + hasPipModeSupport, + player.getAspectRatio() + ) } + private var pipReceiver: BroadcastReceiver? = null override fun onPictureInPictureModeChanged(isInPictureInPictureMode: Boolean) { super.onPictureInPictureModeChanged(isInPictureInPictureMode) - playerHostView?.onPictureInPictureModeChanged(isInPictureInPictureMode, 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() + 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) + } + } + + open fun hasNextMirror(): Boolean { + throw NotImplementedError() + } + + open fun nextMirror() { + throw NotImplementedError() + } + + private fun requestAudioFocus() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + activity?.requestLocalAudioFocus(AppContextUtils.getFocusRequest()) + } + } + + 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 + ) + } + } + } + + private fun onSubStyleChanged(style: SaveCaptionStyle) { + player.updateSubtitleStyle(style) + // Forcefully update the subtitle encoding in case the edge size is changed + player.seekTime(-1) + } + + + @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 + } + } + + @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 onDestroy() { - playerHostView?.release() + 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) super.onDestroy() } - override fun onPause() { - playerHostView?.releaseKeyEventListener() - super.onPause() + 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 onStop() { - playerHostView?.onStop() + player.onStop() super.onStop() } override fun onResume() { context?.let { ctx -> - playerHostView?.onResume(ctx) + player.onResume(ctx) } + super.onResume() } - fun nextResize() { - playerHostView?.nextResize() + 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 } - - open fun resize(resize: PlayerResize, showToast: Boolean) { - playerHostView?.resize(resize, showToast) - } -} +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/CS3IPlayer.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/CS3IPlayer.kt index d7e10c814..1bd2e722c 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,11 +12,9 @@ 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 -import androidx.core.net.toUri import androidx.media3.common.C.TIME_UNSET import androidx.media3.common.C.TRACK_TYPE_AUDIO import androidx.media3.common.C.TRACK_TYPE_TEXT @@ -30,7 +28,6 @@ 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,28 +39,23 @@ import androidx.media3.datasource.cache.LeastRecentlyUsedCacheEvictor import androidx.media3.datasource.cache.SimpleCache import androidx.media3.datasource.cronet.CronetDataSource import androidx.media3.datasource.okhttp.OkHttpDataSource -import androidx.media3.exoplayer.DecoderCounters -import androidx.media3.exoplayer.DecoderReuseEvaluation -import androidx.media3.exoplayer.DefaultLivePlaybackSpeedControl import androidx.media3.exoplayer.DefaultLoadControl import androidx.media3.exoplayer.DefaultRenderersFactory import androidx.media3.exoplayer.ExoPlayer import androidx.media3.exoplayer.Renderer.STATE_ENABLED import androidx.media3.exoplayer.Renderer.STATE_STARTED import androidx.media3.exoplayer.SeekParameters -import androidx.media3.exoplayer.analytics.AnalyticsListener import androidx.media3.exoplayer.dash.DashMediaSource import androidx.media3.exoplayer.drm.DefaultDrmSessionManager import androidx.media3.exoplayer.drm.FrameworkMediaDrm 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 import androidx.media3.exoplayer.source.DefaultMediaSourceFactory -import androidx.media3.exoplayer.source.MediaSource import androidx.media3.exoplayer.source.MergingMediaSource +import androidx.media3.exoplayer.source.MediaSource import androidx.media3.exoplayer.source.SingleSampleMediaSource import androidx.media3.exoplayer.text.TextOutput import androidx.media3.exoplayer.text.TextRenderer @@ -73,7 +65,6 @@ import androidx.media3.extractor.mp4.FragmentedMp4Extractor import androidx.media3.ui.SubtitleView import androidx.preference.PreferenceManager import com.lagradost.cloudstream3.APIHolder.getApiFromNameNull -import com.lagradost.cloudstream3.AudioFile import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey import com.lagradost.cloudstream3.CommonActivity.activity @@ -82,43 +73,43 @@ import com.lagradost.cloudstream3.MainActivity.Companion.deleteFileOnExit import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.TvType import com.lagradost.cloudstream3.USER_AGENT +import com.lagradost.cloudstream3.AudioFile import com.lagradost.cloudstream3.app import com.lagradost.cloudstream3.mvvm.debugAssert import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.mvvm.safe import com.lagradost.cloudstream3.ui.player.CustomDecoder.Companion.fixSubtitleAlignment -import com.lagradost.cloudstream3.ui.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.TV import com.lagradost.cloudstream3.ui.settings.Globals.isLayout import com.lagradost.cloudstream3.ui.subtitles.SaveCaptionStyle import com.lagradost.cloudstream3.ui.subtitles.SubtitlesFragment.Companion.applyStyle import com.lagradost.cloudstream3.utils.AppContextUtils.isUsingMobileData import com.lagradost.cloudstream3.utils.AppContextUtils.setDefaultFocus -import com.lagradost.cloudstream3.utils.CLEARKEY_DRM_UUID import com.lagradost.cloudstream3.utils.Coroutines.ioSafe import com.lagradost.cloudstream3.utils.Coroutines.runOnMainThread import com.lagradost.cloudstream3.utils.DataStoreHelper.currentAccount import com.lagradost.cloudstream3.utils.DrmExtractorLink +import com.lagradost.cloudstream3.utils.EpisodeSkip import com.lagradost.cloudstream3.utils.ExtractorLink +import com.lagradost.cloudstream3.utils.CLEARKEY_UUID +import com.lagradost.cloudstream3.utils.WIDEVINE_UUID +import com.lagradost.cloudstream3.utils.PLAYREADY_UUID import com.lagradost.cloudstream3.utils.ExtractorLinkPlayList import com.lagradost.cloudstream3.utils.ExtractorLinkType -import com.lagradost.cloudstream3.utils.PLAYREADY_DRM_UUID import com.lagradost.cloudstream3.utils.SubtitleHelper.fromTagToLanguageName -import com.lagradost.cloudstream3.utils.WIDEVINE_DRM_UUID -import com.lagradost.cloudstream3.utils.videoskip.VideoSkipStamp +import io.github.anilbeesetti.nextlib.media3ext.ffdecoder.NextRenderersFactory import kotlinx.coroutines.delay -import okhttp3.Interceptor import org.chromium.net.CronetEngine import java.io.File -import java.security.SecureRandom import java.util.UUID import java.util.concurrent.Executors import javax.net.ssl.HttpsURLConnection import javax.net.ssl.SSLContext import javax.net.ssl.SSLSession -import kotlin.uuid.toJavaUuid +import kotlin.collections.HashSet +import kotlin.text.StringBuilder +import androidx.core.net.toUri +import okhttp3.Interceptor const val TAG = "CS3ExoPlayer" const val PREFERRED_AUDIO_LANGUAGE_KEY = "preferred_audio_language" @@ -208,14 +199,16 @@ 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 thread. - if (Looper.getMainLooper().isCurrentThread) { - eventHandler?.invoke(event) - } else runOnMainThread { + // Ensure that all work is done on the main looper, aka main thread + if (Looper.myLooper() == mainHandler.looper) { eventHandler?.invoke(event) + } else { + mainHandler.post { + eventHandler?.invoke(event) + } } } @@ -235,9 +228,8 @@ class CS3IPlayer : IPlayer { } } - @AnyThread override fun initCallbacks( - @MainThread eventHandler: ((PlayerEvent) -> Unit), + eventHandler: ((PlayerEvent) -> Unit), requestedListeningPercentages: List?, ) { this.requestedListeningPercentages = requestedListeningPercentages @@ -248,6 +240,23 @@ 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+:"""), "") } @@ -261,10 +270,6 @@ 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() } @@ -378,47 +383,44 @@ class CS3IPlayer : IPlayer { ?: return } - override fun setPreferredAudioTrack(trackLanguage: String?, id: String?, formatIndex: Int?) { + override fun setPreferredAudioTrack(trackLanguage: String?, id: String?) { preferredAudioTrackLanguage = trackLanguage - id?.let { trackId -> - val trackFormatIndex = formatIndex ?: 0 - exoPlayer?.currentTracks?.groups - ?.filter { it.type == TRACK_TYPE_AUDIO } - ?.find { group -> - group.getFormats().any { (format, _) -> - format.id == trackId - } - } - ?.let { group -> - exoPlayer?.trackSelectionParameters - ?.buildUpon() - ?.setOverrideForType( - TrackSelectionOverride( - group.mediaTrackGroup, - trackFormatIndex - ) + + if (id != null) { + val audioTrack = + exoPlayer?.currentTracks?.groups?.filter { it.type == TRACK_TYPE_AUDIO } + ?.getTrack(id) + + if (audioTrack != null) { + exoPlayer?.trackSelectionParameters = exoPlayer?.trackSelectionParameters + ?.buildUpon() + ?.setOverrideForType( + TrackSelectionOverride( + audioTrack.first, + audioTrack.second ) - ?.build() - } - ?.let { newParams -> - exoPlayer?.trackSelectionParameters = newParams - return - } + ) + ?.build() + ?: return + return + } } - // Fallback to language-based selection + exoPlayer?.trackSelectionParameters = exoPlayer?.trackSelectionParameters ?.buildUpon() ?.setPreferredAudioLanguage(trackLanguage) - ?.build() ?: return + ?.build() + ?: return } + /** * Gets all supported formats in a list * */ private fun List.getFormats(): List> { - return this.flatMap { + return this.map { it.getFormats() - } + }.flatten() } private fun Tracks.Group.getFormats(): List> { @@ -429,14 +431,11 @@ class CS3IPlayer : IPlayer { } } - private fun Format.toAudioTrack(formatIndex: Int?): AudioTrack { + private fun Format.toAudioTrack(): AudioTrack { return AudioTrack( - this.id, + this.id?.stripTrackId(), this.label, - this.language, - this.sampleMimeType, - this.channelCount, - formatIndex ?: 0, + this.language ) } @@ -445,7 +444,7 @@ class CS3IPlayer : IPlayer { this.id?.stripTrackId(), this.label, this.language, - this.sampleMimeType, + this.sampleMimeType ) } @@ -456,35 +455,27 @@ class CS3IPlayer : IPlayer { this.language, this.width, this.height, - this.sampleMimeType ) } override fun getVideoTracks(): CurrentTracks { - val allTrackGroups = exoPlayer?.currentTracks?.groups ?: emptyList() - val videoTracks = allTrackGroups.filter { it.type == TRACK_TYPE_VIDEO } + val allTracks = exoPlayer?.currentTracks?.groups ?: emptyList() + val videoTracks = allTracks.filter { it.type == TRACK_TYPE_VIDEO } .getFormats() .map { it.first.toVideoTrack() } - var currentAudioTrack: AudioTrack? = null - val audioTracks = allTrackGroups.filter { it.type == TRACK_TYPE_AUDIO } - .flatMap { group -> - group.getFormats().map { (format, formatIndex) -> - val audioTrack = format.toAudioTrack(formatIndex) - if (group.isTrackSelected(formatIndex)) { - currentAudioTrack = audioTrack - } - audioTrack - } - } - val textTracks = allTrackGroups.filter { it.type == TRACK_TYPE_TEXT } - .getFormats() + val audioTracks = allTracks.filter { it.type == TRACK_TYPE_AUDIO }.getFormats() + .map { it.first.toAudioTrack() } + + val textTracks = allTracks.filter { it.type == TRACK_TYPE_TEXT }.getFormats() .map { it.first.toSubtitleTrack() } + val currentTextTracks = textTracks.filter { track -> playerSelectedSubtitleTracks.any { it.second && it.first == track.id } } + return CurrentTracks( exoPlayer?.videoFormat?.toVideoTrack(), - currentAudioTrack, + exoPlayer?.audioFormat?.toAudioTrack(), currentTextTracks, videoTracks, audioTracks, @@ -498,43 +489,60 @@ class CS3IPlayer : IPlayer { override fun setPreferredSubtitles(subtitle: SubtitleData?): Boolean { Log.i(TAG, "setPreferredSubtitles init $subtitle") currentSubtitles = subtitle - val trackSelector = exoPlayer?.trackSelector as? DefaultTrackSelector ?: return false - // Disable subtitles if null - if (subtitle == null) { - trackSelector.setParameters( - trackSelector.buildUponParameters() - .setTrackTypeDisabled(TRACK_TYPE_TEXT, true) - .clearOverridesOfType(TRACK_TYPE_TEXT) - ) - return false - } - // Handle subtitle based on status - when (subtitleHelper.subtitleStatus(subtitle)) { - SubtitleStatus.REQUIRES_RELOAD -> { - Log.i(TAG, "setPreferredSubtitles REQUIRES_RELOAD") - return true - } - SubtitleStatus.NOT_FOUND -> { - Log.i(TAG, "setPreferredSubtitles NOT_FOUND") - return true - } + fun getTextTrack(id: String) = + exoPlayer?.currentTracks?.groups?.filter { it.type == TRACK_TYPE_TEXT } + ?.getTrack(id) + + return (exoPlayer?.trackSelector as? DefaultTrackSelector?)?.let { trackSelector -> + if (subtitle == null) { + trackSelector.setParameters( + trackSelector.buildUponParameters() + .setTrackTypeDisabled(TRACK_TYPE_TEXT, true) + .clearOverridesOfType(TRACK_TYPE_TEXT) + ) + } else { + when (subtitleHelper.subtitleStatus(subtitle)) { + SubtitleStatus.REQUIRES_RELOAD -> { + Log.i(TAG, "setPreferredSubtitles REQUIRES_RELOAD") + return@let true + } + + SubtitleStatus.IS_ACTIVE -> { + Log.i(TAG, "setPreferredSubtitles IS_ACTIVE") - SubtitleStatus.IS_ACTIVE -> { - Log.i(TAG, "setPreferredSubtitles IS_ACTIVE") - exoPlayer?.currentTracks?.groups - ?.filter { it.type == TRACK_TYPE_TEXT } - ?.getTrack(subtitle.getId()) - ?.let { (trackGroup, trackIndex) -> trackSelector.setParameters( trackSelector.buildUponParameters() - .setTrackTypeDisabled(TRACK_TYPE_TEXT, false) - .setOverrideForType(TrackSelectionOverride(trackGroup, trackIndex)) + .apply { + val track = getTextTrack(subtitle.getId()) + if (track != null) { + setTrackTypeDisabled(TRACK_TYPE_TEXT, false) + setOverrideForType( + TrackSelectionOverride( + track.first, + track.second + ) + ) + } + } ) + + // ugliest code I have written, it seeks 1ms to *update* the subtitles + //exoPlayer?.applicationLooper?.let { + // Handler(it).postDelayed({ + // seekTime(1L) + // }, 1) + //} } - return false + + SubtitleStatus.NOT_FOUND -> { + Log.i(TAG, "setPreferredSubtitles NOT_FOUND") + return@let true + } + } } - } + return false + } ?: false } private var currentSubtitleOffset: Long = 0 @@ -543,10 +551,10 @@ class CS3IPlayer : IPlayer { currentSubtitleOffset = offset CustomDecoder.subtitleOffset = offset if (currentTextRenderer?.state == STATE_ENABLED || currentTextRenderer?.state == STATE_STARTED) { - exoPlayer?.currentPosition?.also { pos -> + exoPlayer?.currentPosition?.let { pos -> // This seems to properly refresh all subtitles // It needs to be done as all subtitle cues with timings are pre-processed - currentTextRenderer?.resetPosition(pos, false) + currentTextRenderer?.resetPosition(pos) } } } @@ -734,23 +742,13 @@ class CS3IPlayer : IPlayer { private var simpleCache: SimpleCache? = null /// Create a small factory for small things, no cache, no cronet - private fun createOnlineSource( - headers: Map?, - interceptor: Interceptor? - ): HttpDataSource.Factory { - val client = if (interceptor == null) { - app.baseClient - } else { - app.baseClient.newBuilder() - .addInterceptor(interceptor) - .build() + private fun createOnlineSource(headers: Map?): HttpDataSource.Factory { + val source = OkHttpDataSource.Factory(app.baseClient).setUserAgent(USER_AGENT) + return source.apply { + if (!headers.isNullOrEmpty()) { + setDefaultRequestProperties(headers) + } } - val source = OkHttpDataSource.Factory(client).setUserAgent(USER_AGENT) - - if (!headers.isNullOrEmpty()) { - source.setDefaultRequestProperties(headers) - } - return source } fun tryCreateEngine(context: Context, diskCacheSize: Long): CronetEngine? { @@ -789,9 +787,10 @@ class CS3IPlayer : IPlayer { private fun createVideoSource( link: ExtractorLink, - engine: CronetEngine?, - interceptor: Interceptor?, + engine: CronetEngine? ): HttpDataSource.Factory { + val provider = getApiFromNameNull(link.source) + val interceptor: Interceptor? = provider?.getVideoInterceptor(link) val userAgent = link.headers.entries.find { it.key.equals("User-Agent", ignoreCase = true) }?.value ?: USER_AGENT @@ -823,7 +822,14 @@ class CS3IPlayer : IPlayer { // These are extra headers the browser like to insert, not sure if we want to include them // for WIDEVINE/drm as well? Do that if someone gets 404 and creates an issue. - val headers = refererMap + link.headers // Adds the headers from the provider, e.g Authorization + val headers = mapOf( + "accept" to "*/*", + "sec-ch-ua" to "\"Chromium\";v=\"91\", \" Not;A Brand\";v=\"99\"", + "sec-ch-ua-mobile" to "?0", + "sec-fetch-user" to "?1", + "sec-fetch-mode" to "navigate", + "sec-fetch-dest" to "video" + ) + refererMap + link.headers // Adds the headers from the provider, e.g Authorization return source.apply { setDefaultRequestProperties(headers) @@ -883,10 +889,10 @@ class CS3IPlayer : IPlayer { private var currentTextRenderer: TextRenderer? = null } - private fun getCurrentTimestamp(writePosition: Long? = null): VideoSkipStamp? { + private fun getCurrentTimestamp(writePosition: Long? = null): EpisodeSkip.SkipStamp? { val position = writePosition ?: this@CS3IPlayer.getPosition() ?: return null for (lastTimeStamp in lastTimeStamps) { - if (lastTimeStamp.timestamp.startMs <= position && (position + (toleranceBeforeUs / 1000L) + 1) < lastTimeStamp.timestamp.endMs) { + if (lastTimeStamp.startMs <= position && (position + (toleranceBeforeUs / 1000L) + 1) < lastTimeStamp.endMs) { return lastTimeStamp } } @@ -945,22 +951,6 @@ 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() } @@ -1014,7 +1004,7 @@ class CS3IPlayer : IPlayer { if (lastTimeStamp.skipToNextEpisode) { handleEvent(CSPlayerEvent.NextEpisode, source) } else { - seekTo(lastTimeStamp.timestamp.endMs + 1L) + seekTo(lastTimeStamp.endMs + 1L) } event(TimestampSkippedEvent(timestamp = lastTimeStamp, source = source)) } @@ -1083,44 +1073,28 @@ class CS3IPlayer : IPlayer { ): ExoPlayer { val exoPlayerBuilder = ExoPlayer.Builder(context) - .setMediaSourceFactory( - DefaultMediaSourceFactory(context).setLiveTargetOffsetMs( - PREFERRED_LIVE_OFFSET - ) - ) - .setLivePlaybackSpeedControl( - DefaultLivePlaybackSpeedControl.Builder() - .setFallbackMaxPlaybackSpeed(1.03f) - .setFallbackMinPlaybackSpeed(0.97f) - .build() - ) - .setRenderersFactory { eventHandler, videoRendererEventListener, audioRendererEventListener, _, metadataRendererOutput -> + .setRenderersFactory { eventHandler, videoRendererEventListener, audioRendererEventListener, textRendererOutput, metadataRendererOutput -> val settingsManager = PreferenceManager.getDefaultSharedPreferences(context) val current = settingsManager.getInt( context.getString(R.string.software_decoding_key), -1 ) - val (isSoftwareDecodingEnabled, isSoftwareDecodingPreferred) = when (current) { - 0 -> true to false // HW+SW, aka on but prefer hw - 2 -> true to true // SW+HW, aka on but prefer sw - 1 -> false to false // HW, aka off + val softwareDecoding = when (current) { + 0 -> true // yes + 1 -> false // no // -1 = automatic - // We do not want tv to have software decoding, because of crashes - else -> isLayout(PHONE or EMULATOR) to false + else -> { + // we do not want tv to have software decoding, because of crashes + !isLayout(TV) + } } - val factory = if (isSoftwareDecodingEnabled) { - FixedNextRenderersFactory(context).apply { + val factory = if (softwareDecoding) { + NextRenderersFactory(context).apply { setEnableDecoderFallback(true) - setExtensionRendererMode( - if (isSoftwareDecodingPreferred) - DefaultRenderersFactory.EXTENSION_RENDERER_MODE_PREFER - else - DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON - ) + setExtensionRendererMode(DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON) } } else { - // no nextlib = EXTENSION_RENDERER_MODE_OFF DefaultRenderersFactory(context) } @@ -1128,7 +1102,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.toList() + val (bitmapCues, textCues) = cue.cues.filterNotNull() .partition { it.bitmap != null } val styledBitmapCues = bitmapCues.map { bitmapCue -> @@ -1196,7 +1170,6 @@ class CS3IPlayer : IPlayer { CustomDecoder.subtitleOffset = subtitleOffset val decoder = CustomSubtitleDecoderFactory() - // @OptIn(ExperimentalApi::class) val currentTextRenderer = TextRenderer( customTextOutput, eventHandler.looper, @@ -1279,7 +1252,7 @@ class CS3IPlayer : IPlayer { item.drm?.let { drm -> when (drm.uuid) { - CLEARKEY_DRM_UUID.toJavaUuid() -> { + CLEARKEY_UUID -> { // Use headers from DrmMetadata for media requests val client = dataSourceFactory ?: throw IllegalArgumentException("Must supply onlineSource") @@ -1300,8 +1273,8 @@ class CS3IPlayer : IPlayer { .createMediaSource(item.mediaItem) } - WIDEVINE_DRM_UUID.toJavaUuid(), - PLAYREADY_DRM_UUID.toJavaUuid() -> { + WIDEVINE_UUID, + PLAYREADY_UUID -> { // Use headers from DrmMetadata for media requests val client = dataSourceFactory ?: throw IllegalArgumentException("Must supply onlineSource") @@ -1335,7 +1308,7 @@ class CS3IPlayer : IPlayer { } else { try { val source = ConcatenatingMediaSource2.Builder() - mediaItemSlices.forEach { item -> + mediaItemSlices.map { item -> source.add( // The duration MUST be known for it to work properly, see https://github.com/google/ExoPlayer/issues/4727 ClippingMediaSource( @@ -1349,7 +1322,7 @@ class CS3IPlayer : IPlayer { @Suppress("DEPRECATION") val source = ConcatenatingMediaSource() // FIXME figure out why ConcatenatingMediaSource2 seems to fail with Torrents only - mediaItemSlices.forEach { item -> + mediaItemSlices.map { item -> source.addMediaSource( // The duration MUST be known for it to work properly, see https://github.com/google/ExoPlayer/issues/4727 ClippingMediaSource( @@ -1372,7 +1345,6 @@ class CS3IPlayer : IPlayer { ) setHandleAudioBecomingNoisy(true) setPlaybackSpeed(playBackSpeed) - this.addAnalyticsListener(tracksAnalyticsListener) } } @@ -1414,23 +1386,6 @@ 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 @@ -1443,8 +1398,6 @@ class CS3IPlayer : IPlayer { return } - LiveHelper.registerPlayer(exoPlayer) - exoPlayer?.addListener(object : Player.Listener { override fun onTracksChanged(tracks: Tracks) { safe { @@ -1553,23 +1506,6 @@ 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)) } @@ -1641,9 +1577,9 @@ class CS3IPlayer : IPlayer { } } - private var lastTimeStamps: List = emptyList() + private var lastTimeStamps: List = emptyList() - override fun addTimeStamps(timeStamps: List) { + override fun addTimeStamps(timeStamps: List) { lastTimeStamps = timeStamps timeStamps.forEach { timestamp -> exoPlayer?.createMessage { _, _ -> @@ -1652,7 +1588,7 @@ class CS3IPlayer : IPlayer { // onTimestampInvoked?.invoke(payload) } ?.setLooper(Looper.getMainLooper()) - ?.setPosition(timestamp.timestamp.startMs) + ?.setPosition(timestamp.startMs) //?.setPayload(timestamp) ?.setDeleteAfterDelivery(false) ?.send() @@ -1699,8 +1635,7 @@ class CS3IPlayer : IPlayer { val (subSources, activeSubtitles) = getSubSources( offlineSourceFactory = offlineSourceFactory, - subHelper = subtitleHelper, - interceptor = null, + subtitleHelper, ) subtitleHelper.setActiveSubtitles(activeSubtitles.toSet()) @@ -1714,7 +1649,6 @@ class CS3IPlayer : IPlayer { private fun getSubSources( offlineSourceFactory: DataSource.Factory?, subHelper: PlayerSubtitleHelper, - interceptor: Interceptor?, ): Pair, List> { val activeSubtitles = ArrayList() val subSources = subHelper.getAllSubtitles().mapNotNull { sub -> @@ -1736,9 +1670,8 @@ class CS3IPlayer : IPlayer { } SubtitleOrigin.URL -> { - val dataSourceFactory = createOnlineSource(sub.headers, interceptor) activeSubtitles.add(sub) - SingleSampleMediaSource.Factory(dataSourceFactory) + SingleSampleMediaSource.Factory(createOnlineSource(sub.headers)) .createMediaSource(subConfig, TIME_UNSET) } } @@ -1753,13 +1686,14 @@ class CS3IPlayer : IPlayer { */ private fun getAudioSources( audioTracks: List, - interceptor: Interceptor?, ): List { + if (audioTracks.isEmpty()) return emptyList() return audioTracks.mapNotNull { audio -> try { val mediaItem = getMediaItem(MimeTypes.AUDIO_UNKNOWN, audio.url) - val dataSourceFactory = createOnlineSource(audio.headers, interceptor) - DefaultMediaSourceFactory(dataSourceFactory).createMediaSource(mediaItem) + DefaultMediaSourceFactory(createOnlineSource(audio.headers)).createMediaSource( + mediaItem + ) } catch (e: Exception) { Log.e(TAG, "Failed to create audio source for ${audio.url}: ${e.message}") null @@ -1771,6 +1705,7 @@ class CS3IPlayer : IPlayer { return exoPlayer != null } + @MainThread private fun loadTorrent(context: Context, link: ExtractorLink) { ioSafe { @@ -1820,7 +1755,7 @@ class CS3IPlayer : IPlayer { defaultSet ) ?.mapNotNull { it.toIntOrNull() ?: return@mapNotNull null } - } catch (_: Throwable) { + } catch (e: Throwable) { null } ?: default @@ -1893,7 +1828,7 @@ class CS3IPlayer : IPlayer { if (ignoreSSL) { // Disables ssl check val sslContext: SSLContext = SSLContext.getInstance("TLS") - sslContext.init(null, arrayOf(SSLTrustManager()), SecureRandom()) + sslContext.init(null, arrayOf(SSLTrustManager()), java.security.SecureRandom()) sslContext.createSSLEngine() HttpsURLConnection.setDefaultHostnameVerifier { _: String, _: SSLSession -> true @@ -1915,7 +1850,7 @@ class CS3IPlayer : IPlayer { drm = DrmMetadata( kid = link.kid, key = link.key, - uuid = link.uuid.toJavaUuid(), + uuid = link.uuid, kty = link.kty, licenseUrl = link.licenseUrl, keyRequestParameters = link.keyRequestParameters, @@ -1930,35 +1865,19 @@ class CS3IPlayer : IPlayer { ) } - // For DASH or HLS single streams (non-playlist), prefer the player's default - // live position instead of starting at 0. Use TIME_UNSET to let ExoPlayer pick - // the live/default position when no explicit start position was provided. - if (playbackPosition == 0L && (link.type == ExtractorLinkType.M3U8 || link.type == ExtractorLinkType.DASH)) { - playbackPosition = TIME_UNSET - } - - val provider = getApiFromNameNull(link.source) - val interceptor: Interceptor? = provider?.getVideoInterceptor(link) - val onlineSourceFactory = - createVideoSource( - link = link, - engine = tryCreateEngine(context, simpleCacheSize), - interceptor = interceptor - ) + createVideoSource(link, tryCreateEngine(context, simpleCacheSize)) val offlineSourceFactory = context.createOfflineSource() val (subSources, activeSubtitles) = getSubSources( offlineSourceFactory = offlineSourceFactory, - subHelper = subtitleHelper, - interceptor = interceptor, // Backwards compatibility, needs a new api to work properly + subtitleHelper ) // Create audio sources from ExtractorLink's audioTracks val audioSources = getAudioSources( audioTracks = link.audioTracks, - interceptor = interceptor, // Backwards compatibility, needs a new api to work properly ) subtitleHelper.setActiveSubtitles(activeSubtitles.toSet()) @@ -1986,38 +1905,4 @@ class CS3IPlayer : IPlayer { loadOfflinePlayer(context, it) } } - - private val tracksAnalyticsListener = object : AnalyticsListener { - - override fun onVideoInputFormatChanged( - eventTime: AnalyticsListener.EventTime, - format: Format, - decoderReuseEvaluation: DecoderReuseEvaluation? - ) { - event(TracksChangedEvent()) - } - - override fun onAudioInputFormatChanged( - eventTime: AnalyticsListener.EventTime, - format: Format, - decoderReuseEvaluation: DecoderReuseEvaluation? - ) { - event(TracksChangedEvent()) - } - - override fun onVideoDisabled( - eventTime: AnalyticsListener.EventTime, - decoderCounters: DecoderCounters - ) { - event(TracksChangedEvent()) - } - - override fun onAudioDisabled( - eventTime: AnalyticsListener.EventTime, - decoderCounters: DecoderCounters - ) { - event(TracksChangedEvent()) - } - } - } diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/CustomSubripParser.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/CustomSubripParser.kt deleted file mode 100644 index c26a4f2df..000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/CustomSubripParser.kt +++ /dev/null @@ -1,296 +0,0 @@ -/* - * Copyright (C) 2016 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - - -/* -* This is a fork of media3 subrip parses as the developers fear a flexible player, and open classes. -*/ -package com.lagradost.cloudstream3.ui.player - -import android.text.Html -import android.text.Spanned -import android.text.TextUtils -import androidx.annotation.VisibleForTesting -import androidx.media3.common.C -import androidx.media3.common.Format -import androidx.media3.common.Format.CueReplacementBehavior -import androidx.media3.common.text.Cue -import androidx.media3.common.text.Cue.AnchorType -import androidx.media3.common.util.Consumer -import androidx.media3.common.util.Log -import androidx.media3.common.util.ParsableByteArray -import androidx.media3.common.util.UnstableApi -import androidx.media3.extractor.text.CuesWithTiming -import androidx.media3.extractor.text.SubtitleParser -import androidx.media3.extractor.text.SubtitleParser.OutputOptions -import com.google.common.base.Preconditions.checkNotNull -import com.google.common.collect.ImmutableList -import java.nio.charset.Charset -import java.nio.charset.StandardCharsets -import java.util.regex.Matcher -import java.util.regex.Pattern - -/** A [SubtitleParser] for SubRip. */ -@UnstableApi -class CustomSubripParser : SubtitleParser { - private val textBuilder: StringBuilder = StringBuilder() - private val tags: ArrayList = ArrayList() - private val parsableByteArray: ParsableByteArray = ParsableByteArray() - - override fun getCueReplacementBehavior(): @CueReplacementBehavior Int { - return CUE_REPLACEMENT_BEHAVIOR - } - - override fun parse( - data: ByteArray, - offset: Int, - length: Int, - outputOptions: OutputOptions, - output: Consumer - ) { - parsableByteArray.reset(data, /* limit= */offset + length) - parsableByteArray.setPosition(offset) - val charset = detectUtfCharset(parsableByteArray) - - val cuesWithTimingBeforeRequestedStartTimeUs: MutableList? = - if (outputOptions.startTimeUs != C.TIME_UNSET && outputOptions.outputAllCues) - ArrayList() - else - null - var currentLine: String? - while ((parsableByteArray.readLine(charset).also { currentLine = it }) != null) { - if (currentLine!!.isEmpty()) { - // Skip blank lines. - continue - } - - // Parse and check the index line. - try { - currentLine.toInt() - } catch (_: NumberFormatException) { - Log.w(TAG, "Skipping invalid index: $currentLine") - continue - } - - // Read and parse the timing line. - currentLine = parsableByteArray.readLine(charset) - if (currentLine == null) { - Log.w(TAG, "Unexpected end") - break - } - - val startTimeUs: Long - val endTimeUs: Long - val matcher = SUBRIP_TIMING_LINE.matcher(currentLine) - if (matcher.matches()) { - startTimeUs = parseTimecode(matcher, /* groupOffset= */1) - endTimeUs = parseTimecode(matcher, /* groupOffset= */6) - } else { - Log.w(TAG, "Skipping invalid timing: $currentLine") - continue - } - - // Read and parse the text and tags. - textBuilder.setLength(0) - tags.clear() - currentLine = parsableByteArray.readLine(charset) - while (!TextUtils.isEmpty(currentLine)) { - if (textBuilder.isNotEmpty()) { - textBuilder.append("
") - } - textBuilder.append(processLine(currentLine!!, tags)) - currentLine = parsableByteArray.readLine(charset) - } - - @Suppress("DEPRECATION") - val text = Html.fromHtml(textBuilder.toString()) - - var alignmentTag: String? = null - for (i in tags.indices) { - val tag = tags[i] - if (tag.matches(SUBRIP_ALIGNMENT_TAG.toRegex())) { - alignmentTag = tag - // Subsequent alignment tags should be ignored. - break - } - } - if (outputOptions.startTimeUs == C.TIME_UNSET || endTimeUs >= outputOptions.startTimeUs) { - output.accept( - CuesWithTiming( - ImmutableList.of(buildCue(text, alignmentTag)), - startTimeUs, /* durationUs= */ - endTimeUs - startTimeUs - ) - ) - } else cuesWithTimingBeforeRequestedStartTimeUs?.add( - CuesWithTiming( - ImmutableList.of(buildCue(text, alignmentTag)), - startTimeUs, /* durationUs= */ - endTimeUs - startTimeUs - ) - ) - } - if (cuesWithTimingBeforeRequestedStartTimeUs != null) { - for (cuesWithTiming in cuesWithTimingBeforeRequestedStartTimeUs) { - output.accept(cuesWithTiming) - } - } - } - - /** - * Determine UTF encoding of the byte array from a byte order mark (BOM), defaulting to UTF-8 if - * no BOM is found. - */ - private fun detectUtfCharset(data: ParsableByteArray): Charset { - val charset = data.readUtfCharsetFromBom() - return charset ?: StandardCharsets.UTF_8 - } - - /** - * Trims and removes tags from the given line. The removed tags are added to `tags`. - * - * @param line The line to process. - * @param tags A list to which removed tags will be added. - * @return The processed line. - */ - private fun processLine(line: String, tags: ArrayList): String { - var line = line - line = line.trim { it <= ' ' } - - var removedCharacterCount = 0 - val processedLine = StringBuilder(line) - val matcher = SUBRIP_TAG_PATTERN.matcher(line) - while (matcher.find()) { - val tag = matcher.group() - tags.add(tag) - val start = matcher.start() - removedCharacterCount - val tagLength = tag.length - processedLine.replace(start, /* end= */start + tagLength, /* str= */"") - removedCharacterCount += tagLength - } - - return processedLine.toString() - } - - /** - * Build a [Cue] based on the given text and alignment tag. - * - * @param text The text. - * @param alignmentTag The alignment tag, or `null` if no alignment tag is available. - * @return Built cue - */ - private fun buildCue(text: Spanned, alignmentTag: String?): Cue { - val cue = Cue.Builder().setText(text) - if (alignmentTag == null) { - return cue.build() - } - - // Horizontal alignment. - when (alignmentTag) { - ALIGN_BOTTOM_LEFT, ALIGN_MID_LEFT, ALIGN_TOP_LEFT -> cue.setPositionAnchor(Cue.ANCHOR_TYPE_START) - ALIGN_BOTTOM_RIGHT, ALIGN_MID_RIGHT, ALIGN_TOP_RIGHT -> cue.setPositionAnchor(Cue.ANCHOR_TYPE_END) - ALIGN_BOTTOM_MID, ALIGN_MID_MID, ALIGN_TOP_MID -> cue.setPositionAnchor(Cue.ANCHOR_TYPE_MIDDLE) - else -> cue.setPositionAnchor(Cue.ANCHOR_TYPE_MIDDLE) - } - - // Vertical alignment. - when (alignmentTag) { - ALIGN_BOTTOM_LEFT, ALIGN_BOTTOM_MID, ALIGN_BOTTOM_RIGHT -> cue.setLineAnchor(Cue.ANCHOR_TYPE_END) - ALIGN_TOP_LEFT, ALIGN_TOP_MID, ALIGN_TOP_RIGHT -> cue.setLineAnchor(Cue.ANCHOR_TYPE_START) - ALIGN_MID_LEFT, ALIGN_MID_MID, ALIGN_MID_RIGHT -> cue.setLineAnchor(Cue.ANCHOR_TYPE_MIDDLE) - else -> cue.setLineAnchor(Cue.ANCHOR_TYPE_MIDDLE) - } - - return cue.setPosition(getFractionalPositionForAnchorType(cue.getPositionAnchor())) - .setLine( - getFractionalPositionForAnchorType(cue.getLineAnchor()), - Cue.LINE_TYPE_FRACTION - ) - .build() - } - - companion object { - /** - * The [CueReplacementBehavior] for consecutive [CuesWithTiming] emitted by this - * implementation. - */ - const val CUE_REPLACEMENT_BEHAVIOR: @CueReplacementBehavior Int = - Format.CUE_REPLACEMENT_BEHAVIOR_MERGE - - // Fractional positions for use when alignment tags are present. - private const val START_FRACTION = 0.08f - private const val END_FRACTION = 1 - START_FRACTION - private const val MID_FRACTION = 0.5f - - private const val TAG = "SubripParser" - - // The google devs are useless, this entire class is just to override this - private const val SUBRIP_TIMECODE = "(?:(\\d+):)?(\\d+):(\\d+)(?:[,.](\\d+))?" - private val SUBRIP_TIMING_LINE: Pattern = - Pattern.compile("\\s*($SUBRIP_TIMECODE)\\s*-->\\s*($SUBRIP_TIMECODE)\\s*") - - // NOTE: Android Studio's suggestion to simplify '\\}' is incorrect [internal: b/144480183]. - private val SUBRIP_TAG_PATTERN: Pattern = Pattern.compile("\\{\\\\.*?\\}") - private const val SUBRIP_ALIGNMENT_TAG = "\\{\\\\an[1-9]\\}" - - // Alignment tags for SSA V4+. - private const val ALIGN_BOTTOM_LEFT = "{\\an1}" - private const val ALIGN_BOTTOM_MID = "{\\an2}" - private const val ALIGN_BOTTOM_RIGHT = "{\\an3}" - private const val ALIGN_MID_LEFT = "{\\an4}" - private const val ALIGN_MID_MID = "{\\an5}" - private const val ALIGN_MID_RIGHT = "{\\an6}" - private const val ALIGN_TOP_LEFT = "{\\an7}" - private const val ALIGN_TOP_MID = "{\\an8}" - private const val ALIGN_TOP_RIGHT = "{\\an9}" - - private fun parseTimecode(matcher: Matcher, groupOffset: Int): Long { - val hours = matcher.group(groupOffset + 1) - var timestampMs = if (hours != null) hours.toLong() * 60 * 60 * 1000 else 0 - timestampMs += checkNotNull(matcher.group(groupOffset + 2)) - .toLong() * 60 * 1000 - timestampMs += checkNotNull(matcher.group(groupOffset + 3)) - .toLong() * 1000 - val millis = matcher.group(groupOffset + 4) - - timestampMs += when (millis?.length) { - null -> 0L - 1 -> millis.toLong() * 100L - 2 -> millis.toLong() * 10L - 3 -> millis.toLong() * 1L - else -> millis.substring(0, 3).toLong() - } - - return timestampMs * 1000 - } - - // TODO(b/289983417): Make package-private again, once it is no longer needed in - // DelegatingSubtitleDecoderWithSubripParserTest.java (i.e. legacy subtitle flow is removed) - @VisibleForTesting(otherwise = VisibleForTesting.Companion.PRIVATE) - fun getFractionalPositionForAnchorType(anchorType: @AnchorType Int): Float { - return when (anchorType) { - Cue.ANCHOR_TYPE_START -> START_FRACTION - Cue.ANCHOR_TYPE_MIDDLE -> MID_FRACTION - Cue.ANCHOR_TYPE_END -> END_FRACTION - Cue.TYPE_UNSET -> // Should never happen. - throw IllegalArgumentException() - - else -> - throw IllegalArgumentException() - } - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/CustomSubtitleDecoderFactory.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/CustomSubtitleDecoderFactory.kt index 61d6f5564..ffcd83664 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/CustomSubtitleDecoderFactory.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/CustomSubtitleDecoderFactory.kt @@ -18,6 +18,7 @@ import androidx.media3.extractor.text.SubtitleParser import androidx.media3.extractor.text.dvb.DvbParser import androidx.media3.extractor.text.pgs.PgsParser import androidx.media3.extractor.text.ssa.SsaParser +import androidx.media3.extractor.text.subrip.SubripParser import androidx.media3.extractor.text.ttml.TtmlParser import androidx.media3.extractor.text.tx3g.Tx3gParser import androidx.media3.extractor.text.webvtt.Mp4WebvttParser @@ -250,14 +251,14 @@ class CustomDecoder(private val fallbackFormat: Format?) : SubtitleParser { ignoreCase = true )) -> SsaParser(fallbackFormat?.initializationData) - trimmedText.startsWith("1", ignoreCase = true) -> CustomSubripParser() + trimmedText.startsWith("1", ignoreCase = true) -> SubripParser() fallbackFormat != null -> { - when (fallbackFormat.sampleMimeType) { + when (val mimeType = fallbackFormat.sampleMimeType) { MimeTypes.TEXT_VTT -> WebvttParser() MimeTypes.TEXT_SSA -> SsaParser(fallbackFormat.initializationData) MimeTypes.APPLICATION_MP4VTT -> Mp4WebvttParser() MimeTypes.APPLICATION_TTML -> TtmlParser() - MimeTypes.APPLICATION_SUBRIP -> CustomSubripParser() + MimeTypes.APPLICATION_SUBRIP -> SubripParser() MimeTypes.APPLICATION_TX3G -> Tx3gParser(fallbackFormat.initializationData) // These decoders are not converted to parsers yet // TODO 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 35f8dcfd8..4c27dbc97 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 @@ -10,17 +10,16 @@ import com.lagradost.cloudstream3.utils.ExtractorLinkType import com.lagradost.cloudstream3.utils.SubtitleHelper.fromLanguageToTagIETF import com.lagradost.cloudstream3.utils.SubtitleUtils.cleanDisplayName import com.lagradost.cloudstream3.utils.SubtitleUtils.isMatchingSubtitle -import com.lagradost.cloudstream3.utils.downloader.DownloadFileManagement.getFolder -import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager.getDownloadFileInfo +import com.lagradost.cloudstream3.utils.VideoDownloadManager.getDownloadFileInfoAndUpdateSettings +import com.lagradost.cloudstream3.utils.VideoDownloadManager.getFolder class DownloadFileGenerator( - episodes: List -) : VideoGenerator(episodes) { + episodes: List, + currentIndex: Int = 0 +) : VideoGenerator(episodes, currentIndex) { 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, @@ -29,14 +28,14 @@ class DownloadFileGenerator( offset: Int, isCasting: Boolean ): Boolean { - val meta = videos.getOrNull(offset) ?: return false + val meta = getCurrent(offset) ?: return false if (meta.uri == Uri.EMPTY) { // We do this here so that we only load it when // we actually need it as it can be more expensive. val info = meta.id?.let { id -> activity?.let { act -> - getDownloadFileInfo(act, id) + getDownloadFileInfoAndUpdateSettings(act, id) } } 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 a086cc16f..a3a9b7125 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,9 +14,7 @@ import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.attachBackPres import com.lagradost.cloudstream3.utils.UIHelper.enableEdgeToEdgeCompat class DownloadedPlayerActivity : AppCompatActivity() { - companion object { - const val TAG = "DownloadedPlayerActivity" - } + private val dTAG = "DownloadedPlayerAct" override fun dispatchKeyEvent(event: KeyEvent): Boolean = CommonActivity.dispatchKeyEvent(this, event) ?: super.dispatchKeyEvent(event) @@ -29,83 +27,53 @@ 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(TAG, "onCreate") - handleIntent(intent) + Log.i(dTAG, "onCreate") - /** - * Use moveTaskToBack instead of finish() so there is always exactly one task - * entry in recents, always reflecting the current file. - * - * finish() destroys the Activity but may leave the task in recents. Each new file - * open can create a new task entry, so recents accumulates stale entries for old - * files. The user then taps a stale entry and gets the wrong file. - * - * moveTaskToBack keeps the Activity alive in the background. There is only ever - * one task entry in recents. New files opened from the file manager arrive via - * onNewIntent on the live instance, updating the player immediately. The single - * recents entry always reflects the current state, ensuring we load the - * correct file. - */ - attachBackPressedCallback("DownloadedPlayerActivity") { moveTaskToBack(true) } - } - - 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 { 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 { // I dont trust android + 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() - when { - item?.uri != null -> playUri(this, item.uri) - url != null -> playLink(this, url) - data != null -> playUri(this, data) - extraText != null -> playLink(this, extraText) - else -> finishAndRemoveTask() + + // 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 } } else if (data?.scheme == "content") { playUri(this, data) - } else finishAndRemoveTask() + } else { + finish() + return + } + + attachBackPressedCallback("DownloadedPlayerActivity") { 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 85db33fc0..a52a3c646 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(null) { +) : NoVideoGenerator() { override suspend fun generateLinks( clearCache: Boolean, sourceTypes: Set, diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/FixedNextRenderersFactory.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/FixedNextRenderersFactory.kt deleted file mode 100644 index 025267cc9..000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/FixedNextRenderersFactory.kt +++ /dev/null @@ -1,28 +0,0 @@ -package com.lagradost.cloudstream3.ui.player - -import android.content.Context -import android.os.Looper -import androidx.media3.common.util.UnstableApi -import androidx.media3.exoplayer.Renderer -import androidx.media3.exoplayer.text.TextOutput -import androidx.media3.exoplayer.text.TextRenderer -import io.github.anilbeesetti.nextlib.media3ext.ffdecoder.NextRenderersFactory - -@UnstableApi -class FixedNextRenderersFactory(context: Context) : NextRenderersFactory(context) { - /** Somehow the nextlib authors decided that we need a text renderer that causes - * "ERROR_CODE_FAILED_RUNTIME_CHECK". - * - * Core issue: https://github.com/anilbeesetti/nextlib/pull/158 - * Comment: https://github.com/recloudstream/cloudstream/pull/2342#issuecomment-3917751718 - * */ - override fun buildTextRenderers( - context: Context, - output: TextOutput, - outputLooper: Looper, - extensionRendererMode: Int, - out: ArrayList - ) { - out.add(TextRenderer(output, outputLooper)) - } -} \ No newline at end of file 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 4ba933e13..b770f541d 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 @@ -10,42 +10,62 @@ import android.content.pm.ActivityInfo import android.content.res.ColorStateList import android.content.res.Configuration import android.graphics.Color +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.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.DecelerateInterpolator +import android.view.animation.Animation +import android.view.animation.AnimationUtils 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.preference.PreferenceManager import androidx.recyclerview.widget.SimpleItemAnimator +import com.daasuu.gpuv.egl.filter.GlBrightnessFilter +import com.daasuu.gpuv.player.GPUPlayerView +import com.daasuu.gpuv.player.PlayerScaleType import com.google.android.material.button.MaterialButton +import com.lagradost.api.Log import com.lagradost.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 @@ -53,38 +73,64 @@ 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.ceil +import kotlin.math.max +import kotlin.math.min +import kotlin.math.round import kotlin.math.roundToInt + +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( - BindingCreator.Bind(FragmentPlayerBinding::bind) -) { - override fun pickLayout(): Int = R.layout.fragment_player +open class FullScreenPlayer : AbstractPlayerFragment() { + private var isVerticalOrientation: Boolean = false protected open var lockRotation = true + protected open var isFullScreenPlayer = true protected var playerBinding: PlayerCustomLayoutBinding? = null + private var gpuPlayerView: GPUPlayerView? = null + private var gpuBrightnessFilter: GlBrightnessFilter? = null + private var hasBrightnessBoostError: Boolean = false + + 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 @@ -93,13 +139,22 @@ 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 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) @@ -113,119 +168,108 @@ 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)) - override fun fixLayout(view: View) = Unit - - /** - * Wet code but this can not be made into a function as it is a setter. - * - * The reason for this setter is to fix a bug with the titlecard popup, as we want it to autohide - * when pressing back. - * - * Note that we move the call to autoHide after field assignment with prevField to avoid inf recursion. */ - protected var selectSourceDialog: Dialog? = null - set(value) { - val prevField = field - field = value - if (value == null && prevField != null) { - autoHide() - } - } - protected var selectTrackDialog: Dialog? = null - set(value) { - val prevField = field - field = value - if (value == null && prevField != null) { - autoHide() - } - } - protected var selectSpeedDialog: Dialog? = null - set(value) { - val prevField = field - field = value - if (value == null && prevField != null) { - autoHide() - } - } - protected var selectSubtitlesDialog: Dialog? = null - set(value) { - val prevField = field - field = value - if (value == null && prevField != null) { - autoHide() + // Create GPUPlayerView dynamically and attach it to the PlayerView's 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) { + val gpu = GPUPlayerView(context) + val lp = android.widget.FrameLayout.LayoutParams( + android.view.ViewGroup.LayoutParams.MATCH_PARENT, + android.view.ViewGroup.LayoutParams.MATCH_PARENT + ) + // Insert as first child so it sits behind any controls inside content frame + contentFrame.addView(gpu, 0, lp) + gpuPlayerView = gpu } } + return root + } - /** Checks if any top level dialog is open and showing */ - fun isDialogOpen() = - selectSourceDialog?.isShowing == true - || selectTrackDialog?.isShowing == true - || selectSpeedDialog?.isShowing == true - || selectSubtitlesDialog?.isShowing == true - || isShowingEpisodeOverlay - private fun scheduleMetadataVisibility() { - val metadataScrim = playerBinding?.playerMetadataScrim ?: return - val ctx = metadataScrim.context ?: return + fun setGpuExtraBrightness(extra: Float) { + gpuBrightnessFilter?.setBrightness(extra) + } - if (!ctx.shouldShowPlayerMetadata()) { - metadataScrim.isVisible = false - metadataVisibilityToken++ - return - } - - if (isLayout(PHONE)) { - metadataScrim.isVisible = false - metadataVisibilityToken++ - return - } - - val isPaused = currentPlayerStatus == CSPlayerLoading.IsPaused - val token = ++metadataVisibilityToken - - if (isPaused) { - metadataScrim.postDelayed({ - /** Make sure the user has not interacted with anything */ - if (token != metadataVisibilityToken) return@postDelayed - /** If already visible, then do not rerun the animation */ - if (metadataScrim.isVisible) return@postDelayed - /** Failsafe, as this should only be shown when paused */ - if (currentPlayerStatus != CSPlayerLoading.IsPaused) return@postDelayed - /** We do not want to show the logo in the background when the user is within another screen */ - if (isDialogOpen()) return@postDelayed - - metadataScrim.alpha = 0f - metadataScrim.isVisible = true - metadataScrim.animate() - .alpha(1f) - .setDuration(500L) - .setInterpolator(DecelerateInterpolator()) - .start() - hidePlayerUI() - }, 8000L) - } else { - if (metadataScrim.isVisible) { - metadataScrim.animate() - .alpha(0f) - .setDuration(300L) - .setInterpolator(AccelerateDecelerateInterpolator()) - .withEndAction { - metadataScrim.alpha = 0f // force final state - metadataScrim.isVisible = false - } - .start() - } + @SuppressLint("UnsafeOptInUsageError") + override fun playerUpdated(player: Any?) { + super.playerUpdated(player) + if (player is ExoPlayer) { + // attach GL renderer filter if available + gpuPlayerView?.setExoPlayer(player) } } override fun onDestroyView() { - playerHostView?.releaseOverlayLayoutListener() + // Clean up dynamic GPUPlayerView if created + safe { + gpuPlayerView?.onPause() + gpuPlayerView?.setGlFilter(null) + gpuBrightnessFilter = null + val parent = gpuPlayerView?.parent as? android.view.ViewGroup + parent?.removeView(gpuPlayerView) + } + + gpuPlayerView = null playerBinding = null super.onDestroyView() } + override fun resize(resize: PlayerResize, showToast: Boolean) { + super.resize(resize, showToast) + safe { + gpuPlayerView?.setPlayerScaleType( + when (resize) { + PlayerResize.Fit -> PlayerScaleType.RESIZE_FIT + PlayerResize.Fill -> PlayerScaleType.RESIZE_FILL + PlayerResize.Zoom -> PlayerScaleType.RESIZE_ZOOM + } + ) + } + } + open fun showMirrorsDialogue() { throw NotImplementedError() } @@ -250,6 +294,43 @@ 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() } @@ -298,19 +379,6 @@ open class FullScreenPlayer : AbstractPlayerFragment( start() } } - playerBinding?.playerVideoInfo?.let { - ObjectAnimator.ofFloat(it, "translationY", titleMove).apply { - duration = 200 - start() - } - } - playerBinding?.playerMetadataScrim?.let { - ObjectAnimator.ofFloat(it, "translationY", 1f).apply { - duration = 200 - start() - } - } - val playerBarMove = if (isShowing) 0f else 50.toPx.toFloat() playerBinding?.bottomPlayerBar?.let { ObjectAnimator.ofFloat(it, "translationY", playerBarMove).apply { @@ -346,10 +414,25 @@ open class FullScreenPlayer : AbstractPlayerFragment( } if (!isLocked) { - playerHostView?.gestureHelper?.animateCenterControls(fadeTo) + playerFfwdHolder.alpha = 1f + playerRewHolder.alpha = 1f + // player_pause_play_holder?.alpha = 1f 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) @@ -361,7 +444,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.mimeType == MimeTypes.APPLICATION_MEDIA3_CUES } // Subtitle offset is not possible on built-in media3 tracks playerBinding?.playerSubtitleOffsetBtt?.isGone = @@ -377,7 +460,7 @@ open class FullScreenPlayer : AbstractPlayerFragment( Configuration.ORIENTATION_PORTRAIT -> ActivityInfo.SCREEN_ORIENTATION_SENSOR_PORTRAIT - else -> playerHostView?.dynamicOrientation() ?: return + else -> dynamicOrientation() } activity.requestedOrientation = orientation } @@ -391,14 +474,14 @@ open class FullScreenPlayer : AbstractPlayerFragment( Configuration.ORIENTATION_PORTRAIT -> ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE - else -> playerHostView?.dynamicOrientation() ?: return + else -> dynamicOrientation() } activity.requestedOrientation = orientation } - private fun lockOrientation(activity: Activity) { + open fun lockOrientation(activity: Activity) { + @Suppress("DEPRECATION") 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 @@ -419,7 +502,7 @@ open class FullScreenPlayer : AbstractPlayerFragment( else ActivityInfo.SCREEN_ORIENTATION_REVERSE_PORTRAIT - else -> orientation = playerHostView?.dynamicOrientation() ?: return + else -> orientation = dynamicOrientation() } activity.requestedOrientation = orientation } @@ -431,36 +514,46 @@ 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 = - playerHostView?.dynamicOrientation() ?: return@apply + this.requestedOrientation = dynamicOrientation() } } } } } - 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 - - player.isActive() -> handleKeyEvent(event, hasNavigated) - else -> false + 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() + } + + protected fun exitFullscreen() { + // 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 + } + activity?.window?.attributes = lp + activity?.showSystemUI() } override fun onResume() { - playerHostView?.enterFullscreen { updateOrientation() } - setupKeyEventListener() - playerHostView?.verifyVolume() + enterFullscreen() + verifyVolume() activity?.attachBackPressedCallback("FullScreenPlayer") { if (isShowingEpisodeOverlay) { // isShowingEpisodeOverlay pauses, so this makes it easier to unpause @@ -476,7 +569,6 @@ open class FullScreenPlayer : AbstractPlayerFragment( activity?.popCurrentPage("FullScreenPlayer") } } - playerHostView?.requestUpdateBrightnessOverlayOnNextLayout() super.onResume() } @@ -486,7 +578,7 @@ open class FullScreenPlayer : AbstractPlayerFragment( } override fun onDestroy() { - playerHostView?.exitFullscreen() + exitFullscreen() super.onDestroy() } @@ -522,18 +614,29 @@ open class FullScreenPlayer : AbstractPlayerFragment( val dialog = Dialog(ctx, R.style.DialogFullscreenPlayer).apply { setContentView(binding.root) } - this.selectSubtitlesDialog = dialog dialog.show() - val isPortrait = - ctx.resources.configuration.orientation == Configuration.ORIENTATION_PORTRAIT + val isPortrait = ctx.resources.configuration.orientation == Configuration.ORIENTATION_PORTRAIT fixSystemBarsPadding(binding.root, fixIme = isPortrait) var currentOffset = subtitleDelay 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) @@ -559,7 +662,7 @@ open class FullScreenPlayer : AbstractPlayerFragment( noSubtitlesLoadedNotice.isVisible = subtitles.isEmpty() val initialSubtitlePosition = (player.getPosition() ?: 0) - currentOffset - val subtitleAdapter = + subtitleAdapter = SubtitleOffsetItemAdapter(initialSubtitlePosition) { subtitleCue -> val playerPosition = player.getPosition() ?: 0 subtitleOffsetInput.text = Editable.Factory.getInstance() @@ -599,28 +702,26 @@ open class FullScreenPlayer : AbstractPlayerFragment( } dialog.setOnDismissListener { - selectSubtitlesDialog = null - activity?.hideSystemUI() + if (isFullScreenPlayer) + activity?.hideSystemUI() } applyBtt.setOnClickListener { - selectSubtitlesDialog = null subtitleDelay = currentOffset dialog.dismissSafe(activity) player.seekTime(1L) } resetBtt.setOnClickListener { - selectSubtitlesDialog = null subtitleDelay = 0 dialog.dismissSafe(activity) player.seekTime(1L) } cancelBtt.setOnClickListener { - selectSubtitlesDialog = null dialog.dismissSafe(activity) } } } + @SuppressLint("SetTextI18n") fun updateSpeedDialogBinding(binding: SpeedDialogBinding) { val speed = player.getPlaybackSpeed() @@ -664,7 +765,7 @@ open class FullScreenPlayer : AbstractPlayerFragment( updateSpeedDialogBinding(binding) } - binding.speedBar.addOnChangeListener { _, value, fromUser -> + binding.speedBar.addOnChangeListener { slider, value, fromUser -> if (fromUser) { setPlayBackSpeed(value) updateSpeedDialogBinding(binding) @@ -672,11 +773,11 @@ open class FullScreenPlayer : AbstractPlayerFragment( } val dismiss = DialogInterface.OnDismissListener { - activity?.hideSystemUI() + if (isFullScreenPlayer) + activity?.hideSystemUI() if (isPlaying) { player.handleEvent(CSPlayerEvent.Play, PlayerEventSource.UI) } - selectSpeedDialog = null } // if (isLayout(PHONE)) { @@ -691,15 +792,94 @@ open class FullScreenPlayer : AbstractPlayerFragment( .setView(binding.root) builder.setOnDismissListener(dismiss) val dialog = builder.create() - this.selectSpeedDialog = dialog dialog.show() //} } + 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) autoHide() - activity?.hideSystemUI() + if (isShowing) { + playerBinding?.playerIntroPlay?.isGone = true + autoHide() + } + if (isFullScreenPlayer) + activity?.hideSystemUI() animateLayoutChanges() if (playerBinding?.playerEpisodeOverlay?.isGone == true) playerBinding?.playerPausePlay?.requestFocus() } @@ -710,7 +890,6 @@ 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) { @@ -722,7 +901,6 @@ 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 @@ -730,6 +908,11 @@ open class FullScreenPlayer : AbstractPlayerFragment( } updateUIVisibility() + // MENUS + // centerMenu.startAnimation(fadeAnimation) + playerPausePlay.startAnimation(fadeAnimation) + playerFfwdHolder.startAnimation(fadeAnimation) + playerRewHolder.startAnimation(fadeAnimation) downloadBothHeader.startAnimation(fadeAnimation) if (hasEpisodes) @@ -739,20 +922,20 @@ open class FullScreenPlayer : AbstractPlayerFragment( // TITLE playerVideoTitleRez.startAnimation(fadeAnimation) - playerVideoInfo.startAnimation(fadeAnimation) playerEpisodeFiller.startAnimation(fadeAnimation) playerVideoTitleHolder.startAnimation(fadeAnimation) playerTopHolder.startAnimation(fadeAnimation) // BOTTOM playerLockHolder.startAnimation(fadeAnimation) // player_go_back_holder?.startAnimation(fadeAnimation) + shadowOverlay.isVisible = true shadowOverlay.startAnimation(fadeAnimation) } updateLockUI() } - private fun updateUIVisibility() { + open fun updateUIVisibility() { val isGone = isLocked || !isShowing var togglePlayerTitleGone = isGone context?.let { @@ -763,23 +946,24 @@ open class FullScreenPlayer : AbstractPlayerFragment( } } playerBinding?.apply { + playerLockHolder.isGone = isGone playerVideoBar.isGone = isGone - playerPausePlayHolderHolder.isGone = - isGone || currentPlayerStatus == CSPlayerLoading.IsBuffering + playerPausePlay.isGone = isGone + // player_buffering?.isGone = isGone playerTopHolder.isGone = isGone val showPlayerEpisodes = !isGone && isThereEpisodes() playerEpisodesButtonRoot.isVisible = showPlayerEpisodes playerEpisodesButton.isVisible = showPlayerEpisodes playerVideoTitleHolder.isGone = togglePlayerTitleGone - playerVideoTitleRez.isGone = isGone || playerVideoTitleRez.text.isBlank() +// player_video_title_rez?.isGone = isGone 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 } } @@ -787,25 +971,23 @@ open class FullScreenPlayer : AbstractPlayerFragment( private fun updateLockUI() { playerBinding?.apply { playerLock.setIconResource(if (isLocked) R.drawable.video_locked else R.drawable.video_unlocked) - 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)) + 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)) + } } } } + private var currentTapIndex = 0 protected fun autoHide() { - metadataVisibilityToken++ - playerHostView?.scheduleAutoHide() - scheduleMetadataVisibility() - } - - override fun onAutoHideUI() { - if (player.getIsPlaying()) onClickChange() + currentTapIndex++ + delayHide() } protected fun hidePlayerUI() { @@ -815,209 +997,581 @@ open class FullScreenPlayer : AbstractPlayerFragment( } } - /** PlayerView.Callbacks touch overrides */ - - override fun isUIShowing(): Boolean = isShowing - - override fun onSingleTap() { - onClickChange() + override fun playerStatusChanged() { + super.playerStatusChanged() + delayHide() } - override fun onTouchDown() { - if (isShowingEpisodeOverlay) toggleEpisodesOverlay(show = false) + private fun delayHide() { + val index = currentTapIndex + playerBinding?.playerHolder?.postDelayed({ + if (!isCurrentTouchValid && isShowing && index == currentTapIndex && player.getIsPlaying()) { + onClickChange() + } + }, 3000) + } + + // 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() + } + } + + 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, + } + + companion object { + 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 + } + } + } + + /** + * 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) + } + } + } + + 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 + } + } + } + + val holdhandler = Handler(Looper.getMainLooper()) + var hasTriggeredSpeedUp = false + val holdRunnable = Runnable { + if (isShowing) { + onClickChange() + } + player.setPlaybackSpeed(2.0f) + showOrHideSpeedUp(true) + hasTriggeredSpeedUp = true + } + + 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() + } } @SuppressLint("SetTextI18n") - override fun onSeekPreviewText(text: String?) { - playerBinding?.playerTimeText?.apply { - isVisible = text != null - if (text != null) this.text = text - } - } + 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 - override fun onHidePlayerUI() { - hidePlayerUI() - } + playerBinding?.apply { + playerIntroPlay.isGone = true - override fun onGestureEnd(hadSwipe: Boolean, wasUiShowing: Boolean) { - if (!player.getIsPlaying() && hadSwipe && wasUiShowing && !isShowing) { - isShowing = true - animateLayoutChanges() - } - autoHide() - } + 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 + } - override fun playerStatusChanged() { - super.playerStatusChanged() - scheduleMetadataVisibility() - } + isBrightnessLocked = currentRequestedBrightness < 1.0f + if (currentRequestedBrightness <= 1.0f) { + hasShownBrightnessToast = false + } - // 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() - } + currentTouchStartTime = System.currentTimeMillis() + currentTouchStart = currentTouch + currentTouchLast = currentTouch + currentTouchStartPlayerTime = player.getPosition() - override fun onConfigurationChanged(newConfig: Configuration) { - super.onConfigurationChanged(newConfig) + getBrightness()?.let { + currentRequestedBrightness = it + currentExtraBrightness + } + verifyVolume() + } + } - // If we rotate the device we need to recalculate the zoom - 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 - playerView?.post { - gh.applyZoomMatrix(matrix, true) - playerHostView?.requestUpdateBrightnessOverlayOnNextLayout() + 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 + + currentLastTouchEndTime = System.currentTimeMillis() + } + + MotionEvent.ACTION_MOVE -> { + // if current touch is valid + + 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 + } + } + } + + // 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 = currentRequestedBrightness + verticalAddition + // + // Log.e("Brightness", "Current: $currentRequestedBrightness, Next: $nextBrightness") + // show toast + if (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 + val level2ProgressBar = playerProgressbarRightLevel2 + + // max is set high to make it smooth + level1ProgressBar.max = 100_000 + level1ProgressBar.progress = + max(2_000, (min(1.0f, currentRequestedBrightness) * 100_000f).toInt()) + + if (!isBrightnessLocked) { + currentExtraBrightness = if (currentRequestedBrightness > 1.0f) min(2.0f, currentRequestedBrightness) - 1.0f else 0.0f + level2ProgressBar.max = 100_000 + level2ProgressBar.progress = + (currentExtraBrightness * 100_000f).toInt().coerceIn(2_000, 100_000) + level2ProgressBar.isVisible = currentRequestedBrightness > 1.0f + + // Only create/remove the GL filter when crossing the 1.0 threshold + val wasExtra = lastRequested > 1.0f + val willExtra = currentRequestedBrightness > 1.0f + + if (willExtra && !wasExtra) { + // crossed from <=1.0 to >1.0: initialize filter + try { + if (gpuBrightnessFilter == null) { + gpuBrightnessFilter = GlBrightnessFilter() + gpuPlayerView?.setGlFilter(gpuBrightnessFilter) + } + setGpuExtraBrightness(currentExtraBrightness) + hasBrightnessBoostError = false + } catch (t: Throwable) { + logError(t) + hasBrightnessBoostError = true + } + } else if (willExtra) { + // still >1.0: only update brightness + try { + setGpuExtraBrightness(currentExtraBrightness) + } catch (t: Throwable) { + logError(t) + hasBrightnessBoostError = true + } + } else if (wasExtra) { + // crossed from >1.0 to <=1.0: remove filter + try { + gpuPlayerView?.setGlFilter(null) + gpuBrightnessFilter = null + } catch (t: Throwable) { + logError(t) + hasBrightnessBoostError = true + } + } + + if (willExtra) { + level2ProgressBar.progressTintList = ColorStateList.valueOf( + ContextCompat.getColor( + level2ProgressBar.context, if (hasBrightnessBoostError) { + R.color.colorPrimaryRed + } else { + R.color.colorPrimaryOrange + } + ) + ) + } + } + + // Log.i("Brightness", "current: $currentRequestedBrightness, ce: $currentExtraBrightness L1: ${level1ProgressBar.progress}, L2: ${level2ProgressBar.progress}") + playerProgressbarRightIcon.setImageResource( + brightnessIcons[min( // clamp the value just in case + brightnessIcons.size - 1, + max( + 0, + round(max(currentRequestedBrightness, 1.0f) * (brightnessIcons.size - 1)).toInt() + ) + )] + ) + } + + TouchAction.Volume -> { + holdhandler.removeCallbacks(holdRunnable) + handleVolumeAdjustment( + verticalAddition, + false + ) + } + + else -> Unit + } + } + } + } } } - } - - override fun resize(resize: PlayerResize, showToast: Boolean) { - super.resize(resize, showToast) - playerHostView?.requestUpdateBrightnessOverlayOnNextLayout() - } - - private fun handleKeyDownEvent(keyCode: Int): Boolean? { - // adb shell input keyevent [INT] - when (keyCode) { - KeyEvent.KEYCODE_FORWARD, KeyEvent.KEYCODE_D, KeyEvent.KEYCODE_MEDIA_SKIP_FORWARD, KeyEvent.KEYCODE_MEDIA_FAST_FORWARD -> { - player.handleEvent(CSPlayerEvent.SeekForward) - } - - KeyEvent.KEYCODE_A, KeyEvent.KEYCODE_MEDIA_SKIP_BACKWARD, KeyEvent.KEYCODE_MEDIA_REWIND -> { - player.handleEvent(CSPlayerEvent.SeekBack) - } - - KeyEvent.KEYCODE_MEDIA_NEXT, KeyEvent.KEYCODE_BUTTON_R1, KeyEvent.KEYCODE_N, KeyEvent.KEYCODE_NUMPAD_2, KeyEvent.KEYCODE_CHANNEL_UP -> { - player.handleEvent(CSPlayerEvent.NextEpisode) - } - - KeyEvent.KEYCODE_MEDIA_PREVIOUS, KeyEvent.KEYCODE_BUTTON_L1, KeyEvent.KEYCODE_B, KeyEvent.KEYCODE_NUMPAD_1, KeyEvent.KEYCODE_CHANNEL_DOWN -> { - player.handleEvent(CSPlayerEvent.PrevEpisode) - } - - KeyEvent.KEYCODE_MEDIA_PAUSE -> { - player.handleEvent(CSPlayerEvent.Pause) - } - - KeyEvent.KEYCODE_MEDIA_PLAY, KeyEvent.KEYCODE_BUTTON_START -> { - player.handleEvent(CSPlayerEvent.Play) - } - - KeyEvent.KEYCODE_L, KeyEvent.KEYCODE_NUMPAD_7, KeyEvent.KEYCODE_7 -> { - toggleLock() - } - - KeyEvent.KEYCODE_H -> { - onClickChange() - } - - KeyEvent.KEYCODE_M, KeyEvent.KEYCODE_VOLUME_MUTE -> { - player.handleEvent(CSPlayerEvent.ToggleMute) - } - - KeyEvent.KEYCODE_S, KeyEvent.KEYCODE_NUMPAD_9, KeyEvent.KEYCODE_9 -> { - showMirrorsDialogue() - } - // OpenSubtitles shortcut - KeyEvent.KEYCODE_O, KeyEvent.KEYCODE_NUMPAD_8, KeyEvent.KEYCODE_8 -> { - val context = context - if (subsProvidersIsActive && context != null) { - openOnlineSubPicker(context, null) {} - } - } - - KeyEvent.KEYCODE_E, KeyEvent.KEYCODE_NUMPAD_3, KeyEvent.KEYCODE_3 -> { - showSpeedDialog() - } - - KeyEvent.KEYCODE_R, KeyEvent.KEYCODE_NUMPAD_0, KeyEvent.KEYCODE_0 -> { - nextResize() - } - - KeyEvent.KEYCODE_C, KeyEvent.KEYCODE_NUMPAD_4, KeyEvent.KEYCODE_4 -> { - skipOp() - } - - KeyEvent.KEYCODE_V, KeyEvent.KEYCODE_NUMPAD_5, KeyEvent.KEYCODE_5 -> { - player.handleEvent(CSPlayerEvent.SkipCurrentChapter) - } - - KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE, KeyEvent.KEYCODE_P, KeyEvent.KEYCODE_SPACE, KeyEvent.KEYCODE_NUMPAD_ENTER -> { // space is not captured due to navigation - player.handleEvent(CSPlayerEvent.PlayPauseToggle) - } - - // KEYCODE_DPAD_CENTER and KEYCODE_ENTER both act as a "select/confirm" button. - // Some remotes (e.g. LG Magic Remote) send KEYCODE_ENTER instead of KEYCODE_DPAD_CENTER. - // When the player UI or a dialog is visible, we let the event pass through (return null) - // so the focused button/item can handle the click normally, rather than always toggling - // play/pause. Only when the UI is hidden do we treat it as a play/pause toggle. - KeyEvent.KEYCODE_DPAD_CENTER, - KeyEvent.KEYCODE_ENTER -> { - if (isShowing || isDialogOpen()) { - return null - } - // If UI is not shown make click instantly skip to next chapter even if locked - if (timestampShowState) { - player.handleEvent(CSPlayerEvent.SkipCurrentChapter) - } else if (!isLocked) { - player.handleEvent(CSPlayerEvent.PlayPauseToggle) - } - onClickChange() - } - - KeyEvent.KEYCODE_DPAD_DOWN, - KeyEvent.KEYCODE_DPAD_UP -> { - if (isShowing || isShowingEpisodeOverlay) { - return null - } - onClickChange() - } - - KeyEvent.KEYCODE_DPAD_LEFT -> { - if (!isShowing && !isLocked && !isShowingEpisodeOverlay) { - player.seekTime(-androidTVInterfaceOffSeekTime) - return true - } else if (playerBinding?.playerPausePlay?.isFocused == true) { - player.seekTime(-androidTVInterfaceOnSeekTime) - return true - } else { - return null - } - } - - KeyEvent.KEYCODE_DPAD_RIGHT -> { - if (!isShowing && !isLocked && !isShowingEpisodeOverlay) { - player.seekTime(androidTVInterfaceOffSeekTime) - } else if (playerBinding?.playerPausePlay?.isFocused == true) { - player.seekTime(androidTVInterfaceOnSeekTime) - } else { - return null - } - } - - KeyEvent.KEYCODE_VOLUME_DOWN, - KeyEvent.KEYCODE_VOLUME_UP -> { - // Handled entirely by PlayerView.handleVolumeKey (checks PHONE/EMULATOR). - if (playerHostView?.handleVolumeKey(keyCode) != true) { - return null - } - } - - KeyEvent.KEYCODE_MENU, - KeyEvent.KEYCODE_SETTINGS -> { - if (isLocked || !isThereEpisodes()) { - return null - } - toggleEpisodesOverlay(true) - } - else -> return null // Avoid capturing all input - } + currentTouchLast = currentTouch return true } + @SuppressLint("GestureBackNavigation") private fun handleKeyEvent(event: KeyEvent, hasNavigated: Boolean): Boolean { if (hasNavigated) { autoHide() @@ -1026,9 +1580,69 @@ open class FullScreenPlayer : AbstractPlayerFragment( val keyCode = event.keyCode if (event.action == KeyEvent.ACTION_DOWN) { - val value = handleKeyDownEvent(keyCode) - if (value != null) { - return value + when (keyCode) { + KeyEvent.KEYCODE_DPAD_CENTER -> { + if (!isShowing) { + 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 + } + } } } @@ -1061,13 +1675,139 @@ open class FullScreenPlayer : AbstractPlayerFragment( return false } - protected fun uiReset() { - metadataVisibilityToken++ - playerBinding?.playerMetadataScrim?.let { - it.animate().cancel() - it.alpha = 0f - it.isVisible = 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() { isShowing = false toggleEpisodesOverlay(false) // if nothing has loaded these buttons should not be visible @@ -1081,8 +1821,8 @@ open class FullScreenPlayer : AbstractPlayerFragment( updateLockUI() updateUIVisibility() animateLayoutChanges() - playerHostView?.gestureHelper?.resetFastForwardText() - playerHostView?.gestureHelper?.resetRewindText() + resetFastForwardText() + resetRewindText() } override fun onSaveInstanceState(outState: Bundle) { @@ -1091,35 +1831,109 @@ open class FullScreenPlayer : AbstractPlayerFragment( super.onSaveInstanceState(outState) } - 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)) - - super.onBindingCreated(binding, savedInstanceState) - - // This player is always full-screen; tell PlayerView so volume-key handling is active. - playerHostView?.isFullScreen = true - - // Wire up the snap-hint outline view and schedule brightness overlay bounds update - playerHostView?.videoOutline = playerBinding?.videoOutline - playerHostView?.requestUpdateBrightnessOverlayOnNextLayout() - - val view = binding.root + @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) + } + + PlayerEventType.ToggleHide -> { + onClickChange() + } + + PlayerEventType.ShowMirrors -> { + showMirrorsDialogue() + } + + PlayerEventType.SearchSubtitlesOnline -> { + if (subsProvidersIsActive) { + openOnlineSubPicker(view.context, null) {} + } + } + + PlayerEventType.SkipOp -> { + skipOp() + } + } + } + // handle tv controls directly based on player state - setupKeyEventListener() + 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 + } 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), @@ -1133,6 +1947,16 @@ 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 @@ -1141,25 +1965,53 @@ 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 + ) + + 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 + profiles.firstOrNull { it.type == 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) } playerBinding?.apply { playerSpeedBtt.isVisible = playBackSpeedEnabled @@ -1200,6 +2052,23 @@ 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) } @@ -1249,6 +2118,16 @@ open class FullScreenPlayer : AbstractPlayerFragment( showSubtitleOffsetDialog() } + playerRew.setOnClickListener { + autoHide() + rewind() + } + + playerFfwd.setOnClickListener { + autoHide() + fastForward() + } + playerGoBack.setOnClickListener { activity?.popCurrentPage("FullScreenPlayer") } @@ -1261,21 +2140,26 @@ 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, + MotionEvent.ACTION_DOWN -> { + currentTapIndex++ + } + MotionEvent.ACTION_MOVE -> { - playerHostView?.cancelAutoHide() + currentTapIndex++ } MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL, MotionEvent.ACTION_BUTTON_RELEASE -> { @@ -1288,6 +2172,11 @@ open class FullScreenPlayer : AbstractPlayerFragment( toggleEpisodesOverlay(show = true) } } + // cs3 is peak media center + setRemainingTimeCounter(durationMode || isLayout(TV)) + playerBinding?.exoPosition?.doOnTextChanged { _, _, _, _ -> + updateRemainingTime() + } // init UI try { uiReset() @@ -1296,6 +2185,7 @@ open class FullScreenPlayer : AbstractPlayerFragment( } } + @SuppressLint("SourceLockedOrientationActivity") private fun toggleRotate() { activity?.let { toggleOrientationWithSensor(it) @@ -1320,14 +2210,40 @@ open class FullScreenPlayer : AbstractPlayerFragment( } override fun playerDimensionsLoaded(width: Int, height: Int) { - // 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 + isVerticalOrientation = height > width 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 { + 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() @@ -1364,4 +2280,4 @@ open class FullScreenPlayer : AbstractPlayerFragment( .start() } } -} +} \ No newline at end of file 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 17bef3ec0..53e8fb647 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 @@ -45,19 +45,11 @@ import androidx.media3.ui.PlayerNotificationManager.MediaDescriptionAdapter import androidx.preference.PreferenceManager import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView +import com.lagradost.cloudstream3.amap import com.lagradost.cloudstream3.APIHolder.getApiFromNameNull import com.lagradost.cloudstream3.CloudStreamApp import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey import com.lagradost.cloudstream3.CommonActivity.showToast -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.getMalId -import com.lagradost.cloudstream3.LoadResponse.Companion.getTMDbId -import com.lagradost.cloudstream3.MainActivity -import com.lagradost.cloudstream3.R -import com.lagradost.cloudstream3.TvType -import com.lagradost.cloudstream3.amap import com.lagradost.cloudstream3.databinding.DialogOnlineSubtitlesBinding import com.lagradost.cloudstream3.databinding.FragmentPlayerBinding import com.lagradost.cloudstream3.databinding.PlayerSelectSourceAndSubsBinding @@ -66,53 +58,63 @@ import com.lagradost.cloudstream3.isAnimeOp import com.lagradost.cloudstream3.isEpisodeBased import com.lagradost.cloudstream3.isLiveStream import com.lagradost.cloudstream3.isMovieType -import com.lagradost.cloudstream3.mvvm.Resource +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.getMalId +import com.lagradost.cloudstream3.LoadResponse.Companion.getTMDbId +import com.lagradost.cloudstream3.MainActivity import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.mvvm.observe import com.lagradost.cloudstream3.mvvm.observeNullable +import com.lagradost.cloudstream3.mvvm.Resource import com.lagradost.cloudstream3.mvvm.safe +import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities.SubtitleSearch import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.subtitleProviders +import com.lagradost.cloudstream3.TvType import com.lagradost.cloudstream3.ui.download.DownloadButtonSetup import com.lagradost.cloudstream3.ui.player.CS3IPlayer.Companion.preferredAudioTrackLanguage import com.lagradost.cloudstream3.ui.player.CustomDecoder.Companion.updateForcedEncoding import com.lagradost.cloudstream3.ui.player.PlayerSubtitleHelper.Companion.toSubtitleMimeType -import com.lagradost.cloudstream3.ui.player.source_priority.LinkSource import com.lagradost.cloudstream3.ui.player.source_priority.QualityDataHelper -import com.lagradost.cloudstream3.ui.player.source_priority.QualityDataHelper.getLinkPriority import com.lagradost.cloudstream3.ui.player.source_priority.QualityProfileDialog import com.lagradost.cloudstream3.ui.result.ACTION_CLICK_DEFAULT import com.lagradost.cloudstream3.ui.result.EpisodeAdapter import com.lagradost.cloudstream3.ui.result.FOCUS_SELF import com.lagradost.cloudstream3.ui.result.ResultEpisode import com.lagradost.cloudstream3.ui.result.ResultFragment -import com.lagradost.cloudstream3.ui.result.ResultFragment.bindLogo import com.lagradost.cloudstream3.ui.result.ResultViewModel2 -import com.lagradost.cloudstream3.ui.result.SyncViewModel import com.lagradost.cloudstream3.ui.result.setLinearListLayout -import com.lagradost.cloudstream3.ui.setRecycledViewPool +import com.lagradost.cloudstream3.ui.result.SyncViewModel +import com.lagradost.cloudstream3.ui.result.VideoWatchState import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR +import com.lagradost.cloudstream3.ui.settings.Globals.isLayout 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.SUBTITLE_AUTO_SELECT_KEY import com.lagradost.cloudstream3.ui.subtitles.SubtitlesFragment import com.lagradost.cloudstream3.ui.subtitles.SubtitlesFragment.Companion.getAutoSelectLanguageTagIETF -import com.lagradost.cloudstream3.utils.AppContextUtils.getShortSeasonText import com.lagradost.cloudstream3.utils.AppContextUtils.html import com.lagradost.cloudstream3.utils.AppContextUtils.sortSubs import com.lagradost.cloudstream3.utils.Coroutines.ioSafe import com.lagradost.cloudstream3.utils.Coroutines.runOnMainThread import com.lagradost.cloudstream3.utils.DataStoreHelper import com.lagradost.cloudstream3.utils.DataStoreHelper.getViewPos +import com.lagradost.cloudstream3.utils.DataStoreHelper.setVideoWatchState +import com.lagradost.cloudstream3.utils.EpisodeSkip import com.lagradost.cloudstream3.utils.ExtractorLink import com.lagradost.cloudstream3.utils.ExtractorLinkType import com.lagradost.cloudstream3.utils.Qualities +import com.lagradost.cloudstream3.utils.setText import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showDialog +import com.lagradost.cloudstream3.utils.SubtitleHelper.fromCodeToLangTagIETF +import com.lagradost.cloudstream3.utils.SubtitleHelper.fromLanguageToTagIETF import com.lagradost.cloudstream3.utils.SubtitleHelper.fromTagToEnglishLanguageName import com.lagradost.cloudstream3.utils.SubtitleHelper.fromTagToLanguageName import com.lagradost.cloudstream3.utils.SubtitleHelper.languages +import com.lagradost.cloudstream3.utils.txt import com.lagradost.cloudstream3.utils.UIHelper.clipboardHelper import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute import com.lagradost.cloudstream3.utils.UIHelper.dismissSafe @@ -120,20 +122,15 @@ import com.lagradost.cloudstream3.utils.UIHelper.fixSystemBarsPadding import com.lagradost.cloudstream3.utils.UIHelper.hideSystemUI import com.lagradost.cloudstream3.utils.UIHelper.popCurrentPage import com.lagradost.cloudstream3.utils.UIHelper.toPx -import com.lagradost.cloudstream3.utils.downloader.DownloadUtils.getImageBitmapFromUrl -import com.lagradost.cloudstream3.utils.setText -import com.lagradost.cloudstream3.utils.txt -import com.lagradost.cloudstream3.utils.videoskip.VideoSkipStamp +import com.lagradost.cloudstream3.utils.VideoDownloadManager.getImageBitmapFromUrl import com.lagradost.safefile.SafeFile -import kotlinx.coroutines.Job -import kotlinx.coroutines.delay -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 +import kotlin.math.abs +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch @OptIn(UnstableApi::class) class GeneratorPlayer : FullScreenPlayer() { @@ -142,18 +139,11 @@ class GeneratorPlayer : FullScreenPlayer() { const val CHANNEL_ID = 7340 const val STOP_ACTION = "stopcs3" - private val generators = ConcurrentHashMap>() - fun newInstance( - generator: VideoGenerator<*>, - index: Int, - syncData: HashMap? = null - ): Bundle { + private var lastUsedGenerator: IGenerator? = null + fun newInstance(generator: IGenerator, syncData: HashMap? = null): Bundle { Log.i(TAG, "newInstance = $syncData") - val uuid = UUID.randomUUID().toString() - generators[uuid] = generator + lastUsedGenerator = generator return Bundle().apply { - putString("uuid", uuid) - putInt("index", index) if (syncData != null) putSerializable("syncData", syncData) } } @@ -164,44 +154,44 @@ class GeneratorPlayer : FullScreenPlayer() { } + private var titleRez = 3 private var limitTitle = 0 - private var showTitle = false - private var showName = false - private var showResolution = false - private var showMediaInfo = false 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 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 currentMeta: Any? = null + private var nextMeta: Any? = null + private var isActive: Boolean = 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 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 - if (subtitle != currentSelectedSubtitles && userInitiated) { + 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 fun setSubtitles(subtitle: SubtitleData?): Boolean { + // If subtitle is changed -> Save the language + if (subtitle != currentSelectedSubtitles) { val subtitleLanguageTagIETF = if (subtitle == null) { "" // -> No Subtitles } else { - subtitle.getIETF_tag() + fromCodeToLangTagIETF(subtitle.languageCode) + ?: fromLanguageToTagIETF(subtitle.languageCode, halfMatch = true) } if (subtitleLanguageTagIETF != null) { - Log.i(TAG, "Set SUBTITLE_AUTO_SELECT_KEY to '$subtitleLanguageTagIETF'") setKey(SUBTITLE_AUTO_SELECT_KEY, subtitleLanguageTagIETF) preferredAutoSelectSubtitles = subtitleLanguageTagIETF } @@ -221,11 +211,10 @@ 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) } - updatePlayerInfo() } override fun playerStatusChanged() { @@ -236,11 +225,11 @@ class GeneratorPlayer : FullScreenPlayer() { } private fun noSubtitles(): Boolean { - return setSubtitles(null, true) + return setSubtitles(null) } private fun getPos(): Long { - val durPos = getViewPos(viewModel.state.generatorState?.id) ?: return 0L + val durPos = DataStoreHelper.getViewPos(viewModel.getId()) ?: return 0L if (durPos.duration == 0L) return 0L if (durPos.position * 100L / durPos.duration > 95L) { return 0L @@ -355,13 +344,16 @@ class GeneratorPlayer : FullScreenPlayer() { } // retry several times with a preview in case the preview generator is slow - repeat(10) { + for (i in 0..10) { val preview = this@GeneratorPlayer.player.getPreview(0.5f) - if (preview != null) { - callback.onBitmap(preview) - return@repeat + if (preview == null) { + delay(1000L) + continue } - delay(1000L) + callback.onBitmap( + preview + ) + break } } @@ -377,7 +369,6 @@ 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) ) @@ -391,7 +382,9 @@ class GeneratorPlayer : FullScreenPlayer() { override fun onCustomAction(player: Player, action: String, intent: Intent) { when (action) { STOP_ACTION -> { - exitPlayer() + exitFullscreen() + this@GeneratorPlayer.player.release() + activity?.popCurrentPage() } } } @@ -491,9 +484,9 @@ class GeneratorPlayer : FullScreenPlayer() { } } - private fun loadLink(link: VideoLink?, sameEpisode: Boolean) { + private fun loadLink(link: Pair?, sameEpisode: Boolean) { if (link == null) return - isPlayerActive.set(true) + // manage UI binding?.playerLoadingOverlay?.isVisible = false val isTorrent = @@ -509,7 +502,16 @@ 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) @@ -519,7 +521,6 @@ class GeneratorPlayer : FullScreenPlayer() { // load player context?.let { ctx -> val (url, uri) = link - val subtitles = viewModel.state.subtitles player.loadPlayer( ctx, sameEpisode, @@ -528,11 +529,11 @@ class GeneratorPlayer : FullScreenPlayer() { startPosition = if (sameEpisode) null else { if (isNextEpisode) 0L else getPos() }, - subtitles, + currentSubs, (if (sameEpisode) currentSelectedSubtitles else null) ?: getAutoSelectSubtitle( - subtitles, settings = true, downloads = true + currentSubs, settings = true, downloads = true ), - preview = true + preview = isFullScreenPlayer ) } @@ -543,6 +544,34 @@ class GeneratorPlayer : FullScreenPlayer() { } } + private fun closestQuality(target: Int?): Qualities { + if (target == null) return Qualities.Unknown + return Qualities.entries.minBy { abs(it.value - target) } + } + + private fun getLinkPriority( + qualityProfile: Int, + link: Pair + ): Int { + val (linkData, _) = link + + val qualityPriority = QualityDataHelper.getQualityPriority( + qualityProfile, + closestQuality(linkData?.quality) + ) + val sourcePriority = + QualityDataHelper.getSourcePriority(qualityProfile, linkData?.source) + + // negative because we want to sort highest quality first + return qualityPriority + sourcePriority + } + + private fun sortLinks(qualityProfile: Int): List> { + return currentLinks.sortedBy { + -getLinkPriority(qualityProfile, it) + } + } + data class TempMetaData( var episode: Int? = null, var season: Int? = null, @@ -621,6 +650,7 @@ 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) @@ -636,7 +666,6 @@ 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) @@ -868,21 +897,22 @@ class GeneratorPlayer : FullScreenPlayer() { vararg subtitleData: SubtitleData ) { if (subtitleData.isEmpty()) return - val ctx = context ?: return val selectedSubtitle = subtitleData.first() - viewModel.addSubtitles(subtitleData.toSet()) + val ctx = context ?: return + + val subs = currentSubs + subtitleData // this is used instead of observe(viewModel._currentSubs), because observe is too slow - player.setActiveSubtitles(viewModel.state.subtitles) + player.setActiveSubtitles(subs) // Save current time as to not reset player to 00:00 player.saveData() player.reloadPlayer(ctx) - setSubtitles(selectedSubtitle, false) + setSubtitles(selectedSubtitle) + viewModel.addSubtitles(subtitleData.toSet()) selectSourceDialog?.dismissSafe() - selectSourceDialog = null showToast( String.format(ctx.getString(R.string.player_loaded_subtitles), selectedSubtitle.name), @@ -923,6 +953,10 @@ class GeneratorPlayer : FullScreenPlayer() { } } + private var selectSourceDialog: Dialog? = null +// var selectTracksDialog: AlertDialog? = null + + /** Will toast both when an error is found and when a subtitle is selected, * so only use from a user click and not a background process */ private fun addFirstSub(query: SubtitleSearch) = @@ -978,7 +1012,7 @@ class GeneratorPlayer : FullScreenPlayer() { } // checks for both a race condition and if any of the subs generated is new - if (this.isActive && !viewModel.state.subtitles.containsAll(subtitles) && !hasSelectASubtitle) { + if (this.isActive && !currentSubs.containsAll(subtitles) && !hasSelectASubtitle) { hasSelectASubtitle = true runOnMainThread { addAndSelectSubtitles(*subtitles.toTypedArray()) @@ -1001,7 +1035,7 @@ class GeneratorPlayer : FullScreenPlayer() { context?.let { ctx -> val isPlaying = player.getIsPlaying() player.handleEvent(CSPlayerEvent.Pause, PlayerEventSource.UI) - val currentSubtitles = sortSubs(viewModel.state.subtitles) + val currentSubtitles = sortSubs(currentSubs) val sourceDialog = Dialog(ctx, R.style.DialogFullscreenPlayer) val binding = @@ -1043,7 +1077,7 @@ class GeneratorPlayer : FullScreenPlayer() { } if (subsProvidersIsActive) { - val currentLoadResponse = viewModel.state.generatorState?.response + val currentLoadResponse = viewModel.getLoadResponse() val loadFromOpenSubsFooter: TextView = layoutInflater.inflate( R.layout.sort_bottom_footer_add_choice, null @@ -1055,7 +1089,6 @@ class GeneratorPlayer : FullScreenPlayer() { loadFromOpenSubsFooter.setOnClickListener { shouldDismiss = false sourceDialog.dismissSafe(activity) - selectSourceDialog = null openOnlineSubPicker(it.context, currentLoadResponse) { dismiss() } @@ -1076,7 +1109,6 @@ class GeneratorPlayer : FullScreenPlayer() { loadFromFirstSubsFooter.setOnClickListener { sourceDialog.dismissSafe(activity) - selectSourceDialog = null showToast(R.string.loading) addFirstSub( SubtitleSearch( @@ -1101,7 +1133,7 @@ class GeneratorPlayer : FullScreenPlayer() { var sortedUrls = emptyList>() fun refreshLinks(qualityProfile: Int) { - sortedUrls = viewModel.state.sortLinks(qualityProfile) + sortedUrls = sortLinks(qualityProfile) if (sortedUrls.isEmpty()) { sourceDialog.findViewById(R.id.sort_sources_holder)?.isGone = true @@ -1252,7 +1284,6 @@ class GeneratorPlayer : FullScreenPlayer() { binding.cancelBtt.setOnClickListener { sourceDialog.dismissSafe(activity) - this.selectSourceDialog = null } fun setProfileName(profile: Int) { @@ -1266,28 +1297,16 @@ class GeneratorPlayer : FullScreenPlayer() { binding.profilesClickSettings.setOnClickListener { val activity = activity ?: return@setOnClickListener - val dialog = QualityProfileDialog( + QualityProfileDialog( activity, R.style.DialogFullscreenPlayer, - viewModel.state.links.mapNotNull { - it.first?.let { extractorLink -> - LinkSource( - extractorLink - ) - } - }, + currentLinks.mapNotNull { it.first }, currentQualityProfile ) { profile -> currentQualityProfile = profile.id setProfileName(profile.id) - } - - dialog.setOnDismissListener { - viewModel.state.clearSortedLinksCache() - refreshLinks(currentQualityProfile) - } - - dialog.show() + refreshLinks(profile.id) + }.show() } binding.subtitlesEncodingFormat.apply { @@ -1315,7 +1334,6 @@ class GeneratorPlayer : FullScreenPlayer() { shouldDismiss = false sourceDialog.dismissSafe(activity) - selectSourceDialog = null val index = prefValues.indexOf(currentPrefMedia) activity?.showDialog( @@ -1344,7 +1362,7 @@ class GeneratorPlayer : FullScreenPlayer() { subtitlesGroupedList.getOrNull(subtitleGroupIndex - 1)?.value?.getOrNull( subtitleOptionIndex )?.let { - setSubtitles(it, true) + setSubtitles(it) } ?: false } } @@ -1354,7 +1372,6 @@ class GeneratorPlayer : FullScreenPlayer() { } } sourceDialog.dismissSafe(activity) - selectSourceDialog = null } } } catch (e: Exception) { @@ -1378,13 +1395,12 @@ class GeneratorPlayer : FullScreenPlayer() { val binding: PlayerSelectTracksBinding = PlayerSelectTracksBinding.inflate(LayoutInflater.from(ctx), null, false) val trackDialog = Dialog(ctx, R.style.DialogFullscreenPlayer) - this.selectTrackDialog = trackDialog trackDialog.setContentView(binding.root) trackDialog.show() fixSystemBarsPadding(binding.root) - // selectTracksDialog = tracksDialog +// selectTracksDialog = tracksDialog val videosList = binding.videoTracksList val audioList = binding.autoTracksList @@ -1427,56 +1443,29 @@ class GeneratorPlayer : FullScreenPlayer() { trackDialog.setOnDismissListener { dismiss() - // selectTracksDialog = null +// selectTracksDialog = null } - var audioIndexStart = currentAudioTracks.indexOfFirst { track -> - track.id == tracks.currentAudioTrack?.id && - track.formatIndex == tracks.currentAudioTrack?.formatIndex - }.coerceAtLeast(0) + var audioIndexStart = currentAudioTracks.indexOf(tracks.currentAudioTrack).takeIf { + it != -1 + } ?: currentVideoTracks.indexOfFirst { + tracks.currentAudioTrack?.id == it.id + } val audioArrayAdapter = ArrayAdapter(ctx, R.layout.sort_bottom_single_choice) - audioArrayAdapter.addAll( - currentAudioTracks.mapIndexed { _, track -> - - val language = ( - track.language?.trim()?.let { raw -> - fromTagToLanguageName(raw) - ?: fromTagToLanguageName( - raw.replace('_', '-').substringBefore('-').lowercase() - ) - ?: raw - } - ?: track.label - ?: "Audio" - ).replaceFirstChar { it.uppercaseChar() } - - val codec = audioCodecName(track.sampleMimeType) - - val channelCount = track.channelCount - - val channels = when { - // May be below 1 or null when unknown - channelCount == null || channelCount <= 0 -> "" - channelCount == 1 -> "Mono" - channelCount == 2 -> "Stereo" - channelCount == 6 -> "5.1" - channelCount == 8 -> "7.1" - else -> "${channelCount}ch" - } - - listOfNotNull( - language.takeIf { it.isNotBlank() } - ?.replaceFirstChar { it.uppercaseChar() }, - channels.takeIf { it.isNotBlank() }, - codec.takeIf { it.isNotBlank() }?.uppercase() - ).joinToString(" • ") - + audioArrayAdapter.addAll(currentAudioTracks.mapIndexed { index, format -> + when { + format.label != null && format.language != null -> + "${format.label} - [${fromTagToLanguageName(format.language) ?: format.language}]" + else -> format.label + ?: format.language?.let { fromTagToLanguageName(it) } + ?: format.language + ?: index.toString() } - ) + }) audioList.adapter = audioArrayAdapter audioList.choiceMode = AbsListView.CHOICE_MODE_SINGLE @@ -1491,15 +1480,12 @@ class GeneratorPlayer : FullScreenPlayer() { binding.cancelBtt.setOnClickListener { trackDialog.dismissSafe(activity) - this.selectTrackDialog = null } binding.applyBtt.setOnClickListener { val currentTrack = currentAudioTracks.getOrNull(audioIndexStart) player.setPreferredAudioTrack( - currentTrack?.language, - currentTrack?.id, - currentTrack?.formatIndex, + currentTrack?.language, currentTrack?.id ) val currentVideo = currentVideoTracks.getOrNull(videoIndex) @@ -1508,8 +1494,8 @@ class GeneratorPlayer : FullScreenPlayer() { if (width != NO_VALUE && height != NO_VALUE) { player.setMaxVideoSize(width, height, currentVideo?.id) } + trackDialog.dismissSafe(activity) - this.selectTrackDialog = null } } } catch (e: Exception) { @@ -1517,6 +1503,7 @@ class GeneratorPlayer : FullScreenPlayer() { } } + override fun playerError(exception: Throwable) { val currentUrl = currentSelectedLink?.let { it.first?.url ?: it.second?.uri?.toString() } ?: "unknown" @@ -1546,75 +1533,20 @@ class GeneratorPlayer : FullScreenPlayer() { } private fun startPlayer() { - // We don't want double load when you skip loading - if (isPlayerActive.get()) { - return - } + if (isActive) return // we don't want double load when you skip loading - val links = viewModel.state.sortLinks(currentQualityProfile) + val links = sortLinks(currentQualityProfile) if (links.isEmpty()) { noLinksFound() return } - // Atomic operation to prevent double loading - if (!isPlayerActive.compareAndSet(false, true)) { - return - } loadLink(links.first(), false) - showPlayerMetadata() - } - - private fun showPlayerMetadata() { - val overlay = playerBinding?.playerMetadataScrim ?: return - - val titleView = overlay.findViewById(R.id.player_movie_title) - val logoView = overlay.findViewById(R.id.player_movie_logo) - val metaView = overlay.findViewById(R.id.player_movie_meta) - val descView = overlay.findViewById(R.id.player_movie_overview) - - val load = viewModel.state.generatorState?.response ?: return - val episode = currentMeta as? ResultEpisode - titleView.text = load.name - - bindLogo( - url = load.logoUrl, - headers = load.posterHeaders, - titleView = titleView, - logoView = logoView - ) - - val meta = arrayOf( - load.tags?.takeIf { it.isNotEmpty() }?.joinToString(", "), - load.year?.toString(), - if (!load.type.isMovieType()) - context?.getShortSeasonText( - episode = episode?.episode, - season = episode?.season - ) - else null, - load.score?.let { "⭐ $it" } - ).filterNotNull() - .joinToString(" • ") - - metaView.text = meta - metaView.isVisible = meta.isNotBlank() - - - val description = load.plot - - if (!description.isNullOrBlank()) { - descView.isVisible = true - descView.text = description - } else { - descView.isVisible = false - - } } override fun nextEpisode() { if (viewModel.hasNextEpisode() == true) { isNextEpisode = true - releasePlayer() + player.release() viewModel.loadLinksNext() } } @@ -1622,18 +1554,18 @@ class GeneratorPlayer : FullScreenPlayer() { override fun prevEpisode() { if (viewModel.hasPrevEpisode() == true) { isNextEpisode = true - releasePlayer() + player.release() viewModel.loadLinksPrev() } } override fun hasNextMirror(): Boolean { - val links = viewModel.state.sortLinks(currentQualityProfile) + val links = sortLinks(currentQualityProfile) return links.isNotEmpty() && links.indexOf(currentSelectedLink) + 1 < links.size } override fun nextMirror() { - val links = viewModel.state.sortLinks(currentQualityProfile) + val links = sortLinks(currentQualityProfile) if (links.isEmpty()) { noLinksFound() return @@ -1680,7 +1612,7 @@ class GeneratorPlayer : FullScreenPlayer() { val percentage = position * 100L / duration DataStoreHelper.setViewPosAndResume( - viewModel.state.generatorState?.id, + viewModel.getId(), position, duration, currentMeta, @@ -1727,32 +1659,33 @@ class GeneratorPlayer : FullScreenPlayer() { } } + private fun SubtitleData.matchesLanguage(langCode: String): Boolean { + val langName = fromTagToEnglishLanguageName(langCode) ?: return false + val cleanedName = originalName.replace(Regex("[^\\p{L}\\p{Mn}\\p{Mc}\\p{Me} ]"), "").trim() + return languageCode == langCode || cleanedName == langName || cleanedName.contains(langName) || cleanedName == langCode + } + private fun getAutoSelectSubtitle( subtitles: Set, settings: Boolean, downloads: Boolean ): SubtitleData? { val langCode = preferredAutoSelectSubtitles ?: return null if (downloads) { - sortSubs(subtitles).firstOrNull { - it.origin == SubtitleOrigin.DOWNLOADED_FILE && it.matchesLanguageCode( - langCode - ) - }?.let { return it } + return sortSubs(subtitles).firstOrNull { it.origin == SubtitleOrigin.DOWNLOADED_FILE && it.matchesLanguage(langCode) } } if (!settings) return null - return sortSubs(subtitles).firstOrNull { it.matchesLanguageCode(langCode) } + return sortSubs(subtitles).firstOrNull { it.matchesLanguage(langCode) } } - + private fun autoSelectFromSettings(): Boolean { // auto select subtitle based on settings val langCode = preferredAutoSelectSubtitles val current = player.getCurrentPreferredSubtitle() Log.i(TAG, "autoSelectFromSettings = $current") context?.let { ctx -> - // Only use the player preferred subtitle if it matches the available language - if (current != null && (langCode == null || current.matchesLanguageCode(langCode))) { - if (setSubtitles(current, false)) { + if (current != null) { + if (setSubtitles(current)) { player.saveData() player.reloadPlayer(ctx) player.handleEvent(CSPlayerEvent.Play) @@ -1760,9 +1693,9 @@ class GeneratorPlayer : FullScreenPlayer() { } } else if (!langCode.isNullOrEmpty()) { getAutoSelectSubtitle( - viewModel.state.subtitles, settings = true, downloads = false + currentSubs, settings = true, downloads = false )?.let { sub -> - if (setSubtitles(sub, false)) { + if (setSubtitles(sub)) { player.saveData() player.reloadPlayer(ctx) player.handleEvent(CSPlayerEvent.Play) @@ -1774,20 +1707,20 @@ class GeneratorPlayer : FullScreenPlayer() { return false } - private fun autoSelectFromDownloads() { - if (player.getCurrentPreferredSubtitle() != null) { - return + private fun autoSelectFromDownloads(): Boolean { + if (player.getCurrentPreferredSubtitle() == null) { + getAutoSelectSubtitle(currentSubs, settings = false, downloads = true)?.let { sub -> + context?.let { ctx -> + if (setSubtitles(sub)) { + player.saveData() + player.reloadPlayer(ctx) + player.handleEvent(CSPlayerEvent.Play) + return true + } + } + } } - 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) + return false } private fun autoSelectSubtitles() { @@ -1853,6 +1786,8 @@ class GeneratorPlayer : FullScreenPlayer() { return "" } + + @SuppressLint("SetTextI18n") fun setTitle() { var playerVideoTitle = getPlayerVideoTitle() @@ -1871,105 +1806,41 @@ class GeneratorPlayer : FullScreenPlayer() { playerBinding?.playerEpisodeFillerHolder?.isVisible = isFiller ?: false playerBinding?.playerVideoTitle?.text = playerVideoTitle - playerBinding?.offlinePin?.isVisible = viewModel.generator is DownloadFileGenerator + playerBinding?.offlinePin?.isVisible = lastUsedGenerator 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 - val title = getHeaderName() + val extra = widthHeight?.let { (w, h) -> "${w}x${h}" } ?: "" + val source = currentSelectedLink?.first?.name ?: currentSelectedLink?.second?.name ?: "NULL" + val headerName = getHeaderName().orEmpty() - val result = listOfNotNull( - title?.takeIf { showTitle && it.isNotBlank() }, - name?.takeIf { showName && it.isNotBlank() }, - resolution?.takeIf { showResolution && it.isNotBlank() }, - ).joinToString(" - ") + val title = when (titleRez) { + 0 -> "" + 1 -> extra + 2 -> source + 3 -> "$source${ + if (source.isBlank()) { + "" + } else { + " - " + } + }$extra" - playerBinding?.playerVideoTitleRez?.apply { - text = result - isVisible = result.isNotBlank() - } - } + 4 -> headerName + 5 -> "$headerName${ + if (headerName.isBlank()) { + "" + } else { + " - " + } + }$extra" - - private fun videoCodecName(mime: String?): String? { - val m = mime?.lowercase() ?: return null - return when { - m.contains("avc") || m.contains("h264") -> "AVC" - m.contains("hevc") || m.contains("h265") -> "HEVC" - m.contains("av1") -> "AV1" - m.contains("vp9") -> "VP9" - m.contains("vp8") -> "VP8" - "/" in m -> m.substringAfter("/").uppercase() - else -> m.uppercase() - } - } - - private fun audioCodecName(mime: String?): String { - val m = mime?.lowercase()?.trim().orEmpty() - if (m.isBlank()) return "" - return when { - m.contains("eac3-joc") -> "Dolby Atmos" - m.contains("truehd") -> "TrueHD" - m.contains("eac3") -> "E-AC3" - m.contains("ac-3") || m.contains("ac3") -> "AC3" - m.contains("aac") || m.contains("mp4a") -> "AAC" - m.contains("opus") -> "Opus" - m.contains("vorbis") -> "Vorbis" - m.contains("mp3") -> "MP3" - m.contains("flac") -> "FLAC" - m.contains("dts") -> "DTS" - m.contains("pcm") -> "PCM" - m.contains("alac") -> "ALAC" - m.contains("amr") -> "AMR" - m.contains("/") -> m.substringAfter("/").uppercase().takeIf { it.isNotBlank() } ?: "" else -> "" } - } - - private fun updatePlayerInfo() { - val tracks = player.getVideoTracks() - - val videoTrack = tracks.currentVideoTrack - val audioTrack = tracks.currentAudioTrack - - val ctx = context ?: return - val prefs = PreferenceManager.getDefaultSharedPreferences(ctx) - showMediaInfo = prefs.getBoolean(ctx.getString(R.string.show_media_info_key), false) - - val videoCodec = videoCodecName(videoTrack?.sampleMimeType) - val audioCodec = audioCodecName(audioTrack?.sampleMimeType) - val languageName = fromTagToLanguageName(audioTrack?.language) - val label = audioTrack?.label - - val channelCount = audioTrack?.channelCount - - val channels = when { - // May be below 1 or null when unknown - channelCount == null || channelCount <= 0 -> "" - channelCount == 1 -> "Mono" - channelCount == 2 -> "Stereo" - channelCount == 6 -> "5.1" - channelCount == 8 -> "7.1" - else -> "${channelCount}ch" - } - - val language = languageName?.takeIf { it.isNotBlank() }?.let { lang -> - label?.takeIf { it.isNotBlank() && !it.equals(lang, true) } - ?.let { lang } - ?: lang - } ?: label?.takeIf { it.isNotBlank() } - - val stats = arrayOf( - videoCodec, - language, - channels, - audioCodec - ).filter { !it.isNullOrBlank() }.joinToString(" • ") - - playerBinding?.playerVideoInfo?.apply { - text = stats - isVisible = showMediaInfo && stats.isNotBlank() + playerBinding?.playerVideoTitleRez?.apply { + text = title + isVisible = title.isNotBlank() } } @@ -1985,13 +1856,31 @@ class GeneratorPlayer : FullScreenPlayer() { } } - /** - * 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 + 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() + } + + var timestampShowState = false var skipAnimator: ValueAnimator? = null var skipIndex = 0 @@ -2012,12 +1901,6 @@ 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 @@ -2026,7 +1909,12 @@ class GeneratorPlayer : FullScreenPlayer() { from, to ).apply { addListener(onEnd = { - if (!show) { + if (show) { + if (!isShowing) { + // Automatically request focus if the menu is not opened + playerBinding?.skipChapterButton?.requestFocus() + } + } else { playerBinding?.skipChapterButton?.isVisible = false if (!isShowing) { // Automatically return focus to play pause @@ -2046,11 +1934,11 @@ class GeneratorPlayer : FullScreenPlayer() { } } - override fun onTimestampSkipped(timestamp: VideoSkipStamp) { + override fun onTimestampSkipped(timestamp: EpisodeSkip.SkipStamp) { displayTimeStamp(false) } - override fun onTimestamp(timestamp: VideoSkipStamp?) { + override fun onTimestamp(timestamp: EpisodeSkip.SkipStamp?) { if (timestamp != null) { playerBinding?.skipChapterButton?.setText(timestamp.uiText) displayTimeStamp(true) @@ -2065,9 +1953,8 @@ class GeneratorPlayer : FullScreenPlayer() { } override fun isThereEpisodes(): Boolean { - // 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 + val meta = allMeta + return !meta.isNullOrEmpty() && meta.size > 1 } override fun showEpisodesOverlay() { @@ -2079,7 +1966,7 @@ class GeneratorPlayer : FullScreenPlayer() { { episodeClick -> if (episodeClick.action == ACTION_CLICK_DEFAULT) { isNextEpisode = false - releasePlayer() + player.release() playerEpisodeOverlay.isGone = true episodeClick.position?.let { viewModel.loadThisEpisode(it) } } @@ -2098,7 +1985,7 @@ class GeneratorPlayer : FullScreenPlayer() { (playerEpisodeList.adapter as? EpisodeAdapter)?.submitList(episodes) // Scroll to current episode - viewModel.state.generatorState?.index?.let { index -> + viewModel.getCurrentIndex()?.let { index -> playerEpisodeList.scrollToPosition(index) // Ensure focus on tv if (isLayout(TV)) { @@ -2117,14 +2004,15 @@ 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( @@ -2142,64 +2030,25 @@ class GeneratorPlayer : FullScreenPlayer() { } } - @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) + @SuppressLint("SetTextI18n") + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + var langFilterList = listOf() + var filterSubByLang = false context?.let { ctx -> val settingsManager = PreferenceManager.getDefaultSharedPreferences(ctx) - showName = settingsManager.getBoolean(ctx.getString(R.string.show_name_key), true) - showResolution = - settingsManager.getBoolean(ctx.getString(R.string.show_resolution_key), true) - showMediaInfo = - settingsManager.getBoolean(ctx.getString(R.string.show_media_info_key), false) - limitTitle = settingsManager.getInt(ctx.getString(R.string.prefer_title_limit_key), 0) + titleRez = settingsManager.getInt(ctx.getString(R.string.prefer_limit_title_rez_key), 3) + limitTitle = settingsManager.getInt(ctx.getString(R.string.prefer_limit_title_key), 0) updateForcedEncoding(ctx) - viewModel.filterSubByLang = + + filterSubByLang = settingsManager.getBoolean(getString(R.string.filter_sub_lang_key), false) - if (viewModel.filterSubByLang) { + if (filterSubByLang) { val langFromPrefMedia = settingsManager.getStringSet( this.getString(R.string.provider_lang_key), mutableSetOf("en") ) - viewModel.langFilterList = langFromPrefMedia?.mapNotNull { + langFilterList = langFromPrefMedia?.mapNotNull { fromTagToEnglishLanguageName(it)?.lowercase() ?: return@mapNotNull null } ?: listOf() } @@ -2212,23 +2061,18 @@ class GeneratorPlayer : FullScreenPlayer() { preferredAutoSelectSubtitles = getAutoSelectLanguageTagIETF() - val selectedLink = currentSelectedLink - if (selectedLink == null) { + if (currentSelectedLink == null) { viewModel.loadLinks() - } else { - // Recreated view, so we need to recreate the - loadLink(selectedLink, true) } - binding.overlayLoadingSkipButton.setOnClickListener { - // Mark as "success" early - viewModel.modifyState { - copy(loading = Resource.Success(Unit)) - } + binding?.overlayLoadingSkipButton?.setOnClickListener { + startPlayer() } - binding.playerLoadingGoBack.setOnClickListener { - exitPlayer() + binding?.playerLoadingGoBack?.setOnClickListener { + exitFullscreen() + player.release() + activity?.popCurrentPage() } playerBinding?.downloadHeader?.setOnClickListener { @@ -2241,29 +2085,14 @@ class GeneratorPlayer : FullScreenPlayer() { } } - observe(viewModel.currentStamps) { (stamps, instance) -> - if (instance != viewModel.state.instance) return@observe // Outdated observe + observe(viewModel.currentStamps) { stamps -> player.addTimeStamps(stamps) } - 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) { + observe(viewModel.loadingLinks) { + when (it) { is Resource.Loading -> { - releasePlayer() + startLoading() } is Resource.Success -> { @@ -2275,31 +2104,30 @@ class GeneratorPlayer : FullScreenPlayer() { } is Resource.Failure -> { - showToast(loading.errorString, Toast.LENGTH_LONG) + showToast(it.errorString, Toast.LENGTH_LONG) startPlayer() } } } - observe(viewModel.currentLinks) { (links, instance) -> - if (instance != viewModel.state.instance) return@observe // Outdated observe + observe(viewModel.currentLinks) { + currentLinks = it + val turnVisible = it.isNotEmpty() && lastUsedGenerator?.canSkipLoading == true + val wasGone = binding?.overlayLoadingSkipButton?.isGone == true - val turnVisible = links.isNotEmpty() && viewModel.generator?.canSkipLoading == true - val wasGone = binding.overlayLoadingSkipButton.isGone - - binding.overlayLoadingSkipButton.apply { + binding?.overlayLoadingSkipButton?.apply { isVisible = turnVisible - if (links.isEmpty()) { + val value = viewModel.currentLinks.value + if (value.isNullOrEmpty()) { setText(R.string.skip_loading) } else { - @SuppressLint("SetTextI18n") - text = "${context.getString(R.string.skip_loading)} (${links.size})" + text = "${context.getString(R.string.skip_loading)} (${value.size})" } } safe { - if (!isPlayerActive.get() && viewModel.state.links.any { link -> - getLinkPriority(currentQualityProfile, link.first) >= + if (currentLinks.any { link -> + getLinkPriority(currentQualityProfile, link) >= QualityDataHelper.AUTO_SKIP_PRIORITY } ) { @@ -2308,7 +2136,33 @@ class GeneratorPlayer : FullScreenPlayer() { } if (turnVisible && wasGone) { - binding.overlayLoadingSkipButton.requestFocus() + 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() } } } 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 3ab46ce21..0a34feee3 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,7 +1,10 @@ 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, @@ -25,27 +28,71 @@ val LOADTYPE_CHROMECAST = setOf( val LOADTYPE_ALL = ExtractorLinkType.entries.toSet() -abstract class NoVideoGenerator(val id : Int?) : VideoGenerator(emptyList()) { +abstract class NoVideoGenerator : VideoGenerator(emptyList(), 0) { override val hasCache = false override val canSkipLoading = false - override fun getId(index: Int): Int? = id } -abstract class VideoGenerator(val videos: List) { - abstract val hasCache: Boolean - abstract val canSkipLoading: Boolean - abstract fun getId(index : Int) : Int? +abstract class VideoGenerator(val videos: List, var videoIndex: Int = 0) : + IGenerator { - fun hasNext(videoIndex : Int): Boolean = videoIndex < videos.lastIndex - fun hasPrev(videoIndex : Int): Boolean = videoIndex > 0 + 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 + } + } - @Throws - abstract suspend fun generateLinks( + 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( clearCache: Boolean, sourceTypes: Set, callback: (Pair) -> Unit, subtitleCallback: (SubtitleData) -> Unit, - offset: Int, - isCasting: Boolean + offset: Int = 0, + isCasting: Boolean = false ): 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 034237266..2ac484648 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,11 +3,30 @@ 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.EpisodeSkip 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), @@ -67,13 +86,13 @@ data class ErrorEvent( /** Event when timestamps appear, null when it should disappear */ data class TimestampInvokedEvent( - val timestamp: VideoSkipStamp, + val timestamp: EpisodeSkip.SkipStamp, override val source: PlayerEventSource = PlayerEventSource.Player, ) : PlayerEvent() /** Event for when a chapter is skipped, aka when event is handled (or for future use when skip automatically ads/sponsor) */ data class TimestampSkippedEvent( - val timestamp: VideoSkipStamp, + val timestamp: EpisodeSkip.SkipStamp, override val source: PlayerEventSource = PlayerEventSource.Player, ) : PlayerEvent() @@ -163,7 +182,6 @@ interface Track { val id: String? val label: String? val language: String? - val sampleMimeType : String? } data class VideoTrack( @@ -172,23 +190,19 @@ data class VideoTrack( override val language: String?, val width: Int?, val height: Int?, - override val sampleMimeType: String?, ) : Track data class AudioTrack( override val id: String?, override val label: String?, override val language: String?, - override val sampleMimeType: String?, - val channelCount: Int?, - val formatIndex: Int?, ) : Track data class TextTrack( override val id: String?, override val label: String?, override val language: String?, - override val sampleMimeType: String?, + val mimeType: String?, ) : Track @@ -201,6 +215,8 @@ data class CurrentTracks( val allTextTracks: List, ) +class InvalidFileException(msg: String) : Exception(msg) + //http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4 const val ACTION_MEDIA_CONTROL = "media_control" const val EXTRA_CONTROL_TYPE = "control_type" @@ -222,9 +238,8 @@ interface IPlayer { fun getSubtitleOffset(): Long // in ms fun setSubtitleOffset(offset: Long) // in ms - @AnyThread fun initCallbacks( - @MainThread eventHandler: ((PlayerEvent) -> Unit), + eventHandler: ((PlayerEvent) -> Unit), /** this is used to request when the player should report back view percentage */ requestedListeningPercentages: List? = null, ) @@ -234,7 +249,7 @@ interface IPlayer { fun updateSubtitleStyle(style: SaveCaptionStyle) fun saveData() - fun addTimeStamps(timeStamps: List) + fun addTimeStamps(timeStamps: List) fun loadPlayer( context: Context, @@ -287,8 +302,8 @@ interface IPlayer { fun setMaxVideoSize(width: Int = Int.MAX_VALUE, height: Int = Int.MAX_VALUE, id: String? = null) /** If no trackLanguage is set it'll default to first track. Specifying the id allows for track overrides as the language can be identical. */ - fun setPreferredAudioTrack(trackLanguage: String?, id: String? = null, formatIndex: Int? = null) + fun setPreferredAudioTrack(trackLanguage: String?, id: String? = null) /** 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 db06e26e9..71513af2c 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,8 +40,7 @@ class LinkGenerator( private val links: List, private val extract: Boolean = true, private val refererUrl: String? = null, - id: Int? -) : NoVideoGenerator(id) { +) : NoVideoGenerator() { override suspend fun generateLinks( clearCache: Boolean, sourceTypes: Set, @@ -79,8 +78,10 @@ class LinkGenerator( class MinimalLinkGenerator( private val links: List, private val subs: List, - id: Int? -) : NoVideoGenerator(id) { + private val id: Int? = null +) : NoVideoGenerator() { + override fun getCurrentId(): Int? = 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 dcf976612..eb9f5c249 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,10 +1,10 @@ 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 -import androidx.navigation.NavOptions import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.actions.temp.CloudStreamPackage import com.lagradost.cloudstream3.utils.AppUtils.tryParseJson @@ -13,25 +13,15 @@ import com.lagradost.cloudstream3.utils.UIHelper.navigate import com.lagradost.safefile.SafeFile object OfflinePlaybackHelper { - /** - * Pop any existing player off the nav back stack before pushing the new one, - * keeping the stack flat (at most one player at a time). This prevents an - * OOM when many files are opened in sequence via DownloadedPlayerActivity. - */ - private val replacePlayerNavOptions = NavOptions.Builder() - .setPopUpTo(R.id.navigation_player, inclusive = true, saveState = false) - .build() - fun playLink(activity: Activity, url: String) { activity.navigate( R.id.global_to_navigation_player, GeneratorPlayer.newInstance( LinkGenerator( listOf( BasicLink(url) - ), id = url.hashCode() - ), 0 - ), - replacePlayerNavOptions + ) + ) + ) ) } @@ -62,9 +52,8 @@ object OfflinePlaybackHelper { links, subs, if (id != -1) id else null, - ), 0 - ), - replacePlayerNavOptions + ) + ) ) return true } @@ -84,12 +73,12 @@ 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 = uri.lastPathSegment?.toLongOrNull()?.hashCode() ?: uri.lastPathSegment?.hashCode() + id = kotlin.runCatching { ContentUris.parseId(uri) }.getOrNull() + ?.hashCode() ) ) - ), 0 - ), - replacePlayerNavOptions + ) + ) ) } } \ No newline at end of file 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 e3c390d50..d8c5e777c 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,188 +9,34 @@ 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.EpisodeSkip 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" } - @Volatile - var generator: VideoGenerator<*>? = null + private var generator: IGenerator? = 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 _currentSubs = MutableLiveData>(setOf()) + val currentSubs: LiveData> = _currentSubs - private val _currentLinks = - MutableLiveData>>>(null) - val currentLinks: LiveData>>> = _currentLinks + private val _loadingLinks = MutableLiveData>() + val loadingLinks: LiveData> = _loadingLinks - private val _currentSubtitles = MutableLiveData>>(null) - val currentSubtitles: LiveData>> = _currentSubtitles - - private val _loadingLinks = MutableLiveData>>() - val loadingLinks: LiveData>> = _loadingLinks - - 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 _currentStamps = MutableLiveData>(emptyList()) + val currentStamps: LiveData> = _currentStamps private val _currentSubtitleYear = MutableLiveData(null) val currentSubtitleYear: LiveData = _currentSubtitleYear @@ -206,32 +52,41 @@ 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(episodeIndex) == true) { - episodeIndex += 1 + if (generator?.hasPrev() == true) { + generator?.prev() loadLinks() } } fun loadLinksNext() { Log.i(TAG, "loadLinksNext") - if (generator?.hasNext(episodeIndex) == true) { - episodeIndex += 1 + if (generator?.hasNext() == true) { + generator?.next() loadLinks() } } fun hasNextEpisode(): Boolean? { - return generator?.hasNext(episodeIndex) + return generator?.hasNext() } fun hasPrevEpisode(): Boolean? { - return generator?.hasPrev(episodeIndex) + return generator?.hasPrev() } fun preLoadNextLinks() { - val id = generator?.getId(episodeIndex) + val id = getId() // Do not preload if already loading if (id == currentLoadingEpisodeId) return @@ -241,15 +96,14 @@ class PlayerGeneratorViewModel : ViewModel() { currentJob = viewModelScope.launch { try { - if (generator?.hasCache == true && generator?.hasNext(episodeIndex) == true) { + if (generator?.hasCache == true && generator?.hasNext() == true) { safeApiCall { generator?.generateLinks( sourceTypes = LOADTYPE_INAPP, clearCache = false, - isCasting = false, callback = {}, subtitleCallback = {}, - offset = episodeIndex + 1 + offset = 1 ) } } @@ -263,137 +117,129 @@ class PlayerGeneratorViewModel : ViewModel() { } } - fun loadThisEpisode(index: Int) { - episodeIndex = index + 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) loadLinks() } - fun attachGenerator(newGenerator: VideoGenerator<*>, index: Int) { - Log.i(TAG, "attachGenerator with generator=$newGenerator and index=$index") - generator = newGenerator - episodeIndex = index + fun getCurrentIndex():Int?{ + val repoGen = generator as? RepoLinkGenerator ?: return null + return repoGen.videoIndex } + fun attachGenerator(newGenerator: IGenerator?) { + if (generator == null) { + generator = newGenerator + } + } + + private var extraSubtitles : MutableSet = mutableSetOf() + /** * If duplicate nothing will happen * */ - fun addSubtitles(file: Set) { - val validFile = file.filter(::isValidSubtitle) - if (validFile.isNotEmpty()) - modifyState { - add(validFile) - } + 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) + } } private var currentJob: Job? = null private var currentStampJob: Job? = null fun loadStamps(duration: Long) { + //currentStampJob?.cancel() currentStampJob = ioSafe { - 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 meta = generator?.getCurrent() + val page = (generator as? RepoLinkGenerator?)?.page + if (page != null && meta is ResultEpisode) { + _currentStamps.postValue(listOf()) + _currentStamps.postValue( + EpisodeSkip.getStamps( + page, + meta, + duration, + hasNextEpisode() ?: false + ) + ) } - 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 with generator=$generator and index=$episodeIndex") + Log.i(TAG, "loadLinks") 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 { - // Load more data + // 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()) val loadingState = safeApiCall { generator?.generateLinks( sourceTypes = sourceTypes, clearCache = forceClearCache, - callback = { link -> - if (isActive) - modifyState { - add(link) + callback = { + synchronized(currentLinks) { + currentLinks.add(it) + // Clone to prevent ConcurrentModificationException + safe { + // Extra safe since .toSet() iterates. + _currentLinks.postValue(currentLinks.toSet()) } + } }, - isCasting = false, - offset = index, - subtitleCallback = { link -> - if (isActive && isValidSubtitle(link)) - modifyState { - add(link) + subtitleCallback = { + synchronized(extraSubtitles) { + currentSubs.add(it) + safe { + _currentSubs.postValue(currentSubs + extraSubtitles) } + } }) - Unit } - 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 - } - } + _loadingLinks.postValue(loadingState) + _currentLinks.postValue(currentLinks) + synchronized(extraSubtitles) { + _currentSubs.postValue(currentSubs + extraSubtitles) } } + } } \ 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 deleted file mode 100644 index 1c7086d12..000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerGestureHelper.kt +++ /dev/null @@ -1,1220 +0,0 @@ -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 0db06499e..343155808 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_singular, + R.string.audio_singluar, CSPlayerEvent.PlayAsAudio ) ) diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerSubtitleHelper.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerSubtitleHelper.kt index ee6170aa5..d9e8963e4 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerSubtitleHelper.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerSubtitleHelper.kt @@ -11,7 +11,6 @@ import androidx.media3.ui.SubtitleView import com.lagradost.cloudstream3.SubtitleFile import com.lagradost.cloudstream3.ui.subtitles.SaveCaptionStyle import com.lagradost.cloudstream3.ui.subtitles.SubtitlesFragment.Companion.setSubtitleViewStyle -import com.lagradost.cloudstream3.utils.SubtitleHelper.fromLanguageToTagIETF import com.lagradost.cloudstream3.utils.UIHelper.toPx enum class SubtitleStatus { @@ -48,16 +47,6 @@ data class SubtitleData( else "$url|$name" } - /** Returns true if langCode is the same as the IETF tag */ - fun matchesLanguageCode(langCode: String): Boolean { - return getIETF_tag() == langCode - } - - /** Tries hard to figure out a valid IETF tag based on language code and name. Will return null if not found. */ - fun getIETF_tag(): String? { - return fromLanguageToTagIETF(this.languageCode) ?: fromLanguageToTagIETF(this.originalName, halfMatch = true) - } - val name = "$originalName $nameSuffix" /** diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerView.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerView.kt deleted file mode 100644 index 0e6f1a367..000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerView.kt +++ /dev/null @@ -1,842 +0,0 @@ -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 0668a194b..0dddf58a1 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 java.util.concurrent.ConcurrentHashMap -import java.util.concurrent.atomic.AtomicInteger +import kotlin.math.max +import kotlin.math.min data class Cache( val linkCache: MutableSet, @@ -23,8 +23,9 @@ data class Cache( class RepoLinkGenerator( episodes: List, + currentIndex: Int = 0, val page: LoadResponse? = null, -) : VideoGenerator(episodes) { +) : VideoGenerator(episodes, currentIndex) { companion object { const val TAG = "RepoLink" val cache: HashMap, Cache> = @@ -33,7 +34,6 @@ 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 = videos.getOrNull(offset) ?: return false + val current = getCurrent(offset) ?: return false val currentCache = synchronized(cache) { cache[current.apiName to current.id] ?: Cache( @@ -61,12 +61,10 @@ class RepoLinkGenerator( } } - // 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() + // 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() synchronized(currentCache) { val outdatedCache = @@ -77,10 +75,7 @@ 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 @@ -93,7 +88,8 @@ class RepoLinkGenerator( currentCache.subtitleCache.forEach { sub -> currentSubsUrls.add(sub.url) - lastCountedSuffix.getOrPut(sub.originalName) { AtomicInteger(0) }.incrementAndGet() + val suffixCount = lastCountedSuffix.getOrDefault(sub.originalName, 0u) + 1u + lastCountedSuffix[sub.originalName] = suffixCount subtitleCallback(sub) } @@ -112,15 +108,17 @@ class RepoLinkGenerator( subtitleCallback = { file -> Log.d(TAG, "Loaded SubtitleFile: $file") val correctFile = PlayerSubtitleHelper.getSubtitleData(file) - if (correctFile.url.isBlank() || !currentSubsUrls.add(correctFile.url)) { + if (correctFile.url.isBlank() || currentSubsUrls.contains(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.getOrPut(nameDecoded) { AtomicInteger(0) }.incrementAndGet() + + 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 updatedFile = correctFile.copy(originalName = nameDecoded, nameSuffix = "$suffixCount") @@ -134,9 +132,10 @@ class RepoLinkGenerator( }, callback = { link -> Log.d(TAG, "Loaded ExtractorLink: $link") - if (link.url.isBlank() || !currentLinksUrls.add(link.url)) { + if (link.url.isBlank() || currentLinksUrls.contains(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/UpdatedDefaultExtractorsFactory.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/UpdatedDefaultExtractorsFactory.kt index b3873bd32..8ea0f4e61 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/UpdatedDefaultExtractorsFactory.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/UpdatedDefaultExtractorsFactory.kt @@ -13,7 +13,6 @@ package com.lagradost.cloudstream3.ui.player import android.net.Uri import androidx.annotation.GuardedBy -import androidx.media3.common.C import androidx.media3.common.FileTypes import androidx.media3.common.Format import androidx.media3.common.util.TimestampAdjuster @@ -49,6 +48,7 @@ import java.lang.reflect.Constructor import java.lang.reflect.InvocationTargetException import java.util.concurrent.atomic.AtomicBoolean + /** * An [ExtractorsFactory] that provides an array of extractors for the following formats: * @@ -103,16 +103,13 @@ class UpdatedDefaultExtractorsFactory : ExtractorsFactory { private var tsTimestampSearchBytes: Int private var textTrackTranscodingEnabled: Boolean private var subtitleParserFactory: SubtitleParser.Factory - private var codecsToParseWithinGopSampleDependencies: @C.VideoCodecFlags Int private var jpegFlags: @JpegExtractor.Flags Int = 0 - private var heifFlags: @HeifExtractor.Flags Int = 0 init { tsMode = TsExtractor.MODE_SINGLE_PMT tsTimestampSearchBytes = TsExtractor.DEFAULT_TIMESTAMP_SEARCH_BYTES subtitleParserFactory = DefaultSubtitleParserFactory() textTrackTranscodingEnabled = true - codecsToParseWithinGopSampleDependencies = C.VIDEO_CODEC_FLAG_H264 or C.VIDEO_CODEC_FLAG_H265 } /** @@ -349,14 +346,6 @@ class UpdatedDefaultExtractorsFactory : ExtractorsFactory { return this } - @Synchronized - override fun experimentalSetCodecsToParseWithinGopSampleDependencies( - codecsToParseWithinGopSampleDependencies: @C.VideoCodecFlags Int - ): UpdatedDefaultExtractorsFactory { - this.codecsToParseWithinGopSampleDependencies = codecsToParseWithinGopSampleDependencies - return this - } - /** * Sets flags for [JpegExtractor] instances created by the factory. * @@ -372,21 +361,6 @@ class UpdatedDefaultExtractorsFactory : ExtractorsFactory { return this } - /** - * Sets flags for [HeifExtractor] instances created by the factory. - * - * @see HeifExtractor.HeifExtractor - * @param flags The flags to use. - * @return The factory, for convenience. - */ - @Synchronized - fun setHeifExtractorFlags( - flags: @HeifExtractor.Flags Int - ): UpdatedDefaultExtractorsFactory { - this.heifFlags = flags - return this - } - @Synchronized override fun createExtractors(): Array { return createExtractors(Uri.EMPTY, HashMap()) @@ -494,26 +468,21 @@ class UpdatedDefaultExtractorsFactory : ExtractorsFactory { extractors.add( FragmentedMp4Extractor( subtitleParserFactory, - fragmentedMp4Flags or - FragmentedMp4Extractor - .codecsToParseWithinGopSampleDependenciesAsFlags( - codecsToParseWithinGopSampleDependencies - ) or - if (textTrackTranscodingEnabled) 0 - else FragmentedMp4Extractor.FLAG_EMIT_RAW_SUBTITLE_DATA + fragmentedMp4Flags + or (if (textTrackTranscodingEnabled) + 0 + else + FragmentedMp4Extractor.FLAG_EMIT_RAW_SUBTITLE_DATA) ) ) - extractors.add( Mp4Extractor( subtitleParserFactory, - mp4Flags or - Mp4Extractor - .codecsToParseWithinGopSampleDependenciesAsFlags( - codecsToParseWithinGopSampleDependencies - ) or - if (textTrackTranscodingEnabled) 0 - else Mp4Extractor.FLAG_EMIT_RAW_SUBTITLE_DATA + mp4Flags + or (if (textTrackTranscodingEnabled) + 0 + else + Mp4Extractor.FLAG_EMIT_RAW_SUBTITLE_DATA) ) ) } @@ -555,7 +524,12 @@ class UpdatedDefaultExtractorsFactory : ExtractorsFactory { FileTypes.PNG -> extractors.add(PngExtractor()) FileTypes.WEBP -> extractors.add(WebpExtractor()) FileTypes.BMP -> extractors.add(BmpExtractor()) - FileTypes.HEIF -> extractors.add(HeifExtractor(heifFlags)) + FileTypes.HEIF -> if ((mp4Flags and Mp4Extractor.FLAG_READ_MOTION_PHOTO_METADATA) == 0 + && (mp4Flags and Mp4Extractor.FLAG_READ_SEF_DATA) == 0 + ) { + extractors.add(HeifExtractor()) + } + FileTypes.AVIF -> extractors.add(AvifExtractor()) FileTypes.WEBVTT, FileTypes.UNKNOWN -> {} else -> {} diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/UpdatedMatroskaExtractor.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/UpdatedMatroskaExtractor.kt index 5937b1973..6868af771 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/UpdatedMatroskaExtractor.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/UpdatedMatroskaExtractor.kt @@ -41,20 +41,18 @@ import androidx.media3.common.ColorInfo import androidx.media3.common.DrmInitData import androidx.media3.common.DrmInitData.SchemeData import androidx.media3.common.Format -import androidx.media3.common.Metadata import androidx.media3.common.MimeTypes import androidx.media3.common.ParserException +import androidx.media3.common.util.Assertions import androidx.media3.common.util.Log import androidx.media3.common.util.ParsableByteArray import androidx.media3.common.util.UnstableApi import androidx.media3.common.util.Util -import androidx.media3.container.DolbyVisionConfig import androidx.media3.container.NalUnitUtil import androidx.media3.extractor.AacUtil import androidx.media3.extractor.AvcConfig import androidx.media3.extractor.ChunkIndex -import androidx.media3.extractor.ChunkIndexProvider -import androidx.media3.extractor.DtsUtil +import androidx.media3.container.DolbyVisionConfig import androidx.media3.extractor.Extractor import androidx.media3.extractor.ExtractorInput import androidx.media3.extractor.ExtractorOutput @@ -63,18 +61,12 @@ import androidx.media3.extractor.HevcConfig import androidx.media3.extractor.MpegAudioUtil import androidx.media3.extractor.PositionHolder import androidx.media3.extractor.SeekMap -import androidx.media3.extractor.SeekMap.SeekPoints -import androidx.media3.extractor.SeekPoint -import androidx.media3.extractor.TrackAwareSeekMap +import androidx.media3.extractor.SeekMap.Unseekable import androidx.media3.extractor.TrackOutput import androidx.media3.extractor.TrackOutput.CryptoData import androidx.media3.extractor.TrueHdSampleRechunker -import androidx.media3.extractor.metadata.ThumbnailMetadata import androidx.media3.extractor.text.SubtitleParser import androidx.media3.extractor.text.SubtitleTranscodingExtractorOutput -import com.google.common.base.Preconditions.checkArgument -import com.google.common.base.Preconditions.checkNotNull -import com.google.common.base.Preconditions.checkState import com.google.common.collect.ImmutableList import java.io.IOException import java.nio.ByteBuffer @@ -82,7 +74,6 @@ import java.nio.ByteOrder import java.util.Arrays import java.util.Collections import java.util.Locale -import java.util.Objects import java.util.UUID import kotlin.math.max import kotlin.math.min @@ -128,8 +119,6 @@ class UpdatedMatroskaExtractor private constructor( private var timecodeScale = C.TIME_UNSET private var durationTimecode = C.TIME_UNSET private var durationUs = C.TIME_UNSET - private var isWebm: Boolean = false - private var pendingEndTracks: Boolean // The track corresponding to the current TrackEntry element, or null. private var currentTrack: Track? = null @@ -142,13 +131,6 @@ class UpdatedMatroskaExtractor private constructor( private var seekEntryPosition: Long = 0 // Cue related elements. - private val perTrackCues: SparseArray> - private var inCuesElement = false - private var currentCueTimeUs: Long = C.TIME_UNSET - private var currentCueTrackNumber: Int = C.INDEX_UNSET - private var currentCueClusterPosition: Long = C.INDEX_UNSET.toLong() - private var currentCueRelativePosition: Long = C.INDEX_UNSET.toLong() - private var primarySeekTrackNumber: Int = C.INDEX_UNSET private var seekForCues = false private var seekForSeekContent = false private var visitedSeekHeads: HashSet = HashSet() @@ -157,6 +139,9 @@ class UpdatedMatroskaExtractor private constructor( private var cuesContentPosition = C.INDEX_UNSET.toLong() private var seekPositionAfterBuildingCues = C.INDEX_UNSET.toLong() private var clusterTimecodeUs = C.TIME_UNSET + private var cueTimesUs: androidx.media3.common.util.LongArray? = null + private var cueClusterPositions: androidx.media3.common.util.LongArray? = null + private var seenClusterPositionForCurrentCuePoint = false // Reading state. private var haveOutputSample = false @@ -233,7 +218,6 @@ class UpdatedMatroskaExtractor private constructor( init { reader.init(InnerEbmlProcessor()) this.subtitleParserFactory = subtitleParserFactory - this.perTrackCues = SparseArray() seekForCuesEnabled = (flags and FLAG_DISABLE_SEEK_FOR_CUES) == 0 parseSubtitlesDuringExtraction = (flags and FLAG_EMIT_RAW_SUBTITLE_DATA) == 0 varintReader = VarintReader() @@ -249,7 +233,6 @@ class UpdatedMatroskaExtractor private constructor( encryptionSubsampleData = ParsableByteArray() supplementalData = ParsableByteArray() blockSampleSizes = IntArray(1) - pendingEndTracks = true } @Throws(IOException::class) @@ -272,17 +255,6 @@ class UpdatedMatroskaExtractor private constructor( reader.reset() varintReader.reset() resetWriteSampleData() - inCuesElement = false - currentCueTimeUs = C.TIME_UNSET - currentCueTrackNumber = C.INDEX_UNSET - currentCueClusterPosition = C.INDEX_UNSET.toLong() - currentCueRelativePosition = C.INDEX_UNSET.toLong() - // To prevent creating duplicate cue points on a re-parse, clear any existing cue data if the - // seek map has not yet been sent. Once sent, the cue data is considered final, and subsequent - // Cues elements will be ignored by the parsing logic. - if (!sentSeekMap) { - perTrackCues.clear() - } for (i in 0.. EbmlProcessor.ELEMENT_TYPE_MASTER - ID_EBML_READ_VERSION, ID_DOC_TYPE_READ_VERSION, ID_SEEK_POSITION, ID_TIMECODE_SCALE, ID_TIME_CODE, ID_BLOCK_DURATION, ID_PIXEL_WIDTH, ID_PIXEL_HEIGHT, ID_DISPLAY_WIDTH, ID_DISPLAY_HEIGHT, ID_DISPLAY_UNIT, ID_TRACK_NUMBER, ID_TRACK_TYPE, ID_FLAG_DEFAULT, ID_FLAG_FORCED, ID_DEFAULT_DURATION, ID_MAX_BLOCK_ADDITION_ID, ID_BLOCK_ADD_ID_TYPE, ID_CODEC_DELAY, ID_SEEK_PRE_ROLL, ID_DISCARD_PADDING, ID_CHANNELS, ID_AUDIO_BIT_DEPTH, ID_CONTENT_ENCODING_ORDER, ID_CONTENT_ENCODING_SCOPE, ID_CONTENT_COMPRESSION_ALGORITHM, ID_CONTENT_ENCRYPTION_ALGORITHM, ID_CONTENT_ENCRYPTION_AES_SETTINGS_CIPHER_MODE, ID_CUE_TIME, ID_CUE_CLUSTER_POSITION, ID_CUE_RELATIVE_POSITION, ID_CUE_TRACK, ID_REFERENCE_BLOCK, ID_STEREO_MODE, ID_COLOUR_BITS_PER_CHANNEL, ID_COLOUR_RANGE, ID_COLOUR_TRANSFER, ID_COLOUR_PRIMARIES, ID_MAX_CLL, ID_MAX_FALL, ID_PROJECTION_TYPE, ID_BLOCK_ADD_ID -> EbmlProcessor.ELEMENT_TYPE_UNSIGNED_INT + ID_EBML_READ_VERSION, ID_DOC_TYPE_READ_VERSION, ID_SEEK_POSITION, ID_TIMECODE_SCALE, ID_TIME_CODE, ID_BLOCK_DURATION, ID_PIXEL_WIDTH, ID_PIXEL_HEIGHT, ID_DISPLAY_WIDTH, ID_DISPLAY_HEIGHT, ID_DISPLAY_UNIT, ID_TRACK_NUMBER, ID_TRACK_TYPE, ID_FLAG_DEFAULT, ID_FLAG_FORCED, ID_DEFAULT_DURATION, ID_MAX_BLOCK_ADDITION_ID, ID_BLOCK_ADD_ID_TYPE, ID_CODEC_DELAY, ID_SEEK_PRE_ROLL, ID_DISCARD_PADDING, ID_CHANNELS, ID_AUDIO_BIT_DEPTH, ID_CONTENT_ENCODING_ORDER, ID_CONTENT_ENCODING_SCOPE, ID_CONTENT_COMPRESSION_ALGORITHM, ID_CONTENT_ENCRYPTION_ALGORITHM, ID_CONTENT_ENCRYPTION_AES_SETTINGS_CIPHER_MODE, ID_CUE_TIME, ID_CUE_CLUSTER_POSITION, ID_REFERENCE_BLOCK, ID_STEREO_MODE, ID_COLOUR_BITS_PER_CHANNEL, ID_COLOUR_RANGE, ID_COLOUR_TRANSFER, ID_COLOUR_PRIMARIES, ID_MAX_CLL, ID_MAX_FALL, ID_PROJECTION_TYPE, ID_BLOCK_ADD_ID -> EbmlProcessor.ELEMENT_TYPE_UNSIGNED_INT ID_DOC_TYPE, ID_NAME, ID_CODEC_ID, ID_LANGUAGE -> EbmlProcessor.ELEMENT_TYPE_STRING ID_SEEK_ID, ID_BLOCK_ADD_ID_EXTRA_DATA, ID_CONTENT_COMPRESSION_SETTINGS, ID_CONTENT_ENCRYPTION_KEY_ID, ID_SIMPLE_BLOCK, ID_BLOCK, ID_CODEC_PRIVATE, ID_PROJECTION_PRIVATE, ID_BLOCK_ADDITIONAL -> EbmlProcessor.ELEMENT_TYPE_BINARY @@ -369,27 +341,11 @@ class UpdatedMatroskaExtractor private constructor( } ID_CUES -> { - if (!sentSeekMap) { - inCuesElement = true - } - } - - ID_CUE_POINT -> { - if (!sentSeekMap) { - assertInCues(id) - currentCueTimeUs = C.TIME_UNSET - } - } - - ID_CUE_TRACK_POSITIONS -> { - if (!sentSeekMap) { - assertInCues(id) - currentCueTrackNumber = C.INDEX_UNSET - currentCueClusterPosition = C.INDEX_UNSET.toLong() - currentCueRelativePosition = C.INDEX_UNSET.toLong() - } + cueTimesUs = androidx.media3.common.util.LongArray() + cueClusterPositions = androidx.media3.common.util.LongArray() } + ID_CUE_POINT -> seenClusterPositionForCurrentCuePoint = false ID_CLUSTER -> if (!sentSeekMap) { // We need to build cues before parsing the cluster. if (seekForCuesEnabled && cuesContentPosition != C.INDEX_UNSET.toLong()) { @@ -402,7 +358,7 @@ class UpdatedMatroskaExtractor private constructor( } else { // We don't know where the Cues element is located. It's most likely omitted. Allow // playback, but disable seeking. - extractorOutput!!.seekMap(SeekMap.Unseekable(durationUs)) + extractorOutput!!.seekMap(Unseekable(durationUs)) sentSeekMap = true } } @@ -414,10 +370,7 @@ class UpdatedMatroskaExtractor private constructor( ID_CONTENT_ENCODING -> {} ID_CONTENT_ENCRYPTION -> getCurrentTrack(id).hasContentEncryption = true - ID_TRACK_ENTRY -> { - currentTrack = Track() - currentTrack!!.isWebm = isWebm - } + ID_TRACK_ENTRY -> currentTrack = Track() ID_MASTERING_METADATA -> getCurrentTrack(id).hasColorInfo = true else -> {} } @@ -456,7 +409,7 @@ class UpdatedMatroskaExtractor private constructor( } else { // Otherwise, if we not found any cues nor any more seek heads then we mark // this as unseekable. - extractorOutput!!.seekMap(SeekMap.Unseekable(durationUs)) + extractorOutput!!.seekMap(Unseekable(durationUs)) sentSeekMap = true } } @@ -485,67 +438,13 @@ class UpdatedMatroskaExtractor private constructor( ID_CUES -> { if (!sentSeekMap) { - var hasAnyCues = false - for (i in 0 until perTrackCues.size()) { - if (perTrackCues.valueAt(i).isNotEmpty()) { - hasAnyCues = true - break - } - } - - if (!hasAnyCues || durationUs == C.TIME_UNSET) { - // Cues are missing, empty, or duration is unknown. - extractorOutput!!.seekMap(SeekMap.Unseekable(durationUs)) - } else { - for (i in 0 until perTrackCues.size()) { - perTrackCues.valueAt(i).sort() - } - - val seekMap = MatroskaSeekMap( - perTrackCues, - durationUs, - primarySeekTrackNumber, - segmentContentPosition, - segmentContentSize - ) - extractorOutput!!.seekMap(seekMap) - } + extractorOutput!!.seekMap(buildSeekMap(cueTimesUs, cueClusterPositions)) sentSeekMap = true - inCuesElement = false - for (i in 0 until tracks.size()) { - val track: Track = tracks.valueAt(i) - track.maybeAddThumbnailMetadata(perTrackCues, durationUs, segmentContentPosition, segmentContentSize) - if (!track.waitingForDtsAnalysis) { - track.assertOutputInitialized() - track.output!!.format(requireNotNull(track.format)) - } - } - maybeEndTracks() - } - } - - ID_CUE_TRACK_POSITIONS -> { - if (!sentSeekMap) { - assertInCues(id) - if (currentCueTimeUs != C.TIME_UNSET - && currentCueTrackNumber != C.INDEX_UNSET - && currentCueClusterPosition != C.INDEX_UNSET.toLong() - ) { - var trackCues = perTrackCues[currentCueTrackNumber] - if (trackCues == null) { - trackCues = ArrayList() - perTrackCues.put(currentCueTrackNumber, trackCues) - } - - trackCues.add( - MatroskaSeekMap.CuePointData( - currentCueTimeUs, - /* clusterPosition= */ segmentContentPosition + currentCueClusterPosition, - /* relativePosition= */ currentCueRelativePosition - ) - ) - } + } else { + // We have already built the cues. Ignore. } + this.cueTimesUs = null + this.cueClusterPositions = null } ID_BLOCK_GROUP -> { @@ -621,15 +520,17 @@ class UpdatedMatroskaExtractor private constructor( } ID_TRACK_ENTRY -> { - val currentTrack = checkNotNull(this.currentTrack) + val currentTrack = Assertions.checkStateNotNull(this.currentTrack) if (currentTrack.codecId == null) { throw ParserException.createForMalformedContainer( "CodecId is missing in TrackEntry element", /* cause= */null ) } else { - if (isCodecSupported(currentTrack.codecId!!)) { - currentTrack.initializeFormat(currentTrack.number); - currentTrack.output = extractorOutput!!.track(currentTrack.number, currentTrack.type); + if (isCodecSupported( + currentTrack.codecId!! + ) + ) { + currentTrack.initializeOutput(extractorOutput!!, currentTrack.number) tracks.put(currentTrack.number, currentTrack) } } @@ -639,63 +540,10 @@ class UpdatedMatroskaExtractor private constructor( ID_TRACKS -> { if (tracks.size() == 0) { throw ParserException.createForMalformedContainer( - "No valid tracks were found", /* cause= */ null + "No valid tracks were found", /* cause= */null ) } - - // Determine the track to use for default seeking. - var defaultVideoTrackNumber: Int = C.INDEX_UNSET - var firstVideoTrackNumber: Int = C.INDEX_UNSET - var defaultAudioTrackNumber: Int = C.INDEX_UNSET - var firstAudioTrackNumber: Int = C.INDEX_UNSET - - // If we're not going to seek for cues, output the formats immediately. - val mayBeSendFormatsEarly = !seekForCuesEnabled || cuesContentPosition == C.INDEX_UNSET.toLong(); - - for (i in 0 until tracks.size()) { - val trackItem: Track = tracks.valueAt(i) - - val trackType: @C.TrackType Int = trackItem.type - when (trackType) { - C.TRACK_TYPE_VIDEO -> { - if (trackItem.flagDefault) { - defaultVideoTrackNumber = trackItem.number - } - if (firstVideoTrackNumber == C.INDEX_UNSET) { - firstVideoTrackNumber = trackItem.number - } - } - - C.TRACK_TYPE_AUDIO -> { - if (trackItem.flagDefault) { - defaultAudioTrackNumber = trackItem.number - } - if (firstAudioTrackNumber == C.INDEX_UNSET) { - firstAudioTrackNumber = trackItem.number - } - } - } - - if (mayBeSendFormatsEarly) { - trackItem.assertOutputInitialized() - if (!trackItem.waitingForDtsAnalysis) { - trackItem.output!!.format(checkNotNull(trackItem.format)) - } - } - } - - primarySeekTrackNumber = when { - defaultVideoTrackNumber != C.INDEX_UNSET -> defaultVideoTrackNumber - firstVideoTrackNumber != C.INDEX_UNSET -> firstVideoTrackNumber - defaultAudioTrackNumber != C.INDEX_UNSET -> defaultAudioTrackNumber - firstAudioTrackNumber != C.INDEX_UNSET -> firstAudioTrackNumber - tracks.size() > 0 -> tracks.valueAt(0).number - else -> C.INDEX_UNSET - } - - if (mayBeSendFormatsEarly) { - maybeEndTracks() - } + extractorOutput!!.endTracks() } else -> {} @@ -738,16 +586,7 @@ class UpdatedMatroskaExtractor private constructor( ID_TRACK_NUMBER -> getCurrentTrack(id).number = value.toInt() ID_FLAG_DEFAULT -> getCurrentTrack(id).flagDefault = value == 1L ID_FLAG_FORCED -> getCurrentTrack(id).flagForced = value == 1L - ID_TRACK_TYPE -> { - val matroskaTrackType = value.toInt() - getCurrentTrack(id).type = when (matroskaTrackType) { - 1 -> C.TRACK_TYPE_VIDEO // Matroska video - 2 -> C.TRACK_TYPE_AUDIO // Matroska audio - 17 -> C.TRACK_TYPE_TEXT // Matroska subtitle - 33 -> C.TRACK_TYPE_METADATA // Matroska metadata - else -> C.TRACK_TYPE_UNKNOWN - } - } + ID_TRACK_TYPE -> getCurrentTrack(id).type = value.toInt() ID_DEFAULT_DURATION -> getCurrentTrack(id).defaultSampleDurationNs = value.toInt() ID_MAX_BLOCK_ADDITION_ID -> getCurrentTrack(id).maxBlockAdditionId = value.toInt() ID_BLOCK_ADD_ID_TYPE -> getCurrentTrack(id).blockAddIdType = value.toInt() @@ -793,35 +632,17 @@ class UpdatedMatroskaExtractor private constructor( } ID_CUE_TIME -> { - if (!sentSeekMap) { - assertInCues(id) - currentCueTimeUs = scaleTimecodeToUs(value) - } + assertInCues(id) + cueTimesUs!!.add(scaleTimecodeToUs(value)) } - ID_CUE_TRACK -> { - if (!sentSeekMap) { - assertInCues(id) - currentCueTrackNumber = value.toInt() - } - } - - ID_CUE_CLUSTER_POSITION -> { - if (!sentSeekMap) { - assertInCues(id) - if (currentCueClusterPosition == C.INDEX_UNSET.toLong()) { - currentCueClusterPosition = value - } - } - } - - ID_CUE_RELATIVE_POSITION -> { - if (!sentSeekMap) { - assertInCues(id) - if (currentCueRelativePosition == C.INDEX_UNSET.toLong()) { - currentCueRelativePosition = value - } - } + ID_CUE_CLUSTER_POSITION -> if (!seenClusterPositionForCurrentCuePoint) { + assertInCues(id) + // If there's more than one video/audio track, then there could be more than one + // CueTrackPositions within a single CuePoint. In such a case, ignore all but the first + // one (since the cluster position will be quite close for all the tracks). + cueClusterPositions!!.add(value) + seenClusterPositionForCurrentCuePoint = true } ID_TIME_CODE -> clusterTimecodeUs = scaleTimecodeToUs(value) @@ -1133,7 +954,7 @@ class UpdatedMatroskaExtractor private constructor( (scratch.data[0].toInt() shl 8) or (scratch.data[1].toInt() and 0xFF) blockTimeUs = clusterTimecodeUs + scaleTimecodeToUs(timecode.toLong()) val isKeyframe = - track.type == C.TRACK_TYPE_AUDIO + track.type == TRACK_TYPE_AUDIO || (id == ID_SIMPLE_BLOCK && (scratch.data[2].toInt() and 0x80) == 0x80) blockFlags = if (isKeyframe) C.BUFFER_FLAG_KEY_FRAME else 0 blockState = BLOCK_STATE_DATA @@ -1225,7 +1046,9 @@ class UpdatedMatroskaExtractor private constructor( } } - @Throws(ParserException::class) + @Throws( + ParserException::class + ) private fun assertInTrackEntry(id: Int) { if (currentTrack == null) { throw ParserException.createForMalformedContainer( @@ -1234,9 +1057,11 @@ class UpdatedMatroskaExtractor private constructor( } } - @Throws(ParserException::class) + @Throws( + ParserException::class + ) private fun assertInCues(id: Int) { - if (!inCuesElement) { + if (cueTimesUs == null || cueClusterPositions == null) { throw ParserException.createForMalformedContainer( "Element $id must be in a Cues", /* cause= */null ) @@ -1265,7 +1090,6 @@ class UpdatedMatroskaExtractor private constructor( } else { if (CODEC_ID_SUBRIP == track.codecId || CODEC_ID_ASS == track.codecId - || CODEC_ID_SSA == track.codecId || CODEC_ID_VTT == track.codecId ) { if (blockSampleCount > 1) { @@ -1355,7 +1179,7 @@ class UpdatedMatroskaExtractor private constructor( if (CODEC_ID_SUBRIP == track.codecId) { writeSubtitleSampleData(input, SUBRIP_PREFIX, size) return finishWriteSampleData() - } else if (CODEC_ID_ASS == track.codecId || CODEC_ID_SSA == track.codecId) { + } else if (CODEC_ID_ASS == track.codecId) { writeSubtitleSampleData(input, SSA_PREFIX, size) return finishWriteSampleData() } else if (CODEC_ID_VTT == track.codecId) { @@ -1363,20 +1187,6 @@ class UpdatedMatroskaExtractor private constructor( return finishWriteSampleData() } - if (track.waitingForDtsAnalysis) { - checkNotNull(track.format) - if (DtsUtil.isSampleDtsHd(input, size)) { - track.format = track.format!! - .buildUpon() - .setSampleMimeType(MimeTypes.AUDIO_DTS_HD) - .build() - } - - track.output!!.format(track.format!!) - track.waitingForDtsAnalysis = false - maybeEndTracks() - } - val output = track.output if (!sampleEncodingHandled) { if (track.hasContentEncryption) { @@ -1543,7 +1353,7 @@ class UpdatedMatroskaExtractor private constructor( } } else { if (track.trueHdSampleRechunker != null) { - checkState(sampleStrippedBytes.limit() == 0) + Assertions.checkState(sampleStrippedBytes.limit() == 0) track.trueHdSampleRechunker!!.startSample(input) } while (sampleBytesRead < size) { @@ -1642,6 +1452,57 @@ class UpdatedMatroskaExtractor private constructor( return bytesWritten } + /** + * Builds a [SeekMap] from the recently gathered Cues information. + * + * @return The built [SeekMap]. The returned [SeekMap] may be unseekable if cues + * information was missing or incomplete. + */ + private fun buildSeekMap( + cueTimesUs: androidx.media3.common.util.LongArray?, + cueClusterPositions: androidx.media3.common.util.LongArray? + ): SeekMap { + if (segmentContentPosition == C.INDEX_UNSET.toLong() || durationUs == C.TIME_UNSET || cueTimesUs == null || cueTimesUs.size() == 0 || cueClusterPositions == null || cueClusterPositions.size() != cueTimesUs.size()) { + // Cues information is missing or incomplete. + return Unseekable(durationUs) + } + val cuePointsSize = cueTimesUs.size() + var sizes = IntArray(cuePointsSize) + var offsets = LongArray(cuePointsSize) + var durationsUs = LongArray(cuePointsSize) + var timesUs = LongArray(cuePointsSize) + for (i in 0.. 0 && timesUs[lastValidIndex] > durationUs) { + lastValidIndex-- + } + + // Calculate sizes and durations for the last valid index + sizes[lastValidIndex] = + (segmentContentPosition + segmentContentSize - offsets[lastValidIndex]).toInt() + durationsUs[lastValidIndex] = durationUs - timesUs[lastValidIndex] + + // If the last valid index is not the last cue point, truncate the arrays + if (lastValidIndex < cuePointsSize - 1) { + Log.w(TAG, "Discarding trailing cue points with timestamps greater than total duration") + sizes = sizes.copyOf(lastValidIndex + 1) + offsets = offsets.copyOf(lastValidIndex + 1) + durationsUs = durationsUs.copyOf(lastValidIndex + 1) + timesUs = timesUs.copyOf(lastValidIndex + 1) + } + + return ChunkIndex(sizes, offsets, durationsUs, timesUs) + } + /** * Updates the position of the holder to Cues element's position if the extractor configuration * permits use of master seek entry. After building Cues sets the holder's position back to where @@ -1661,7 +1522,7 @@ class UpdatedMatroskaExtractor private constructor( // (until cues or end of segment). However this also means that we only need to seek // back to the top once, instead seeking back in a stack like manner. if (seekForSeekContent) { - checkArgument(pendingSeekHeads.isNotEmpty(), "Illegal value of seekForSeekContent") + Assertions.checkArgument(pendingSeekHeads.isNotEmpty(), "Illegal value of seekForSeekContent") // The exact order does not really matter, but it is easiest to just do stack (FILO) val next = pendingSeekHeads.removeAt(pendingSeekHeads.size - 1) seekPosition.position = next @@ -1708,22 +1569,11 @@ class UpdatedMatroskaExtractor private constructor( } private fun assertInitialized() { - checkNotNull( + Assertions.checkStateNotNull( extractorOutput ) } - private fun maybeEndTracks() { - if (!pendingEndTracks) return - - for (i in 0 until tracks.size()) { - if (tracks.valueAt(i).waitingForDtsAnalysis) return - } - - checkNotNull(extractorOutput).endTracks() - pendingEndTracks = false - } - /** Passes events through to the outer [UpdatedMatroskaExtractor]. */ private inner class InnerEbmlProcessor : EbmlProcessor { override fun getElementType(id: Int): @EbmlProcessor.ElementType Int { @@ -1768,11 +1618,10 @@ class UpdatedMatroskaExtractor private constructor( /** Holds data corresponding to a single track. */ protected class Track { // Common elements. - var isWebm: Boolean = false var name: String? = null var codecId: String? = null var number: Int = 0 - var type: @C.TrackType Int = 0 + var type: Int = 0 var defaultSampleDurationNs: Int = 0 var maxBlockAdditionId: Int = 0 var blockAddIdType: Int = 0 @@ -1822,24 +1671,23 @@ class UpdatedMatroskaExtractor private constructor( var sampleRate: Int = 8000 var codecDelayNs: Long = 0 var seekPreRollNs: Long = 0 - var trueHdSampleRechunker: TrueHdSampleRechunker? = null - var waitingForDtsAnalysis: Boolean = false + var trueHdSampleRechunker: TrueHdSampleRechunker? = + null // Text elements. var flagForced: Boolean = false - - // Common track elements. var flagDefault: Boolean = true var language: String = "eng" // Set when the output is initialized. nalUnitLengthFieldLength is only set for H264/H265. var output: TrackOutput? = null - var format: Format? = null var nalUnitLengthFieldLength: Int = 0 - /** Builds the [Format] for the track. */ - @Throws(ParserException::class) - fun initializeFormat(trackId: Int) { + /** Initializes the track with an output. */ + @Throws( + ParserException::class + ) + fun initializeOutput(output: ExtractorOutput, trackId: Int) { var mimeType: String var maxInputSize = Format.NO_VALUE var pcmEncoding: @PcmEncoding Int = Format.NO_VALUE @@ -1847,20 +1695,8 @@ class UpdatedMatroskaExtractor private constructor( var codecs: String? = null when (codecId) { CODEC_ID_VP8 -> mimeType = MimeTypes.VIDEO_VP8 - CODEC_ID_VP9 -> { - mimeType = MimeTypes.VIDEO_VP9 - initializationData = - if (codecPrivate == null) null else ImmutableList.of( - codecPrivate!! - ) - } - CODEC_ID_AV1 -> { - mimeType = MimeTypes.VIDEO_AV1 - initializationData = - if (codecPrivate == null) null else ImmutableList.of( - codecPrivate!! - ) - } + CODEC_ID_VP9 -> mimeType = MimeTypes.VIDEO_VP9 + CODEC_ID_AV1 -> mimeType = MimeTypes.VIDEO_AV1 CODEC_ID_MPEG2 -> mimeType = MimeTypes.VIDEO_MPEG2 CODEC_ID_MPEG4_SP, CODEC_ID_MPEG4_ASP, CODEC_ID_MPEG4_AP -> { mimeType = MimeTypes.VIDEO_MP4V @@ -1972,10 +1808,7 @@ class UpdatedMatroskaExtractor private constructor( trueHdSampleRechunker = TrueHdSampleRechunker() } - CODEC_ID_DTS, CODEC_ID_DTS_EXPRESS -> { - mimeType = MimeTypes.AUDIO_DTS // temporary - waitingForDtsAnalysis = true - } + CODEC_ID_DTS, CODEC_ID_DTS_EXPRESS -> mimeType = MimeTypes.AUDIO_DTS CODEC_ID_DTS_LOSSLESS -> mimeType = MimeTypes.AUDIO_DTS_HD CODEC_ID_FLAC -> { mimeType = MimeTypes.AUDIO_FLAC @@ -2074,7 +1907,7 @@ class UpdatedMatroskaExtractor private constructor( } CODEC_ID_SUBRIP -> mimeType = MimeTypes.APPLICATION_SUBRIP - CODEC_ID_ASS, CODEC_ID_SSA -> { + CODEC_ID_ASS -> { mimeType = MimeTypes.TEXT_SSA initializationData = ImmutableList.of( SSA_DIALOGUE_FORMAT, getCodecPrivate( @@ -2120,15 +1953,18 @@ class UpdatedMatroskaExtractor private constructor( selectionFlags = selectionFlags or if (flagDefault) C.SELECTION_FLAG_DEFAULT else 0 selectionFlags = selectionFlags or if (flagForced) C.SELECTION_FLAG_FORCED else 0 + val type: Int val formatBuilder = Format.Builder() // TODO: Consider reading the name elements of the tracks and, if present, incorporating them // into the trackId passed when creating the formats. if (MimeTypes.isAudio(mimeType)) { + type = C.TRACK_TYPE_AUDIO formatBuilder .setChannelCount(channelCount) .setSampleRate(sampleRate) .setPcmEncoding(pcmEncoding) } else if (MimeTypes.isVideo(mimeType)) { + type = C.TRACK_TYPE_VIDEO if (displayUnit == DISPLAY_UNIT_PIXELS) { displayWidth = if (displayWidth == Format.NO_VALUE) width else displayWidth displayHeight = if (displayHeight == Format.NO_VALUE) height else displayHeight @@ -2189,6 +2025,7 @@ class UpdatedMatroskaExtractor private constructor( || MimeTypes.APPLICATION_PGS == mimeType || MimeTypes.APPLICATION_DVBSUBS == mimeType ) { + type = C.TRACK_TYPE_TEXT } else { throw ParserException.createForMalformedContainer( "Unexpected MIME type.", /* cause= */null @@ -2199,10 +2036,9 @@ class UpdatedMatroskaExtractor private constructor( formatBuilder.setLabel(name) } - format = + val format = formatBuilder .setId(trackId) - .setContainerMimeType(if (isWebm) MimeTypes.VIDEO_WEBM else MimeTypes.VIDEO_MATROSKA) .setSampleMimeType(mimeType) .setMaxInputSize(maxInputSize) .setLanguage(language) @@ -2211,6 +2047,9 @@ class UpdatedMatroskaExtractor private constructor( .setCodecs(codecs) .setDrmInitData(drmInitData) .build() + + this.output = output.track(number, type) + this.output!!.format(format) } /** Forces any pending sample metadata to be flushed to the output. */ @@ -2285,90 +2124,6 @@ class UpdatedMatroskaExtractor private constructor( return hdrStaticInfoData } - /** - * Finds the best thumbnail timestamp from the cue points and adds it to the track's format as - * [ThumbnailMetadata]. - */ - fun maybeAddThumbnailMetadata( - perTrackCues: SparseArray>, - durationUs: Long, - segmentContentPosition: Long, - segmentContentSize: Long - ) { - if (type != C.TRACK_TYPE_VIDEO) return - - val cuePoints = perTrackCues[number] - if (cuePoints.isNullOrEmpty()) return - - val thumbnailTimestampUs = findBestThumbnailPresentationTimeUs( - cuePoints, durationUs, segmentContentPosition, segmentContentSize - ) - - if (thumbnailTimestampUs != C.TIME_UNSET) { - val currentFormat = requireNotNull(format) - val existingMetadata = currentFormat.metadata - val thumbnailMetadata = ThumbnailMetadata(thumbnailTimestampUs) - val newMetadata = if (existingMetadata == null) { - Metadata(thumbnailMetadata) - } else { - existingMetadata.copyWithAppendedEntries(thumbnailMetadata) - } - format = currentFormat.buildUpon().setMetadata(newMetadata).build() - } - } - - /** - * Finds the best thumbnail timestamp from the provided cue points. - * - *

The heuristic seeks to find a visually interesting frame by assuming that a larger chunk - * size corresponds to a more complex and representative frame. It calculates an approximate - * bitrate for each chunk and selects the timestamp of the chunk with the highest bitrate. - */ - private fun findBestThumbnailPresentationTimeUs( - cuePoints: MutableList, - durationUs: Long, - segmentContentPosition: Long, - segmentContentSize: Long - ): Long { - if (cuePoints.isEmpty()) return C.TIME_UNSET - - var maxBitrate = 0.0 - var bestCueIndex = -1 - val scanLimit = min(cuePoints.size, MAX_CHUNKS_TO_SCAN_FOR_THUMBNAIL) - - for (i in 0 until scanLimit) { - val cue = cuePoints[i] - - if (cue.timeUs > MAX_DURATION_US_TO_SCAN_FOR_THUMBNAIL) break - - val bytesBetweenCues: Long - val durationBetweenCuesUs: Long - - if (i < cuePoints.size - 1) { - val nextCue = cuePoints[i + 1] - bytesBetweenCues = (nextCue.clusterPosition + nextCue.relativePosition) - - (cue.clusterPosition + cue.relativePosition) - durationBetweenCuesUs = nextCue.timeUs - cue.timeUs - } else { - // Last cue point - bytesBetweenCues = (segmentContentPosition + segmentContentSize) - - (cue.clusterPosition + cue.relativePosition) - durationBetweenCuesUs = durationUs - cue.timeUs - } - - if (durationBetweenCuesUs > 0) { - // This is an approximation of the bitrate for thumbnail heuristic. - val bitrate = bytesBetweenCues.toDouble() / durationBetweenCuesUs - if (bitrate > maxBitrate) { - maxBitrate = bitrate - bestCueIndex = i - } - } - } - - return if (bestCueIndex == -1) C.TIME_UNSET else cuePoints[bestCueIndex].timeUs - } - /** * Checks that the track has an output. * @@ -2378,12 +2133,14 @@ class UpdatedMatroskaExtractor private constructor( * fact at runtime. */ fun assertOutputInitialized() { - checkNotNull( + Assertions.checkNotNull( output ) } - @Throws(ParserException::class) + @Throws( + ParserException::class + ) private fun getCodecPrivate(codecId: String): ByteArray { if (codecPrivate == null) { throw ParserException.createForMalformedContainer( @@ -2622,7 +2379,6 @@ class UpdatedMatroskaExtractor private constructor( private const val CODEC_ID_PCM_FLOAT = "A_PCM/FLOAT/IEEE" private const val CODEC_ID_SUBRIP = "S_TEXT/UTF8" private const val CODEC_ID_ASS = "S_TEXT/ASS" - private const val CODEC_ID_SSA = "S_TEXT/SSA" private const val CODEC_ID_VTT = "S_TEXT/WEBVTT" private const val CODEC_ID_VOBSUB = "S_VOBSUB" private const val CODEC_ID_PGS = "S_HDMV/PGS" @@ -2699,10 +2455,8 @@ class UpdatedMatroskaExtractor private constructor( private const val ID_CUES = 0x1C53BB6B private const val ID_CUE_POINT = 0xBB private const val ID_CUE_TIME = 0xB3 - private const val ID_CUE_TRACK = 0xF7 private const val ID_CUE_TRACK_POSITIONS = 0xB7 private const val ID_CUE_CLUSTER_POSITION = 0xF1 - private const val ID_CUE_RELATIVE_POSITION = 0xF0 private const val ID_LANGUAGE = 0x22B59C private const val ID_PROJECTION = 0x7670 private const val ID_PROJECTION_TYPE = 0x7671 @@ -2757,12 +2511,6 @@ class UpdatedMatroskaExtractor private constructor( private const val FOURCC_COMPRESSION_H263 = 0x33363248 private const val FOURCC_COMPRESSION_VC1 = 0x31435657 - /** The maximum number of chunks to scan when searching for a thumbnail. */ - private const val MAX_CHUNKS_TO_SCAN_FOR_THUMBNAIL = 20 - - /** The maximum duration to scan for a thumbnail, in microseconds. */ - private const val MAX_DURATION_US_TO_SCAN_FOR_THUMBNAIL = 10_000_000L - /** * A template for the prefix that must be added to each subrip sample. * @@ -2984,8 +2732,8 @@ class UpdatedMatroskaExtractor private constructor( * See documentation on [.SSA_DIALOGUE_FORMAT] and [.SUBRIP_PREFIX] for why we use * the duration as the end timecode. * - * @param codecId The subtitle codec; must be [.CODEC_ID_SUBRIP], [.CODEC_ID_ASS], - * [.CODEC_ID_SSA] or [.CODEC_ID_VTT]. + * @param codecId The subtitle codec; must be [.CODEC_ID_SUBRIP], [.CODEC_ID_ASS] or + * [.CODEC_ID_VTT]. * @param durationUs The duration of the sample, in microseconds. * @param subtitleData The subtitle sample in which to overwrite the end timecode (output * parameter). @@ -3004,7 +2752,7 @@ class UpdatedMatroskaExtractor private constructor( endTimecodeOffset = SUBRIP_PREFIX_END_TIMECODE_OFFSET } - CODEC_ID_ASS, CODEC_ID_SSA -> { + CODEC_ID_ASS -> { endTimecode = formatSubtitleTimecode( durationUs, SSA_TIMECODE_FORMAT, SSA_TIMECODE_LAST_VALUE_SCALING_FACTOR @@ -3032,7 +2780,7 @@ class UpdatedMatroskaExtractor private constructor( timeUs: Long, timecodeFormat: String, lastTimecodeValueScalingFactor: Long ): ByteArray { var timeUs = timeUs - checkArgument(timeUs != C.TIME_UNSET) + Assertions.checkArgument(timeUs != C.TIME_UNSET) val timeCodeData: ByteArray val hours = (timeUs / (3600 * C.MICROS_PER_SECOND)).toInt() timeUs -= (hours * 3600L * C.MICROS_PER_SECOND) @@ -3050,7 +2798,7 @@ class UpdatedMatroskaExtractor private constructor( private fun isCodecSupported(codecId: String): Boolean { return when (codecId) { - CODEC_ID_VP8, CODEC_ID_VP9, CODEC_ID_AV1, CODEC_ID_MPEG2, CODEC_ID_MPEG4_SP, CODEC_ID_MPEG4_ASP, CODEC_ID_MPEG4_AP, CODEC_ID_H264, CODEC_ID_H265, CODEC_ID_FOURCC, CODEC_ID_THEORA, CODEC_ID_OPUS, CODEC_ID_VORBIS, CODEC_ID_AAC, CODEC_ID_MP2, CODEC_ID_MP3, CODEC_ID_AC3, CODEC_ID_E_AC3, CODEC_ID_TRUEHD, CODEC_ID_DTS, CODEC_ID_DTS_EXPRESS, CODEC_ID_DTS_LOSSLESS, CODEC_ID_FLAC, CODEC_ID_ACM, CODEC_ID_PCM_INT_LIT, CODEC_ID_PCM_INT_BIG, CODEC_ID_PCM_FLOAT, CODEC_ID_SUBRIP, CODEC_ID_ASS, CODEC_ID_SSA, CODEC_ID_VTT, CODEC_ID_VOBSUB, CODEC_ID_PGS, CODEC_ID_DVBSUB -> true + CODEC_ID_VP8, CODEC_ID_VP9, CODEC_ID_AV1, CODEC_ID_MPEG2, CODEC_ID_MPEG4_SP, CODEC_ID_MPEG4_ASP, CODEC_ID_MPEG4_AP, CODEC_ID_H264, CODEC_ID_H265, CODEC_ID_FOURCC, CODEC_ID_THEORA, CODEC_ID_OPUS, CODEC_ID_VORBIS, CODEC_ID_AAC, CODEC_ID_MP2, CODEC_ID_MP3, CODEC_ID_AC3, CODEC_ID_E_AC3, CODEC_ID_TRUEHD, CODEC_ID_DTS, CODEC_ID_DTS_EXPRESS, CODEC_ID_DTS_LOSSLESS, CODEC_ID_FLAC, CODEC_ID_ACM, CODEC_ID_PCM_INT_LIT, CODEC_ID_PCM_INT_BIG, CODEC_ID_PCM_FLOAT, CODEC_ID_SUBRIP, CODEC_ID_ASS, CODEC_ID_VTT, CODEC_ID_VOBSUB, CODEC_ID_PGS, CODEC_ID_DVBSUB -> true else -> false } @@ -3074,169 +2822,4 @@ class UpdatedMatroskaExtractor private constructor( } } } - - class MatroskaSeekMap( - private val perTrackCues: SparseArray>, - private val durationUs: Long, - private val primarySeekTrackNumber: Int, - segmentContentPosition: Long, - segmentContentSize: Long - ) : TrackAwareSeekMap, ChunkIndexProvider { - - private val chunkIndex: ChunkIndex? = - buildChunkIndex( - perTrackCues, - durationUs, - primarySeekTrackNumber, - segmentContentPosition, - segmentContentSize - ) - - override fun isSeekable(): Boolean { - // The media is seekable overall only if the primary seek track has cue points. - return isSeekable(primarySeekTrackNumber) - } - - override fun isSeekable(trackId: Int): Boolean { - val cuePoints = perTrackCues[trackId] - return !cuePoints.isNullOrEmpty() - } - - override fun getDurationUs(): Long = durationUs - - override fun getSeekPoints(timeUs: Long): SeekPoints = - chunkIndex?.getSeekPoints(timeUs) - ?: SeekPoints(SeekPoint.START) - - override fun getSeekPoints(timeUs: Long, trackId: Int): SeekPoints { - var cuePoints = perTrackCues[trackId] - - if ((cuePoints.isNullOrEmpty()) && trackId != primarySeekTrackNumber) { - cuePoints = perTrackCues[primarySeekTrackNumber] - } - - if (cuePoints.isNullOrEmpty()) { - return SeekPoints(SeekPoint.START) - } - - val bestIndex = Util.binarySearchFloor( - cuePoints, - CuePointData(timeUs, C.INDEX_UNSET.toLong(), C.INDEX_UNSET.toLong()), - /* inclusive= */ true, - /* stayInBounds= */ false - ) - - return if (bestIndex != -1) { - val bestCue = cuePoints[bestIndex] - val firstPoint = SeekPoint(bestCue.timeUs, bestCue.clusterPosition) - - if (bestCue.timeUs < timeUs && bestIndex + 1 < cuePoints.size) { - val nextCue = cuePoints[bestIndex + 1] - val secondPoint = SeekPoint(nextCue.timeUs, nextCue.clusterPosition) - SeekPoints(firstPoint, secondPoint) - } else { - SeekPoints(firstPoint) - } - } else { - val firstCue = cuePoints[0] - SeekPoints(SeekPoint(firstCue.timeUs, firstCue.clusterPosition)) - } - } - - override fun getChunkIndex(): ChunkIndex? = chunkIndex - - private companion object { - - private fun buildChunkIndex( - perTrackCues: SparseArray>, - durationUs: Long, - primarySeekTrackNumber: Int, - segmentContentPosition: Long, - segmentContentSize: Long - ): ChunkIndex? { - - val primaryTrackCuePoints = - perTrackCues[primarySeekTrackNumber] ?: return null - - if (primaryTrackCuePoints.isEmpty()) { - return null - } - - val cuePointsSize = primaryTrackCuePoints.size - var sizes = IntArray(cuePointsSize) - var offsets = LongArray(cuePointsSize) - var durationsUs = LongArray(cuePointsSize) - var timesUs = LongArray(cuePointsSize) - - for (i in 0 until cuePointsSize) { - val cue = primaryTrackCuePoints[i] - timesUs[i] = cue.timeUs - offsets[i] = cue.clusterPosition - } - - for (i in 0 until cuePointsSize - 1) { - sizes[i] = (offsets[i + 1] - offsets[i]).toInt() - durationsUs[i] = timesUs[i + 1] - timesUs[i] - } - - // Start from the last cue point and move backward until a valid duration is found. - var lastValidIndex = cuePointsSize - 1 - while (lastValidIndex > 0 && timesUs[lastValidIndex] >= durationUs) { - lastValidIndex-- - } - - // Calculate sizes and durations for the last valid index - sizes[lastValidIndex] = - (segmentContentPosition + segmentContentSize - offsets[lastValidIndex]).toInt() - durationsUs[lastValidIndex] = durationUs - timesUs[lastValidIndex] - - // If trailing cue points were found, truncate the arrays to the last valid index. - if (lastValidIndex < cuePointsSize - 1) { - Log.w(TAG, "Discarding trailing cue points with timestamps greater than total duration.") - sizes = sizes.copyOf(lastValidIndex + 1) - offsets = offsets.copyOf(lastValidIndex + 1) - durationsUs = durationsUs.copyOf(lastValidIndex + 1) - timesUs = timesUs.copyOf(lastValidIndex + 1) - } - - return ChunkIndex(sizes, offsets, durationsUs, timesUs) - } - } - - class CuePointData( - /** The timestamp of the cue point, in microseconds. */ - val timeUs: Long, - - /** The absolute byte offset of the start of the cluster containing this cue point. */ - val clusterPosition: Long, - - /** - * The relative byte offset of the cue point's data block within its cluster. - * - *

Note: For seeking, use {@link #clusterPosition} to prevent A/V desync. - */ - val relativePosition: Long - ) : Comparable { - - override fun compareTo(other: CuePointData): Int { - return timeUs.compareTo(other.timeUs) - } - - override fun equals(other: Any?): Boolean { - if (this === other) { - return true - } - if (other !is CuePointData) { - return false - } - return this.timeUs == other.timeUs && - this.clusterPosition == other.clusterPosition && - this.relativePosition == other.relativePosition - } - - override fun hashCode(): Int { - return Objects.hash(timeUs, clusterPosition, relativePosition) - } - } - } -} +} \ No newline at end of file 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 deleted file mode 100644 index 52cd4361b..000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/live/LiveHelper.kt +++ /dev/null @@ -1,77 +0,0 @@ -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 deleted file mode 100644 index 8d848d46a..000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/live/LiveManager.kt +++ /dev/null @@ -1,97 +0,0 @@ -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 deleted file mode 100644 index 3001281fd..000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/live/LivePreviewTimeBar.kt +++ /dev/null @@ -1,38 +0,0 @@ -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/player/source_priority/ProfilesAdapter.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/source_priority/ProfilesAdapter.kt index 85c2a85df..79e1dbcdd 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/source_priority/ProfilesAdapter.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/source_priority/ProfilesAdapter.kt @@ -20,7 +20,7 @@ import com.lagradost.cloudstream3.utils.drawableToBitmap import com.lagradost.cloudstream3.utils.setText class ProfilesAdapter( - val usedProfile: Int?, + val usedProfile: Int, val clickCallback: (oldIndex: Int?, newIndex: Int) -> Unit, ) : NoStateAdapter(diffCallback = BaseDiffCallback(itemSame = { a, b -> @@ -68,27 +68,25 @@ class ProfilesAdapter( val profileBg: ImageView = binding.profileImageBackground val wifiText: TextView = binding.textIsWifi val dataText: TextView = binding.textIsMobileData - val downloadText: TextView = binding.textIsDownloadData val outline: View = binding.outline val cardView: View = binding.cardView val itemView = holder.itemView priorityText.setText(item.name) - dataText.isVisible = item.types.contains(QualityDataHelper.QualityProfileType.Data) - wifiText.isVisible = item.types.contains(QualityDataHelper.QualityProfileType.WiFi) - downloadText.isVisible = item.types.contains(QualityDataHelper.QualityProfileType.Download) + dataText.isVisible = item.type == QualityDataHelper.QualityProfileType.Data + wifiText.isVisible = item.type == QualityDataHelper.QualityProfileType.WiFi fun setCurrentItem() { - val prevIndex = currentItem + val prevIndex = currentItem?.first // Prevent UI bug when re-selecting the item quickly if (prevIndex == position) { return } - currentItem = position + currentItem = position to item clickCallback.invoke(prevIndex, position) } - outline.isVisible = currentItem == position + outline.isVisible = currentItem?.second?.id == item.id val drawableResId = art[position % art.size] profileBg.loadImage(drawableResId) @@ -109,7 +107,6 @@ class ProfilesAdapter( if (color != null) { wifiText.backgroundTintList = ColorStateList.valueOf(color) dataText.backgroundTintList = ColorStateList.valueOf(color) - downloadText.backgroundTintList = ColorStateList.valueOf(color) } } } @@ -129,9 +126,9 @@ class ProfilesAdapter( } } - private var currentItem: Int? = null + private var currentItem: Pair? = null fun getCurrentProfile(): QualityDataHelper.QualityProfile? { - return currentItem?.let { index -> immutableCurrentList.getOrNull(index) } + return currentItem?.second } } \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/source_priority/QualityDataHelper.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/source_priority/QualityDataHelper.kt index 02470484e..467efa68b 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/source_priority/QualityDataHelper.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/source_priority/QualityDataHelper.kt @@ -2,7 +2,6 @@ package com.lagradost.cloudstream3.ui.player.source_priority import androidx.annotation.StringRes import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey -import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKeys import com.lagradost.cloudstream3.CloudStreamApp.Companion.removeKey import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey import com.lagradost.cloudstream3.R @@ -10,23 +9,14 @@ import com.lagradost.cloudstream3.mvvm.debugAssert import com.lagradost.cloudstream3.utils.UiText import com.lagradost.cloudstream3.utils.txt import com.lagradost.cloudstream3.utils.DataStoreHelper.currentAccount -import com.lagradost.cloudstream3.utils.ExtractorLink import com.lagradost.cloudstream3.utils.Qualities -import kotlin.math.abs object QualityDataHelper { private const val VIDEO_SOURCE_PRIORITY = "video_source_priority" private const val VIDEO_PROFILE_NAME = "video_profile_name" private const val VIDEO_QUALITY_PRIORITY = "video_quality_priority" - - // Old key only supporting one type per profile - @Deprecated("Changed to support multiple types per profile") private const val VIDEO_PROFILE_TYPE = "video_profile_type" - // New key supporting more than one type per profile - - private const val VIDEO_PROFILE_TYPES = "video_profile_types_2" private const val DEFAULT_SOURCE_PRIORITY = 1 - /** * Automatically skip loading links once this priority is reached **/ @@ -43,14 +33,13 @@ object QualityDataHelper { enum class QualityProfileType(@StringRes val stringRes: Int, val unique: Boolean) { None(R.string.none, false), WiFi(R.string.wifi, true), - Data(R.string.mobile_data, true), - Download(R.string.download, true) + Data(R.string.mobile_data, true) } data class QualityProfile( val name: UiText, val id: Int, - val types: Set + val type: QualityProfileType ) fun getSourcePriority(profile: Int, name: String?): Int { @@ -62,21 +51,8 @@ object QualityDataHelper { ) ?: DEFAULT_SOURCE_PRIORITY } - fun getAllSourcePriorityNames(profile: Int): List { - val folder = "$currentAccount/$VIDEO_SOURCE_PRIORITY/$profile" - return getKeys(folder)?.map { key -> - key.substringAfter("$folder/") - } ?: emptyList() - } - fun setSourcePriority(profile: Int, name: String, priority: Int) { - val folder = "$currentAccount/$VIDEO_SOURCE_PRIORITY/$profile" - // Prevent unnecessary keys - if (priority == DEFAULT_SOURCE_PRIORITY) { - removeKey(folder, name) - } else { - setKey(folder, name, priority) - } + setKey("$currentAccount/$VIDEO_SOURCE_PRIORITY/$profile", name, priority) } fun setProfileName(profile: Int, name: String?) { @@ -109,40 +85,16 @@ object QualityDataHelper { ) } + fun getQualityProfileType(profile: Int): QualityProfileType { + return getKey("$currentAccount/$VIDEO_PROFILE_TYPE/$profile") ?: QualityProfileType.None + } - @Suppress("DEPRECATION") - fun getQualityProfileTypes(profile: Int): Set { - val newKey = "$currentAccount/$VIDEO_PROFILE_TYPES/$profile" - // Use arrays for to make with work with setKey properly (weird crashes otherwise) - val newProfiles = getKey>(newKey)?.toSet() - - // Migrate to new profile key - if (newProfiles == null) { - val oldProfile = - getKey("$currentAccount/$VIDEO_PROFILE_TYPE/$profile") - val newSet = oldProfile?.let { arrayOf(it) } ?: arrayOf() - setKey(newKey, newSet) - return newSet.toSet() + fun setQualityProfileType(profile: Int, type: QualityProfileType?) { + val path = "$currentAccount/$VIDEO_PROFILE_TYPE/$profile" + if (type == QualityProfileType.None) { + removeKey(path) } else { - return newProfiles - } - } - - fun addQualityProfileType(profile: Int, type: QualityProfileType) { - val path = "$currentAccount/$VIDEO_PROFILE_TYPES/$profile" - val currentTypes = getQualityProfileTypes(profile) - - if (type != QualityProfileType.None) { - setKey(path, (currentTypes + type).toTypedArray()) - } - } - - fun removeQualityProfileType(profile: Int, type: QualityProfileType) { - val path = "$currentAccount/$VIDEO_PROFILE_TYPES/$profile" - val currentTypes = getQualityProfileTypes(profile) - - if (type != QualityProfileType.None) { - setKey(path, (currentTypes - type).toTypedArray()) + setKey(path, type) } } @@ -154,39 +106,37 @@ object QualityDataHelper { val availableTypes = QualityProfileType.entries.toMutableList() val profiles = (1..PROFILE_COUNT).map { profileNumber -> // Get the real type - val types = getQualityProfileTypes(profileNumber) + val type = getQualityProfileType(profileNumber) - val uniqueTypes = types.mapNotNull { type -> - // This makes it impossible to get more than one of each type - if (type.unique && !availableTypes.remove(type)) { - null - } else { - type - } - }.toSet() + // This makes it impossible to get more than one of each type + // Duplicates will be turned to None + val uniqueType = if (type.unique && !availableTypes.remove(type)) { + QualityProfileType.None + } else { + type + } QualityProfile( getProfileName(profileNumber), profileNumber, - uniqueTypes + uniqueType ) }.toMutableList() /** - * If no profile of this type exists: insert it on the earliest profile + * If no profile of this type exists: insert it on the earliest profile with None type **/ fun insertType( list: MutableList, type: QualityProfileType ) { - if (list.any { it.types.contains(type) }) return - - synchronized(list) { - val firstItem = list.firstOrNull() ?: return - val fixedTypes = firstItem.types + type - val fixedItem = firstItem.copy(types = fixedTypes) - list.set(0, fixedItem) - } + if (list.any { it.type == type }) return + val index = + list.indexOfFirst { it.type == QualityProfileType.None } + list.getOrNull(index)?.copy(type = type) + ?.let { fixed -> + list.set(index, fixed) + } } QualityProfileType.entries.forEach { @@ -195,7 +145,7 @@ object QualityDataHelper { debugAssert({ !QualityProfileType.entries.all { type -> - !type.unique || profiles.any { it.types.contains(type) } + !type.unique || profiles.any { it.type == type } } }, { "All unique quality types do not exist" }) @@ -205,22 +155,4 @@ object QualityDataHelper { return profiles } - - fun getLinkPriority( - qualityProfile: Int, - linkData: ExtractorLink? - ): Int { - val qualityPriority = getQualityPriority( - qualityProfile, - closestQuality(linkData?.quality) - ) - val sourcePriority = getSourcePriority(qualityProfile, linkData?.source) - - return qualityPriority + sourcePriority - } - - private fun closestQuality(target: Int?): Qualities { - if (target == null) return Qualities.Unknown - return Qualities.entries.minBy { abs(it.value - target) } - } } \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/source_priority/QualityProfileDialog.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/source_priority/QualityProfileDialog.kt index 6a0f12e9a..4ab72c3c4 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/source_priority/QualityProfileDialog.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/source_priority/QualityProfileDialog.kt @@ -2,74 +2,45 @@ package com.lagradost.cloudstream3.ui.player.source_priority import android.app.Dialog import androidx.annotation.StyleRes -import androidx.core.view.isVisible import androidx.fragment.app.FragmentActivity import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.databinding.PlayerQualityProfileDialogBinding -import com.lagradost.cloudstream3.ui.player.source_priority.QualityDataHelper.getAllSourcePriorityNames import com.lagradost.cloudstream3.ui.player.source_priority.QualityDataHelper.getProfileName import com.lagradost.cloudstream3.ui.player.source_priority.QualityDataHelper.getProfiles -import com.lagradost.cloudstream3.utils.Coroutines.ioWork import com.lagradost.cloudstream3.utils.txt import com.lagradost.cloudstream3.utils.ExtractorLink -import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showMultiDialog +import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showBottomDialog import com.lagradost.cloudstream3.utils.UIHelper.dismissSafe import com.lagradost.cloudstream3.utils.UIHelper.fixSystemBarsPadding import com.lagradost.cloudstream3.utils.setText -/** Simplified ExtractorLink for the quality profile dialog */ -data class LinkSource( - val source: String -) { - constructor(extractorLink: ExtractorLink) : this(extractorLink.source) -} - - -class QualityProfileDialog private constructor( +class QualityProfileDialog( val activity: FragmentActivity, @StyleRes val themeRes: Int, - private val links: List, - private val usedProfile: Int?, - private val profileSelectionCallback: ((QualityDataHelper.QualityProfile) -> Unit)?, - private val useProfileSelection: Boolean + private val links: List, + private val usedProfile: Int, + private val profileSelectionCallback: (QualityDataHelper.QualityProfile) -> Unit ) : Dialog(activity, themeRes) { - constructor( - activity: FragmentActivity, - @StyleRes themeRes: Int, - links: List, - usedProfile: Int, - profileSelectionCallback: ((QualityDataHelper.QualityProfile) -> Unit), - ) : this(activity, themeRes, links, usedProfile, profileSelectionCallback, true) - - constructor( - activity: FragmentActivity, - @StyleRes themeRes: Int, - links: List - ) : this(activity, themeRes, links, null, null, false) - - companion object { - // Run on IO as this may be a heavy operation - suspend fun getAllDefaultSources(): List = ioWork { - getProfiles().flatMap { - getAllSourcePriorityNames(it.id) - }.distinct().map { LinkSource(it) } - } - } - override fun show() { + val binding = PlayerQualityProfileDialogBinding.inflate(this.layoutInflater, null, false) - setContentView(binding.root) + setContentView(binding.root)//R.layout.player_quality_profile_dialog) fixSystemBarsPadding(binding.root) + /*val profilesRecyclerView: RecyclerView = profiles_recyclerview + val useBtt: View = use_btt + val editBtt: View = edit_btt + val cancelBtt: View = cancel_btt + val defaultBtt: View = set_default_btt + val currentProfileText: TextView = currently_selected_profile_text + val selectedItemActionsHolder: View = selected_item_holder*/ binding.apply { fun getCurrentProfile(): QualityDataHelper.QualityProfile? { return (profilesRecyclerview.adapter as? ProfilesAdapter)?.getCurrentProfile() } fun refreshProfiles() { - if (usedProfile != null) { - currentlySelectedProfileText.setText(getProfileName(usedProfile)) - } + currentlySelectedProfileText.setText(getProfileName(usedProfile)) (profilesRecyclerview.adapter as? ProfilesAdapter)?.submitList(getProfiles()) } @@ -96,52 +67,37 @@ class QualityProfileDialog private constructor( setDefaultBtt.setOnClickListener { val currentProfile = getCurrentProfile() ?: return@setOnClickListener - val choices = - QualityDataHelper.QualityProfileType.entries.filter { it != QualityDataHelper.QualityProfileType.None } + val choices = QualityDataHelper.QualityProfileType.entries + .filter { it != QualityDataHelper.QualityProfileType.None } val choiceNames = choices.map { txt(it.stringRes).asString(context) } - val selectedIndices = choices.mapIndexed { index, type -> index to type } - .filter { currentProfile.types.contains(it.second) }.map { it.first } - activity.showMultiDialog( + activity.showBottomDialog( choiceNames, - selectedIndices, + choices.indexOf(currentProfile.type), txt(R.string.set_default).asString(context), + false, {}, { index -> - val pickedChoices = index.mapNotNull { choices.getOrNull(it) } - - pickedChoices.forEach { pickedChoice -> - // Remove previous picks - if (pickedChoice.unique) { - getProfiles().filter { it.types.contains(pickedChoice) }.forEach { - QualityDataHelper.removeQualityProfileType(it.id, pickedChoice) - } + val pickedChoice = choices.getOrNull(index) ?: return@showBottomDialog + // Remove previous picks + if (pickedChoice.unique) { + getProfiles().filter { it.type == pickedChoice }.forEach { + QualityDataHelper.setQualityProfileType(it.id, null) } - - QualityDataHelper.addQualityProfileType(currentProfile.id, pickedChoice) } + QualityDataHelper.setQualityProfileType(currentProfile.id, pickedChoice) refreshProfiles() }) } - cancelBtt.isVisible = useProfileSelection - useBtt.isVisible = useProfileSelection - applyBtt.isVisible = !useProfileSelection + cancelBtt.setOnClickListener { + this@QualityProfileDialog.dismissSafe() + } - if (useProfileSelection) { - cancelBtt.setOnClickListener { - this@QualityProfileDialog.dismissSafe() - } - - useBtt.setOnClickListener { - getCurrentProfile()?.let { - profileSelectionCallback?.invoke(it) - this@QualityProfileDialog.dismissSafe() - } - } - } else { - applyBtt.setOnClickListener { + useBtt.setOnClickListener { + getCurrentProfile()?.let { + profileSelectionCallback.invoke(it) this@QualityProfileDialog.dismissSafe() } } diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/source_priority/SourcePriorityDialog.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/source_priority/SourcePriorityDialog.kt index c8ac96ebb..9dcf93ccc 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/source_priority/SourcePriorityDialog.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/source_priority/SourcePriorityDialog.kt @@ -8,6 +8,7 @@ import androidx.appcompat.app.AlertDialog import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.databinding.PlayerSelectSourcePriorityBinding import com.lagradost.cloudstream3.utils.txt +import com.lagradost.cloudstream3.utils.ExtractorLink import com.lagradost.cloudstream3.utils.Qualities import com.lagradost.cloudstream3.utils.UIHelper.dismissSafe import com.lagradost.cloudstream3.utils.UIHelper.fixSystemBarsPadding @@ -15,7 +16,7 @@ import com.lagradost.cloudstream3.utils.UIHelper.fixSystemBarsPadding class SourcePriorityDialog( val ctx: Context, @StyleRes themeRes: Int, - val links: List, + val links: List, private val profile: QualityDataHelper.QualityProfile, /** * Notify that the profile overview should be updated, for example if the name has been updated diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/quicksearch/QuickSearchFragment.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/quicksearch/QuickSearchFragment.kt index cf9bc9975..724276ab7 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/quicksearch/QuickSearchFragment.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/quicksearch/QuickSearchFragment.kt @@ -32,7 +32,6 @@ import com.lagradost.cloudstream3.ui.search.SearchAdapter import com.lagradost.cloudstream3.ui.search.SearchClickCallback import com.lagradost.cloudstream3.ui.search.SearchHelper import com.lagradost.cloudstream3.ui.search.SearchViewModel -import com.lagradost.cloudstream3.ui.setRecycledViewPool import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR import com.lagradost.cloudstream3.ui.settings.Globals.PHONE import com.lagradost.cloudstream3.ui.settings.Globals.TV diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/result/ActorAdaptor.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/result/ActorAdaptor.kt index 056588d0b..733933913 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/result/ActorAdaptor.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/result/ActorAdaptor.kt @@ -6,6 +6,7 @@ import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.core.view.isVisible +import androidx.recyclerview.widget.RecyclerView import com.lagradost.cloudstream3.ActorData import com.lagradost.cloudstream3.ActorRole import com.lagradost.cloudstream3.R @@ -13,7 +14,6 @@ import com.lagradost.cloudstream3.databinding.CastItemBinding import com.lagradost.cloudstream3.ui.BaseDiffCallback import com.lagradost.cloudstream3.ui.NoStateAdapter import com.lagradost.cloudstream3.ui.ViewHolderState -import com.lagradost.cloudstream3.ui.newSharedPool import com.lagradost.cloudstream3.ui.settings.Globals.PHONE import com.lagradost.cloudstream3.ui.settings.Globals.isLayout import com.lagradost.cloudstream3.utils.ImageLoader.loadImage @@ -26,7 +26,7 @@ class ActorAdaptor( })) { companion object { val sharedPool = - newSharedPool { setMaxRecycledViews(CONTENT, 10) } + RecyclerView.RecycledViewPool().apply { this.setMaxRecycledViews(CONTENT, 10) } } // Easier to store it here than to store it in the ActorData diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/result/EpisodeAdapter.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/result/EpisodeAdapter.kt index 5e5504164..818e79d74 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/result/EpisodeAdapter.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/result/EpisodeAdapter.kt @@ -1,5 +1,6 @@ package com.lagradost.cloudstream3.ui.result +import android.annotation.SuppressLint import android.content.Context import android.view.LayoutInflater import android.view.ViewGroup @@ -7,6 +8,8 @@ import androidx.core.view.isGone import androidx.core.view.isVisible import androidx.core.view.setPadding import androidx.preference.PreferenceManager +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.RecyclerView import coil3.dispose import com.lagradost.cloudstream3.APIHolder.unixTimeMS import com.lagradost.cloudstream3.CommonActivity @@ -21,7 +24,6 @@ import com.lagradost.cloudstream3.ui.ViewHolderState 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.DownloadClickEvent -import com.lagradost.cloudstream3.ui.newSharedPool import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR import com.lagradost.cloudstream3.ui.settings.Globals.PHONE import com.lagradost.cloudstream3.ui.settings.Globals.TV @@ -30,8 +32,7 @@ import com.lagradost.cloudstream3.utils.AppContextUtils.html import com.lagradost.cloudstream3.utils.Coroutines.main import com.lagradost.cloudstream3.utils.ImageLoader.loadImage import com.lagradost.cloudstream3.utils.UIHelper.toPx -import com.lagradost.cloudstream3.utils.downloader.DownloadObjects -import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager +import com.lagradost.cloudstream3.utils.VideoDownloadHelper import com.lagradost.cloudstream3.utils.setText import com.lagradost.cloudstream3.utils.txt import java.text.DateFormat @@ -91,10 +92,11 @@ class EpisodeAdapter( } val sharedPool = - newSharedPool { - setMaxRecycledViews(HAS_POSTER or CONTENT, 10) - setMaxRecycledViews(HAS_NO_POSTER or CONTENT, 10) - } + RecyclerView.RecycledViewPool() + .apply { + this.setMaxRecycledViews(HAS_POSTER or CONTENT, 10) + this.setMaxRecycledViews(HAS_NO_POSTER or CONTENT, 10) + } } override fun onClearView(holder: ViewHolderState) { @@ -158,7 +160,7 @@ class EpisodeAdapter( downloadButton.isVisible = hasDownloadSupport downloadButton.setDefaultClickListener( - DownloadObjects.DownloadEpisodeCached( + VideoDownloadHelper.DownloadEpisodeCached( name = item.name, poster = item.poster, episode = item.episode, @@ -197,11 +199,6 @@ class EpisodeAdapter( } } - val status = VideoDownloadManager.downloadStatus[item.id] - downloadButton.resetView() - downloadButton.setPersistentId(item.id) - downloadButton.setStatus(status) - val name = if (item.name == null) "${episodeText.context.getString(R.string.episode)} ${item.episode}" else "${item.episode}. ${item.name}" episodeFiller.isVisible = item.isFiller == true @@ -379,7 +376,7 @@ class EpisodeAdapter( binding.apply { downloadButton.isVisible = hasDownloadSupport downloadButton.setDefaultClickListener( - DownloadObjects.DownloadEpisodeCached( + VideoDownloadHelper.DownloadEpisodeCached( name = item.name, poster = item.poster, episode = item.episode, @@ -418,11 +415,6 @@ class EpisodeAdapter( } } - val status = VideoDownloadManager.downloadStatus[item.id] - downloadButton.resetView() - downloadButton.setPersistentId(item.id) - downloadButton.setStatus(status) - val name = if (item.name == null) "${episodeText.context.getString(R.string.episode)} ${item.episode}" else "${item.episode}. ${item.name}" episodeFiller.isVisible = item.isFiller == true diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/result/ImageAdapter.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/result/ImageAdapter.kt index 54657ed57..0513564fe 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/result/ImageAdapter.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/result/ImageAdapter.kt @@ -2,11 +2,11 @@ package com.lagradost.cloudstream3.ui.result import android.view.LayoutInflater import android.view.ViewGroup +import androidx.recyclerview.widget.RecyclerView import com.lagradost.cloudstream3.databinding.ResultMiniImageBinding import com.lagradost.cloudstream3.ui.BaseDiffCallback import com.lagradost.cloudstream3.ui.NoStateAdapter import com.lagradost.cloudstream3.ui.ViewHolderState -import com.lagradost.cloudstream3.ui.newSharedPool import com.lagradost.cloudstream3.ui.settings.Globals.TV import com.lagradost.cloudstream3.ui.settings.Globals.isLayout import com.lagradost.cloudstream3.utils.ImageLoader.loadImage @@ -27,7 +27,7 @@ class ImageAdapter( ) { companion object { val sharedPool = - newSharedPool { setMaxRecycledViews(CONTENT, 10) } + RecyclerView.RecycledViewPool().apply { this.setMaxRecycledViews(CONTENT, 10) } } override fun onCreateContent(parent: ViewGroup): ViewHolderState { 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 38b24b265..f6d0fee76 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,11 +4,12 @@ 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 @@ -17,19 +18,18 @@ import android.view.animation.DecelerateInterpolator import android.widget.AbsListView import android.widget.ArrayAdapter import android.widget.Toast -import androidx.appcompat.app.AlertDialog import androidx.core.view.isGone import androidx.core.view.isInvisible 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 import com.google.android.gms.cast.framework.CastButtonFactory import com.google.android.gms.cast.framework.CastContext +import com.google.android.gms.cast.framework.CastState import com.google.android.material.bottomsheet.BottomSheetDialog import com.google.android.material.button.MaterialButton import com.lagradost.cloudstream3.APIHolder @@ -45,34 +45,25 @@ 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.IPlayer -import com.lagradost.cloudstream3.ui.player.PlayerView -import com.lagradost.cloudstream3.ui.player.source_priority.QualityProfileDialog +import com.lagradost.cloudstream3.ui.player.FullScreenPlayer import com.lagradost.cloudstream3.ui.quicksearch.QuickSearchFragment import com.lagradost.cloudstream3.ui.result.ResultFragment.bindLogo import com.lagradost.cloudstream3.ui.result.ResultFragment.getStoredData 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 @@ -81,7 +72,6 @@ import com.lagradost.cloudstream3.utils.AppContextUtils.updateHasTrailers import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.attachBackPressedCallback import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.detachBackPressedCallback import com.lagradost.cloudstream3.utils.BatteryOptimizationChecker.openBatteryOptimizationSettings -import com.lagradost.cloudstream3.utils.Coroutines.ioSafe import com.lagradost.cloudstream3.utils.ExtractorLink import com.lagradost.cloudstream3.utils.ImageLoader.loadImage import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showBottomDialog @@ -97,20 +87,14 @@ 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.VideoDownloadHelper import com.lagradost.cloudstream3.utils.getImageFromDrawable 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 : BaseFragment( - BindingCreator.Inflate(FragmentResultSwipeBinding::inflate) -), PlayerView.Callbacks { +open class ResultFragmentPhone : FullScreenPlayer() { private val gestureRegionsListener = object : PanelsChildGestureRegionObserver.GestureRegionsListener { override fun onGestureRegionsUpdate(gestureRegions: List) { @@ -118,105 +102,34 @@ open class ResultFragmentPhone : BaseFragment( } } - /** 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 - 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 var layout = R.layout.fragment_result_swipe - protected var playerHostView: PlayerView? = null + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { + viewModel = ViewModelProvider(this)[ResultViewModel2::class.java] + syncModel = ViewModelProvider(this)[SyncViewModel::class.java] + updateUIEvent += ::updateUI - open fun updateUIVisibility() {} + 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 + } - protected fun uiReset() { - isShowing = false - updateUIVisibility() - } - - open fun showMirrorsDialogue() {} - open fun showTracksDialogue() {} - open fun openOnlineSubPicker( - context: android.content.Context, - loadResponse: LoadResponse?, - dismissCallback: () -> Unit - ) {} - - override fun fixLayout(view: View) { - fixSystemBarsPadding(view) + return root } override fun onCreate(savedInstanceState: Bundle?) { @@ -240,7 +153,7 @@ open class ResultFragmentPhone : BaseFragment( override fun playerError(exception: Throwable) { if (player.getIsPlaying()) { // because we don't want random toasts in player - playerHostView?.playerError(exception) + super.playerError(exception) } else { nextMirror() } @@ -340,8 +253,7 @@ open class ResultFragmentPhone : BaseFragment( } updateUIEvent -= ::updateUI - playerHostView?.release() - playerBinding = null + binding = null resultBinding?.resultScroll?.setOnClickListener(null) resultBinding = null syncBinding = null @@ -365,6 +277,7 @@ open class ResultFragmentPhone : BaseFragment( var selectSeason: String? = null var selectEpisodeRange: String? = null + var selectSort: EpisodeSortType? = null private fun setUrl(url: String?) { if (url == null) { @@ -407,10 +320,6 @@ open class ResultFragmentPhone : BaseFragment( 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) @@ -418,44 +327,30 @@ open class ResultFragmentPhone : BaseFragment( override fun onStop() { afterPluginsLoadedEvent -= ::reloadViewModel - playerHostView?.onStop() super.onStop() } - @Suppress("UNUSED_PARAMETER") private fun updateUI(id: Int?) { syncModel.updateUserData() viewModel.reloadEpisodes() } - 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 + override fun onConfigurationChanged(newConfig: Configuration) { + super.onConfigurationChanged(newConfig) + view?.let { fixSystemBarsPadding(it) } + } - 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() + @SuppressLint("SetTextI18n") + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) // ===== setup ===== + fixSystemBarsPadding(view) val storedData = getStoredData() ?: return activity?.window?.decorView?.clearFocus() activity?.loadCache() context?.updateHasTrailers() - hideKeyboard(binding.root) + hideKeyboard() if (storedData.restart || !viewModel.hasLoaded()) viewModel.load( activity, @@ -473,7 +368,7 @@ open class ResultFragmentPhone : BaseFragment( // 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 { @@ -485,8 +380,8 @@ open class ResultFragmentPhone : BaseFragment( // ===== ===== ===== - binding.resultSearch.isGone = storedData.name.isBlank() - binding.resultSearch.setOnClickListener { + binding?.resultSearch?.isGone = storedData.name.isBlank() + binding?.resultSearch?.setOnClickListener { QuickSearchFragment.pushSearch(activity, storedData.name) } @@ -515,7 +410,7 @@ open class ResultFragmentPhone : BaseFragment( 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 @@ -533,13 +428,7 @@ open class ResultFragmentPhone : BaseFragment( EpisodeAdapter( api?.hasDownloadSupport == true, { episodeClick -> - when (episodeClick.action) { - ACTION_DOWNLOAD_EPISODE, ACTION_DOWNLOAD_MIRROR -> { - requirePathForActions(listOf(episodeClick.action to episodeClick.data)) - } - - else -> viewModel.handleAction(episodeClick) - } + viewModel.handleAction(episodeClick) }, { downloadClickEvent -> DownloadButtonSetup.handleDownloadClick(downloadClickEvent) @@ -574,9 +463,9 @@ open class ResultFragmentPhone : BaseFragment( 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 @@ -588,7 +477,7 @@ open class ResultFragmentPhone : BaseFragment( }) } - binding.apply { + binding?.apply { resultOverlappingPanels.setStartPanelLockState(OverlappingPanelsLayout.LockState.CLOSE) resultOverlappingPanels.setEndPanelLockState(OverlappingPanelsLayout.LockState.CLOSE) resultBack.setOnClickListener { @@ -601,13 +490,6 @@ open class ResultFragmentPhone : BaseFragment( } else resultOverlappingPanels.closePanels() } - resultMiniSync.setOnClickListener { - if (resultOverlappingPanels.getSelectedPanel().ordinal == 1) { - resultOverlappingPanels.openStartPanel() - } else resultOverlappingPanels.closePanels() - } - - /* resultMiniSync.setRecycledViewPool(ImageAdapter.sharedPool) resultMiniSync.adapter = ImageAdapter( nextFocusDown = R.id.result_sync_set_score, @@ -618,7 +500,6 @@ open class ResultFragmentPhone : BaseFragment( } else resultOverlappingPanels.closePanels() } }) - */ resultSubscribe.setOnClickListener { viewModel.toggleSubscriptionStatus(context) { newStatus: Boolean? -> if (newStatus == null) return@toggleSubscriptionStatus @@ -781,7 +662,7 @@ open class ResultFragmentPhone : BaseFragment( } observeNullable(viewModel.subscribeStatus) { isSubscribed -> - binding.resultSubscribe.isVisible = isSubscribed != null + binding?.resultSubscribe?.isVisible = isSubscribed != null if (isSubscribed == null) return@observeNullable val drawable = if (isSubscribed) { @@ -790,11 +671,11 @@ open class ResultFragmentPhone : BaseFragment( 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) { @@ -803,7 +684,7 @@ open class ResultFragmentPhone : BaseFragment( R.drawable.ic_baseline_favorite_border_24 } - binding.resultFavorite.setImageResource(drawable) + binding?.resultFavorite?.setImageResource(drawable) } observeNullable(viewModel.episodes) { episodes -> @@ -811,58 +692,8 @@ open class ResultFragmentPhone : BaseFragment( // no failure? resultEpisodeLoading.isVisible = episodes is Resource.Loading resultEpisodes.isVisible = episodes is Resource.Success - resultBatchDownloadButton.isVisible = - episodes is Resource.Success && episodes.value.isNotEmpty() - if (episodes is Resource.Success) { (resultEpisodes.adapter as? EpisodeAdapter)?.submitList(episodes.value) - - // Show quality dialog with all sources - resultBatchDownloadButton.setOnLongClickListener { - ioSafe { - val defaultSources = QualityProfileDialog.getAllDefaultSources() - val activity = activity ?: return@ioSafe - activity.runOnUiThread { - QualityProfileDialog( - activity, - R.style.DialogFullscreenPlayer, - defaultSources, - ).show() - } - } - - true - } - - resultBatchDownloadButton.setOnClickListener { view -> - val episodeStart = - episodes.value.firstOrNull()?.episode ?: return@setOnClickListener - val episodeEnd = - episodes.value.lastOrNull()?.episode ?: return@setOnClickListener - - val episodeRange = if (episodeStart == episodeEnd) { - episodeStart.toString() - } else { - txt( - R.string.episodes_range, - episodeStart, - episodeEnd - ).asString(view.context) - } - - val rangeMessage = txt( - R.string.download_episode_range, - episodeRange - ).asString(view.context) - - AlertDialog.Builder(view.context, R.style.AlertDialogCustom) - .setTitle(R.string.download_all) - .setMessage(rangeMessage) - .setPositiveButton(R.string.yes) { _, _ -> - requirePathForActions(episodes.value.map { ACTION_DOWNLOAD_EPISODE to it }) - } - .setNegativeButton(R.string.cancel) { _, _ -> }.show() - } } } } @@ -892,11 +723,8 @@ open class ResultFragmentPhone : BaseFragment( ) return@setOnLongClickListener true } - - val status = VideoDownloadManager.downloadStatus[ep.id] - downloadButton.setStatus(status) downloadButton.setDefaultClickListener( - DownloadObjects.DownloadEpisodeCached( + VideoDownloadHelper.DownloadEpisodeCached( name = ep.name, poster = ep.poster, episode = 0, @@ -913,11 +741,18 @@ open class ResultFragmentPhone : BaseFragment( when (click.action) { DOWNLOAD_ACTION_DOWNLOAD -> { - requirePathForActions(listOf(ACTION_DOWNLOAD_EPISODE to ep)) + viewModel.handleAction( + EpisodeClickEvent(ACTION_DOWNLOAD_EPISODE, ep) + ) } DOWNLOAD_ACTION_LONG_CLICK -> { - requirePathForActions(listOf(ACTION_DOWNLOAD_MIRROR to ep)) + viewModel.handleAction( + EpisodeClickEvent( + ACTION_DOWNLOAD_MIRROR, + ep + ) + ) } else -> DownloadButtonSetup.handleDownloadClick(click) @@ -991,15 +826,8 @@ open class ResultFragmentPhone : BaseFragment( resultComingSoon.isVisible = d.comingSoon resultDataHolder.isGone = d.comingSoon - 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()) + resultCastItems.isGone = d.actors.isNullOrEmpty() + (resultCastItems.adapter as? ActorAdaptor)?.submitList(d.actors) if (d.contentRatingText == null) { // If there is no rating to display, we don't want an empty gap @@ -1013,7 +841,7 @@ open class ResultFragmentPhone : BaseFragment( syncModel.addFromUrl(d.url) } - binding.apply { + binding?.apply { resultSearch.isGone = d.title.isBlank() resultSearch.setOnClickListener { QuickSearchFragment.pushSearch(activity, d.title) @@ -1048,11 +876,10 @@ open class ResultFragmentPhone : BaseFragment( } (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 @@ -1100,7 +927,7 @@ open class ResultFragmentPhone : BaseFragment( } observe(viewModel.trailers) { trailers -> - setTrailers(trailers.flatMap { it.mirros }) // I don't care about subtitles yet! + setTrailers(trailers.flatMap { it.mirros }) // I dont care about subtitles yet! } observe(syncModel.synced) { list -> @@ -1109,7 +936,8 @@ open class ResultFragmentPhone : BaseFragment( val newList = list.filter { it.isSynced && it.hasAccount } - binding.resultMiniSync.isVisible = newList.isNotEmpty() + binding?.resultMiniSync?.isVisible = newList.isNotEmpty() + (binding?.resultMiniSync?.adapter as? ImageAdapter)?.submitList(newList.mapNotNull { it.icon }) } @@ -1204,7 +1032,7 @@ open class ResultFragmentPhone : BaseFragment( } } } - 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) @@ -1265,7 +1093,7 @@ open class ResultFragmentPhone : BaseFragment( } observe(viewModel.watchStatus) { watchType -> - binding.resultBookmarkFab.apply { + binding?.resultBookmarkFab?.apply { setText(watchType.stringRes) if (watchType == WatchType.NONE) { context?.colorFromAttribute(R.attr.white) @@ -1320,7 +1148,6 @@ open class ResultFragmentPhone : BaseFragment( viewModel.skipLoading() } isVisible = true - @SuppressLint("SetTextI18n") text = "${context.getString(R.string.skip_loading)} (${load.linksLoaded})" } } @@ -1441,7 +1268,6 @@ open class ResultFragmentPhone : BaseFragment( } 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 cfbacc5d1..4f67ee33a 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 @@ -42,7 +42,6 @@ import com.lagradost.cloudstream3.ui.result.ResultFragment.updateUIEvent import com.lagradost.cloudstream3.ui.search.SEARCH_ACTION_FOCUSED 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.Globals.EMULATOR import com.lagradost.cloudstream3.ui.settings.Globals.TV import com.lagradost.cloudstream3.ui.settings.Globals.isLayout @@ -62,6 +61,7 @@ import com.lagradost.cloudstream3.utils.UIHelper.hideKeyboard import com.lagradost.cloudstream3.utils.UIHelper.navigate import com.lagradost.cloudstream3.utils.UIHelper.populateChips import com.lagradost.cloudstream3.utils.UIHelper.setNavigationBarColorCompat +import com.lagradost.cloudstream3.utils.UiImage import com.lagradost.cloudstream3.utils.getImageFromDrawable import com.lagradost.cloudstream3.utils.setText import com.lagradost.cloudstream3.utils.setTextHtml @@ -559,7 +559,7 @@ class ResultFragmentTv : BaseFragment( ExtractorLinkGenerator( extractedTrailerLinks, emptyList() - ), 0 + ) ) ) } @@ -925,16 +925,11 @@ 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 + resultCastItems.isGone = d.actors.isNullOrEmpty() + (resultCastItems.adapter as? ActorAdaptor)?.submitList( + d.actors ) - resultCastItems.isGone = !showCast || d.actors.isNullOrEmpty() - (resultCastItems.adapter as? ActorAdaptor)?.submitList(if (showCast) d.actors else emptyList()) - if (d.contentRatingText == null) { // If there is no rating to display, we don't want an empty gap resultMetaContentRating.width = 0 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 3b1471e6a..969fa6d95 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,74 +5,41 @@ 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 -class ResultTrailerPlayer : ResultFragmentPhone() { +open class ResultTrailerPlayer : ResultFragmentPhone() { override var lockRotation = false override var isFullScreenPlayer = false override var hasPipModeSupport = false companion object { - const val TAG = "ResultTrailerPlayer" + const val TAG = "RESULT_TRAILER" } private var playerWidthHeight: Pair? = null - private var introVisible = true - - // Single-tap on empty player area: toggle controls. - override fun onSingleTap() { - if (introVisible) return - if (isShowing) uiReset() else showControls() - } - - private fun showControls() { - if (introVisible) return - isShowing = true - updateUIVisibility() - playerHostView?.scheduleAutoHide() - } - - 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 playerPositionChanged(position: Long, duration : Long) {} + override fun nextMirror() {} override fun onConfigurationChanged(newConfig: Configuration) { @@ -82,28 +49,33 @@ class ResultTrailerPlayer : ResultFragmentPhone() { } private fun fixPlayerSize() { - binding?.apply { - if (isFullScreenPlayer) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + binding?.apply { + if (isFullScreenPlayer) { + // Remove listener ViewCompat.setOnApplyWindowInsetsListener(root, null) - root.overlay.clear() - } - root.setPadding(0, 0, 0, 0) - } else { - fixSystemBarsPadding(root) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + 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) 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 @@ -111,30 +83,35 @@ 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 { va -> - val v = va.animatedValue as Int - val lp: ViewGroup.LayoutParams = layoutParams - lp.height = v - layoutParams = lp + anim.addUpdateListener { valueAnimator -> + val `val` = valueAnimator.animatedValue as Int + val layoutParams: ViewGroup.LayoutParams = + layoutParams + layoutParams.height = `val` + setLayoutParams(layoutParams) } anim.duration = 200 anim.start() @@ -143,14 +120,9 @@ 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() {} @@ -160,39 +132,33 @@ 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 - } + ) { } + override fun subtitlesChanged() {} + + override fun embeddedSubtitlesFetched(subtitles: List) {} + override fun onTracksInfoChanged() {} + + override fun exitedPipMode() {} 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) { - playerHostView?.enterFullscreen() + 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 @@ -203,55 +169,36 @@ class ResultTrailerPlayer : ResultFragmentPhone() { resultBinding?.resultSmallscreenHolder?.addView(view) } } - playerHostView?.exitFullscreen() + 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?.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) + playerBinding?.playerGoBackHolder?.isVisible = false } - override fun playerStatusChanged() { - if (introVisible) { - playerBinding?.playerPausePlayHolderHolder?.isVisible = false + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + playerBinding?.playerFullscreen?.setOnClickListener { + updateFullscreen(!isFullScreenPlayer) } - } - - 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 c519e0de2..d0d9b8c93 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,8 +1,7 @@ package com.lagradost.cloudstream3.ui.result import android.app.Activity -import android.content.Context -import android.content.DialogInterface +import android.content.* import android.util.Log import android.widget.Toast import androidx.annotation.MainThread @@ -11,67 +10,33 @@ import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.lagradost.cloudstream3.APIHolder +import com.lagradost.cloudstream3.* +import com.lagradost.cloudstream3.actions.AlwaysAskAction +import com.lagradost.cloudstream3.actions.VideoClickActionHolder 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 -import com.lagradost.cloudstream3.mvvm.debugException -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.runAllAsync -import com.lagradost.cloudstream3.sortUrls +import com.lagradost.cloudstream3.mvvm.* import com.lagradost.cloudstream3.syncproviders.AccountManager import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.secondsToReadable -import com.lagradost.cloudstream3.syncproviders.SyncAPI import com.lagradost.cloudstream3.syncproviders.providers.Kitsu +import com.lagradost.cloudstream3.syncproviders.SyncAPI import com.lagradost.cloudstream3.ui.APIRepository -import com.lagradost.cloudstream3.ui.WatchType +import com.lagradost.cloudstream3.ui.download.DOWNLOAD_NAVIGATE_TO 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 @@ -79,22 +44,21 @@ import com.lagradost.cloudstream3.ui.player.LOADTYPE_INAPP_DOWNLOAD import com.lagradost.cloudstream3.ui.player.RepoLinkGenerator import com.lagradost.cloudstream3.ui.player.SubtitleData import com.lagradost.cloudstream3.ui.result.EpisodeAdapter.Companion.getPlayerAction +import com.lagradost.cloudstream3.ui.subtitles.SubtitlesFragment +import com.lagradost.cloudstream3.ui.WatchType +import com.lagradost.cloudstream3.utils.* import com.lagradost.cloudstream3.utils.AppContextUtils.getNameFull import com.lagradost.cloudstream3.utils.AppContextUtils.isConnectedToChromecast import com.lagradost.cloudstream3.utils.AppContextUtils.setDefaultFocus import com.lagradost.cloudstream3.utils.AppContextUtils.sortSubs -import com.lagradost.cloudstream3.utils.AppUtils.toJson import com.lagradost.cloudstream3.utils.CastHelper.startCast import com.lagradost.cloudstream3.utils.Coroutines.ioSafe import com.lagradost.cloudstream3.utils.Coroutines.ioWork import com.lagradost.cloudstream3.utils.Coroutines.ioWorkSafe import com.lagradost.cloudstream3.utils.Coroutines.main -import com.lagradost.cloudstream3.utils.DOWNLOAD_HEADER_CACHE -import com.lagradost.cloudstream3.utils.DataStore import com.lagradost.cloudstream3.utils.DataStore.editor import com.lagradost.cloudstream3.utils.DataStore.getFolderName import com.lagradost.cloudstream3.utils.DataStore.setKey -import com.lagradost.cloudstream3.utils.DataStoreHelper import com.lagradost.cloudstream3.utils.DataStoreHelper.currentAccount import com.lagradost.cloudstream3.utils.DataStoreHelper.deleteBookmarkedData import com.lagradost.cloudstream3.utils.DataStoreHelper.getAllBookmarkedData @@ -121,31 +85,10 @@ import com.lagradost.cloudstream3.utils.DataStoreHelper.setResultWatchState import com.lagradost.cloudstream3.utils.DataStoreHelper.setSubscribedData import com.lagradost.cloudstream3.utils.DataStoreHelper.setVideoWatchState import com.lagradost.cloudstream3.utils.DataStoreHelper.updateSubscribedData -import com.lagradost.cloudstream3.utils.Editor -import com.lagradost.cloudstream3.utils.ExtractorLink -import com.lagradost.cloudstream3.utils.ExtractorLinkType -import com.lagradost.cloudstream3.utils.FillerEpisodeCheck -import com.lagradost.cloudstream3.utils.INFER_TYPE -import com.lagradost.cloudstream3.utils.Qualities +import com.lagradost.cloudstream3.utils.SubtitleHelper.fromTagToEnglishLanguageName 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.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 -import com.lagradost.cloudstream3.utils.txt -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Job -import kotlinx.coroutines.cancelChildren -import kotlinx.coroutines.coroutineScope -import kotlinx.coroutines.isActive -import kotlinx.coroutines.job -import kotlinx.coroutines.launch import java.util.concurrent.TimeUnit +import kotlinx.coroutines.* /** This starts at 1 */ data class EpisodeRange( @@ -319,12 +262,11 @@ 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_singular + TvType.Music -> R.string.music_singlar TvType.AudioBook -> R.string.audio_book_singular - 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 + TvType.CustomMedia -> R.string.custom_media_singluar + TvType.Audio -> R.string.audio_singluar + TvType.Podcast -> R.string.podcast_singluar } ), yearText = txt(year?.toString()), @@ -449,7 +391,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(), ) @@ -479,8 +421,8 @@ class ResultViewModel2 : ViewModel() { private var currentShowFillers: Boolean = false var currentRepo: APIRepository? = null private var currentId: Int? = null - private var fillers: HashSet = hashSetOf() - private var generator: RepoLinkGenerator? = null + private var fillers: Map = emptyMap() + private var generator: IGenerator? = null private var preferDubStatus: DubStatus? = null private var preferStartEpisode: Int? = null private var preferStartSeason: Int? = null @@ -725,6 +667,228 @@ class ResultViewModel2 : ViewModel() { index to list }.toMap() } + + private fun downloadSubtitle( + context: Context?, + link: ExtractorSubtitleLink, + fileName: String, + folder: String + ) { + ioSafe { + VideoDownloadManager.downloadThing( + context ?: return@ioSafe, + link, + "$fileName ${link.name}", + folder, + if (link.url.contains(".srt")) "srt" else "vtt", + false, + null, createNotificationCallback = {} + ) + } + } + + private fun getFolder(currentType: TvType, titleName: String): String { + return if (currentType.isEpisodeBased()) { + val sanitizedFileName = VideoDownloadManager.sanitizeFilename(titleName) + "${currentType.getFolderPrefix()}/$sanitizedFileName" + } else currentType.getFolderPrefix() + } + + private fun downloadSubtitle( + context: Context?, + link: SubtitleData, + meta: VideoDownloadManager.DownloadEpisodeMetadata, + ) { + context?.let { ctx -> + val fileName = VideoDownloadManager.getFileName(ctx, meta) + val folder = getFolder(meta.type ?: return, meta.mainName) + downloadSubtitle( + ctx, + ExtractorSubtitleLink(link.name, link.url, "", link.headers), + fileName, + folder + ) + } + } + + fun startDownload( + context: Context?, + episode: ResultEpisode, + currentIsMovie: Boolean, + currentHeaderName: String, + currentType: TvType, + currentPoster: String?, + apiName: String, + parentId: Int, + url: String, + links: List, + subs: List? + ) { + try { + if (context == null) return + + val meta = + getMeta( + episode, + currentHeaderName, + apiName, + currentPoster, + currentIsMovie, + currentType + ) + + val folder = getFolder(currentType, currentHeaderName) + + val src = "$DOWNLOAD_NAVIGATE_TO/$parentId" // url ?: return@let + + // SET VISUAL KEYS + setKey( + DOWNLOAD_HEADER_CACHE, + parentId.toString(), + VideoDownloadHelper.DownloadHeaderCached( + apiName = apiName, + url = url, + type = currentType, + name = currentHeaderName, + poster = currentPoster, + id = parentId, + cacheTime = System.currentTimeMillis(), + ) + ) + + setKey( + DataStore.getFolderName( + DOWNLOAD_EPISODE_CACHE, + parentId.toString() + ), // 3 deep folder for faster acess + episode.id.toString(), + VideoDownloadHelper.DownloadEpisodeCached( + name = episode.name, + poster = episode.poster, + episode = episode.episode, + season = episode.season, + id = episode.id, + parentId = parentId, + score = episode.score, + description = episode.description, + cacheTime = System.currentTimeMillis(), + ) + ) + + // DOWNLOAD VIDEO + VideoDownloadManager.downloadEpisodeUsingWorker( + context, + src,//url ?: return, + folder, + meta, + links + ) + + // 1. Checks if the lang should be downloaded + // 2. Makes it into the download format + // 3. Downloads it as a .vtt file + val downloadList = SubtitlesFragment.getDownloadSubsLanguageTagIETF() + + subs?.filter { subtitle -> + downloadList.any { langTagIETF -> + subtitle.languageCode == langTagIETF || + subtitle.originalName.contains( + fromTagToEnglishLanguageName( + langTagIETF + ) ?: langTagIETF + ) + } + } + ?.map { ExtractorSubtitleLink(it.name, it.url, "", it.headers) } + ?.take(3) // max subtitles download hardcoded (?_?) + ?.forEach { link -> + val fileName = VideoDownloadManager.getFileName(context, meta) + downloadSubtitle(context, link, fileName, folder) + } + } catch (e: Exception) { + logError(e) + } + } + + suspend fun downloadEpisode( + activity: Activity?, + episode: ResultEpisode, + currentIsMovie: Boolean, + currentHeaderName: String, + currentType: TvType, + currentPoster: String?, + apiName: String, + parentId: Int, + url: String, + ) { + ioSafe { + val generator = RepoLinkGenerator(listOf(episode)) + val currentLinks = mutableSetOf() + val currentSubs = mutableSetOf() + generator.generateLinks( + clearCache = false, + sourceTypes = LOADTYPE_INAPP_DOWNLOAD, + callback = { + it.first?.let { link -> + currentLinks.add(link) + } + }, + subtitleCallback = { sub -> + currentSubs.add(sub) + }) + + if (currentLinks.isEmpty()) { + main { + showToast( + R.string.no_links_found_toast, + Toast.LENGTH_SHORT + ) + } + return@ioSafe + } else { + main { + showToast( + R.string.download_started, + Toast.LENGTH_SHORT + ) + } + } + + startDownload( + activity, + episode, + currentIsMovie, + currentHeaderName, + currentType, + currentPoster, + apiName, + parentId, + url, + sortUrls(currentLinks), + sortSubs(currentSubs), + ) + } + } + + private fun getMeta( + episode: ResultEpisode, + titleName: String, + apiName: String, + currentPoster: String?, + currentIsMovie: Boolean, + tvType: TvType, + ): VideoDownloadManager.DownloadEpisodeMetadata { + return VideoDownloadManager.DownloadEpisodeMetadata( + episode.id, + VideoDownloadManager.sanitizeFilename(titleName), + apiName, + episode.poster ?: currentPoster, + episode.name, + if (currentIsMovie) null else episode.season, + if (currentIsMovie) null else episode.episode, + tvType, + ) + } } private val _watchStatus: MutableLiveData = MutableLiveData(WatchType.NONE) @@ -903,28 +1067,6 @@ class ResultViewModel2 : ViewModel() { } } - private fun getMeta( - episode: ResultEpisode, - titleName: String, - apiName: String, - currentPoster: String?, - currentIsMovie: Boolean, - tvType: TvType, - ): DownloadObjects.DownloadEpisodeMetadata { - return DownloadObjects.DownloadEpisodeMetadata( - episode.id, - episode.parentId, - sanitizeFilename(titleName), - apiName, - episode.poster ?: currentPoster, - episode.name, - if (currentIsMovie) null else episode.season, - if (currentIsMovie) null else episode.episode, - tvType, - ) - } - - /** * Toggles the favorite status of an item. * @@ -1293,10 +1435,9 @@ class ResultViewModel2 : ViewModel() { subs += sub updatePage() }, - isCasting = isCasting, - offset = 0 + isCasting = isCasting ) - } catch (_: CancellationException) { + } catch (e: CancellationException) { // Do nothing } catch (e: Exception) { logError(e) @@ -1325,7 +1466,7 @@ class ResultViewModel2 : ViewModel() { episodeIds: Array, watchState: VideoWatchState ) { - val watchStateString = watchState.toJson() + val watchStateString = DataStore.mapper.writeValueAsString(watchState) episodeIds.forEach { if (getVideoWatchState(it.toInt()) != watchState) { editor.setKeyRaw( @@ -1471,17 +1612,16 @@ class ResultViewModel2 : ViewModel() { ACTION_DOWNLOAD_EPISODE -> { val response = currentResponse ?: return - DownloadQueueManager.addToQueue( - DownloadObjects.DownloadQueueItem( - click.data, - response.isMovie(), - response.name, - response.type, - response.posterUrl, - response.apiName, - response.getId(), - response.url, - ).toWrapper() + downloadEpisode( + activity, + click.data, + response.isMovie(), + response.name, + response.type, + response.posterUrl, + response.apiName, + response.getId(), + response.url ) } @@ -1492,8 +1632,9 @@ class ResultViewModel2 : ViewModel() { LOADTYPE_INAPP_DOWNLOAD, txt(R.string.episode_action_download_mirror) ) { (result, index) -> - DownloadQueueManager.addToQueue( - DownloadObjects.DownloadQueueItem( + ioSafe { + startDownload( + activity, click.data, response.isMovie(), response.name, @@ -1504,8 +1645,8 @@ class ResultViewModel2 : ViewModel() { response.url, listOf(result.links[index]), result.subs, - ).toWrapper() - ) + ) + } showToast( R.string.download_started, Toast.LENGTH_SHORT @@ -1545,24 +1686,26 @@ 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( - offset = index, + generator?.generateLinks( clearCache = true, - isCasting = false, - sourceTypes = LOADTYPE_ALL, + LOADTYPE_ALL, callback = {}, subtitleCallback = {}) } else { activity?.navigate( R.id.global_to_navigation_player, GeneratorPlayer.newInstance( - generator, index,list + generator ?: return, list ) ) } @@ -1686,13 +1829,14 @@ class ResultViewModel2 : ViewModel() { } val realRecommendations = ArrayList() - val apiNames = apis.filter { - it.name.contains("gogoanime", true) || - it.name.contains("9anime", true) - }.map { - it.name + val apiNames = synchronized(apis) { + apis.filter { + it.name.contains("gogoanime", true) || + it.name.contains("9anime", true) + }.map { + it.name + } } - meta.recommendations?.forEach { rec -> apiNames.forEach { name -> realRecommendations.add(rec.copy(apiName = name)) @@ -1711,7 +1855,7 @@ class ResultViewModel2 : ViewModel() { { if (this !is AnimeLoadResponse) return@runAllAsync // already exist, no need to run getTracker - if (this.getAniListId() != null && this.getKitsuId() != null && this.getMalId() != null) return@runAllAsync + if (this.getAniListId() != null && this.getMalId() != null) return@runAllAsync val res = APIHolder.getTracker( listOfNotNull( @@ -1729,12 +1873,9 @@ class ResultViewModel2 : ViewModel() { this.year ) - val kitsuId = AccountManager.kitsuApi.getAnimeIdByTitle(this.name) - val ids = arrayOf( AccountManager.malApi.idPrefix to res?.malId?.toString(), - AccountManager.aniListApi.idPrefix to res?.aniId, - AccountManager.kitsuApi.idPrefix to kitsuId + AccountManager.aniListApi.idPrefix to res?.aniId ) if (ids.any { (id, new) -> @@ -1831,10 +1972,11 @@ class ResultViewModel2 : ViewModel() { } - private suspend fun updateFillers(data: LoadResponse) { - fillers = ioWorkSafe { - FillerEpisodeCheck.getFillerEpisodes(data) - } ?: hashSetOf() + private suspend fun updateFillers(name: String) { + fillers = + ioWorkSafe { + FillerEpisodeCheck.getFillerEpisodes(name) + } ?: emptyMap() } fun changeDubStatus(status: DubStatus) { @@ -2171,8 +2313,8 @@ class ResultViewModel2 : ViewModel() { ) { _episodes.postValue(Resource.Loading()) - if (updateFillers) { - updateFillers(loadResponse) + if (updateFillers && loadResponse is AnimeLoadResponse) { + updateFillers(loadResponse.name) } val allEpisodes = when (loadResponse) { @@ -2213,7 +2355,7 @@ class ResultViewModel2 : ViewModel() { index, i.score, i.description, - fillers.contains(episode), + fillers.getOrDefault(episode, false), loadResponse.type, mainId, totalIndex, @@ -2453,34 +2595,26 @@ 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 @@ -2678,7 +2812,7 @@ class ResultViewModel2 : ViewModel() { setKey( DOWNLOAD_HEADER_CACHE, mainId.toString(), - DownloadObjects.DownloadHeaderCached( + VideoDownloadHelper.DownloadHeaderCached( apiName = apiName, url = validUrl, type = loadResponse.type, @@ -2706,4 +2840,4 @@ class ResultViewModel2 : ViewModel() { } } } -} +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/result/SyncViewModel.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/result/SyncViewModel.kt index 6c5c64ff8..35680b060 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/result/SyncViewModel.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/result/SyncViewModel.kt @@ -11,7 +11,6 @@ import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.mvvm.throwAbleToResource import com.lagradost.cloudstream3.syncproviders.AccountManager import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.aniListApi -import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.kitsuApi import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.malApi import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.simklApi import com.lagradost.cloudstream3.syncproviders.SyncAPI @@ -277,7 +276,6 @@ class SyncViewModel : ViewModel() { // fix because of bad old data :pensive: val realName = when (syncName) { "MAL" -> malApi.idPrefix - "Kitsu" -> kitsuApi.idPrefix "Simkl" -> simklApi.idPrefix "AniList" -> aniListApi.idPrefix else -> syncName diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchAdaptor.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchAdaptor.kt index 7b63b6ede..9338d4942 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchAdaptor.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchAdaptor.kt @@ -4,6 +4,7 @@ import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.widget.FrameLayout +import androidx.recyclerview.widget.RecyclerView import com.lagradost.cloudstream3.SearchResponse import com.lagradost.cloudstream3.databinding.SearchResultGridBinding import com.lagradost.cloudstream3.databinding.SearchResultGridExpandedBinding @@ -11,7 +12,6 @@ import com.lagradost.cloudstream3.ui.AutofitRecyclerView import com.lagradost.cloudstream3.ui.BaseDiffCallback import com.lagradost.cloudstream3.ui.NoStateAdapter import com.lagradost.cloudstream3.ui.ViewHolderState -import com.lagradost.cloudstream3.ui.newSharedPool import com.lagradost.cloudstream3.utils.UIHelper.isBottomLayout import kotlin.math.roundToInt @@ -43,7 +43,7 @@ class SearchAdapter( })) { companion object { val sharedPool = - newSharedPool { setMaxRecycledViews(CONTENT, 10) } + RecyclerView.RecycledViewPool().apply { this.setMaxRecycledViews(CONTENT, 10) } } var hasNext: Boolean = false diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchFragment.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchFragment.kt index 5f5b064b5..ae31d03fb 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchFragment.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchFragment.kt @@ -21,9 +21,7 @@ import androidx.core.view.isVisible import androidx.fragment.app.activityViewModels import androidx.preference.PreferenceManager import androidx.recyclerview.widget.GridLayoutManager -import androidx.recyclerview.widget.LinearLayoutManager import androidx.activity.result.contract.ActivityResultContracts -import androidx.core.view.doOnLayout import com.google.android.material.bottomsheet.BottomSheetBehavior import com.google.android.material.bottomsheet.BottomSheetDialog import com.google.android.material.button.MaterialButton @@ -57,9 +55,7 @@ import com.lagradost.cloudstream3.ui.home.HomeViewModel import com.lagradost.cloudstream3.ui.home.ParentItemAdapter import com.lagradost.cloudstream3.ui.result.FOCUS_SELF import com.lagradost.cloudstream3.ui.result.setLinearListLayout -import com.lagradost.cloudstream3.ui.setRecycledViewPool import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR -import com.lagradost.cloudstream3.ui.settings.Globals.PHONE import com.lagradost.cloudstream3.ui.settings.Globals.TV import com.lagradost.cloudstream3.ui.settings.Globals.isLandscape import com.lagradost.cloudstream3.ui.settings.Globals.isLayout @@ -75,8 +71,6 @@ import com.lagradost.cloudstream3.utils.Coroutines.main import com.lagradost.cloudstream3.utils.DataStoreHelper import com.lagradost.cloudstream3.utils.DataStoreHelper.currentAccount import com.lagradost.cloudstream3.utils.SubtitleHelper -import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.attachBackPressedCallback -import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.detachBackPressedCallback import com.lagradost.cloudstream3.utils.UIHelper.dismissSafe import com.lagradost.cloudstream3.utils.UIHelper.fixSystemBarsPadding import com.lagradost.cloudstream3.utils.UIHelper.getSpanCount @@ -143,7 +137,6 @@ class SearchFragment : BaseFragment( override fun onDestroyView() { hideKeyboard() bottomSheetDialog?.ownHide() - activity?.detachBackPressedCallback("SearchFragment") super.onDestroyView() } @@ -407,29 +400,17 @@ class SearchFragment : BaseFragment( val settingsManager = context?.let { PreferenceManager.getDefaultSharedPreferences(it) } val isAdvancedSearch = settingsManager?.getBoolean("advanced_search", true) ?: true - val isSearchSuggestionsEnabled = settingsManager?.getBoolean("search_suggestions_enabled", true) ?: true selectedSearchTypes = DataStoreHelper.searchPreferenceTags.toMutableList() - if (!isLayout(PHONE)) { + if (isLayout(TV)) { binding.searchFilter.isFocusable = true binding.searchFilter.isFocusableInTouchMode = true } - // Hide suggestions when search view loses focus (phone only) - if (isLayout(PHONE)) { - binding.mainSearch.setOnQueryTextFocusChangeListener { _, hasFocus -> - if (!hasFocus) { - searchViewModel.clearSuggestions() - } - } - } - - binding.mainSearch.setOnQueryTextListener(object : SearchView.OnQueryTextListener { override fun onQueryTextSubmit(query: String): Boolean { search(query) - searchViewModel.clearSuggestions() binding.mainSearch.let { hideKeyboard(it) @@ -444,25 +425,51 @@ class SearchFragment : BaseFragment( if (showHistory) { searchViewModel.clearSearch() searchViewModel.updateHistory() - searchViewModel.clearSuggestions() - } else { - // Fetch suggestions when user is typing (if enabled) - if (isSearchSuggestionsEnabled) { - searchViewModel.fetchSuggestions(newText) - } } binding.apply { - searchHistoryRecycler.isVisible = showHistory + searchHistoryHolder.isVisible = showHistory searchMasterRecycler.isVisible = !showHistory && isAdvancedSearch searchAutofitResults.isVisible = !showHistory && !isAdvancedSearch - // Hide suggestions when showing history or showing search results - searchSuggestionsRecycler.isVisible = !showHistory && isSearchSuggestionsEnabled } return true } }) + binding.searchClearCallHistory.setOnClickListener { + activity?.let { ctx -> + val builder: AlertDialog.Builder = AlertDialog.Builder(ctx) + val dialogClickListener = + DialogInterface.OnClickListener { _, which -> + when (which) { + DialogInterface.BUTTON_POSITIVE -> { + removeKeys("$currentAccount/$SEARCH_HISTORY_KEY") + searchViewModel.updateHistory() + } + + DialogInterface.BUTTON_NEGATIVE -> { + } + } + } + + try { + builder.setTitle(R.string.clear_history).setMessage( + ctx.getString(R.string.delete_message).format( + ctx.getString(R.string.history) + ) + ) + .setPositiveButton(R.string.sort_clear, dialogClickListener) + .setNegativeButton(R.string.cancel, dialogClickListener) + .show().setDefaultFocus() + } catch (e: Exception) { + logError(e) + // ye you somehow fucked up formatting did you? + } + } + + + } + observe(searchViewModel.searchResponse) { when (it) { is Resource.Success -> { @@ -496,30 +503,24 @@ class SearchFragment : BaseFragment( try { // https://stackoverflow.com/questions/6866238/concurrent-modification-exception-adding-to-an-arraylist listLock.lock() - - val pinnedOrder = DataStoreHelper.pinnedProviders.reversedArray() - - val sortedList = list.toList().sortedWith(compareBy { (providerName, _) -> - val index = pinnedOrder.indexOf(providerName) - if (index == -1) Int.MAX_VALUE else index - }) - (binding.searchMasterRecycler.adapter as? ParentItemAdapter)?.apply { - val newItems = sortedList.map { (providerName, providerData) -> - val dataList = providerData.list + val newItems = list.map { ongoing -> + val dataList = ongoing.value.list val dataListFiltered = context?.filterSearchResultByFilmQuality(dataList) ?: dataList val homePageList = HomePageList( - providerName, + ongoing.key, dataListFiltered ) - HomeViewModel.ExpandableHomepageList( + val expandableList = HomeViewModel.ExpandableHomepageList( homePageList, - providerData.currentPage, - providerData.hasNext + ongoing.value.currentPage, + ongoing.value.hasNext ) + + expandableList } submitList(newItems) @@ -558,7 +559,6 @@ class SearchFragment : BaseFragment( val searchItem = click.item when (click.clickAction) { SEARCH_HISTORY_OPEN -> { - if (searchItem == null) return@SearchHistoryAdaptor searchViewModel.clearSearch() if (searchItem.type.isNotEmpty()) updateChips( @@ -569,76 +569,21 @@ class SearchFragment : BaseFragment( } SEARCH_HISTORY_REMOVE -> { - if (searchItem == null) return@SearchHistoryAdaptor removeKey("$currentAccount/$SEARCH_HISTORY_KEY", searchItem.key) searchViewModel.updateHistory() } - SEARCH_HISTORY_CLEAR -> { - // Show confirmation dialog (from footer button) - activity?.let { ctx -> - val builder: AlertDialog.Builder = AlertDialog.Builder(ctx) - val dialogClickListener = - DialogInterface.OnClickListener { _, which -> - when (which) { - DialogInterface.BUTTON_POSITIVE -> { - removeKeys("$currentAccount/$SEARCH_HISTORY_KEY") - searchViewModel.updateHistory() - } - - DialogInterface.BUTTON_NEGATIVE -> { - } - } - } - - try { - builder.setTitle(R.string.clear_history).setMessage( - ctx.getString(R.string.delete_message).format( - ctx.getString(R.string.history) - ) - ) - .setPositiveButton(R.string.sort_clear, dialogClickListener) - .setNegativeButton(R.string.cancel, dialogClickListener) - .show().setDefaultFocus() - } catch (e: Exception) { - logError(e) - } - } - } - else -> { // wth are you doing??? } } } - val suggestionAdapter = SearchSuggestionAdapter { callback -> - when (callback.clickAction) { - SEARCH_SUGGESTION_CLICK -> { - // Search directly - binding.mainSearch.setQuery(callback.suggestion, true) - searchViewModel.clearSuggestions() - } - SEARCH_SUGGESTION_FILL -> { - // Fill the search box without searching - binding.mainSearch.setQuery(callback.suggestion, false) - } - SEARCH_SUGGESTION_CLEAR -> { - // Clear suggestions (from footer button) - searchViewModel.clearSuggestions() - } - } - } - binding.apply { searchHistoryRecycler.adapter = historyAdapter searchHistoryRecycler.setLinearListLayout(isHorizontal = false, nextRight = FOCUS_SELF) //searchHistoryRecycler.layoutManager = GridLayoutManager(context, 1) - // Setup suggestions RecyclerView - searchSuggestionsRecycler.adapter = suggestionAdapter - searchSuggestionsRecycler.layoutManager = LinearLayoutManager(context) - searchMasterRecycler.setRecycledViewPool(ParentItemAdapter.sharedPool) searchMasterRecycler.adapter = masterAdapter //searchMasterRecycler.setLinearListLayout(isHorizontal = false, nextRight = FOCUS_SELF) @@ -654,11 +599,7 @@ class SearchFragment : BaseFragment( sq?.let { query -> if (query.isBlank()) return@let - - // Queries are dropped if you are submitted before layout finishes - mainSearch.doOnLayout { - mainSearch.setQuery(query, true) - } + mainSearch.setQuery(query, true) // Clear the query as to not make it request the same query every time the page is opened arguments?.remove(SEARCH_QUERY) savedInstanceState?.remove(SEARCH_QUERY) @@ -667,34 +608,8 @@ class SearchFragment : BaseFragment( } observe(searchViewModel.currentHistory) { list -> + binding.searchClearCallHistory.isVisible = list.isNotEmpty() (binding.searchHistoryRecycler.adapter as? SearchHistoryAdaptor?)?.submitList(list) - // Scroll to top to show newest items (list is sorted by newest first) - if (list.isNotEmpty()) { - binding.searchHistoryRecycler.scrollToPosition(0) - } - } - - // Observe search suggestions - observe(searchViewModel.searchSuggestions) { suggestions -> - val hasSuggestions = suggestions.isNotEmpty() - binding.searchSuggestionsRecycler.isVisible = hasSuggestions - (binding.searchSuggestionsRecycler.adapter as? SearchSuggestionAdapter?)?.submitList(suggestions) - - // On non-phone layouts, redirect focus and handle back button - if (!isLayout(PHONE)) { - if (hasSuggestions) { - binding.tvtypesChipsScroll.tvtypesChips.root.nextFocusDownId = R.id.search_suggestions_recycler - // Attach back button callback to clear suggestions - activity?.attachBackPressedCallback("SearchFragment") { - searchViewModel.clearSuggestions() - } - } else { - // Reset to default focus target (history) - binding.tvtypesChipsScroll.tvtypesChips.root.nextFocusDownId = R.id.search_history_recycler - // Detach back button callback when no suggestions - activity?.detachBackPressedCallback("SearchFragment") - } - } } searchViewModel.updateHistory() diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchHelper.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchHelper.kt index 449a04bf8..e176d6c9b 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchHelper.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchHelper.kt @@ -11,7 +11,7 @@ import com.lagradost.cloudstream3.ui.download.DownloadClickEvent import com.lagradost.cloudstream3.ui.result.START_ACTION_LOAD_EP import com.lagradost.cloudstream3.utils.AppContextUtils.loadSearchResult import com.lagradost.cloudstream3.utils.DataStoreHelper -import com.lagradost.cloudstream3.utils.downloader.DownloadObjects +import com.lagradost.cloudstream3.utils.VideoDownloadHelper object SearchHelper { fun handleSearchClickCallback(callback: SearchClickCallback) { @@ -31,7 +31,7 @@ object SearchHelper { handleDownloadClick( DownloadClickEvent( DOWNLOAD_ACTION_PLAY_FILE, - DownloadObjects.DownloadEpisodeCached( + VideoDownloadHelper.DownloadEpisodeCached( name = card.name, poster = card.posterUrl, episode = card.episode ?: 0, diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchHistoryAdaptor.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchHistoryAdaptor.kt index 4868abb3d..2a95c76b2 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchHistoryAdaptor.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchHistoryAdaptor.kt @@ -2,17 +2,12 @@ package com.lagradost.cloudstream3.ui.search import android.view.LayoutInflater import android.view.ViewGroup -import androidx.core.view.isGone import com.fasterxml.jackson.annotation.JsonProperty import com.lagradost.cloudstream3.TvType -import com.lagradost.cloudstream3.databinding.SearchHistoryFooterBinding import com.lagradost.cloudstream3.databinding.SearchHistoryItemBinding import com.lagradost.cloudstream3.ui.BaseDiffCallback import com.lagradost.cloudstream3.ui.NoStateAdapter import com.lagradost.cloudstream3.ui.ViewHolderState -import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR -import com.lagradost.cloudstream3.ui.settings.Globals.TV -import com.lagradost.cloudstream3.ui.settings.Globals.isLayout data class SearchHistoryItem( @JsonProperty("searchedAt") val searchedAt: Long, @@ -22,31 +17,18 @@ data class SearchHistoryItem( ) data class SearchHistoryCallback( - val item: SearchHistoryItem?, + val item: SearchHistoryItem, val clickAction: Int, ) const val SEARCH_HISTORY_OPEN = 0 const val SEARCH_HISTORY_REMOVE = 1 -const val SEARCH_HISTORY_CLEAR = 2 class SearchHistoryAdaptor( private val clickCallback: (SearchHistoryCallback) -> Unit, ) : NoStateAdapter(diffCallback = BaseDiffCallback(itemSame = { a,b -> a.searchedAt == b.searchedAt && a.searchText == b.searchText })) { - - // Add footer for all layouts - override val footers = 1 - - override fun submitList(list: Collection?, commitCallback: Runnable?) { - super.submitList(list, commitCallback) - // Notify footer to rebind when list changes to update visibility - if (footers > 0) { - notifyItemChanged(itemCount - 1) - } - } - override fun onCreateContent(parent: ViewGroup): ViewHolderState { return ViewHolderState( SearchHistoryItemBinding.inflate(LayoutInflater.from(parent.context), parent, false), @@ -70,25 +52,4 @@ class SearchHistoryAdaptor( } } } - - override fun onCreateFooter(parent: ViewGroup): ViewHolderState { - return ViewHolderState( - SearchHistoryFooterBinding.inflate(LayoutInflater.from(parent.context), parent, false) - ) - } - - override fun onBindFooter(holder: ViewHolderState) { - val binding = holder.view as? SearchHistoryFooterBinding ?: return - // Hide footer when list is empty - binding.searchClearCallHistory.apply { - isGone = immutableCurrentList.isEmpty() - if (isLayout(TV or EMULATOR)) { - isFocusable = true - isFocusableInTouchMode = true - } - setOnClickListener { - clickCallback.invoke(SearchHistoryCallback(null, SEARCH_HISTORY_CLEAR)) - } - } - } } diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchSuggestionAdapter.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchSuggestionAdapter.kt deleted file mode 100644 index 74d5e7b08..000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchSuggestionAdapter.kt +++ /dev/null @@ -1,85 +0,0 @@ -package com.lagradost.cloudstream3.ui.search - -import android.view.LayoutInflater -import android.view.ViewGroup -import androidx.core.view.isGone -import com.lagradost.cloudstream3.databinding.SearchSuggestionFooterBinding -import com.lagradost.cloudstream3.databinding.SearchSuggestionItemBinding -import com.lagradost.cloudstream3.ui.BaseDiffCallback -import com.lagradost.cloudstream3.ui.NoStateAdapter -import com.lagradost.cloudstream3.ui.ViewHolderState -import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR -import com.lagradost.cloudstream3.ui.settings.Globals.TV -import com.lagradost.cloudstream3.ui.settings.Globals.isLayout - -const val SEARCH_SUGGESTION_CLICK = 0 -const val SEARCH_SUGGESTION_FILL = 1 -const val SEARCH_SUGGESTION_CLEAR = 2 - -data class SearchSuggestionCallback( - val suggestion: String, - val clickAction: Int, -) - -class SearchSuggestionAdapter( - private val clickCallback: (SearchSuggestionCallback) -> Unit, -) : NoStateAdapter(diffCallback = BaseDiffCallback(itemSame = { a, b -> a == b })) { - - // Add footer for all layouts - override val footers = 1 - - override fun submitList(list: Collection?, commitCallback: Runnable?) { - super.submitList(list, commitCallback) - // Notify footer to rebind when list changes to update visibility - if (footers > 0) { - notifyItemChanged(itemCount - 1) - } - } - - override fun onCreateContent(parent: ViewGroup): ViewHolderState { - return ViewHolderState( - SearchSuggestionItemBinding.inflate(LayoutInflater.from(parent.context), parent, false), - ) - } - - override fun onBindContent( - holder: ViewHolderState, - item: String, - position: Int - ) { - val binding = holder.view as? SearchSuggestionItemBinding ?: return - binding.apply { - suggestionText.text = item - - // Click on the whole item to search - suggestionItem.setOnClickListener { - clickCallback.invoke(SearchSuggestionCallback(item, SEARCH_SUGGESTION_CLICK)) - } - - // Click on the arrow to fill the search box without searching - suggestionFill.setOnClickListener { - clickCallback.invoke(SearchSuggestionCallback(item, SEARCH_SUGGESTION_FILL)) - } - } - } - - override fun onCreateFooter(parent: ViewGroup): ViewHolderState { - return ViewHolderState( - SearchSuggestionFooterBinding.inflate(LayoutInflater.from(parent.context), parent, false) - ) - } - - override fun onBindFooter(holder: ViewHolderState) { - val binding = holder.view as? SearchSuggestionFooterBinding ?: return - binding.clearSuggestionsButton.apply { - isGone = immutableCurrentList.isEmpty() - if (isLayout(TV or EMULATOR)) { - isFocusable = true - isFocusableInTouchMode = true - } - setOnClickListener { - clickCallback.invoke(SearchSuggestionCallback("", SEARCH_SUGGESTION_CLEAR)) - } - } - } -} diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchSuggestionApi.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchSuggestionApi.kt deleted file mode 100644 index 8dbd78178..000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchSuggestionApi.kt +++ /dev/null @@ -1,74 +0,0 @@ -package com.lagradost.cloudstream3.ui.search - -import com.fasterxml.jackson.annotation.JsonProperty -import com.lagradost.cloudstream3.app -import com.lagradost.cloudstream3.mvvm.logError -import com.lagradost.nicehttp.NiceResponse - -/** - * API for fetching search suggestions from external sources. - * Uses TheMovieDB API to provide movie/show/anime related suggestions. - */ -object SearchSuggestionApi { - private const val TMDB_API_URL = "https://api.themoviedb.org/3/search/multi" - private const val TMDB_API_KEY = "e6333b32409e02a4a6eba6fb7ff866bb" - - data class TmdbSearchResult( - @JsonProperty("results") val results: List? - ) - - data class TmdbSearchItem( - @JsonProperty("media_type") val mediaType: String?, - @JsonProperty("title") val title: String?, - @JsonProperty("name") val name: String?, - @JsonProperty("original_title") val originalTitle: String?, - @JsonProperty("original_name") val originalName: String? - ) - - /** - * Fetches search suggestions from TheMovieDB multi search API. - * Returns suggestions for movies, TV series, and anime. - * - * @param query The search query to get suggestions for - * @return List of suggestion strings, empty list on failure - */ - suspend fun getSuggestions(query: String): List { - if (query.isBlank() || query.length < 2) return emptyList() - - return try { - val response = app.get( - TMDB_API_URL, - params = mapOf( - "api_key" to TMDB_API_KEY, - "query" to query, - "language" to "en-US" - ), - cacheTime = 60 * 24 // Cache for 1 day (cacheUnit default is Minutes) - ) - - parseSuggestions(response) - } catch (e: Exception) { - logError(e) - emptyList() - } - } - - /** - * Parses the TMDB search response and extracts movie/TV show titles. - * Filters to only include movies, TV shows, and anime. - */ - private fun parseSuggestions(response: NiceResponse): List { - return try { - val parsed = response.parsed() - parsed.results - ?.filter { it.mediaType == "movie" || it.mediaType == "tv" } - ?.mapNotNull { it.title ?: it.name } - ?.distinct() - ?.take(10) - ?: emptyList() - } catch (e: Exception) { - logError(e) - emptyList() - } - } -} diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchViewModel.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchViewModel.kt index f60588e35..63fb8c10e 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchViewModel.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchViewModel.kt @@ -21,7 +21,6 @@ import com.lagradost.cloudstream3.utils.Coroutines.ioSafe import com.lagradost.cloudstream3.utils.DataStoreHelper.currentAccount import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job -import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -44,12 +43,7 @@ class SearchViewModel : ViewModel() { private val _currentHistory: MutableLiveData> = MutableLiveData() val currentHistory: LiveData> get() = _currentHistory - private val _searchSuggestions: MutableLiveData> = MutableLiveData() - val searchSuggestions: LiveData> get() = _searchSuggestions - - private var suggestionJob: Job? = null - - private var repos = apis.withLock { apis.map { APIRepository(it) } } + private var repos = synchronized(apis) { apis.map { APIRepository(it) } } fun clearSearch() { _searchResponse.postValue(Resource.Success(ExpandableSearchList(emptyList(), 0, false))) @@ -68,7 +62,7 @@ class SearchViewModel : ViewModel() { private var onGoingSearch: Job? = null fun reloadRepos() { - repos = apis.withLock { apis.map { APIRepository(it) } } + repos = synchronized(apis) { apis.map { APIRepository(it) } } } fun searchAndCancel( @@ -89,35 +83,6 @@ class SearchViewModel : ViewModel() { _currentHistory.postValue(items) } - /** - * Fetches search suggestions with debouncing. - * Waits 300ms before making the API call to avoid too many requests. - * - * @param query The search query to get suggestions for - */ - fun fetchSuggestions(query: String) { - suggestionJob?.cancel() - - if (query.isBlank() || query.length < 2) { - _searchSuggestions.postValue(emptyList()) - return - } - - suggestionJob = ioSafe { - delay(300) // Debounce - val suggestions = SearchSuggestionApi.getSuggestions(query) - _searchSuggestions.postValue(suggestions) - } - } - - /** - * Clears the current search suggestions. - */ - fun clearSuggestions() { - suggestionJob?.cancel() - _searchSuggestions.postValue(emptyList()) - } - private val lock: MutableSet = mutableSetOf() // ExpandableHomepageList because the home adapter is reused in the search fragment diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsAccount.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsAccount.kt index 8d96a6b14..53d29cdb8 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,16 +28,13 @@ 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 import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.simklApi import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.subDlApi 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 @@ -465,12 +462,10 @@ class SettingsAccount : BasePreferenceFragmentCompat(), BiometricCallback { val syncApis = listOf( R.string.mal_key to SyncRepo(malApi), - R.string.kitsu_key to SyncRepo(kitsuApi), R.string.anilist_key to SyncRepo(aniListApi), R.string.simkl_key to SyncRepo(simklApi), R.string.opensubtitles_key to SubtitleRepo(openSubtitlesApi), 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/SettingsFragment.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsFragment.kt index e41109b59..097eb2c60 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsFragment.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsFragment.kt @@ -27,7 +27,6 @@ import com.lagradost.cloudstream3.ui.settings.Globals.TV import com.lagradost.cloudstream3.ui.settings.Globals.isLandscape import com.lagradost.cloudstream3.ui.settings.Globals.isLayout import com.lagradost.cloudstream3.utils.DataStoreHelper -import com.lagradost.cloudstream3.utils.GitInfo.currentCommitHash import com.lagradost.cloudstream3.utils.ImageLoader.loadImage import com.lagradost.cloudstream3.utils.UIHelper.clipboardHelper import com.lagradost.cloudstream3.utils.UIHelper.fixSystemBarsPadding @@ -248,7 +247,7 @@ class SettingsFragment : BaseFragment( } val appVersion = BuildConfig.VERSION_NAME - val commitHash = activity?.currentCommitHash() ?: "" + val commitInfo = getString(R.string.commit_hash) val buildTimestamp = SimpleDateFormat.getDateTimeInstance(DateFormat.LONG, DateFormat.LONG, Locale.getDefault() ).apply { timeZone = TimeZone.getTimeZone("UTC") @@ -256,9 +255,8 @@ class SettingsFragment : BaseFragment( binding.appVersion.text = appVersion binding.buildDate.text = buildTimestamp - binding.commitHash.text = commitHash binding.appVersionInfo.setOnLongClickListener { - clipboardHelper(txt(R.string.extension_version), "$appVersion $commitHash $buildTimestamp") + clipboardHelper(txt(R.string.extension_version), "$appVersion $commitInfo $buildTimestamp") true } } 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 57f5aa870..4c64b175b 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,7 +8,6 @@ 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 @@ -45,9 +44,8 @@ import com.lagradost.cloudstream3.utils.UIHelper.dismissSafe import com.lagradost.cloudstream3.utils.UIHelper.hideKeyboard import com.lagradost.cloudstream3.utils.UIHelper.navigate import com.lagradost.cloudstream3.utils.USER_PROVIDER_API -import com.lagradost.cloudstream3.utils.downloader.DownloadFileManagement -import com.lagradost.cloudstream3.utils.downloader.DownloadFileManagement.getBasePath -import com.lagradost.cloudstream3.utils.downloader.DownloadQueueManager +import com.lagradost.cloudstream3.utils.VideoDownloadManager +import com.lagradost.cloudstream3.utils.VideoDownloadManager.getBasePath import java.util.Locale // Change local language settings in the app. @@ -156,23 +154,16 @@ class SettingsGeneral : BasePreferenceFragmentCompat() { val lang: String, ) - companion object { - fun Fragment.pickDownloadPath(uri: Uri?, path: String?) { - if (uri == null) return - - val context = context ?: CloudStreamApp.context ?: return - val visual = path ?: uri.toString() + private val pathPicker = getChooseFolderLauncher { uri, path -> + val context = context ?: CloudStreamApp.context ?: return@getChooseFolderLauncher + (path ?: uri.toString()).let { PreferenceManager.getDefaultSharedPreferences(context).edit { putString(getString(R.string.download_path_key), uri.toString()) - putString(context.getString(R.string.download_path_key_visual), visual) + putString(getString(R.string.download_path_key_visual), it) } } } - private val pathPicker = getChooseFolderLauncher { uri, path -> - pickDownloadPath(uri, path) - } - override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { hideKeyboard() setPreferencesFromResource(R.xml.settings_general, rootKey) @@ -219,7 +210,7 @@ class SettingsGeneral : BasePreferenceFragmentCompat() { } fun showAdd() { - val providers = allProviders.distinctBy { it::class }.sortedBy { it.name } + val providers = synchronized(allProviders) { allProviders.distinctBy { it.javaClass }.sortedBy { it.name } } activity?.showDialog( providers.map { "${it.name} (${it.mainUrl})" }, -1, @@ -337,7 +328,7 @@ class SettingsGeneral : BasePreferenceFragmentCompat() { fun getDownloadDirs(): List { return safe { context?.let { ctx -> - val defaultDir = DownloadFileManagement.getDefaultDir(ctx)?.filePath() + val defaultDir = VideoDownloadManager.getDefaultDir(ctx)?.filePath() val first = listOf(defaultDir) (try { @@ -359,18 +350,12 @@ class SettingsGeneral : BasePreferenceFragmentCompat() { return@setOnPreferenceChangeListener true } - getPref(R.string.download_parallel_key)?.setOnPreferenceChangeListener { _, _ -> - // Notify that the queue logic has been changed - DownloadQueueManager.forceRefreshQueue() - return@setOnPreferenceChangeListener true - } - getPref(R.string.download_path_key)?.setOnPreferenceClickListener { val dirs = getDownloadDirs() val currentDir = settingsManager.getString(getString(R.string.download_path_key_visual), null) - ?: context?.let { ctx -> DownloadFileManagement.getDefaultDir(ctx)?.filePath() } + ?: context?.let { ctx -> VideoDownloadManager.getDefaultDir(ctx)?.filePath() } activity?.showBottomDialog( dirs + listOf(getString(R.string.custom)), diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsPlayer.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsPlayer.kt index 0a0fb33c8..e301e8cc4 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsPlayer.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsPlayer.kt @@ -9,7 +9,6 @@ import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.actions.VideoClickActionHolder import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.ui.BasePreferenceFragmentCompat -import com.lagradost.cloudstream3.ui.player.source_priority.QualityProfileDialog import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR import com.lagradost.cloudstream3.ui.settings.Globals.PHONE import com.lagradost.cloudstream3.ui.settings.Globals.TV @@ -22,11 +21,9 @@ import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setTool import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setUpToolbar import com.lagradost.cloudstream3.ui.subtitles.ChromecastSubtitlesFragment import com.lagradost.cloudstream3.ui.subtitles.SubtitlesFragment -import com.lagradost.cloudstream3.utils.Coroutines.ioSafe import com.lagradost.cloudstream3.utils.Qualities import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showBottomDialog import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showDialog -import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showMultiDialog import com.lagradost.cloudstream3.utils.UIHelper.hideKeyboard class SettingsPlayer : BasePreferenceFragmentCompat() { @@ -36,7 +33,6 @@ class SettingsPlayer : BasePreferenceFragmentCompat() { setPaddingBottom() setToolBarScrollFlags() } - override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { hideKeyboard() setPreferencesFromResource(R.xml.settings_player, rootKey) @@ -52,7 +48,7 @@ class SettingsPlayer : BasePreferenceFragmentCompat() { ), TV or EMULATOR ) - + getPref(R.string.preview_seekbar_key)?.hideOn(TV) getPref(R.string.pref_category_android_tv_key)?.hideOn(PHONE) @@ -115,39 +111,23 @@ class SettingsPlayer : BasePreferenceFragmentCompat() { return@setOnPreferenceClickListener true } - getPref(R.string.prefer_limit_show_player_info)?.setOnPreferenceClickListener { - val ctx = context ?: return@setOnPreferenceClickListener false + getPref(R.string.prefer_limit_title_rez_key)?.setOnPreferenceClickListener { + val prefNames = resources.getStringArray(R.array.limit_title_rez_pref_names) + val prefValues = resources.getIntArray(R.array.limit_title_rez_pref_values) + val current = settingsManager.getInt(getString(R.string.prefer_limit_title_rez_key), 3) - val prefNames = resources.getStringArray(R.array.title_info_pref_names) - val keys = resources.getStringArray(R.array.title_info_pref_values) - - // Player defaults - val playerDefaults = mapOf( - ctx.getString(R.string.show_name_key) to true, - ctx.getString(R.string.show_resolution_key) to true, - ctx.getString(R.string.show_media_info_key) to false - ) - - val selectedIndices = keys.map { key -> - settingsManager.getBoolean(key, playerDefaults[key] ?: false) - }.mapIndexedNotNull { index, enabled -> - if (enabled) index else null - } - - activity?.showMultiDialog( + activity?.showBottomDialog( prefNames.toList(), - selectedIndices, + prefValues.indexOf(current), getString(R.string.limit_title_rez), + true, {} - ) { selected -> + ) { settingsManager.edit { - for ((index, key) in keys.withIndex()) { - putBoolean(key, selected.contains(index)) - } + putInt(getString(R.string.prefer_limit_title_rez_key), prefValues[it]) } } - - true + return@setOnPreferenceClickListener true } getPref(R.string.hide_player_control_names_key)?.hideOn(TV) @@ -214,8 +194,7 @@ class SettingsPlayer : BasePreferenceFragmentCompat() { add("") addAll(players.map { it.uniqueId() }) } - val current = - settingsManager.getString(getString(R.string.player_default_key), "") ?: "" + val current = settingsManager.getString(getString(R.string.player_default_key), "") ?: "" activity?.showBottomDialog( prefNames.toList(), @@ -241,21 +220,6 @@ class SettingsPlayer : BasePreferenceFragmentCompat() { return@setOnPreferenceClickListener true } - getPref(R.string.player_source_priority_key)?.setOnPreferenceClickListener { - ioSafe { - val defaultSources = QualityProfileDialog.getAllDefaultSources() - val activity = activity ?: return@ioSafe - activity.runOnUiThread { - QualityProfileDialog( - activity, - R.style.DialogFullscreenPlayer, - defaultSources, - ).show() - } - } - return@setOnPreferenceClickListener true - } - getPref(R.string.video_buffer_disk_key)?.setOnPreferenceClickListener { val prefNames = resources.getStringArray(R.array.video_buffer_size_names) val prefValues = resources.getIntArray(R.array.video_buffer_size_values) diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsProviders.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsProviders.kt index c8478a840..076f17a0a 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsProviders.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsProviders.kt @@ -111,10 +111,10 @@ class SettingsProviders : BasePreferenceFragmentCompat() { getPref(R.string.provider_lang_key)?.setOnPreferenceClickListener { activity?.getApiProviderLangSettings()?.let { currentLangTags -> - val languagesTagName = APIHolder.apis.withLock { - listOf(Pair(AllLanguagesName, getString(R.string.all_languages_preference))) + - APIHolder.apis.map { Pair(it.lang, getNameNextToFlagEmoji(it.lang) ?: it.lang) } - .toSet().sortedBy { it.second.substringAfter("\u00a0").lowercase() } + val languagesTagName = synchronized(APIHolder.apis) { + listOf( Pair(AllLanguagesName, getString(R.string.all_languages_preference)) ) + + APIHolder.apis.map { Pair(it.lang, getNameNextToFlagEmoji(it.lang) ?: it.lang) } + .toSet().sortedBy { it.second.substringAfter("\u00a0").lowercase() } // name ignoring flag emoji } val currentIndexList = currentLangTags.map { langTag -> diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsUI.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsUI.kt index f4c522bf9..33add0e95 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsUI.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsUI.kt @@ -12,13 +12,13 @@ import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.SearchQuality import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.ui.BasePreferenceFragmentCompat -import com.lagradost.cloudstream3.ui.clear import com.lagradost.cloudstream3.ui.home.HomeChildItemAdapter import com.lagradost.cloudstream3.ui.home.ParentItemAdapter import com.lagradost.cloudstream3.ui.search.SearchAdapter import com.lagradost.cloudstream3.ui.search.SearchResultBuilder 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.updateTv import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.getPref import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.hideOn @@ -44,6 +44,8 @@ class SettingsUI : BasePreferenceFragmentCompat() { setPreferencesFromResource(R.xml.settings_ui, rootKey) val settingsManager = PreferenceManager.getDefaultSharedPreferences(requireContext()) + getPref(R.string.random_button_key)?.hideOn(EMULATOR or TV) + (getPref(R.string.overscan_key)?.hideOn(PHONE or EMULATOR) as? SeekBarPreference)?.setOnPreferenceChangeListener { pref, newValue -> val padding = (newValue as? Int)?.toPx ?: return@setOnPreferenceChangeListener true (pref.context.getActivity() as? MainActivity)?.binding?.homeRoot?.setPadding(padding, padding, padding, padding) diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsUpdates.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsUpdates.kt index c04215594..148f0ee5b 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 @@ -23,6 +23,7 @@ import com.lagradost.cloudstream3.plugins.PluginManager import com.lagradost.cloudstream3.services.BackupWorkManager import com.lagradost.cloudstream3.ui.BasePreferenceFragmentCompat import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR +import com.lagradost.cloudstream3.ui.settings.Globals.TV import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.getPref import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.hideOn import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setPaddingBottom @@ -39,7 +40,7 @@ import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showDialog import com.lagradost.cloudstream3.utils.UIHelper.clipboardHelper import com.lagradost.cloudstream3.utils.UIHelper.dismissSafe import com.lagradost.cloudstream3.utils.UIHelper.hideKeyboard -import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager +import com.lagradost.cloudstream3.utils.VideoDownloadManager import com.lagradost.cloudstream3.utils.txt import java.io.BufferedReader import java.io.InputStreamReader @@ -58,8 +59,6 @@ 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 { @@ -69,6 +68,7 @@ class SettingsUpdates : BasePreferenceFragmentCompat() { } } + @Suppress("DEPRECATION_ERROR") override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { hideKeyboard() setPreferencesFromResource(R.xml.settings_updates, rootKey) @@ -207,9 +207,8 @@ 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), 1) + settingsManager.getInt(getString(R.string.apk_installer_key), 0) 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 af0d3dfe7..bc85cc478 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,14 +119,13 @@ class ExtensionsFragment : BaseFragment( }, { repo -> // Prompt user before deleting repo main { - val uiContext = context ?: binding.root.context - val builder = AlertDialog.Builder(uiContext) + val builder = AlertDialog.Builder(context ?: binding.root.context) val dialogClickListener = DialogInterface.OnClickListener { _, which -> when (which) { DialogInterface.BUTTON_POSITIVE -> { ioSafe { - RepositoryManager.removeRepository(uiContext.applicationContext, repo) + RepositoryManager.removeRepository(binding.root.context, repo) extensionViewModel.loadStats() extensionViewModel.loadRepositories() } @@ -137,7 +136,9 @@ class ExtensionsFragment : BaseFragment( } builder.setTitle(R.string.delete_repository) - .setMessage(uiContext.getString(R.string.delete_repository_plugins)) + .setMessage( + context?.getString(R.string.delete_repository_plugins) + ) .setPositiveButton(R.string.delete, dialogClickListener) .setNegativeButton(R.string.cancel, dialogClickListener) .show().setDefaultFocus() @@ -209,9 +210,9 @@ class ExtensionsFragment : BaseFragment( binding.applyBtt.setOnClickListener secondListener@{ val name = binding.repoNameInput.text?.toString() - val urlInput = binding.repoUrlInput.text?.toString() ioSafe { - val url = urlInput?.let { it1 -> RepositoryManager.parseRepoUrl(it1) } + val url = binding.repoUrlInput.text?.toString() + ?.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/PluginAdapter.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/PluginAdapter.kt index d0f9ff565..47b0b3da3 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/PluginAdapter.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/PluginAdapter.kt @@ -8,6 +8,7 @@ import android.view.ViewGroup import androidx.appcompat.app.AppCompatActivity import androidx.core.view.isGone import androidx.core.view.isVisible +import androidx.recyclerview.widget.RecyclerView import androidx.viewbinding.ViewBinding import com.lagradost.cloudstream3.CloudStreamApp.Companion.getActivity import com.lagradost.cloudstream3.PROVIDER_STATUS_DOWN @@ -15,13 +16,15 @@ import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.TvType import com.lagradost.cloudstream3.databinding.RepositoryItemBinding import com.lagradost.cloudstream3.plugins.PluginManager +import com.lagradost.cloudstream3.plugins.VotingApi.getVotes import com.lagradost.cloudstream3.ui.BaseDiffCallback import com.lagradost.cloudstream3.ui.NoStateAdapter import com.lagradost.cloudstream3.ui.ViewHolderState -import com.lagradost.cloudstream3.ui.newSharedPool import com.lagradost.cloudstream3.ui.settings.Globals.TV import com.lagradost.cloudstream3.ui.settings.Globals.isLayout import com.lagradost.cloudstream3.utils.AppContextUtils.html +import com.lagradost.cloudstream3.utils.Coroutines.ioSafe +import com.lagradost.cloudstream3.utils.Coroutines.main import com.lagradost.cloudstream3.utils.ImageLoader.loadImage import com.lagradost.cloudstream3.utils.SubtitleHelper.getNameNextToFlagEmoji import com.lagradost.cloudstream3.utils.UIHelper.toPx @@ -201,7 +204,7 @@ class PluginAdapter( companion object { // A high count as we can render in the entire list as the same time val sharedPool = - newSharedPool { setMaxRecycledViews(CONTENT, 15) } + RecyclerView.RecycledViewPool().apply { this.setMaxRecycledViews(CONTENT, 15) } private tailrec fun findClosestBase2(target: Int, current: Int = 16, max: Int = 512): Int { if (current >= max) return max diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/PluginsFragment.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/PluginsFragment.kt index 534ffa62a..ee333abad 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/PluginsFragment.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/PluginsFragment.kt @@ -15,7 +15,6 @@ import com.lagradost.cloudstream3.ui.BaseFragment import com.lagradost.cloudstream3.ui.home.HomeFragment.Companion.bindChips import com.lagradost.cloudstream3.ui.result.FOCUS_SELF import com.lagradost.cloudstream3.ui.result.setLinearListLayout -import com.lagradost.cloudstream3.ui.setRecycledViewPool import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR import com.lagradost.cloudstream3.ui.settings.Globals.TV import com.lagradost.cloudstream3.ui.settings.Globals.isLayout 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 0cbef9cf2..e0fd906b4 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 @@ -23,7 +23,7 @@ import com.lagradost.cloudstream3.utils.txt import com.lagradost.cloudstream3.utils.Coroutines.ioSafe import com.lagradost.cloudstream3.utils.Coroutines.main import com.lagradost.cloudstream3.utils.Coroutines.runOnMainThread -import com.lagradost.cloudstream3.utils.Levenshtein +import me.xdrop.fuzzywuzzy.FuzzySearch import java.io.File // String => repository url @@ -128,7 +128,6 @@ class PluginsViewModel : ViewModel() { PluginManager.downloadPlugin( activity, metadata.url, - metadata.fileHash, metadata.internalName, repo, metadata.status != PROVIDER_STATUS_DOWN @@ -180,7 +179,6 @@ class PluginsViewModel : ViewModel() { PluginManager.downloadPlugin( activity, metadata.url, - metadata.fileHash, metadata.internalName, repo, isEnabled @@ -246,7 +244,7 @@ class PluginsViewModel : ViewModel() { this.sortedBy { it.plugin.second.name } } else { this.sortedBy { - -Levenshtein.partialRatio( + -FuzzySearch.partialRatio( it.plugin.second.name.lowercase(), query.lowercase() ) 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 4ec005a09..37677c1d8 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) } - observe(testViewModel.providerResults) { + observeNullable(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/testing/TestView.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/testing/TestView.kt index 65ed47a54..eea495a26 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/testing/TestView.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/testing/TestView.kt @@ -9,7 +9,6 @@ import androidx.annotation.DrawableRes import androidx.annotation.StringRes import androidx.cardview.widget.CardView import androidx.core.content.ContextCompat -import androidx.core.content.withStyledAttributes import androidx.core.view.isVisible import androidx.core.widget.ContentLoadingProgressBar import com.google.android.material.button.MaterialButton @@ -60,9 +59,10 @@ class TestView @JvmOverloads constructor( playPauseButton = findViewById(R.id.tests_play_pause) attrs?.let { - context.withStyledAttributes(it, R.styleable.TestView) { - mainSectionHeader?.text = getString(R.styleable.TestView_header_text) - } + val typedArray = context.obtainStyledAttributes(it, R.styleable.TestView) + val headerText = typedArray.getString(R.styleable.TestView_header_text) + mainSectionHeader?.text = headerText + typedArray.recycle() } playPauseButton?.setOnClickListener { diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/testing/TestViewModel.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/testing/TestViewModel.kt index 22500d931..818f1fd79 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/testing/TestViewModel.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/testing/TestViewModel.kt @@ -5,7 +5,7 @@ import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import com.lagradost.cloudstream3.APIHolder import com.lagradost.cloudstream3.MainAPI -import com.lagradost.cloudstream3.utils.Coroutines.atomicListOf +import com.lagradost.cloudstream3.utils.Coroutines.threadSafeListOf import com.lagradost.cloudstream3.utils.TestingUtils import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -40,7 +40,7 @@ class TestViewModel : ViewModel() { get() = scope != null private var filter = ProviderFilter.All - private val providers = atomicListOf>() + private val providers = threadSafeListOf>() private var passed = 0 private var failed = 0 private var total = 0 @@ -51,9 +51,9 @@ class TestViewModel : ViewModel() { } private fun postProviders() { - providers.withLock { + synchronized(providers) { val filtered = when (filter) { - ProviderFilter.All -> providers.toList() + ProviderFilter.All -> providers ProviderFilter.Passed -> providers.filter { it.second.success } ProviderFilter.Failed -> providers.filter { !it.second.success } } @@ -68,7 +68,7 @@ class TestViewModel : ViewModel() { } private fun addProvider(api: MainAPI, results: TestingUtils.TestResultProvider) { - providers.withLock { + synchronized(providers) { val index = providers.indexOfFirst { it.first == api } if (index == -1) { providers.add(api to results) @@ -81,14 +81,14 @@ class TestViewModel : ViewModel() { } fun init() { - total = APIHolder.allProviders.withLock { APIHolder.allProviders.size } + total = synchronized(APIHolder.allProviders) { APIHolder.allProviders.size } updateProgress() } fun startTest() { scope = CoroutineScope(Dispatchers.Default) - val apis = APIHolder.allProviders.withLock { APIHolder.allProviders.toTypedArray() } + val apis = synchronized(APIHolder.allProviders) { APIHolder.allProviders.toTypedArray() } total = apis.size failed = 0 passed = 0 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 dfc931174..08a79b4b4 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,10 +10,7 @@ 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) { - dirSelected(null, null) - return@registerForActivityResult - } + if (uri == 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/ui/setup/SetupFragmentExtensions.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/setup/SetupFragmentExtensions.kt index 8c2e8e344..501ee0eef 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/setup/SetupFragmentExtensions.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/setup/SetupFragmentExtensions.kt @@ -84,7 +84,7 @@ class SetupFragmentExtensions : BaseFragment( if (isSetup) if ( // If any available languages - apis.distinctBy { it.lang }.size > 1 + synchronized(apis) { apis.distinctBy { it.lang }.size > 1 } ) { findNavController().navigate(R.id.action_navigation_setup_extensions_to_navigation_setup_provider_languages) } else { diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/setup/SetupFragmentProviderLanguage.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/setup/SetupFragmentProviderLanguage.kt index c18be8a2f..3c4a09ade 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/setup/SetupFragmentProviderLanguage.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/setup/SetupFragmentProviderLanguage.kt @@ -36,10 +36,10 @@ class SetupFragmentProviderLanguage : BaseFragment diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/AniSkip.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/AniSkip.kt new file mode 100644 index 000000000..820a01f9f --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/AniSkip.kt @@ -0,0 +1,139 @@ +package com.lagradost.cloudstream3.utils + +import android.util.Log +import androidx.annotation.StringRes +import com.fasterxml.jackson.databind.annotation.JsonSerialize +import com.lagradost.cloudstream3.* +import com.lagradost.cloudstream3.LoadResponse.Companion.getMalId +import com.lagradost.cloudstream3.mvvm.logError +import com.lagradost.cloudstream3.ui.result.ResultEpisode +import java.lang.Long.min + +object EpisodeSkip { + private const val TAG = "EpisodeSkip" + + enum class SkipType(@StringRes name: Int) { + Opening(R.string.skip_type_op), + Ending(R.string.skip_type_ed), + 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), + Intro(R.string.skip_type_creddits), + } + + data class SkipStamp( + val type: SkipType, + val skipToNextEpisode: Boolean, + val startMs: Long, + val endMs: Long, + ) { + val uiText = if (skipToNextEpisode) txt(R.string.next_episode) else txt( + R.string.skip_type_format, + txt(type.name) + ) + } + + private val cachedStamps = HashMap>() + + private fun shouldSkipToNextEpisode(endMs: Long, episodeDurationMs: Long): Boolean { + return episodeDurationMs - endMs < 20_000L // some might have outro that we don't care about tbh + } + + suspend fun getStamps( + data: LoadResponse, + episode: ResultEpisode, + episodeDurationMs: Long, + hasNextEpisode: Boolean, + ): List { + cachedStamps[episode.id]?.let { list -> + return list + } + + val out = mutableListOf() + Log.i(TAG, "Requesting SkipStamp from ${data.syncData}") + + if (data is AnimeLoadResponse && (data.type == TvType.Anime || data.type == TvType.OVA)) { + data.getMalId()?.toIntOrNull()?.let { malId -> + val (resultLength, stamps) = AniSkip.getResult( + malId, + episode.episode, + episodeDurationMs + ) ?: return@let null + // because it also returns an expected episode length we use that just in case it is mismatched with like 2s next episode will still work + val dur = min(episodeDurationMs, resultLength) + stamps.mapNotNull { stamp -> + val skipType = when (stamp.skipType) { + "op" -> SkipType.Opening + "ed" -> SkipType.Ending + "recap" -> SkipType.Recap + "mixed-ed" -> SkipType.MixedEnding + "mixed-op" -> SkipType.MixedOpening + else -> null + } ?: return@mapNotNull null + val end = (stamp.interval.endTime * 1000.0).toLong() + val start = (stamp.interval.startTime * 1000.0).toLong() + SkipStamp( + type = skipType, + skipToNextEpisode = hasNextEpisode && shouldSkipToNextEpisode( + end, + dur + ), + startMs = start, + endMs = end + ) + }.let { list -> + out.addAll(list) + } + } + } + if (out.isNotEmpty()) + cachedStamps[episode.id] = out + return out + } +} + +// taken from https://github.com/saikou-app/saikou/blob/3803f8a7a59b826ca193664d46af3a22bbc989f7/app/src/main/java/ani/saikou/others/AniSkip.kt +// the following is GPLv3 code https://github.com/saikou-app/saikou/blob/main/LICENSE.md +object AniSkip { + private const val TAG = "AniSkip" + suspend fun getResult( + malId: Int, + episodeNumber: Int, + episodeLength: Long + ): Pair>? { + return try { + val url = + "https://api.aniskip.com/v2/skip-times/$malId/$episodeNumber?types[]=ed&types[]=mixed-ed&types[]=mixed-op&types[]=op&types[]=recap&episodeLength=${episodeLength / 1000L}" + Log.i(TAG, "Requesting $url") + + val a = app.get(url) + val res = a.parsed() + Log.i(TAG, "Found ${res.found} with ${res.results?.size} results") + if (res.found && !res.results.isNullOrEmpty()) (res.results[0].episodeLength * 1000).toLong() to res.results else null + } catch (t: Throwable) { + Log.i(TAG, "error = ${t.message}") + logError(t) + null + } + } + + data class AniSkipResponse( + @JsonSerialize val found: Boolean, + @JsonSerialize val results: List?, + @JsonSerialize val message: String?, + @JsonSerialize val statusCode: Int + ) + + data class Stamp( + @JsonSerialize val interval: AniSkipInterval, + @JsonSerialize val skipType: String, + @JsonSerialize val skipId: String, + @JsonSerialize val episodeLength: Double + ) + + data class AniSkipInterval( + @JsonSerialize val startTime: Double, + @JsonSerialize val endTime: Double + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/AppContextUtils.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/AppContextUtils.kt index 7278fcdd7..65f928cc4 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/AppContextUtils.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/AppContextUtils.kt @@ -40,6 +40,7 @@ import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentActivity import androidx.navigation.fragment.findNavController import androidx.preference.PreferenceManager +import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import androidx.tvprovider.media.tv.PreviewChannelHelper @@ -85,7 +86,6 @@ import com.lagradost.cloudstream3.utils.DataStoreHelper.getLastWatched import com.lagradost.cloudstream3.utils.FillerEpisodeCheck.toClassDir import com.lagradost.cloudstream3.utils.JsUnpacker.Companion.load import com.lagradost.cloudstream3.utils.UIHelper.navigate -import com.lagradost.cloudstream3.utils.downloader.DownloadObjects import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import okhttp3.Cache @@ -152,7 +152,7 @@ object AppContextUtils { private fun buildWatchNextProgramUri( context: Context, card: DataStoreHelper.ResumeWatchingResult, - resumeWatching: DownloadObjects.ResumeWatching? + resumeWatching: VideoDownloadHelper.ResumeWatching? ): WatchNextProgram { val isSeries = card.type?.isMovieType() == false val title = if (isSeries) { @@ -319,7 +319,7 @@ object AppContextUtils { val context = this continueWatchingLock.withLock { // A way to get all last watched timestamps - val timeStampHashMap = HashMap() + val timeStampHashMap = HashMap() getAllResumeStateIds()?.forEach { id -> val lastWatched = getLastWatched(id) ?: return@forEach timeStampHashMap[lastWatched.parentId] = lastWatched @@ -369,10 +369,28 @@ object AppContextUtils { } fun Context.getApiSettings(): HashSet { + //val settingsManager = PreferenceManager.getDefaultSharedPreferences(this) + val hashSet = HashSet() val activeLangs = getApiProviderLangSettings() val hasUniversal = activeLangs.contains(AllLanguagesName) - hashSet.addAll(apis.filter { hasUniversal || activeLangs.contains(it.lang) }.map { it.name }) + hashSet.addAll(synchronized(apis) { apis.filter { hasUniversal || activeLangs.contains(it.lang) } } + .map { it.name }) + + /*val set = settingsManager.getStringSet( + this.getString(R.string.search_providers_list_key), + hashSet + )?.toHashSet() ?: hashSet + + val list = HashSet() + for (name in set) { + val api = getApiFromNameNull(name) ?: continue + if (activeLangs.contains(api.lang)) { + list.add(name) + } + }*/ + //if (list.isEmpty()) return hashSet + //return list return hashSet } @@ -431,14 +449,6 @@ object AppContextUtils { return settingsManager.getBoolean(this.getString(R.string.show_trailers_key), true) } - fun Context.shouldShowPlayerMetadata(): Boolean { - val prefs = PreferenceManager.getDefaultSharedPreferences(this) - return prefs.getBoolean( - getString(R.string.show_player_metadata_key), - true - ) - } - fun Context.filterProviderByPreferredMedia(hasHomePageIsRequired: Boolean = true): List { // We are getting the weirdest crash ever done: // java.lang.ClassCastException: com.lagradost.cloudstream3.TvType cannot be cast to com.lagradost.cloudstream3.TvType @@ -463,7 +473,9 @@ object AppContextUtils { } ?: default val langs = this.getApiProviderLangSettings() val hasUniversal = langs.contains(AllLanguagesName) - val allApis = apis.filter { api -> (hasUniversal || langs.contains(api.lang)) && (api.hasMainPage || !hasHomePageIsRequired) } + val allApis = synchronized(apis) { + apis.filter { api -> (hasUniversal || langs.contains(api.lang)) && (api.hasMainPage || !hasHomePageIsRequired) } + } return if (currentPrefMedia.isEmpty()) { allApis } else { diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/BackPressedCallbackHelper.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/BackPressedCallbackHelper.kt index 10736e13e..d68b254b0 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/BackPressedCallbackHelper.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/BackPressedCallbackHelper.kt @@ -56,7 +56,7 @@ object BackPressedCallbackHelper { fun ComponentActivity.detachBackPressedCallback(id: String) { val callbackMap = backPressedCallbacks[this] ?: return callbackMap[id]?.let { callback -> - callback.remove() + callback.isEnabled = false callbackMap.remove(id) } diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/BackupUtils.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/BackupUtils.kt index 62426197e..90305182e 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/BackupUtils.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/BackupUtils.kt @@ -10,6 +10,7 @@ import androidx.core.net.toUri import androidx.fragment.app.FragmentActivity import androidx.preference.PreferenceManager import com.fasterxml.jackson.annotation.JsonProperty +import com.fasterxml.jackson.module.kotlin.readValue import com.lagradost.cloudstream3.CloudStreamApp.Companion.getActivity import com.lagradost.cloudstream3.CommonActivity.showToast import com.lagradost.cloudstream3.R @@ -19,21 +20,16 @@ import com.lagradost.cloudstream3.plugins.PLUGINS_KEY_LOCAL import com.lagradost.cloudstream3.syncproviders.AccountManager import com.lagradost.cloudstream3.syncproviders.providers.AniListApi.Companion.ANILIST_CACHED_LIST import com.lagradost.cloudstream3.syncproviders.providers.MALApi.Companion.MAL_CACHED_LIST -import com.lagradost.cloudstream3.syncproviders.providers.KitsuApi.Companion.KITSU_CACHED_LIST -import com.lagradost.cloudstream3.utils.AppUtils.parseJson -import com.lagradost.cloudstream3.utils.AppUtils.toJson import com.lagradost.cloudstream3.utils.Coroutines.ioSafe import com.lagradost.cloudstream3.utils.Coroutines.main import com.lagradost.cloudstream3.utils.DataStore.getDefaultSharedPrefs import com.lagradost.cloudstream3.utils.DataStore.getSharedPrefs +import com.lagradost.cloudstream3.utils.DataStore.mapper import com.lagradost.cloudstream3.utils.UIHelper.checkWrite import com.lagradost.cloudstream3.utils.UIHelper.requestRW -import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager.setupStream -import com.lagradost.cloudstream3.utils.downloader.DownloadObjects -import com.lagradost.cloudstream3.utils.downloader.DownloadQueueManager.QUEUE_KEY -import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager.KEY_DOWNLOAD_INFO -import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager.KEY_RESUME_IN_QUEUE -import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager.KEY_RESUME_PACKAGES +import com.lagradost.cloudstream3.utils.VideoDownloadManager.StreamData +import com.lagradost.cloudstream3.utils.VideoDownloadManager.getBasePath +import com.lagradost.cloudstream3.utils.VideoDownloadManager.setupStream import com.lagradost.safefile.MediaFileContentType import com.lagradost.safefile.SafeFile import okhttp3.internal.closeQuietly @@ -53,7 +49,6 @@ object BackupUtils { private val nonTransferableKeys = listOf( ANILIST_CACHED_LIST, MAL_CACHED_LIST, - KITSU_CACHED_LIST, // The plugins themselves are not backed up PLUGINS_KEY, @@ -62,7 +57,6 @@ object BackupUtils { AccountManager.ACCOUNT_TOKEN, AccountManager.ACCOUNT_IDS, - // TODO proper getter for string res keys to ensure that they are updated "biometric_key", // can lock down users if backup is shared on a incompatible device "nginx_user", // Nginx user key @@ -83,31 +77,6 @@ object BackupUtils { "open_subtitles_user", "subdl_user", "simkl_token", - - - // Downloads can not be restored from backups. - // The download path URI can not be transferred. - // In the future we may potentially write metadata to files in the download directory - // and make it possible to restore download folders using that metadata. - DOWNLOAD_EPISODE_CACHE_BACKUP, - DOWNLOAD_EPISODE_CACHE, - - // Download headers are unintuitively used in the resume watching system. - // We can therefore not prune download headers in backups. - //DOWNLOAD_HEADER_CACHE_BACKUP, - //DOWNLOAD_HEADER_CACHE, - - - // This may overwrite valid local data with invalid data - KEY_DOWNLOAD_INFO, - - // Prevent backups from automatically starting downloads - KEY_RESUME_IN_QUEUE, - KEY_RESUME_PACKAGES, - QUEUE_KEY, - - // Prevent automatic plugin download after restoring backup - "auto_download_plugins_key2" ) /** false if key should not be contained in backup */ @@ -133,7 +102,9 @@ object BackupUtils { ) @Suppress("UNCHECKED_CAST") - private fun getBackup(context: Context): BackupFile { + private fun getBackup(context: Context?): BackupFile? { + if (context == null) return null + val allData = context.getSharedPrefs().all.filter { it.key.isTransferable() } val allSettings = context.getDefaultSharedPrefs().all.filter { it.key.isTransferable() } @@ -212,7 +183,7 @@ object BackupUtils { fileStream = stream.openNew() printStream = PrintWriter(fileStream) - printStream.print(backupFile.toJson()) + printStream.print(mapper.writeValueAsString(backupFile)) showToast( R.string.backup_success, @@ -235,7 +206,7 @@ object BackupUtils { } @Throws(IOException::class) - private fun setupBackupStream(context: Context, name: String, ext: String = "txt"): DownloadObjects.StreamData { + private fun setupBackupStream(context: Context, name: String, ext: String = "txt"): StreamData { return setupStream( baseFile = getCurrentBackupDir(context).first ?: getDefaultBackupDir(context) ?: throw IOException("Bad config"), @@ -257,8 +228,8 @@ object BackupUtils { val input = activity.contentResolver.openInputStream(uri) ?: return@ioSafe - val text = input.bufferedReader().readText() - val restoredValue = parseJson(text) + val restoredValue = + mapper.readValue(input) restore( activity, @@ -317,7 +288,7 @@ object BackupUtils { } /** - * Copy of [com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager.basePathToFile], [com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager.getDefaultDir] and [com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager.getBasePath] + * Copy of [VideoDownloadManager.basePathToFile], [VideoDownloadManager.getDefaultDir] and [VideoDownloadManager.getBasePath] * modded for backup specific paths * */ diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/DataStore.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/DataStore.kt index 02ee69791..20d33c112 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/DataStore.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/DataStore.kt @@ -2,24 +2,22 @@ package com.lagradost.cloudstream3.utils import android.content.Context import android.content.SharedPreferences -import androidx.core.content.edit import androidx.preference.PreferenceManager +import com.fasterxml.jackson.databind.DeserializationFeature +import com.fasterxml.jackson.databind.json.JsonMapper +import com.fasterxml.jackson.module.kotlin.kotlinModule import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKeyClass import com.lagradost.cloudstream3.CloudStreamApp.Companion.removeKey import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKeyClass import com.lagradost.cloudstream3.mvvm.logError -import com.lagradost.cloudstream3.utils.AppUtils.parseJson -import com.lagradost.cloudstream3.utils.AppUtils.toJsonLiteral import kotlin.reflect.KClass import kotlin.reflect.KProperty +import androidx.core.content.edit -/** Used to display metadata about downloads and resume watching */ const val DOWNLOAD_HEADER_CACHE = "download_header_cache" -const val DOWNLOAD_HEADER_CACHE_BACKUP = "BACKUP_download_header_cache" //const val WATCH_HEADER_CACHE = "watch_header_cache" const val DOWNLOAD_EPISODE_CACHE = "download_episode_cache" -const val DOWNLOAD_EPISODE_CACHE_BACKUP = "BACKUP_download_episode_cache" const val VIDEO_PLAYER_BRIGHTNESS = "video_player_alpha_key" const val USER_SELECTED_HOMEPAGE_API = "home_api_used" const val USER_PROVIDER_API = "user_custom_sites" @@ -87,18 +85,8 @@ data class Editor( } object DataStore { - // Extensions shouldn't have really been using this version of it, but it seems - // some have. Since there has always been a very easy alternative, we won't - // need to deprecate it that long, and should be able to fully remove it - // once extensions at least use the other version. - @Deprecated( - "Please do not use the mapper version from DataStore. Preferably use methods from AppUtils " + - "to parse JSON. However, you can use the stable-API version of the mapper at " + - "com.lagradost.cloudstream3.mapper to access the mapper directly if necessary.", - level = DeprecationLevel.ERROR, - replaceWith = ReplaceWith("com.lagradost.cloudstream3.mapper"), - ) - val mapper = com.lagradost.cloudstream3.mapper + val mapper: JsonMapper = JsonMapper.builder().addModule(kotlinModule()) + .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false).build() private fun getPreferences(context: Context): SharedPreferences { return context.getSharedPreferences(PREFERENCES_NAME, Context.MODE_PRIVATE) @@ -108,6 +96,7 @@ object DataStore { return getPreferences(this) } + fun getFolderName(folder: String, path: String): String { return "${folder}/${path}" } @@ -124,9 +113,7 @@ object DataStore { } fun Context.getKeys(folder: String): List { - // Ensure that the folder ends with "/" to prevent matching with other folders - val fixedFolder = folder.trimEnd('/') + "/" - return this.getSharedPrefs().all.keys.filter { it.startsWith(fixedFolder) } + return this.getSharedPrefs().all.keys.filter { it.startsWith(folder) } } fun Context.removeKey(folder: String, path: String) { @@ -173,17 +160,17 @@ object DataStore { fun Context.setKey(path: String, value: T) { try { getSharedPrefs().edit { - putString(path, value?.toJsonLiteral()) + putString(path, mapper.writeValueAsString(value)) } } catch (e: Exception) { logError(e) } } - fun Context.getKey(path: String, valueType: Class): T? { + fun Context.getKey(path: String, valueType: Class): T? { try { val json: String = getSharedPrefs().getString(path, null) ?: return null - return parseJson(json, valueType.kotlin) + return json.toKotlinObject(valueType) } catch (e: Exception) { return null } @@ -194,11 +181,11 @@ object DataStore { } inline fun String.toKotlinObject(): T { - return parseJson(this) + return mapper.readValue(this, T::class.java) } - fun String.toKotlinObject(valueType: Class): T { - return parseJson(this, valueType.kotlin) + fun String.toKotlinObject(valueType: Class): T { + return mapper.readValue(this, valueType) } // GET KEY GIVEN PATH AND DEFAULT VALUE, NULL IF ERROR @@ -222,4 +209,4 @@ object DataStore { inline fun Context.getKey(folder: String, path: String, defVal: T?): T? { return getKey(getFolderName(folder, path), defVal) ?: defVal } -} +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/DataStoreHelper.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/DataStoreHelper.kt index 19caead21..217dc2a52 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/DataStoreHelper.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/DataStoreHelper.kt @@ -30,7 +30,6 @@ import com.lagradost.cloudstream3.ui.result.EpisodeSortType import com.lagradost.cloudstream3.ui.result.ResultEpisode import com.lagradost.cloudstream3.ui.result.VideoWatchState import com.lagradost.cloudstream3.utils.AppContextUtils.filterProviderByPreferredMedia -import com.lagradost.cloudstream3.utils.downloader.DownloadObjects import java.util.Calendar import java.util.Date import java.util.GregorianCalendar @@ -530,7 +529,7 @@ object DataStoreHelper { setKey( "$currentAccount/$RESULT_RESUME_WATCHING", parentId.toString(), - DownloadObjects.ResumeWatching( + VideoDownloadHelper.ResumeWatching( parentId, episodeId, episode, @@ -551,7 +550,7 @@ object DataStoreHelper { removeKey("$currentAccount/$RESULT_RESUME_WATCHING", parentId.toString()) } - fun getLastWatched(id: Int?): DownloadObjects.ResumeWatching? { + fun getLastWatched(id: Int?): VideoDownloadHelper.ResumeWatching? { if (id == null) return null return getKey( "$currentAccount/$RESULT_RESUME_WATCHING", @@ -559,7 +558,7 @@ object DataStoreHelper { ) } - private fun getLastWatchedOld(id: Int?): DownloadObjects.ResumeWatching? { + private fun getLastWatchedOld(id: Int?): VideoDownloadHelper.ResumeWatching? { if (id == null) return null return getKey( "$currentAccount/$RESULT_RESUME_WATCHING_OLD", diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/DownloadFileWorkManager.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/DownloadFileWorkManager.kt new file mode 100644 index 000000000..0b9b81e40 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/DownloadFileWorkManager.kt @@ -0,0 +1,104 @@ +package com.lagradost.cloudstream3.utils + +import android.app.Notification +import android.content.Context +import android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC +import android.os.Build.VERSION.SDK_INT +import androidx.work.CoroutineWorker +import androidx.work.ForegroundInfo +import androidx.work.WorkerParameters +import com.lagradost.cloudstream3.CloudStreamApp.Companion.removeKey +import com.lagradost.cloudstream3.mvvm.logError +import com.lagradost.cloudstream3.utils.Coroutines.main +import com.lagradost.cloudstream3.utils.DataStore.getKey +import com.lagradost.cloudstream3.utils.VideoDownloadManager.WORK_KEY_INFO +import com.lagradost.cloudstream3.utils.VideoDownloadManager.WORK_KEY_PACKAGE +import com.lagradost.cloudstream3.utils.VideoDownloadManager.downloadCheck +import com.lagradost.cloudstream3.utils.VideoDownloadManager.downloadEpisode +import com.lagradost.cloudstream3.utils.VideoDownloadManager.downloadFromResume +import com.lagradost.cloudstream3.utils.VideoDownloadManager.downloadStatusEvent +import com.lagradost.cloudstream3.utils.VideoDownloadManager.getDownloadResumePackage +import kotlinx.coroutines.delay + +const val DOWNLOAD_CHECK = "DownloadCheck" + +class DownloadFileWorkManager(val context: Context, private val workerParams: WorkerParameters) : + CoroutineWorker(context, workerParams) { + + override suspend fun doWork(): Result { + val key = workerParams.inputData.getString("key") + try { + if (key == DOWNLOAD_CHECK) { + downloadCheck(applicationContext, ::handleNotification) + } else if (key != null) { + val info = + applicationContext.getKey(WORK_KEY_INFO, key) + val pkg = + applicationContext.getKey( + WORK_KEY_PACKAGE, + key + ) + + if (info != null) { + getDownloadResumePackage(applicationContext, info.ep.id)?.let { dpkg -> + downloadFromResume(applicationContext, dpkg, ::handleNotification) + } ?: run { + downloadEpisode( + applicationContext, + info.source, + info.folder, + info.ep, + info.links, + ::handleNotification + ) + } + } else if (pkg != null) { + downloadFromResume(applicationContext, pkg, ::handleNotification) + } + removeKeys(key) + } + return Result.success() + } catch (e: Exception) { + logError(e) + if (key != null) { + removeKeys(key) + } + return Result.failure() + } + } + + private fun removeKeys(key: String) { + removeKey(WORK_KEY_INFO, key) + removeKey(WORK_KEY_PACKAGE, key) + } + + private suspend fun awaitDownload(id: Int) { + var isDone = false + val listener = { (localId, localType): Pair -> + if (id == localId) { + when (localType) { + VideoDownloadManager.DownloadType.IsDone, VideoDownloadManager.DownloadType.IsFailed, VideoDownloadManager.DownloadType.IsStopped -> { + isDone = true + } + + else -> Unit + } + } + } + downloadStatusEvent += listener + while (!isDone) { + println("AWAITING $id") + delay(1000) + } + downloadStatusEvent -= listener + } + + private fun handleNotification(id: Int, notification: Notification) { + main { + if (SDK_INT >= 29) + setForegroundAsync(ForegroundInfo(id, notification, FOREGROUND_SERVICE_TYPE_DATA_SYNC)) + else setForegroundAsync(ForegroundInfo(id, notification)) + + } + } +} \ No newline at end of file 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 8456094d1..09d4683bc 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/FillerEpisodeCheck.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/FillerEpisodeCheck.kt @@ -1,166 +1,112 @@ package com.lagradost.cloudstream3.utils -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.app 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" + + var list: HashMap? = null + var cache: HashMap> = hashMapOf() + + private fun fixName(name: String): String { + return name.lowercase(Locale.ROOT)/*.replace(" ", "")*/.replace("-", " ") + .replace("[^a-zA-Z0-9 ]".toRegex(), "") + } + + 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 + } + + 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() + } + return false + } + fun String?.toClassDir(): String { val q = this ?: "null" val z = (6..10).random().calc() return q + "cache" + z } - 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, - ) + suspend fun getFillerEpisodes(query: String): HashMap? { + try { + cache[query]?.let { + return it + } + if (!getFillerList()) return null + val localList = list ?: return null - 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?, - ) + // 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(")", "\\)") + })""" + ) - data class Season( - @JsonProperty("tvdb") - val tvdb: Long?, - @JsonProperty("tmdb") - val tmdb: Long?, - ) - - 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 - } - - /** The entire "database" is stored as a json file we can parse */ - val stream: InputStream = com.lagradost.AnimeDB.getDatabaseStream()!! - val text = stream.reader().readText() - - 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 - } - - val loadCache: HashMap?> = hashMapOf() - - @Synchronized - @Throws - @WorkerThread - fun getFillerEpisodes(data: LoadResponse): HashSet? { - /** Only for anime */ - if (data.type != TvType.Anime) { + 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() 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/GitInfo.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/GitInfo.kt deleted file mode 100644 index 58ff44bb2..000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/GitInfo.kt +++ /dev/null @@ -1,20 +0,0 @@ -package com.lagradost.cloudstream3.utils - -import android.content.Context - -/** - * Simple helper to get the short commit hash from assets. - * The hash is generated at build and stored as an asset - * that can be accessed at runtime for Gradle - * configuration cache support. - */ -object GitInfo { - fun Context.currentCommitHash(): String = try { - assets.open("git-hash.txt") - .bufferedReader() - .readText() - .trim() - } catch (_: Exception) { - "" - } -} diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/ImageModuleCoil.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/ImageModuleCoil.kt index 96193fe45..9d5c75289 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/ImageModuleCoil.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/ImageModuleCoil.kt @@ -3,7 +3,6 @@ package com.lagradost.cloudstream3.utils import android.graphics.Bitmap import android.graphics.drawable.Drawable import android.net.Uri -import android.os.Build import android.os.Build.VERSION.SDK_INT import android.util.Log import android.widget.ImageView @@ -12,7 +11,6 @@ import coil3.EventListener import coil3.ImageLoader import coil3.PlatformContext import coil3.SingletonImageLoader -import coil3.decode.BitmapFactoryDecoder import coil3.disk.DiskCache import coil3.dispose import coil3.load @@ -24,86 +22,82 @@ import coil3.request.CachePolicy import coil3.request.ErrorResult import coil3.request.ImageRequest import coil3.request.allowHardware -import coil3.request.bitmapConfig import coil3.request.crossfade import coil3.util.DebugLogger import com.lagradost.cloudstream3.BuildConfig import com.lagradost.cloudstream3.USER_AGENT import com.lagradost.cloudstream3.network.buildDefaultClient +import okhttp3.HttpUrl import okio.Path.Companion.toOkioPath import java.io.File import java.nio.ByteBuffer object ImageLoader { + private const val TAG = "CoilImgLoader" - internal fun buildImageLoader(context: PlatformContext): ImageLoader { - val isBrokenHardware = hasPotentialBrokenHardware() - return ImageLoader.Builder(context) + + internal fun buildImageLoader(context: PlatformContext): ImageLoader = ImageLoader.Builder(context) .crossfade(200) - .allowHardware(SDK_INT >= 28 && !isBrokenHardware) + .allowHardware(SDK_INT >= 28) // SDK_INT >= 28, cant use hardware bitmaps for Palette Builder .diskCachePolicy(CachePolicy.ENABLED) .networkCachePolicy(CachePolicy.ENABLED) .memoryCache { - MemoryCache.Builder().maxSizePercent(context, 0.1)//10 % of heap for mem-cache - .strongReferencesEnabled(false) + MemoryCache.Builder().maxSizePercent(context, 0.1) // Use 10 % of the app's available memory for caching .build() } .diskCache { DiskCache.Builder() .directory(context.cacheDir.resolve("cs3_image_cache").toOkioPath()) .maxSizeBytes(512L * 1024 * 1024) // 512 MB - .maxSizePercent(0.04) // max 4% of storage for disk caching + .maxSizePercent(0.04) // Use 4 % of the device's storage space for disk caching .build() } /** Pass interceptors with care, unnecessary passing tokens to servers or image hosting services causes unauthorized exceptions **/ - .components { - add(OkHttpNetworkFetcherFactory(callFactory = { buildDefaultClient(context) })) - if (isBrokenHardware) { - add(BitmapFactoryDecoder.Factory()) - } // sw decoder - } - .apply { - if (isBrokenHardware) { // coil will auto choose optimal config on modern device - bitmapConfig(Bitmap.Config.ARGB_8888) - } - setupCoilLogger() + .components { add(OkHttpNetworkFetcherFactory(callFactory = { buildDefaultClient(context) })) } + .also { + it.setupCoilLogger() + Log.d(TAG, "buildImageLoader: Setting COIL Image Loader.") } .build() - } - /** DebugLogger on debug builds which won't slow down release builds & use EventListener for + /** Use DebugLogger on debug builds which won't slow down release builds & use EventListener for Errors on release builds. **/ internal fun ImageLoader.Builder.setupCoilLogger() { if (BuildConfig.DEBUG) { logger(DebugLogger()) + Log.d(TAG, "setupCoilLogger: Activated DEBUG_LOGGER FOR COIL") } else { eventListener(object : EventListener() { override fun onError(request: ImageRequest, result: ErrorResult) { super.onError(request, result) - Log.e(TAG, "Image load error: ${result.throwable.message ?: result.throwable}") - Log.e(TAG, " URL: ${request.data}") - Log.e(TAG, " allowHardware: ${request.allowHardware}") - Log.e(TAG, " hardware: ${Build.HARDWARE}, board: ${Build.BOARD}") + Log.e(TAG, "Error loading image: ${result.throwable}") } }) + Log.d(TAG, "setupCoilLogger: Activated EVENT_LISTENER FOR COIL") } } - /** coil's built in loader attached w/ global synchronized instance **/ + /** we use coil's built in loader with our global synchronized instance, this way we achieve + latest and complete functionality as well as stability **/ private fun ImageView.loadImageInternal( imageData: Any?, headers: Map? = null, builder: ImageRequest.Builder.() -> Unit = {} // for placeholder, error & transformations ) { - // clear image to avoid loading & flickering issue at fast scrolling (~recycler view/lazy column) + // clear image to avoid loading & flickering issue at fast scrolling (e.g, an image recycler) this.dispose() - if (imageData == null) return + + if(imageData == null) return // Just in case + // setImageResource is better than coil3 on resources due to attr - if (imageData is Int) { - this.setImageResource(imageData); return + if(imageData is Int) { + this.setImageResource(imageData) + return } - // headers can be overridden by extensions. + + // Use Coil's built-in load method but with our custom module & a decent USER-AGENT always + // which can be overridden by extensions. this.load(imageData, SingletonImageLoader.get(context)) { this.httpHeaders(NetworkHeaders.Builder().also { headerBuilder -> headerBuilder["User-Agent"] = USER_AGENT @@ -111,22 +105,11 @@ object ImageLoader { headerBuilder[key] = value } }.build()) + builder() // if passed } } - private fun hasPotentialBrokenHardware(): Boolean { - val hardware = Build.HARDWARE?.lowercase() ?: "" - val board = Build.BOARD?.lowercase() ?: "" - val model = Build.MODEL?.lowercase() ?: "" - val manufacturer = Build.MANUFACTURER?.lowercase() ?: "" - val allwinnerPatterns = listOf("sun50iw9", "h713", "allwinner", "sunxi") - val problematicModels = - listOf("hy320", "hy300", "a10plus", "magcubic", "sinoy", "android tv box") - return allwinnerPatterns.any { it in hardware || it in board || it in manufacturer } || - problematicModels.any { it in model } - } - /** TYPE_SAFE_LOADERS **/ fun ImageView.loadImage( imageData: UiImage?, @@ -155,6 +138,12 @@ object ImageLoader { builder: ImageRequest.Builder.() -> Unit = {} ) = loadImageInternal(imageData = imageData, headers = headers, builder = builder) + fun ImageView.loadImage( + imageData: HttpUrl?, + headers: Map? = null, + builder: ImageRequest.Builder.() -> Unit = {} + ) = loadImageInternal(imageData = imageData, headers = headers, builder = builder) + fun ImageView.loadImage( imageData: File?, builder: ImageRequest.Builder.() -> Unit = {} @@ -184,4 +173,4 @@ object ImageLoader { imageData: ByteBuffer?, builder: ImageRequest.Builder.() -> Unit = {} ) = loadImageInternal(imageData = imageData, builder = builder) -} +} \ No newline at end of file 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 b01f6e07e..057923eb0 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/InAppUpdater.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/InAppUpdater.kt @@ -24,7 +24,6 @@ import com.lagradost.cloudstream3.services.PackageInstallerService import com.lagradost.cloudstream3.utils.AppContextUtils.setDefaultFocus import com.lagradost.cloudstream3.utils.AppUtils.parseJson import com.lagradost.cloudstream3.utils.Coroutines.ioSafe -import com.lagradost.cloudstream3.utils.GitInfo.currentCommitHash import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import okio.BufferedSink @@ -93,9 +92,9 @@ object InAppUpdater { private suspend fun Activity.getReleaseUpdate(): Update { val url = "https://api.github.com/repos/$GITHUB_USER_NAME/$GITHUB_REPO/releases" val headers = mapOf("Accept" to "application/vnd.github.v3+json") - val response = parseJson>( + val response = parseJson>( app.get(url, headers = headers).text - ).toList() + ) val versionRegex = Regex("""(.*?((\d+)\.(\d+)\.(\d+))\.apk)""") val versionRegexLocal = Regex("""(.*?((\d+)\.(\d+)\.(\d+)).*)""") @@ -103,7 +102,9 @@ object InAppUpdater { !rel.prerelease }.sortedWith(compareBy { release -> release.assets.firstOrNull { it.contentType == "application/vnd.android.package-archive" }?.name?.let { it1 -> - versionRegex.find(it1)?.groupValues?.let { + versionRegex.find( + it1 + )?.groupValues?.let { it[3].toInt() * 100_000_000 + it[4].toInt() * 10_000 + it[5].toInt() } } @@ -148,9 +149,9 @@ object InAppUpdater { "https://api.github.com/repos/$GITHUB_USER_NAME/$GITHUB_REPO/git/ref/tags/pre-release" val releaseUrl = "https://api.github.com/repos/$GITHUB_USER_NAME/$GITHUB_REPO/releases" val headers = mapOf("Accept" to "application/vnd.github.v3+json") - val response = parseJson>( + val response = parseJson>( app.get(releaseUrl, headers = headers).text - ).toList() + ) val found = response.lastOrNull { rel -> rel.prerelease || rel.tagName == "pre-release" @@ -169,7 +170,7 @@ object InAppUpdater { Log.d(LOG_TAG, "Fetched GitHub tag: $updateCommitHash") return Update( - currentCommitHash() != updateCommitHash, + getString(R.string.commit_hash) != updateCommitHash, foundAsset.browserDownloadUrl, updateCommitHash, found.body, @@ -306,7 +307,7 @@ object InAppUpdater { } val currentInstaller = settingsManager.getInt( - getString(R.string.apk_installer_key), 1 + getString(R.string.apk_installer_key), 0 ) when (currentInstaller) { diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/PercentageCropImageView.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/PercentageCropImageView.kt index 6580182bb..46eaee655 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/PercentageCropImageView.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/PercentageCropImageView.kt @@ -4,9 +4,7 @@ import android.content.Context import android.graphics.Matrix import android.graphics.drawable.Drawable import android.util.AttributeSet -import androidx.core.content.withStyledAttributes import com.lagradost.cloudstream3.R -import com.lagradost.cloudstream3.mvvm.logError /** * A custom [AppCompatImageView] that allows precise control over the visible crop area @@ -146,23 +144,22 @@ class PercentageCropImageView : androidx.appcompat.widget.AppCompatImageView { private fun initAttrs(context: Context, attrs: AttributeSet?) { attrs ?: return - context.withStyledAttributes(attrs, R.styleable.PercentageCropImageView) { - try { - if (hasValue(R.styleable.PercentageCropImageView_cropYCenterOffsetPct)) { - mCropYCenterOffsetPct = getFloat( - R.styleable.PercentageCropImageView_cropYCenterOffsetPct, - 0.5f - ) - } - if (hasValue(R.styleable.PercentageCropImageView_cropXCenterOffsetPct)) { - mCropXCenterOffsetPct = getFloat( - R.styleable.PercentageCropImageView_cropXCenterOffsetPct, - 0.5f - ) - } - } catch (e: Exception) { - logError(e) + val typedArray = context.obtainStyledAttributes(attrs, R.styleable.PercentageCropImageView) + try { + if (typedArray.hasValue(R.styleable.PercentageCropImageView_cropYCenterOffsetPct)) { + mCropYCenterOffsetPct = typedArray.getFloat( + R.styleable.PercentageCropImageView_cropYCenterOffsetPct, + 0.5f + ) } + if (typedArray.hasValue(R.styleable.PercentageCropImageView_cropXCenterOffsetPct)) { + mCropXCenterOffsetPct = typedArray.getFloat( + R.styleable.PercentageCropImageView_cropXCenterOffsetPct, + 0.5f + ) + } + } finally { + typedArray.recycle() } } } \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/SubtitleUtils.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/SubtitleUtils.kt index c0068f91a..97be98aea 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/SubtitleUtils.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/SubtitleUtils.kt @@ -2,8 +2,7 @@ package com.lagradost.cloudstream3.utils import android.content.Context import com.lagradost.api.Log -import com.lagradost.cloudstream3.utils.downloader.DownloadFileManagement.basePathToFile -import com.lagradost.cloudstream3.utils.downloader.DownloadObjects +import com.lagradost.cloudstream3.utils.VideoDownloadManager.basePathToFile object SubtitleUtils { @@ -13,7 +12,7 @@ object SubtitleUtils { ".ttml", ".sbv", ".dfxp" ) - fun deleteMatchingSubtitles(context: Context, info: DownloadObjects.DownloadedFileInfo) { + fun deleteMatchingSubtitles(context: Context, info: VideoDownloadManager.DownloadedFileInfo) { val cleanDisplay = cleanDisplayName(info.displayName) val base = basePathToFile(context, info.basePath) @@ -43,7 +42,7 @@ object SubtitleUtils { cleanDisplay: String ): Boolean { // Check if the file has a valid subtitle extension - val hasValidExtension = allowedExtensions.any { name.endsWith(it, ignoreCase = true) } + val hasValidExtension = allowedExtensions.any { name.contains(it, ignoreCase = true) } // We can't have the exact same file as a subtitle val isNotDisplayName = !name.equals(display, ignoreCase = true) @@ -57,4 +56,4 @@ object SubtitleUtils { fun cleanDisplayName(name: String): String { return name.substringBeforeLast('.').trim() } -} +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/SyncUtil.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/SyncUtil.kt index 6e74fa00a..351e77c8d 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/SyncUtil.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/SyncUtil.kt @@ -8,7 +8,7 @@ import com.lagradost.cloudstream3.APIHolder.apis //import com.lagradost.cloudstream3.animeproviders.AniflixProvider import com.lagradost.cloudstream3.app import com.lagradost.cloudstream3.mvvm.logError -import com.lagradost.cloudstream3.utils.AppUtils.tryParseJson +import com.lagradost.cloudstream3.utils.AppUtils.parseJson import java.util.concurrent.TimeUnit object SyncUtil { @@ -71,7 +71,7 @@ object SyncUtil { val url = "https://raw.githubusercontent.com/MALSync/MAL-Sync-Backup/master/data/pages/$site/$slug.json" val response = app.get(url, cacheTime = 1, cacheUnit = TimeUnit.DAYS).text - val mapped = tryParseJson(response) + val mapped = parseJson(response) val overrideMal = mapped?.malId ?: mapped?.mal?.id ?: mapped?.anilist?.malId val overrideAnilist = mapped?.aniId ?: mapped?.anilist?.id @@ -96,8 +96,10 @@ object SyncUtil { .mapNotNull { it.url }.toMutableList() if (type == "anilist") { // TODO MAKE BETTER - apis.filter { it.name.contains("Aniflix", ignoreCase = true) }.forEach { - current.add("${it.mainUrl}/anime/$id") + synchronized(apis) { + apis.filter { it.name.contains("Aniflix", ignoreCase = true) }.forEach { + current.add("${it.mainUrl}/anime/$id") + } } } return current @@ -167,4 +169,4 @@ object SyncUtil { @JsonProperty("updatedAt") val updatedAt: String?, @JsonProperty("deletedAt") val deletedAt: String? ) -} +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/TestingUtils.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/TestingUtils.kt index 8c50afee7..91c8a2fc1 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/TestingUtils.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/TestingUtils.kt @@ -3,10 +3,10 @@ package com.lagradost.cloudstream3.utils import com.lagradost.cloudstream3.* import com.lagradost.cloudstream3.mvvm.logError import kotlinx.coroutines.* +import org.junit.Assert import kotlin.random.Random object TestingUtils { - open class TestResult(val success: Boolean) { companion object { val Pass = TestResult(true) @@ -48,10 +48,6 @@ object TestingUtils { messageLog.add(Message(LogLevel.Error, message)) } } - - private fun fail(message: String): Nothing = throw AssertionError(message) - private fun assertTrue(message: String, condition: Boolean) { if (!condition) fail(message) } - private fun assertNotNull(message: String, value: Any?) { if (value == null) fail(message) } class TestResultList(val results: List) : TestResult(true) class TestResultLoad(val extractorData: String, val shouldLoadLinks: Boolean) : TestResult(true) @@ -91,7 +87,7 @@ object TestingUtils { } catch (e: Throwable) { when (e) { is NotImplementedError -> { - fail("Provider marked as hasMainPage, while in reality is has not been implemented") + Assert.fail("Provider marked as hasMainPage, while in reality is has not been implemented") } is CancellationException -> { @@ -119,7 +115,7 @@ object TestingUtils { api.search(query, 1)?.items?.takeIf { it.isNotEmpty() } } catch (e: Throwable) { if (e is NotImplementedError) { - fail("Provider has not implemented search()") + Assert.fail("Provider has not implemented search()") } else if (e is CancellationException) { throw e } @@ -129,7 +125,7 @@ object TestingUtils { } return if (searchResults.isNullOrEmpty()) { - fail("Api ${api.name} did not return any search responses") + Assert.fail("Api ${api.name} did not return any search responses") TestResult.Fail // Should not be reached } else { TestResultList(searchResults) @@ -220,7 +216,7 @@ object TestingUtils { // return TestResult(validResults) } catch (e: Throwable) { if (e is NotImplementedError) { - fail("Provider has not implemented load()") + Assert.fail("Provider has not implemented load()") } throw e } @@ -232,14 +228,14 @@ object TestingUtils { url: String?, logger: Logger ): TestResult { - assertNotNull("Api ${api.name} has invalid url on episode", url) + Assert.assertNotNull("Api ${api.name} has invalid url on episode", url) if (url == null) return TestResult.Fail // Should never trigger var linksLoaded = 0 try { val success = api.loadLinks(url, false, {}) { link -> logger.log("Video loaded: ${link.name}") - assertTrue( + Assert.assertTrue( "Api ${api.name} returns link with invalid url ${link.url}", link.url.length > 4 ) @@ -249,12 +245,12 @@ object TestingUtils { logger.log("Links loaded: $linksLoaded") return TestResult(linksLoaded > 0) } else { - fail("Api ${api.name} returns false on loadLinks() with $linksLoaded links loaded") + Assert.fail("Api ${api.name} returns false on loadLinks() with $linksLoaded links loaded") } } catch (e: Throwable) { when (e) { is NotImplementedError -> { - fail("Provider has not implemented loadLinks()") + Assert.fail("Provider has not implemented loadLinks()") } else -> { @@ -280,7 +276,7 @@ object TestingUtils { // Test Homepage val homepage = testHomepage(api, logger) - assertTrue("Homepage failed to load", homepage.success) + Assert.assertTrue("Homepage failed to load", homepage.success) val homePageList = (homepage as? TestResultList)?.results ?: emptyList() // Test Search Results @@ -291,7 +287,7 @@ object TestingUtils { listOf("over", "iron", "guy")).take(3) val searchResults = testSearch(api, searchQueries, logger) - assertTrue("Failed to get search results", searchResults.success) + Assert.assertTrue("Failed to get search results", searchResults.success) searchResults as TestResultList // Test Load and LoadLinks @@ -325,4 +321,4 @@ object TestingUtils { } } } -} +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/UIHelper.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/UIHelper.kt index c12674816..c901b7a45 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/UIHelper.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/UIHelper.kt @@ -43,7 +43,6 @@ import androidx.appcompat.widget.PopupMenu import androidx.core.app.ActivityCompat import androidx.core.content.ContextCompat import androidx.core.content.getSystemService -import androidx.core.content.withStyledAttributes import androidx.core.graphics.alpha import androidx.core.graphics.blue import androidx.core.graphics.green @@ -259,12 +258,10 @@ object UIHelper { } // Open activities from an activity outside the nav graph - fun Context.openActivity(activity: Class<*>, args: Bundle? = null, baseIntent: Intent? = null) { + fun Context.openActivity(activity: Class<*>, args: Bundle? = null) { val tag = "NavComponent" try { - val intent = baseIntent ?: Intent() - intent.setClass(this, activity) - + val intent = Intent(this, activity) if (args != null) { intent.putExtras(args) } @@ -307,23 +304,16 @@ object UIHelper { @ColorInt fun Context.getResourceColor(@AttrRes resource: Int, alphaFactor: Float = 1f): Int { - val color = colorFromAttribute(resource) - return if (alphaFactor < 1f) adjustAlpha(color, alphaFactor) else color - } + val typedArray = obtainStyledAttributes(intArrayOf(resource)) + val color = typedArray.getColor(0, 0) + typedArray.recycle() - @ColorInt - fun Context.colorFromAttribute(@AttrRes attribute: Int): Int { - var color = 0 - withStyledAttributes(attrs = intArrayOf(attribute)) { - color = getColor(0, 0) + if (alphaFactor < 1f) { + val alpha = (color.alpha * alphaFactor).roundToInt() + return Color.argb(alpha, color.red, color.green, color.blue) } - return color - } - @ColorInt - fun adjustAlpha(@ColorInt color: Int, factor: Float): Int { - val alpha = (color.alpha * factor).roundToInt() - return Color.argb(alpha, color.red, color.green, color.blue) + return color } var createPaletteAsyncCache: HashMap = hashMapOf() @@ -340,6 +330,21 @@ object UIHelper { } } + fun adjustAlpha(@ColorInt color: Int, factor: Float): Int { + val alpha = (Color.alpha(color) * factor).roundToInt() + val red = Color.red(color) + val green = Color.green(color) + val blue = Color.blue(color) + return Color.argb(alpha, red, green, blue) + } + + fun Context.colorFromAttribute(attribute: Int): Int { + val attributes = obtainStyledAttributes(intArrayOf(attribute)) + val color = attributes.getColor(0, 0) + attributes.recycle() + return color + } + fun Activity.hideSystemUI() { // Enables regular immersive mode. if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/VideoDownloadHelper.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/VideoDownloadHelper.kt new file mode 100644 index 000000000..fcee1e45a --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/VideoDownloadHelper.kt @@ -0,0 +1,55 @@ +package com.lagradost.cloudstream3.utils + +import com.fasterxml.jackson.annotation.JsonProperty +import com.lagradost.cloudstream3.Score +import com.lagradost.cloudstream3.TvType +object VideoDownloadHelper { + abstract class DownloadCached( + @JsonProperty("id") open val id: Int, + ) + + data class DownloadEpisodeCached( + @JsonProperty("name") val name: String?, + @JsonProperty("poster") val poster: String?, + @JsonProperty("episode") val episode: Int, + @JsonProperty("season") val season: Int?, + @JsonProperty("parentId") val parentId: Int, + @JsonProperty("score") var score: Score? = null, + @JsonProperty("description") val description: String?, + @JsonProperty("cacheTime") val cacheTime: Long, + override val id: Int, + ): DownloadCached(id) { + @JsonProperty("rating", access = JsonProperty.Access.WRITE_ONLY) + @Deprecated( + "`rating` is the old scoring system, use score instead", + replaceWith = ReplaceWith("score"), + level = DeprecationLevel.ERROR + ) + var rating: Int? = null + set(value) { + if (value != null) { + @Suppress("DEPRECATION_ERROR") + score = Score.fromOld(value) + } + } + } + + data class DownloadHeaderCached( + @JsonProperty("apiName") val apiName: String, + @JsonProperty("url") val url: String, + @JsonProperty("type") val type: TvType, + @JsonProperty("name") val name: String, + @JsonProperty("poster") val poster: String?, + @JsonProperty("cacheTime") val cacheTime: Long, + override val id: Int, + ): DownloadCached(id) + + data class ResumeWatching( + @JsonProperty("parentId") val parentId: Int, + @JsonProperty("episodeId") val episodeId: Int?, + @JsonProperty("episode") val episode: Int?, + @JsonProperty("season") val season: Int?, + @JsonProperty("updateTime") val updateTime: Long, + @JsonProperty("isFromDownload") val isFromDownload: Boolean, + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/downloader/DownloadManager.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/VideoDownloadManager.kt similarity index 65% rename from app/src/main/java/com/lagradost/cloudstream3/utils/downloader/DownloadManager.kt rename to app/src/main/java/com/lagradost/cloudstream3/utils/VideoDownloadManager.kt index 7cb190667..cdda11868 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/downloader/DownloadManager.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/VideoDownloadManager.kt @@ -1,28 +1,38 @@ -package com.lagradost.cloudstream3.utils.downloader - +package com.lagradost.cloudstream3.utils import android.Manifest import android.annotation.SuppressLint import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager import android.app.PendingIntent -import android.content.Context -import android.content.Intent +import android.content.* import android.content.pm.PackageManager +import android.graphics.Bitmap +import android.net.Uri import android.os.Build import android.os.Build.VERSION.SDK_INT import android.util.Log -import android.widget.Toast import androidx.annotation.DrawableRes import androidx.core.app.ActivityCompat import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat import androidx.core.app.PendingIntentCompat +import androidx.core.graphics.drawable.toBitmap import androidx.core.net.toUri import androidx.preference.PreferenceManager +import androidx.work.Data +import androidx.work.ExistingWorkPolicy +import androidx.work.OneTimeWorkRequest +import androidx.work.WorkManager +import coil3.Extras +import coil3.SingletonImageLoader +import coil3.asDrawable +import coil3.request.ImageRequest +import coil3.request.SuccessResult +import com.fasterxml.jackson.annotation.JsonProperty import com.lagradost.cloudstream3.APIHolder.getApiFromNameNull import com.lagradost.cloudstream3.BuildConfig -import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey -import com.lagradost.cloudstream3.CommonActivity.showToast import com.lagradost.cloudstream3.CloudStreamApp.Companion.removeKey import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey import com.lagradost.cloudstream3.IDownloadableMinimum @@ -32,58 +42,14 @@ import com.lagradost.cloudstream3.TvType import com.lagradost.cloudstream3.app import com.lagradost.cloudstream3.mvvm.launchSafe import com.lagradost.cloudstream3.mvvm.logError -import com.lagradost.cloudstream3.mvvm.safe import com.lagradost.cloudstream3.services.VideoDownloadService -import com.lagradost.cloudstream3.sortUrls -import com.lagradost.cloudstream3.ui.download.DOWNLOAD_NAVIGATE_TO -import com.lagradost.cloudstream3.ui.player.LOADTYPE_INAPP_DOWNLOAD -import com.lagradost.cloudstream3.ui.player.RepoLinkGenerator -import com.lagradost.cloudstream3.ui.player.SubtitleData -import com.lagradost.cloudstream3.ui.player.source_priority.QualityDataHelper -import com.lagradost.cloudstream3.ui.player.source_priority.QualityDataHelper.getLinkPriority -import com.lagradost.cloudstream3.ui.result.ExtractorSubtitleLink -import com.lagradost.cloudstream3.ui.result.ResultEpisode -import com.lagradost.cloudstream3.ui.subtitles.SubtitlesFragment -import com.lagradost.cloudstream3.utils.AppContextUtils.createNotificationChannel -import com.lagradost.cloudstream3.utils.AppContextUtils.sortSubs import com.lagradost.cloudstream3.utils.Coroutines.ioSafe import com.lagradost.cloudstream3.utils.Coroutines.main -import com.lagradost.cloudstream3.utils.DOWNLOAD_EPISODE_CACHE -import com.lagradost.cloudstream3.utils.DOWNLOAD_HEADER_CACHE -import com.lagradost.cloudstream3.utils.DataStore.getFolderName import com.lagradost.cloudstream3.utils.DataStore.getKey import com.lagradost.cloudstream3.utils.DataStore.removeKey -import com.lagradost.cloudstream3.utils.Event -import com.lagradost.cloudstream3.utils.ExtractorLink -import com.lagradost.cloudstream3.utils.ExtractorLinkType -import com.lagradost.cloudstream3.utils.M3u8Helper -import com.lagradost.cloudstream3.utils.M3u8Helper2 -import com.lagradost.cloudstream3.utils.SubtitleHelper.fromTagToEnglishLanguageName import com.lagradost.cloudstream3.utils.SubtitleUtils.deleteMatchingSubtitles import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute -import com.lagradost.cloudstream3.utils.downloader.DownloadFileManagement.getBasePath -import com.lagradost.cloudstream3.utils.downloader.DownloadFileManagement.getDefaultDir -import com.lagradost.cloudstream3.utils.downloader.DownloadFileManagement.getFileName -import com.lagradost.cloudstream3.utils.downloader.DownloadFileManagement.getFolder -import com.lagradost.cloudstream3.utils.downloader.DownloadFileManagement.sanitizeFilename -import com.lagradost.cloudstream3.utils.downloader.DownloadFileManagement.toFile -import com.lagradost.cloudstream3.utils.downloader.DownloadObjects.CreateNotificationMetadata -import com.lagradost.cloudstream3.utils.downloader.DownloadObjects.DownloadEpisodeMetadata -import com.lagradost.cloudstream3.utils.downloader.DownloadObjects.DownloadItem -import com.lagradost.cloudstream3.utils.downloader.DownloadObjects.DownloadResumePackage -import com.lagradost.cloudstream3.utils.downloader.DownloadObjects.DownloadStatus -import com.lagradost.cloudstream3.utils.downloader.DownloadObjects.DownloadedFileInfo -import com.lagradost.cloudstream3.utils.downloader.DownloadObjects.DownloadedFileInfoResult -import com.lagradost.cloudstream3.utils.downloader.DownloadObjects.LazyStreamDownloadResponse -import com.lagradost.cloudstream3.utils.downloader.DownloadObjects.StreamData -import com.lagradost.cloudstream3.utils.downloader.DownloadObjects.DownloadQueueWrapper -import com.lagradost.cloudstream3.utils.downloader.DownloadUtils.appendAndDontOverride -import com.lagradost.cloudstream3.utils.downloader.DownloadUtils.cancel -import com.lagradost.cloudstream3.utils.downloader.DownloadUtils.downloadSubtitle -import com.lagradost.cloudstream3.utils.downloader.DownloadUtils.getEstimatedTimeLeft -import com.lagradost.cloudstream3.utils.downloader.DownloadUtils.getImageBitmapFromUrl -import com.lagradost.cloudstream3.utils.downloader.DownloadUtils.join -import com.lagradost.cloudstream3.utils.txt +import com.lagradost.safefile.MediaFileContentType import com.lagradost.safefile.SafeFile import com.lagradost.safefile.closeQuietly import kotlinx.coroutines.CancellationException @@ -94,24 +60,23 @@ import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.cancel import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.update import kotlinx.coroutines.isActive import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import java.io.Closeable import java.io.IOException import java.io.OutputStream +import java.util.* const val DOWNLOAD_CHANNEL_ID = "cloudstream3.general" const val DOWNLOAD_CHANNEL_NAME = "Downloads" const val DOWNLOAD_CHANNEL_DESCRIPT = "The download notification channel" object VideoDownloadManager { - fun maxConcurrentDownloads(context: Context): Int = + private fun maxConcurrentDownloads(context: Context): Int = PreferenceManager.getDefaultSharedPreferences(context) ?.getInt(context.getString(R.string.download_parallel_key), 3) ?: 3 @@ -119,11 +84,8 @@ object VideoDownloadManager { PreferenceManager.getDefaultSharedPreferences(context) ?.getInt(context.getString(R.string.download_concurrent_key), 3) ?: 3 - private val _currentDownloads: MutableStateFlow> = MutableStateFlow(emptySet()) - val currentDownloads: StateFlow> = _currentDownloads - + private var currentDownloads = mutableListOf() const val TAG = "VDM" - private const val DOWNLOAD_NOTIFICATION_TAG = "FROM_DOWNLOADER" private const val USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.0.0 Safari/537.36" @@ -167,6 +129,56 @@ object VideoDownloadManager { Stop, } + data class DownloadEpisodeMetadata( + @JsonProperty("id") val id: Int, + @JsonProperty("mainName") val mainName: String, + @JsonProperty("sourceApiName") val sourceApiName: String?, + @JsonProperty("poster") val poster: String?, + @JsonProperty("name") val name: String?, + @JsonProperty("season") val season: Int?, + @JsonProperty("episode") val episode: Int?, + @JsonProperty("type") val type: TvType?, + ) + + data class DownloadItem( + @JsonProperty("source") val source: String?, + @JsonProperty("folder") val folder: String?, + @JsonProperty("ep") val ep: DownloadEpisodeMetadata, + @JsonProperty("links") val links: List, + ) + + data class DownloadResumePackage( + @JsonProperty("item") val item: DownloadItem, + @JsonProperty("linkIndex") val linkIndex: Int?, + ) + + data class DownloadedFileInfo( + @JsonProperty("totalBytes") val totalBytes: Long, + @JsonProperty("relativePath") val relativePath: String, + @JsonProperty("displayName") val displayName: String, + @JsonProperty("extraInfo") val extraInfo: String? = null, + @JsonProperty("basePath") val basePath: String? = null // null is for legacy downloads. See getDefaultPath() + ) + + data class DownloadedFileInfoResult( + @JsonProperty("fileLength") val fileLength: Long, + @JsonProperty("totalBytes") val totalBytes: Long, + @JsonProperty("path") val path: Uri, + ) + + data class DownloadQueueResumePackage( + @JsonProperty("index") val index: Int, + @JsonProperty("pkg") val pkg: DownloadResumePackage, + ) + + data class DownloadStatus( + /** if you should retry with the same args and hope for a better result */ + val retrySame: Boolean, + /** if you should try the next mirror */ + val tryNext: Boolean, + /** if the result is what the user intended */ + val success: Boolean, + ) /** Invalid input, just skip to the next one as the same args will give the same error */ private val DOWNLOAD_INVALID_INPUT = @@ -183,60 +195,117 @@ object VideoDownloadManager { /** the process failed due to some reason, so we retry and also try the next mirror */ private val DOWNLOAD_FAILED = DownloadStatus(retrySame = true, tryNext = true, success = false) - /** The download only downloaded partial */ - private val DOWNLOAD_PARTIAL_SUCCESS = - DownloadStatus(retrySame = true, tryNext = false, success = true) - - /** 50MB minimum size */ - const val DOWNLOAD_PARTIAL_MIN_SIZE = 1_048_576L * 50L - /** bad config, skip all mirrors as every call to download will have the same bad config */ private val DOWNLOAD_BAD_CONFIG = DownloadStatus(retrySame = false, tryNext = false, success = false) - const val KEY_RESUME_PACKAGES = "download_resume_2" + const val KEY_RESUME_PACKAGES = "download_resume" const val KEY_DOWNLOAD_INFO = "download_info" - - /** A key to save all the downloads which have not yet started and those currently running, using [DownloadQueueWrapper] - * [KEY_RESUME_PACKAGES] can store keys which should not be automatically queued, unlike this key. - */ - const val KEY_RESUME_IN_QUEUE = "download_resume_queue_key" -// private const val KEY_RESUME_QUEUE_PACKAGES = "download_q_resume" + private const val KEY_RESUME_QUEUE_PACKAGES = "download_q_resume" val downloadStatus = HashMap() val downloadStatusEvent = Event>() val downloadDeleteEvent = Event() val downloadEvent = Event>() val downloadProgressEvent = Event>() -// val downloadQueue = LinkedList() + val downloadQueue = LinkedList() - private var hasCreatedNotChannel = false + private var hasCreatedNotChanel = false private fun Context.createNotificationChannel() { - hasCreatedNotChannel = true - - this.createNotificationChannel( - DOWNLOAD_CHANNEL_ID, - DOWNLOAD_CHANNEL_NAME, - DOWNLOAD_CHANNEL_DESCRIPT - ) - } - - fun cancelAllDownloadNotifications(context: Context) { - val manager = NotificationManagerCompat.from(context) - manager.activeNotifications.forEach { notification -> - if (notification.tag == DOWNLOAD_NOTIFICATION_TAG) { - manager.cancel(DOWNLOAD_NOTIFICATION_TAG, notification.id) + hasCreatedNotChanel = true + // Create the NotificationChannel, but only on API 26+ because + // the NotificationChannel class is new and not in the support library + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val name = DOWNLOAD_CHANNEL_NAME //getString(R.string.channel_name) + val descriptionText = DOWNLOAD_CHANNEL_DESCRIPT//getString(R.string.channel_description) + val importance = NotificationManager.IMPORTANCE_DEFAULT + val channel = NotificationChannel(DOWNLOAD_CHANNEL_ID, name, importance).apply { + description = descriptionText } + // Register the channel with the system + val notificationManager: NotificationManager = + this.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + notificationManager.createNotificationChannel(channel) } } + ///** Will return IsDone if not found or error */ + //fun getDownloadState(id: Int): DownloadType { + // return try { + // downloadStatus[id] ?: DownloadType.IsDone + // } catch (e: Exception) { + // logError(e) + // DownloadType.IsDone + // } + //} + private val cachedBitmaps = hashMapOf() + fun Context.getImageBitmapFromUrl(url: String, headers: Map? = null): Bitmap? { + try { + if (cachedBitmaps.containsKey(url)) { + return cachedBitmaps[url] + } + + val imageLoader = SingletonImageLoader.get(this) + + val request = ImageRequest.Builder(this) + .data(url) + .apply { + headers?.forEach { (key, value) -> + extras[Extras.Key(key)] = value + } + } + .build() + + val bitmap = runBlocking { + val result = imageLoader.execute(request) + (result as? SuccessResult)?.image?.asDrawable(applicationContext.resources) + ?.toBitmap() + } + + bitmap?.let { + cachedBitmaps[url] = it + } + + return bitmap + } catch (e: Exception) { + logError(e) + return null + } + } + //calculate the time + private fun getEstimatedTimeLeft(context:Context,bytesPerSecond: Long, progress: Long, total: Long):String{ + if(bytesPerSecond <= 0 ) return "" + val timeInSec = (total - progress)/bytesPerSecond + val hrs = timeInSec/3600 + val mins = (timeInSec%3600)/ 60 + val secs = timeInSec % 60 + val timeFormated:UiText? = when{ + hrs>0 -> txt( + R.string.download_time_left_hour_min_sec_format, + hrs, + mins, + secs + ) + mins>0 -> txt( + R.string.download_time_left_min_sec_format, + mins, + secs + ) + secs>0 -> txt( + R.string.download_time_left_sec_format, + secs + ) + else -> null + } + return timeFormated?.asString(context) ?: "" + } /** * @param hlsProgress will together with hlsTotal display another notification if used, to lessen the confusion about estimated size. * */ @SuppressLint("StringFormatInvalid") - private suspend fun createDownloadNotification( + private suspend fun createNotification( context: Context, source: String?, linkName: String?, @@ -252,6 +321,7 @@ object VideoDownloadManager { try { if (total <= 0) return null// crash, invalid data +// main { // DON'T WANT TO SLOW IT DOWN val builder = NotificationCompat.Builder(context, DOWNLOAD_CHANNEL_ID) .setAutoCancel(true) .setColorized(true) @@ -335,9 +405,9 @@ object VideoDownloadManager { } else "" val remainingTime = - if (state == DownloadType.IsDownloading) { - getEstimatedTimeLeft(context, bytesPerSecond, progress, total) - } else "" + if(state == DownloadType.IsDownloading){ + getEstimatedTimeLeft(context,bytesPerSecond, progress, total) + }else "" val bigText = when (state) { @@ -400,7 +470,7 @@ object VideoDownloadManager { builder.setContentText(txt) } - if ((state == DownloadType.IsDownloading || state == DownloadType.IsPaused || state == DownloadType.IsPending) && SDK_INT >= Build.VERSION_CODES.O) { + if ((state == DownloadType.IsDownloading || state == DownloadType.IsPaused) && SDK_INT >= Build.VERSION_CODES.O) { val actionTypes: MutableList = ArrayList() // INIT if (state == DownloadType.IsDownloading) { @@ -412,9 +482,6 @@ object VideoDownloadManager { actionTypes.add(DownloadActionType.Resume) actionTypes.add(DownloadActionType.Stop) } - if (state == DownloadType.IsPending) { - actionTypes.add(DownloadActionType.Stop) - } // ADD ACTIONS for ((index, i) in actionTypes.withIndex()) { @@ -453,7 +520,7 @@ object VideoDownloadManager { } } - if (!hasCreatedNotChannel) { + if (!hasCreatedNotChanel) { context.createNotificationChannel() } @@ -468,7 +535,7 @@ object VideoDownloadManager { ) { return null } - notify(DOWNLOAD_NOTIFICATION_TAG, ep.id, notification) + notify(ep.id, notification) } return notification } catch (e: Exception) { @@ -477,6 +544,69 @@ object VideoDownloadManager { } } + private const val RESERVED_CHARS = "|\\?*<\":>+[]/\'" + fun sanitizeFilename(name: String, removeSpaces: Boolean = false): String { + var tempName = name + for (c in RESERVED_CHARS) { + tempName = tempName.replace(c, ' ') + } + if (removeSpaces) tempName = tempName.replace(" ", "") + return tempName.replace(" ", " ").trim(' ') + } + + /** + * Used for getting video player subs. + * @return List of pairs for the files in this format: + * */ + fun getFolder( + context: Context, + relativePath: String, + basePath: String? + ): List>? { + val base = basePathToFile(context, basePath) + val folder = + base?.gotoDirectory(relativePath, createMissingDirectories = false) ?: return null + + //if (folder.isDirectory() != false) return null + + return folder.listFiles() + ?.mapNotNull { (it.name() ?: "") to (it.uri() ?: return@mapNotNull null) } + } + + + data class CreateNotificationMetadata( + val type: DownloadType, + val bytesDownloaded: Long, + val bytesTotal: Long, + val hlsProgress: Long? = null, + val hlsTotal: Long? = null, + val bytesPerSecond: Long + ) + + data class StreamData( + private val fileLength: Long, + val file: SafeFile, + //val fileStream: OutputStream, + ) { + @Throws(IOException::class) + fun open(): OutputStream { + return file.openOutputStreamOrThrow(resume) + } + + @Throws(IOException::class) + fun openNew(): OutputStream { + return file.openOutputStreamOrThrow(false) + } + + fun delete(): Boolean { + return file.delete() == true + } + + val resume: Boolean get() = fileLength > 0L + val startAt: Long get() = if (resume) fileLength else 0L + val exists: Boolean get() = file.exists() == true + } + @Throws(IOException::class) fun setupStream( @@ -498,7 +628,7 @@ object VideoDownloadManager { /** * Sets up the appropriate file and creates a data stream from the file. - * Used for initializing downloads and backups. + * Used for initializing downloads. * */ @Throws(IOException::class) fun setupStream( @@ -531,7 +661,6 @@ object VideoDownloadManager { /** This class handles the notifications, as well as the relevant key */ data class DownloadMetaData( private val id: Int?, - private val linkHash : Int, var bytesDownloaded: Long = 0, var bytesWritten: Long = 0, @@ -543,7 +672,7 @@ object VideoDownloadManager { private val createNotificationCallback: (CreateNotificationMetadata) -> Unit, private var internalType: DownloadType = DownloadType.IsPending, - val isHLS : Boolean, + // how many segments that we have downloaded var hlsProgress: Int = 0, // how many segments that exist @@ -561,17 +690,13 @@ object VideoDownloadManager { lastDownloadedBytes = length } - /** Returns the appropriate failed status based on download progress */ - fun failedStatus() = if (this.bytesWritten > DOWNLOAD_PARTIAL_MIN_SIZE) - DOWNLOAD_PARTIAL_SUCCESS - else - DOWNLOAD_FAILED - val approxTotalBytes: Long get() = totalBytes ?: hlsTotal?.let { total -> (bytesDownloaded * (total / hlsProgress.toFloat())).toLong() } ?: bytesDownloaded + private val isHLS get() = hlsTotal != null + private var stopListener: (() -> Unit)? = null /** on cancel button pressed or failed invoke this once and only once */ @@ -592,6 +717,8 @@ object VideoDownloadManager { DownloadActionType.Stop -> { type = DownloadType.IsStopped + removeKey(KEY_RESUME_PACKAGES, event.first.toString()) + saveQueue() stopListener?.invoke() stopListener = null } @@ -606,32 +733,11 @@ object VideoDownloadManager { private fun updateFileInfo() { if (id == null) return downloadFileInfoTemplate?.let { template -> - /** This looks strange, but fixes an issue where we do an instant retry, and it fails immediately, - * eg. by turning off wifi */ - val totalBytesValue = if (approxTotalBytes <= bytesDownloaded) { - val prevInfo = getKey( - KEY_DOWNLOAD_INFO, - id.toString() - ) - - /** If this link is the same as the last cached video link metadata */ - if (prevInfo != null && prevInfo.linkHash == linkHash) { - /** Try to use totalBytes if it exists, otherwise the max of the prev data, - * and download size to ensure total >= downloaded */ - totalBytes ?: maxOf(prevInfo.totalBytes, bytesDownloaded) - } else { - approxTotalBytes - } - } else { - approxTotalBytes - } - setKey( KEY_DOWNLOAD_INFO, id.toString(), template.copy( - linkHash = linkHash, - totalBytes = totalBytesValue, + totalBytes = approxTotalBytes, extraInfo = if (isHLS) hlsWrittenProgress.toString() else null ) ) @@ -774,12 +880,34 @@ object VideoDownloadManager { } } + /** bytes have the size end-start where the byte range is [start,end) + * note that ByteArray is a pointer and therefore cant be stored without cloning it */ + data class LazyStreamDownloadResponse( + val bytes: ByteArray, + val startByte: Long, + val endByte: Long, + ) { + val size get() = endByte - startByte + + override fun toString(): String { + return "$startByte->$endByte" + } + + override fun equals(other: Any?): Boolean { + if (other !is LazyStreamDownloadResponse) return false + return other.startByte == startByte && other.endByte == endByte + } + + override fun hashCode(): Int { + return Objects.hash(startByte, endByte) + } + } data class LazyStreamDownloadData( private val url: String, private val headers: Map, private val referer: String, - /** This specifies where chunk i starts and ends, + /** This specifies where chunck i starts and ends, * bytes=${chuckStartByte[ i ]}-${chuckStartByte[ i+1 ] -1} * where out of bounds => bytes=${chuckStartByte[ i ]}- */ private val chuckStartByte: LongArray, @@ -804,7 +932,6 @@ object VideoDownloadManager { private suspend fun resolve( startByte: Long, endByte: Long?, - buffer: ByteArray, callback: (suspend CoroutineScope.(LazyStreamDownloadResponse) -> Unit) ): Long = withContext(Dispatchers.IO) { var currentByte: Long = startByte @@ -823,6 +950,7 @@ object VideoDownloadManager { ) val requestStream = request.body.byteStream() + val buffer = ByteArray(bufferSize) var read: Int try { @@ -853,7 +981,6 @@ object VideoDownloadManager { suspend fun resolveSafe( index: Int, retries: Int = 3, - buffer: ByteArray, callback: (suspend CoroutineScope.(LazyStreamDownloadResponse) -> Unit) ): Boolean { var start = chuckStartByte.getOrNull(index) ?: return false @@ -862,16 +989,16 @@ object VideoDownloadManager { for (i in 0 until retries) { try { // in case - start = resolve(start, end, buffer, callback) + start = resolve(start, end, callback) // no end defined, so we don't care exactly where it ended if (end == null) return true // we have download more or exactly what we needed if (start >= end) return true - } catch (_: IllegalStateException) { + } catch (e: IllegalStateException) { return false - } catch (_: CancellationException) { + } catch (e: CancellationException) { return false - } catch (_: Throwable) { + } catch (t: Throwable) { continue } } @@ -990,6 +1117,38 @@ object VideoDownloadManager { ) } + /** Helper function to make sure duplicate attributes don't get overriden or inserted without lowercase cmp + * example: map("a" to 1) appendAndDontOverride map("A" to 2, "a" to 3, "c" to 4) = map("a" to 1, "c" to 4) + * */ + private fun Map.appendAndDontOverride(rhs: Map): Map { + val out = this.toMutableMap() + val current = this.keys.map { it.lowercase() } + for ((key, value) in rhs) { + if (current.contains(key.lowercase())) continue + out[key] = value + } + return out + } + + private fun List.cancel() { + forEach { job -> + try { + job.cancel() + } catch (t: Throwable) { + logError(t) + } + } + } + + private suspend fun List.join() { + forEach { job -> + try { + job.join() + } catch (t: Throwable) { + logError(t) + } + } + } /** download a file that consist of a single stream of data*/ suspend fun downloadThing( @@ -1017,8 +1176,6 @@ object VideoDownloadManager { bytesDownloaded = 0, createNotificationCallback = createNotificationCallback, id = parentId, - linkHash = link.url.hashCode(), - isHLS = false ) try { // get the file path @@ -1040,7 +1197,14 @@ 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", ) ) ) @@ -1159,29 +1323,13 @@ object VideoDownloadManager { } } - // Reuse a download buffer to decrease unnecessary alloc - val buffer = ByteArray(items.bufferSize) - - // This will take up the first available job and resolve + // 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 @@ -1192,7 +1340,7 @@ object VideoDownloadManager { // in case something has gone wrong set to failed if the fail is not caused by // user cancellation - if (!items.resolveSafe(index, buffer = buffer, callback = callback)) { + if (!items.resolveSafe(index, callback = callback)) { fileMutex.withLock { if (metadata.type != DownloadType.IsStopped) { metadata.type = DownloadType.IsFailed @@ -1217,7 +1365,7 @@ object VideoDownloadManager { if (!stream.exists) metadata.type = DownloadType.IsStopped if (metadata.type == DownloadType.IsFailed) { - return@withContext metadata.failedStatus() + return@withContext DOWNLOAD_FAILED } if (metadata.type == DownloadType.IsStopped) { @@ -1247,11 +1395,11 @@ object VideoDownloadManager { throw e } catch (t: Throwable) { // some sort of network error, will error - logError(t) + // note that when failing we don't want to delete the file, // only user interaction has that power metadata.type = DownloadType.IsFailed - return@withContext metadata.failedStatus() + return@withContext DOWNLOAD_FAILED } finally { fileStream?.closeQuietly() //requestStream?.closeQuietly() @@ -1273,9 +1421,7 @@ object VideoDownloadManager { val metadata = DownloadMetaData( createNotificationCallback = createNotificationCallback, - id = parentId, - linkHash = link.url.hashCode(), - isHLS = true + id = parentId ) var fileStream: OutputStream? = null try { @@ -1298,7 +1444,6 @@ object VideoDownloadManager { // push the metadata metadata.setResumeLength(stream.startAt) metadata.hlsProgress = startAt - metadata.hlsWrittenProgress = startAt metadata.type = DownloadType.IsPending metadata.setDownloadFileInfoTemplate( DownloadedFileInfo( @@ -1313,6 +1458,8 @@ 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() ) @@ -1350,23 +1497,10 @@ 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 @@ -1386,45 +1520,50 @@ object VideoDownloadManager { return@launch } - fileMutex.withLock { + try { + fileMutex.lock() + // user pause + while (metadata.type == DownloadType.IsPaused) delay(100) + // if stopped then break to delete + if (metadata.type == DownloadType.IsStopped || metadata.type == DownloadType.IsFailed || !isActive) return@launch + + val segmentLength = bytes.size.toLong() + // send notification, no matter the actual write order + metadata.addSegment(segmentLength) + + // directly write the bytes if you are first + if (metadata.hlsWrittenProgress == index) { + fileStream.write(bytes) + + metadata.addBytesWritten(segmentLength) + metadata.setWrittenSegment(index) + } else { + // no need to clone as there will be no modification of this bytearray + pendingData[index] = bytes + } + + // write the cached bytes submitted by other threads + while (true) { + val cache = pendingData.remove(metadata.hlsWrittenProgress) ?: break + val cacheLength = cache.size.toLong() + + fileStream.write(cache) + + metadata.addBytesWritten(cacheLength) + metadata.setWrittenSegment(metadata.hlsWrittenProgress) + } + } catch (t: Throwable) { + // this is in case of write fail + logError(t) + if (metadata.type != DownloadType.IsStopped) { + metadata.type = DownloadType.IsFailed + } + } finally { try { - // user pause - while (metadata.type == DownloadType.IsPaused) delay(100) - // if stopped then break to delete - if (metadata.type == DownloadType.IsStopped || metadata.type == DownloadType.IsFailed || !isActive) return@launch - - val segmentLength = bytes.size.toLong() - // send notification, no matter the actual write order - metadata.addSegment(segmentLength) - - // directly write the bytes if you are first - if (metadata.hlsWrittenProgress == index) { - fileStream.write(bytes) - - metadata.addBytesWritten(segmentLength) - metadata.setWrittenSegment(index) - } else { - // no need to clone as there will be no modification of this bytearray - pendingData[index] = bytes - } - - // write the cached bytes submitted by other threads - while (true) { - val cache = - pendingData.remove(metadata.hlsWrittenProgress) ?: break - val cacheLength = cache.size.toLong() - - fileStream.write(cache) - - metadata.addBytesWritten(cacheLength) - metadata.setWrittenSegment(metadata.hlsWrittenProgress) - } + // may cause java.lang.IllegalStateException: Mutex is not locked because of cancelling + fileMutex.unlock() } catch (t: Throwable) { - // this is in case of write fail logError(t) - if (metadata.type != DownloadType.IsStopped) { - metadata.type = DownloadType.IsFailed - } } } } @@ -1444,7 +1583,7 @@ object VideoDownloadManager { if (!stream.exists) metadata.type = DownloadType.IsStopped if (metadata.type == DownloadType.IsFailed) { - return@withContext metadata.failedStatus() + return@withContext DOWNLOAD_FAILED } if (metadata.type == DownloadType.IsStopped) { @@ -1460,7 +1599,7 @@ object VideoDownloadManager { } catch (t: Throwable) { logError(t) metadata.type = DownloadType.IsFailed - return@withContext metadata.failedStatus() + return@withContext DOWNLOAD_FAILED } finally { fileStream?.closeQuietly() metadata.close() @@ -1471,6 +1610,75 @@ object VideoDownloadManager { return "$name.$extension" } + /** + * Gets the default download path as an UniFile. + * Vital for legacy downloads, be careful about changing anything here. + * + * As of writing UniFile is used for everything but download directory on scoped storage. + * Special ContentResolver fuckery is needed for that as UniFile doesn't work. + * */ + fun getDefaultDir(context: Context): SafeFile? { + // See https://www.py4u.net/discuss/614761 + return SafeFile.fromMedia( + context, MediaFileContentType.Downloads + ) + } + + /** + * Turns a string to an UniFile. Used for stored string paths such as settings. + * Should only be used to get a download path. + * */ + fun basePathToFile(context: Context, path: String?): SafeFile? { + return when { + path.isNullOrBlank() -> getDefaultDir(context) + path.startsWith("content://") -> SafeFile.fromUri(context, path.toUri()) + else -> SafeFile.fromFilePath(context, path) + } + } + + /** + * Base path where downloaded things should be stored, changes depending on settings. + * Returns the file and a string to be stored for future file retrieval. + * UniFile.filePath is not sufficient for storage. + * */ + fun Context.getBasePath(): Pair { + val settingsManager = PreferenceManager.getDefaultSharedPreferences(this) + val basePathSetting = settingsManager.getString(getString(R.string.download_path_key), null) + return basePathToFile(this, basePathSetting) to basePathSetting + } + + fun getFileName(context: Context, metadata: DownloadEpisodeMetadata): String { + return getFileName(context, metadata.name, metadata.episode, metadata.season) + } + + private fun getFileName( + context: Context, + epName: String?, + episode: Int?, + season: Int? + ): String { + // kinda ugly ik + return sanitizeFilename( + if (epName == null) { + if (season != null) { + "${context.getString(R.string.season)} $season ${context.getString(R.string.episode)} $episode" + } else { + "${context.getString(R.string.episode)} $episode" + } + } else { + if (episode != null) { + if (season != null) { + "${context.getString(R.string.season)} $season ${context.getString(R.string.episode)} $episode - $epName" + } else { + "${context.getString(R.string.episode)} $episode - $epName" + } + } else { + epName + } + } + ) + } + private suspend fun downloadSingleEpisode( context: Context, source: String?, @@ -1496,7 +1704,7 @@ object VideoDownloadManager { val callback: (CreateNotificationMetadata) -> Unit = { meta -> main { - createDownloadNotification( + createNotification( context, source, link.name, @@ -1550,17 +1758,100 @@ object VideoDownloadManager { ) } - else -> throw IllegalArgumentException("Unsupported download type") + else -> throw IllegalArgumentException("unsuported download type") } - } catch (_: Throwable) { + } catch (t: Throwable) { return DOWNLOAD_FAILED } finally { extractorJob.cancel() } } + suspend fun downloadCheck( + context: Context, notificationCallback: (Int, Notification) -> Unit, + ) { + if (!(currentDownloads.size < maxConcurrentDownloads(context) && downloadQueue.size > 0)) return - fun getDownloadFileInfo( + val pkg = downloadQueue.removeAt(0) + val item = pkg.item + val id = item.ep.id + if (currentDownloads.contains(id)) { // IF IT IS ALREADY DOWNLOADING, RESUME IT + downloadEvent.invoke(id to DownloadActionType.Resume) + return + } + + currentDownloads.add(id) + try { + for (index in (pkg.linkIndex ?: 0) until item.links.size) { + val link = item.links[index] + val resume = pkg.linkIndex == index + + setKey( + KEY_RESUME_PACKAGES, + id.toString(), + DownloadResumePackage(item, index) + ) + + var connectionResult = + downloadSingleEpisode( + context, + item.source, + item.folder, + item.ep, + link, + notificationCallback, + resume + ) + + if (connectionResult.retrySame) { + connectionResult = downloadSingleEpisode( + context, + item.source, + item.folder, + item.ep, + link, + notificationCallback, + true + ) + } + + if (connectionResult.success) { // SUCCESS + removeKey(KEY_RESUME_PACKAGES, id.toString()) + break + } else if (!connectionResult.tryNext || index >= item.links.lastIndex) { + downloadStatusEvent.invoke(Pair(id, DownloadType.IsFailed)) + break + } + } + } catch (e: Exception) { + logError(e) + } finally { + currentDownloads.remove(id) + // Because otherwise notifications will not get caught by the work manager + downloadCheckUsingWorker(context) + } + + // return id + } + + /* fun getDownloadFileInfoAndUpdateSettings(context: Context, id: Int): DownloadedFileInfoResult? { + val res = getDownloadFileInfo(context, id) + if (res == null) context.removeKey(KEY_DOWNLOAD_INFO, id.toString()) + return res + } + */ + fun getDownloadFileInfoAndUpdateSettings(context: Context, id: Int): DownloadedFileInfoResult? = + getDownloadFileInfo(context, id) + + private fun DownloadedFileInfo.toFile(context: Context): SafeFile? { + return basePathToFile(context, this.basePath)?.gotoDirectory( + relativePath, + createMissingDirectories = false + ) + ?.findFile(displayName) + } + + private fun getDownloadFileInfo( context: Context, id: Int, ): DownloadedFileInfoResult? { @@ -1570,7 +1861,8 @@ object VideoDownloadManager { val file = info.toFile(context) // only delete the key if the file is not found - if (file == null || file.exists() == false) { + if (file == null || !file.existsOrThrow()) { + //if (removeKeys) context.removeKey(KEY_DOWNLOAD_INFO, id.toString()) // TODO READD return null } @@ -1621,20 +1913,35 @@ object VideoDownloadManager { return success } + /*private fun deleteFile( + context: Context, + folder: SafeFile?, + relativePath: String, + displayName: String + ): Boolean { + val file = folder?.gotoDirectory(relativePath)?.findFile(displayName) ?: return false + if (file.exists() == false) return true + return try { + file.delete() + } catch (e: Exception) { + logError(e) + (context.contentResolver?.delete(file.uri() ?: return true, null, null) + ?: return false) > 0 + } + }*/ + private fun deleteFile(context: Context, id: Int): Boolean { val info = context.getKey(KEY_DOWNLOAD_INFO, id.toString()) ?: return false val file = info.toFile(context) - val isFileDeleted = file?.delete() == true || file?.exists() == false + downloadEvent.invoke(id to DownloadActionType.Stop) + downloadProgressEvent.invoke(Triple(id, 0, 0)) + downloadStatusEvent.invoke(id to DownloadType.IsStopped) + downloadDeleteEvent.invoke(id) - if (isFileDeleted) { - deleteMatchingSubtitles(context, info) - downloadEvent.invoke(id to DownloadActionType.Stop) - downloadProgressEvent.invoke(Triple(id, 0, 0)) - downloadStatusEvent.invoke(id to DownloadType.IsStopped) - downloadDeleteEvent.invoke(id) - } + val isFileDeleted = file?.delete() == true || file?.exists() == false + if (isFileDeleted) deleteMatchingSubtitles(context, info) return isFileDeleted } @@ -1643,453 +1950,119 @@ object VideoDownloadManager { return context.getKey(KEY_RESUME_PACKAGES, id.toString()) } - fun getDownloadQueuePackage(context: Context, id: Int): DownloadQueueWrapper? { - return context.getKey(KEY_RESUME_IN_QUEUE, id.toString()) + suspend fun downloadFromResume( + context: Context, + pkg: DownloadResumePackage, + notificationCallback: (Int, Notification) -> Unit, + setKey: Boolean = true + ) { + if (!currentDownloads.any { it == pkg.item.ep.id } && !downloadQueue.any { it.item.ep.id == pkg.item.ep.id }) { + downloadQueue.addLast(pkg) + downloadCheck(context, notificationCallback) + if (setKey) saveQueue() + //ret + } else { + downloadEvent( + pkg.item.ep.id to DownloadActionType.Resume + ) + //null + } } - fun getDownloadEpisodeMetadata( - episode: ResultEpisode, - titleName: String, - apiName: String, - currentPoster: String?, - currentIsMovie: Boolean, - tvType: TvType, - ): DownloadEpisodeMetadata { - return DownloadEpisodeMetadata( - episode.id, - episode.parentId, - sanitizeFilename(titleName), - apiName, - episode.poster ?: currentPoster, - episode.name, - if (currentIsMovie) null else episode.season, - if (currentIsMovie) null else episode.episode, - tvType, + private fun saveQueue() { + try { + val dQueue = + downloadQueue.toList() + .mapIndexed { index, any -> DownloadQueueResumePackage(index, any) } + .toTypedArray() + setKey(KEY_RESUME_QUEUE_PACKAGES, dQueue) + } catch (t: Throwable) { + logError(t) + } + } + + /*fun isMyServiceRunning(context: Context, serviceClass: Class<*>): Boolean { + val manager = context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager? + for (service in manager!!.getRunningServices(Int.MAX_VALUE)) { + if (serviceClass.name == service.service.className) { + return true + } + } + return false + }*/ + + suspend fun downloadEpisode( + context: Context?, + source: String?, + folder: String?, + ep: DownloadEpisodeMetadata, + links: List, + notificationCallback: (Int, Notification) -> Unit, + ) { + if (context == null) return + if (links.isEmpty()) return + downloadFromResume( + context, + DownloadResumePackage(DownloadItem(source, folder, ep, links), null), + notificationCallback ) } - class EpisodeDownloadInstance( - val context: Context, - val downloadQueueWrapper: DownloadQueueWrapper - ) { - private val TAG = "EpisodeDownloadInstance" - private var subtitleDownloadJob: Job? = null - private var downloadJob: Job? = null - private var linkLoadingJob: Job? = null - - /** isCompleted just means the download should not be retried. - * It includes stopped by user AND completion of file download. - * */ - var isCompleted = false - set(value) { - field = value - if (value) { - removeKey(KEY_RESUME_IN_QUEUE, downloadQueueWrapper.id.toString()) - // Do not emit events when completed as it may also trigger on cancellation. - - - // Force refresh the queue when completed. - // May lead to some redundant calls, but ensures that the queue is always up to date. - DownloadQueueManager.forceRefreshQueue() - } - } - - /** Cancels all active jobs and sets instance to failed. */ - fun cancelDownload() { - val cause = "Cancel call from cancelDownload" - this.subtitleDownloadJob?.cancel(cause) - this.linkLoadingJob?.cancel(cause) - - // Should not cancel the download job, it may need to clean up itself. - // Better to send a status event using isStopped and let it cancel itself. - isCancelled = true - } - - // Run to cancel ongoing work, delete partial work and refresh queue - private fun cleanup(status: DownloadType) { - removeKey(KEY_RESUME_IN_QUEUE, downloadQueueWrapper.id.toString()) - val id = downloadQueueWrapper.id - - // Delete subtitles on cancel - safe { - val info = context.getKey(KEY_DOWNLOAD_INFO, id.toString()) - if (info != null) { - deleteMatchingSubtitles(context, info) - } - } - - downloadStatusEvent.invoke(Pair(id, status)) - downloadStatus[id] = status - downloadEvent.invoke(Pair(id, DownloadActionType.Stop)) - - // Force refresh the queue when failed. - // May lead to some redundant calls, but ensures that the queue is always up to date. - DownloadQueueManager.forceRefreshQueue() - } - - var isCancelled = false - set(value) { - val oldField = field - field = value - - // Clean up cancelled work, but only once - if (value && !oldField) { - cleanup(DownloadType.IsStopped) - } - } - - - /** This failure can be both downloader and user initiated. - * Do not automatically retry in case of failure. */ - var isFailed = false - set(value) { - val oldField = field - field = value - - // Clean up failed work, but only once - if (value && !oldField) { - cleanup(DownloadType.IsFailed) - } - } - - 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) - } - } - } - - private suspend fun downloadFromResume( - downloadResumePackage: DownloadResumePackage, - notificationCallback: (Int, Notification) -> Unit, - ) { - val item = downloadResumePackage.item - val id = item.ep.id - if (currentDownloads.value.contains(id)) { // IF IT IS ALREADY DOWNLOADING, RESUME IT - downloadEvent.invoke(id to DownloadActionType.Resume) - return - } - - _currentDownloads.update { downloads -> - downloads + id - } - - try { - for (index in (downloadResumePackage.linkIndex ?: 0) until item.links.size) { - val link = item.links[index] - val resume = downloadResumePackage.linkIndex == index - - setKey( - KEY_RESUME_PACKAGES, - id.toString(), - DownloadResumePackage(item, index) - ) - - var connectionResult = - downloadSingleEpisode( - context, - item.source, - item.folder, - item.ep, - link, - notificationCallback, - resume - ) - - if (connectionResult.retrySame) { - connectionResult = downloadSingleEpisode( - context, - item.source, - item.folder, - item.ep, - link, - notificationCallback, - true - ) - } - - if (connectionResult.success) { // SUCCESS - isCompleted = true - break - } else if (!connectionResult.tryNext || index >= item.links.lastIndex) { - isFailed = true - break - } - } - } catch (e: Exception) { - isFailed = true - logError(e) - } finally { - isFailed = !isCompleted - _currentDownloads.update { downloads -> - downloads - id - } - } - } - - private suspend fun startDownload( - info: DownloadItem?, - pkg: DownloadResumePackage? - ) { - try { - if (info != null) { - getDownloadResumePackage(context, info.ep.id)?.let { dpkg -> - downloadFromResume(dpkg) { id, notification -> - displayNotification(context, id, notification) - } - } ?: run { - if (info.links.isEmpty()) return - downloadFromResume( - DownloadResumePackage(info, null) - ) { id, notification -> - displayNotification(context, id, notification) - } - } - } else if (pkg != null) { - downloadFromResume(pkg) { id, notification -> - displayNotification(context, id, notification) - } - } - return - } catch (e: Exception) { - isFailed = true - logError(e) - return - } - } - - private suspend fun downloadFromResume() { - val resumePackage = downloadQueueWrapper.resumePackage ?: return - downloadFromResume(resumePackage) { id, notification -> - displayNotification(context, id, notification) - } - } - - fun startDownload() { - Log.d(TAG, "Starting download ${downloadQueueWrapper.id}") - setKey(KEY_RESUME_IN_QUEUE, downloadQueueWrapper.id.toString(), downloadQueueWrapper) - - ioSafe { - if (downloadQueueWrapper.resumePackage != null) { - downloadFromResume() - // Load links if they are not already loaded - } else if (downloadQueueWrapper.downloadItem != null && downloadQueueWrapper.downloadItem.links.isNullOrEmpty()) { - downloadEpisodeWithoutLinks() - } else if (downloadQueueWrapper.downloadItem?.links != null) { - downloadEpisodeWithLinks( - sortUrls(downloadQueueWrapper.downloadItem.links.toSet()), - downloadQueueWrapper.downloadItem.subs - ) - } - } - } - - private fun downloadEpisodeWithLinks( - links: List, - subs: List? - ) { - val downloadItem = downloadQueueWrapper.downloadItem ?: return - try { - // Prepare visual keys - setKey( - DOWNLOAD_HEADER_CACHE, - downloadItem.resultId.toString(), - DownloadObjects.DownloadHeaderCached( - apiName = downloadItem.apiName, - url = downloadItem.resultUrl, - type = downloadItem.resultType, - name = downloadItem.resultName, - poster = downloadItem.resultPoster, - id = downloadItem.resultId, - cacheTime = System.currentTimeMillis(), - ) - ) - setKey( - getFolderName( - DOWNLOAD_EPISODE_CACHE, - downloadItem.resultId.toString() - ), // 3 deep folder for faster access - downloadItem.episode.id.toString(), - DownloadObjects.DownloadEpisodeCached( - name = downloadItem.episode.name, - poster = downloadItem.episode.poster, - episode = downloadItem.episode.episode, - season = downloadItem.episode.season, - id = downloadItem.episode.id, - parentId = downloadItem.resultId, - score = downloadItem.episode.score, - description = downloadItem.episode.description, - cacheTime = System.currentTimeMillis(), - ) - ) - - val meta = - getDownloadEpisodeMetadata( - downloadItem.episode, - downloadItem.resultName, - downloadItem.apiName, - downloadItem.resultPoster, - downloadItem.isMovie, - downloadItem.resultType - ) - - val folder = - getFolder(downloadItem.resultType, downloadItem.resultName) - val src = "$DOWNLOAD_NAVIGATE_TO/${downloadItem.resultId}" - - // DOWNLOAD VIDEO - val info = DownloadItem(src, folder, meta, links) - - this.downloadJob = ioSafe { - startDownload(info, null) - } - - // 1. Checks if the lang should be downloaded - // 2. Makes it into the download format - // 3. Downloads it as a .vtt file - this.subtitleDownloadJob = ioSafe { - try { - val downloadList = SubtitlesFragment.getDownloadSubsLanguageTagIETF() - - subs?.filter { subtitle -> - downloadList.any { langTagIETF -> - subtitle.languageCode == langTagIETF || - subtitle.originalName.contains( - fromTagToEnglishLanguageName( - langTagIETF - ) ?: langTagIETF - ) - } - } - ?.map { ExtractorSubtitleLink(it.name, it.url, "", it.headers) } - ?.take(3) // max subtitles download hardcoded (?_?) - ?.forEach { link -> - val fileName = getFileName(context, meta) - downloadSubtitle(context, link, fileName, folder) - } - - } catch (_: CancellationException) { - val fileName = getFileName(context, meta) - - val info = DownloadedFileInfo( - totalBytes = 0, - relativePath = folder, - displayName = fileName, - basePath = context.getBasePath().second - ) - - deleteMatchingSubtitles(context, info) - } - } - } catch (e: Exception) { - // The work is only failed if the job did not get started - if (this.downloadJob == null) { - isFailed = true - } - logError(e) - } - } - - private suspend fun downloadEpisodeWithoutLinks() { - val downloadItem = downloadQueueWrapper.downloadItem ?: return - - val generator = RepoLinkGenerator(listOf(downloadItem.episode)) - val currentLinks = mutableSetOf() - val currentSubs = mutableSetOf() - val meta = - getDownloadEpisodeMetadata( - downloadItem.episode, - downloadItem.resultName, - downloadItem.apiName, - downloadItem.resultPoster, - downloadItem.isMovie, - downloadItem.resultType - ) - - createDownloadNotification( - context, - downloadItem.apiName, - txt(R.string.loading).asString(context), - meta, - DownloadType.IsPending, - 0, - 1, - { _, _ -> }, - null, - null, - 0 - )?.let { linkLoadingNotification -> - displayNotification(context, downloadItem.episode.id, linkLoadingNotification) - } - - linkLoadingJob = ioSafe { - generator.generateLinks( - offset = 0, - isCasting = false, - clearCache = false, - sourceTypes = LOADTYPE_INAPP_DOWNLOAD, - callback = { - it.first?.let { link -> - currentLinks.add(link) - } - }, - subtitleCallback = { sub -> - currentSubs.add(sub) - }) - } - - // Wait for link loading completion - linkLoadingJob?.join() - - // Remove link loading notification - NotificationManagerCompat.from(context) - .cancel(DOWNLOAD_NOTIFICATION_TAG, downloadItem.episode.id) - - if (linkLoadingJob?.isCancelled == true) { - // Same as if no links, but no toast. - // Cancelled link loading is presumed to be user initiated - isCancelled = true - return - } else if (currentLinks.isEmpty()) { - main { - showToast( - R.string.no_links_found_toast, - Toast.LENGTH_SHORT - ) - } - isFailed = true - return - } else { - main { - showToast( - R.string.download_started, - Toast.LENGTH_SHORT - ) - } - } - - // Profiles should always contain a download type - val profile = QualityDataHelper.getProfiles().first { - it.types.contains( - QualityDataHelper.QualityProfileType.Download - ) - } - - val sortedLinks = currentLinks.sortedBy { link -> - // Negative, because the highest priority should be first - -getLinkPriority(profile.id, link) - } - - downloadEpisodeWithLinks( - sortedLinks, - sortSubs(currentSubs), + /** Worker stuff */ + private fun startWork(context: Context, key: String) { + val req = OneTimeWorkRequest.Builder(DownloadFileWorkManager::class.java) + .setInputData( + Data.Builder() + .putString("key", key) + .build() ) - } + .build() + (WorkManager.getInstance(context)).enqueueUniqueWork( + key, + ExistingWorkPolicy.KEEP, + req + ) } + + fun downloadCheckUsingWorker( + context: Context, + ) { + startWork(context, DOWNLOAD_CHECK) + } + + fun downloadFromResumeUsingWorker( + context: Context, + pkg: DownloadResumePackage, + ) { + val key = pkg.item.ep.id.toString() + setKey(WORK_KEY_PACKAGE, key, pkg) + startWork(context, key) + } + + // Keys are needed to transfer the data to the worker reliably and without exceeding the data limit + const val WORK_KEY_PACKAGE = "work_key_package" + const val WORK_KEY_INFO = "work_key_info" + + fun downloadEpisodeUsingWorker( + context: Context, + source: String?, + folder: String?, + ep: DownloadEpisodeMetadata, + links: List, + ) { + val info = DownloadInfo( + source, folder, ep, links + ) + + val key = info.ep.id.toString() + setKey(WORK_KEY_INFO, key, info) + startWork(context, key) + } + + data class DownloadInfo( + @JsonProperty("source") val source: String?, + @JsonProperty("folder") val folder: String?, + @JsonProperty("ep") val ep: DownloadEpisodeMetadata, + @JsonProperty("links") val links: List + ) } diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/downloader/DownloadFileManagement.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/downloader/DownloadFileManagement.kt deleted file mode 100644 index 898c30a1c..000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/downloader/DownloadFileManagement.kt +++ /dev/null @@ -1,132 +0,0 @@ -package com.lagradost.cloudstream3.utils.downloader - -import android.content.Context -import android.net.Uri -import androidx.core.net.toUri -import androidx.preference.PreferenceManager -import com.lagradost.cloudstream3.R -import com.lagradost.cloudstream3.TvType -import com.lagradost.cloudstream3.getFolderPrefix -import com.lagradost.cloudstream3.isEpisodeBased -import com.lagradost.safefile.MediaFileContentType -import com.lagradost.safefile.SafeFile - -object DownloadFileManagement { - private const val RESERVED_CHARS = "|\\?*<\":>+[]/\'" - internal fun sanitizeFilename(name: String, removeSpaces: Boolean = false): String { - var tempName = name - for (c in RESERVED_CHARS) { - tempName = tempName.replace(c, ' ') - } - if (removeSpaces) tempName = tempName.replace(" ", "") - return tempName.replace(" ", " ").trim(' ') - } - - /** - * Used for getting video player subs. - * @return List of pairs for the files in this format: - * */ - internal fun getFolder( - context: Context, - relativePath: String, - basePath: String? - ): List>? { - val base = basePathToFile(context, basePath) - val folder = - base?.gotoDirectory(relativePath, createMissingDirectories = false) ?: return null - - //if (folder.isDirectory() != false) return null - - return folder.listFiles() - ?.mapNotNull { (it.name() ?: "") to (it.uri() ?: return@mapNotNull null) } - } - - /** - * Turns a string to an UniFile. Used for stored string paths such as settings. - * Should only be used to get a download path. - * */ - internal fun basePathToFile(context: Context, path: String?): SafeFile? { - return when { - path.isNullOrBlank() -> getDefaultDir(context) - path.startsWith("content://") -> SafeFile.fromUri(context, path.toUri()) - else -> SafeFile.fromFilePath(context, path) - } - } - - /** - * Base path where downloaded things should be stored, changes depending on settings. - * Returns the file and a string to be stored for future file retrieval. - * UniFile.filePath is not sufficient for storage. - * */ - internal fun Context.getBasePath(): Pair { - val settingsManager = PreferenceManager.getDefaultSharedPreferences(this) - val basePathSetting = settingsManager.getString(getString(R.string.download_path_key), null) - return basePathToFile(this, basePathSetting) to basePathSetting - } - - internal fun getFileName( - context: Context, - metadata: DownloadObjects.DownloadEpisodeMetadata - ): String { - return getFileName(context, metadata.name, metadata.episode, metadata.season) - } - - internal fun getFileName( - context: Context, - epName: String?, - episode: Int?, - season: Int? - ): String { - // kinda ugly ik - return sanitizeFilename( - if (epName == null) { - if (season != null) { - "${context.getString(R.string.season)} $season ${context.getString(R.string.episode)} $episode" - } else { - "${context.getString(R.string.episode)} $episode" - } - } else { - if (episode != null) { - if (season != null) { - "${context.getString(R.string.season)} $season ${context.getString(R.string.episode)} $episode - $epName" - } else { - "${context.getString(R.string.episode)} $episode - $epName" - } - } else { - epName - } - } - ) - } - - - internal fun DownloadObjects.DownloadedFileInfo.toFile(context: Context): SafeFile? { - return basePathToFile(context, this.basePath)?.gotoDirectory( - relativePath, - createMissingDirectories = false - ) - ?.findFile(displayName) - } - - internal fun getFolder(currentType: TvType, titleName: String): String { - return if (currentType.isEpisodeBased()) { - val sanitizedFileName = sanitizeFilename(titleName) - "${currentType.getFolderPrefix()}/$sanitizedFileName" - } else currentType.getFolderPrefix() - } - - /** - * Gets the default download path as an UniFile. - * Vital for legacy downloads, be careful about changing anything here. - * - * As of writing UniFile is used for everything but download directory on scoped storage. - * Special ContentResolver fuckery is needed for that as UniFile doesn't work. - * */ - fun getDefaultDir(context: Context): SafeFile? { - // See https://www.py4u.net/discuss/614761 - return SafeFile.fromMedia( - context, MediaFileContentType.Downloads - ) - } - -} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/downloader/DownloadObjects.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/downloader/DownloadObjects.kt deleted file mode 100644 index 25a9fdf2a..000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/downloader/DownloadObjects.kt +++ /dev/null @@ -1,224 +0,0 @@ -package com.lagradost.cloudstream3.utils.downloader - -import android.net.Uri -import com.fasterxml.jackson.annotation.JsonProperty -import com.lagradost.cloudstream3.Score -import com.lagradost.cloudstream3.TvType -import com.lagradost.cloudstream3.services.DownloadQueueService -import com.lagradost.cloudstream3.ui.player.SubtitleData -import com.lagradost.cloudstream3.ui.result.ResultEpisode -import com.lagradost.cloudstream3.utils.ExtractorLink -import com.lagradost.safefile.SafeFile -import java.io.IOException -import java.io.OutputStream -import java.util.Objects - -object DownloadObjects { - /** An item can either be something to resume or something new to start */ - data class DownloadQueueWrapper( - @JsonProperty("resumePackage") val resumePackage: DownloadResumePackage?, - @JsonProperty("downloadItem") val downloadItem: DownloadQueueItem?, - ) { - init { - assert(resumePackage != null || downloadItem != null) { - "ResumeID and downloadItem cannot both be null at the same time!" - } - } - - /** Loop through the current download instances to see if it is currently downloading. Also includes link loading. */ - fun isCurrentlyDownloading(): Boolean { - return DownloadQueueService.downloadInstances.value.any { it.downloadQueueWrapper.id == this.id } - } - - @JsonProperty("id") - val id = resumePackage?.item?.ep?.id ?: downloadItem!!.episode.id - - @JsonProperty("parentId") - val parentId = resumePackage?.item?.ep?.parentId ?: downloadItem!!.episode.parentId - } - - /** General data about the episode and show to start a download from. */ - data class DownloadQueueItem( - @JsonProperty("episode") val episode: ResultEpisode, - @JsonProperty("isMovie") val isMovie: Boolean, - @JsonProperty("resultName") val resultName: String, - @JsonProperty("resultType") val resultType: TvType, - @JsonProperty("resultPoster") val resultPoster: String?, - @JsonProperty("apiName") val apiName: String, - @JsonProperty("resultId") val resultId: Int, - @JsonProperty("resultUrl") val resultUrl: String, - @JsonProperty("links") val links: List? = null, - @JsonProperty("subs") val subs: List? = null, - ) { - fun toWrapper(): DownloadQueueWrapper { - return DownloadQueueWrapper(null, this) - } - } - - - abstract class DownloadCached( - @JsonProperty("id") open val id: Int, - ) - - data class DownloadEpisodeCached( - @JsonProperty("name") val name: String?, - @JsonProperty("poster") val poster: String?, - @JsonProperty("episode") val episode: Int, - @JsonProperty("season") val season: Int?, - @JsonProperty("parentId") val parentId: Int, - @JsonProperty("score") var score: Score? = null, - @JsonProperty("description") val description: String?, - @JsonProperty("cacheTime") val cacheTime: Long, - override val id: Int, - ) : DownloadCached(id) { - @JsonProperty("rating", access = JsonProperty.Access.WRITE_ONLY) - @Deprecated( - "`rating` is the old scoring system, use score instead", - replaceWith = ReplaceWith("score"), - level = DeprecationLevel.ERROR - ) - var rating: Int? = null - set(value) { - if (value != null) { - @Suppress("DEPRECATION_ERROR") - score = Score.fromOld(value) - } - } - } - - /** What to display to the user for a downloaded show/movie. Includes info such as name, poster and url */ - data class DownloadHeaderCached( - @JsonProperty("apiName") val apiName: String, - @JsonProperty("url") val url: String, - @JsonProperty("type") val type: TvType, - @JsonProperty("name") val name: String, - @JsonProperty("poster") val poster: String?, - @JsonProperty("cacheTime") val cacheTime: Long, - override val id: Int, - ) : DownloadCached(id) - - data class DownloadResumePackage( - @JsonProperty("item") val item: DownloadItem, - /** Tills which link should get resumed */ - @JsonProperty("linkIndex") val linkIndex: Int?, - ) { - fun toWrapper(): DownloadQueueWrapper { - return DownloadQueueWrapper(this, null) - } - } - - data class DownloadItem( - @JsonProperty("source") val source: String?, - @JsonProperty("folder") val folder: String?, - @JsonProperty("ep") val ep: DownloadEpisodeMetadata, - @JsonProperty("links") val links: List, - ) - - /** Metadata for a specific episode and how to display it. */ - data class DownloadEpisodeMetadata( - @JsonProperty("id") val id: Int, - @JsonProperty("parentId") val parentId: Int, - @JsonProperty("mainName") val mainName: String, - @JsonProperty("sourceApiName") val sourceApiName: String?, - @JsonProperty("poster") val poster: String?, - @JsonProperty("name") val name: String?, - @JsonProperty("season") val season: Int?, - @JsonProperty("episode") val episode: Int?, - @JsonProperty("type") val type: TvType?, - ) - - - data class DownloadedFileInfo( - @JsonProperty("totalBytes") val totalBytes: Long, - @JsonProperty("relativePath") val relativePath: String, - @JsonProperty("displayName") val displayName: String, - @JsonProperty("extraInfo") val extraInfo: String? = null, - @JsonProperty("basePath") val basePath: String? = null, // null is for legacy downloads. See getBasePath() - // Hash of the link associated with this DownloadFile, used so not override old data in the DownloadedFileInfo - @JsonProperty("linkHash") val linkHash : Int? = null - ) - - data class DownloadedFileInfoResult( - @JsonProperty("fileLength") val fileLength: Long, - @JsonProperty("totalBytes") val totalBytes: Long, - @JsonProperty("path") val path: Uri, - ) - - - data class ResumeWatching( - @JsonProperty("parentId") val parentId: Int, - @JsonProperty("episodeId") val episodeId: Int?, - @JsonProperty("episode") val episode: Int?, - @JsonProperty("season") val season: Int?, - @JsonProperty("updateTime") val updateTime: Long, - @JsonProperty("isFromDownload") val isFromDownload: Boolean, - ) - - - data class DownloadStatus( - /** if you should retry with the same args and hope for a better result */ - val retrySame: Boolean, - /** if you should try the next mirror */ - val tryNext: Boolean, - /** if the result is what the user intended */ - val success: Boolean, - ) - - - data class CreateNotificationMetadata( - val type: VideoDownloadManager.DownloadType, - val bytesDownloaded: Long, - val bytesTotal: Long, - val hlsProgress: Long? = null, - val hlsTotal: Long? = null, - val bytesPerSecond: Long - ) - - data class StreamData( - private val fileLength: Long, - val file: SafeFile, - //val fileStream: OutputStream, - ) { - @Throws(IOException::class) - fun open(): OutputStream { - return file.openOutputStreamOrThrow(resume) - } - - @Throws(IOException::class) - fun openNew(): OutputStream { - return file.openOutputStreamOrThrow(false) - } - - fun delete(): Boolean { - return file.delete() == true - } - - val resume: Boolean get() = fileLength > 0L - val startAt: Long get() = if (resume) fileLength else 0L - val exists: Boolean get() = file.exists() == true - } - - - /** bytes have the size end-start where the byte range is [start,end) - * note that ByteArray is a pointer and therefore cant be stored without cloning it */ - data class LazyStreamDownloadResponse( - val bytes: ByteArray, - val startByte: Long, - val endByte: Long, - ) { - val size get() = endByte - startByte - - override fun toString(): String { - return "$startByte->$endByte" - } - - override fun equals(other: Any?): Boolean { - if (other !is LazyStreamDownloadResponse) return false - return other.startByte == startByte && other.endByte == endByte - } - - override fun hashCode(): Int { - return Objects.hash(startByte, endByte) - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/downloader/DownloadQueueManager.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/downloader/DownloadQueueManager.kt deleted file mode 100644 index f38664088..000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/downloader/DownloadQueueManager.kt +++ /dev/null @@ -1,250 +0,0 @@ -package com.lagradost.cloudstream3.utils.downloader - -import android.content.Context -import android.util.Log -import androidx.core.content.ContextCompat -import com.lagradost.cloudstream3.CloudStreamApp -import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey -import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKeys -import com.lagradost.cloudstream3.CloudStreamApp.Companion.removeKeys -import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey -import com.lagradost.cloudstream3.MainActivity.Companion.lastError -import com.lagradost.cloudstream3.mvvm.safe -import com.lagradost.cloudstream3.plugins.PluginManager -import com.lagradost.cloudstream3.services.DownloadQueueService -import com.lagradost.cloudstream3.services.DownloadQueueService.Companion.downloadInstances -import com.lagradost.cloudstream3.utils.Coroutines.ioSafe -import com.lagradost.cloudstream3.utils.downloader.DownloadObjects.DownloadQueueWrapper -import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager.KEY_RESUME_IN_QUEUE -import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager.downloadStatus -import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager.downloadStatusEvent -import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager.getDownloadFileInfo -import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager.getDownloadQueuePackage -import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager.getDownloadResumePackage -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.getAndUpdate -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.flow.updateAndGet - -// 1. Put a download on the queue -// 2. The queue manager starts a foreground service to handle the queue -// 3. The service starts work manager jobs to handle the downloads? -object DownloadQueueManager { - private const val TAG = "DownloadQueueManager" - const val QUEUE_KEY = "download_queue_key" - - /** Flow of all active queued download, no active downloads. - * This flow may see many changes, do not place expensive observers. - * downloadInstances is the flow keeping track of active downloads. - * @see com.lagradost.cloudstream3.services.DownloadQueueService.Companion.downloadInstances - */ - private val _queue: MutableStateFlow> by lazy { - /** Persistent queue */ - val currentValue = getKey>(QUEUE_KEY) ?: emptyArray() - MutableStateFlow(currentValue) - } - - val queue: StateFlow> by lazy { _queue } - - /** Start the queue, marks all queue objects as in progress. - * Note that this may run twice without the service restarting - * because MainActivity may be recreated. */ - fun init(context: Context) { - ioSafe { - _queue.collect { queue -> - setKey(QUEUE_KEY, queue) - } - } - - ioSafe startQueue@{ - // Do not automatically start the queue if safe mode is activated. - if (PluginManager.isSafeMode()) { - // Prevent misleading UI - VideoDownloadManager.cancelAllDownloadNotifications(context) - return@startQueue - } - - val resumeQueue = - getPreResumeIds().filterNot { - VideoDownloadManager.currentDownloads.value.contains(it) - } - .mapNotNull { id -> - getDownloadResumePackage(context, id)?.toWrapper() - ?: getDownloadQueuePackage(context, id) - } - - val newQueue = _queue.updateAndGet { localQueue -> - // Add resume packages to the first part of the queue, since they may have been removed from the queue when they started - (resumeQueue + localQueue).distinctBy { it.id }.toTypedArray() - } - - // Once added to the queue they can be safely removed - removeKeys(KEY_RESUME_IN_QUEUE) - - // Make sure the download buttons display a pending status - newQueue.forEach { obj -> - setQueueStatus(obj.id, VideoDownloadManager.DownloadType.IsPending) - } - - if (newQueue.any()) { - startQueueService(context) - } - } - } - - /** Downloads not yet started or in progress. */ - private fun getPreResumeIds(): Set { - return getKeys(KEY_RESUME_IN_QUEUE)?.mapNotNull { - it.substringAfter("$KEY_RESUME_IN_QUEUE/").toIntOrNull() - }?.toSet() - ?: emptySet() - } - - /** Adds an object to the internal persistent queue. It does not re-add an existing item. @return true if successfully added */ - private fun add(downloadQueueWrapper: DownloadQueueWrapper): Boolean { - Log.d(TAG, "Download added to queue: $downloadQueueWrapper") - val newQueue = _queue.updateAndGet { localQueue -> - // Do not add the same episode twice - if (downloadQueueWrapper.isCurrentlyDownloading() || localQueue.any { it.id == downloadQueueWrapper.id }) { - return@updateAndGet localQueue - } - localQueue + downloadQueueWrapper - } - return newQueue.any { it.id == downloadQueueWrapper.id } - } - - /** Removes all objects with the same id from the internal persistent queue */ - private fun remove(id: Int) { - Log.d(TAG, "Download removed from the queue: $id") - _queue.update { localQueue -> - // The check is to prevent unnecessary updates - if (!localQueue.any { it.id == id }) { - return@update localQueue - } - - localQueue.filter { it.id != id }.toTypedArray() - } - } - - /** Removes all items and returns the previous queue */ - private fun removeAll(): Array { - Log.d(TAG, "Removed everything from queue") - return _queue.getAndUpdate { - emptyArray() - } - } - - private fun reorder(downloadQueueWrapper: DownloadQueueWrapper, newPosition: Int) { - _queue.update { localQueue -> - val newIndex = newPosition.coerceIn(0, localQueue.size) - val id = downloadQueueWrapper.id - - val newQueue = localQueue.filter { it.id != id }.toMutableList().apply { - this.add(newIndex, downloadQueueWrapper) - }.toTypedArray() - - newQueue - } - } - - /** Start a real download from the first item in the queue */ - fun popQueue(context: Context): VideoDownloadManager.EpisodeDownloadInstance? { - val first = queue.value.firstOrNull() ?: return null - - remove(first.id) - - val downloadInstance = VideoDownloadManager.EpisodeDownloadInstance(context, first) - - return downloadInstance - } - - /** Marks the item as in queue for the download button */ - private fun setQueueStatus(id: Int, status: VideoDownloadManager.DownloadType) { - downloadStatusEvent.invoke( - Pair( - id, - status - ) - ) - downloadStatus[id] = status - } - - private fun startQueueService(context: Context?) { - if (context == null) { - Log.d(TAG, "Cannot start download queue service, null context.") - return - } - // Do not restart the download queue service - if (DownloadQueueService.isRunning) { - return - } - ioSafe { - val intent = DownloadQueueService.getIntent(context) - ContextCompat.startForegroundService(context, intent) - } - } - - /** Cancels an active download or removes it from queue depending on where it is. */ - fun cancelDownload(id: Int) { - Log.d(TAG, "Cancelling download: $id") - - val currentInstance = downloadInstances.value.find { it.downloadQueueWrapper.id == id } - - if (currentInstance != null) { - currentInstance.cancelDownload() - } else { - removeFromQueue(id) - } - } - - /** Removes all queued items */ - fun removeAllFromQueue() { - removeAll().forEach { wrapper -> - setQueueStatus(wrapper.id, VideoDownloadManager.DownloadType.IsStopped) - } - } - - /** Removes all objects with the same id from the internal persistent queue */ - fun removeFromQueue(id: Int) { - ioSafe { - remove(id) - setQueueStatus(id, VideoDownloadManager.DownloadType.IsStopped) - } - } - - /** Will move the download queue wrapper to a new position in the queue. - * If the item does not exist it will also insert it. */ - fun reorderItem(downloadQueueWrapper: DownloadQueueWrapper, newPosition: Int) { - ioSafe { - reorder(downloadQueueWrapper, newPosition) - } - } - - /** Add a new object to the queue. Will not queue completed downloads or current downloads. */ - fun addToQueue(downloadQueueWrapper: DownloadQueueWrapper) = safe { - val context = CloudStreamApp.context ?: return@safe - val fileInfo = getDownloadFileInfo(context, downloadQueueWrapper.id) - val isComplete = fileInfo != null && - // Assure no division by 0 - fileInfo.totalBytes > 0 && - // If more than 98% downloaded then do not add to queue - (fileInfo.fileLength.toFloat() / fileInfo.totalBytes.toFloat()) > 0.98f - // Do not queue completed files! - if (isComplete) return@safe - - if (add(downloadQueueWrapper)) { - setQueueStatus(downloadQueueWrapper.id, VideoDownloadManager.DownloadType.IsPending) - startQueueService(context) - } - } - - - /** Refreshes the queue flow with the same value, but copied. - * Good to run if the downloads are affected by some outside value change. */ - fun forceRefreshQueue() { - _queue.update { localQueue -> - localQueue.copyOf() - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/downloader/DownloadUtils.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/downloader/DownloadUtils.kt deleted file mode 100644 index 9f2c31d9a..000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/downloader/DownloadUtils.kt +++ /dev/null @@ -1,165 +0,0 @@ -package com.lagradost.cloudstream3.utils.downloader - -import android.content.Context -import android.graphics.Bitmap -import androidx.core.graphics.drawable.toBitmap -import coil3.Extras -import coil3.SingletonImageLoader -import coil3.asDrawable -import coil3.request.ImageRequest -import coil3.request.SuccessResult -import com.lagradost.cloudstream3.R -import com.lagradost.cloudstream3.mvvm.logError -import com.lagradost.cloudstream3.mvvm.safe -import com.lagradost.cloudstream3.ui.player.SubtitleData -import com.lagradost.cloudstream3.ui.result.ExtractorSubtitleLink -import com.lagradost.cloudstream3.utils.Coroutines.ioSafe -import com.lagradost.cloudstream3.utils.UiText -import com.lagradost.cloudstream3.utils.downloader.DownloadFileManagement.getFileName -import com.lagradost.cloudstream3.utils.downloader.DownloadFileManagement.getFolder -import com.lagradost.cloudstream3.utils.txt -import kotlinx.coroutines.Job -import kotlinx.coroutines.runBlocking -import java.util.concurrent.ConcurrentHashMap - -/** Separate object with helper functions for the downloader */ -object DownloadUtils { - private val cachedBitmaps = ConcurrentHashMap() - internal fun Context.getImageBitmapFromUrl( - url: String, - headers: Map? = null - ): Bitmap? = safe { - cachedBitmaps[url]?.let { - return@safe it - } - - val imageLoader = SingletonImageLoader.get(this) - - val request = ImageRequest.Builder(this) - .data(url) - .apply { - headers?.forEach { (key, value) -> - extras[Extras.Key(key)] = value - } - } - .build() - - val bitmap = runBlocking { - val result = imageLoader.execute(request) - (result as? SuccessResult)?.image?.asDrawable(applicationContext.resources) - ?.toBitmap() - } - - bitmap?.let { - cachedBitmaps.putIfAbsent(url, it) - } - - return@safe bitmap - } - - //calculate the time - internal fun getEstimatedTimeLeft( - context: Context, - bytesPerSecond: Long, - progress: Long, - total: Long - ): String { - if (bytesPerSecond <= 0) return "" - val timeInSec = (total - progress) / bytesPerSecond - val hrs = timeInSec / 3600 - val mins = (timeInSec % 3600) / 60 - val secs = timeInSec % 60 - val timeFormated: UiText? = when { - hrs > 0 -> txt( - R.string.download_time_left_hour_min_sec_format, - hrs, - mins, - secs - ) - - mins > 0 -> txt( - R.string.download_time_left_min_sec_format, - mins, - secs - ) - - secs > 0 -> txt( - R.string.download_time_left_sec_format, - secs - ) - - else -> null - } - return timeFormated?.asString(context) ?: "" - } - - internal fun downloadSubtitle( - context: Context?, - link: ExtractorSubtitleLink, - fileName: String, - folder: String - ) { - ioSafe { - VideoDownloadManager.downloadThing( - context ?: return@ioSafe, - link, - "$fileName ${link.name}", - folder, - if (link.url.contains(".srt")) "srt" else "vtt", - false, - null, createNotificationCallback = {} - ) - } - } - - fun downloadSubtitle( - context: Context?, - link: SubtitleData, - meta: DownloadObjects.DownloadEpisodeMetadata, - ) { - context?.let { ctx -> - val fileName = getFileName(ctx, meta) - val folder = getFolder(meta.type ?: return, meta.mainName) - downloadSubtitle( - ctx, - ExtractorSubtitleLink(link.name, link.url, "", link.headers), - fileName, - folder - ) - } - } - - - /** Helper function to make sure duplicate attributes don't get overridden or inserted without lowercase cmp - * example: map("a" to 1) appendAndDontOverride map("A" to 2, "a" to 3, "c" to 4) = map("a" to 1, "c" to 4) - * */ - internal fun Map.appendAndDontOverride(rhs: Map): Map { - val out = this.toMutableMap() - val current = this.keys.map { it.lowercase() } - for ((key, value) in rhs) { - if (current.contains(key.lowercase())) continue - out[key] = value - } - return out - } - - internal fun List.cancel() { - forEach { job -> - try { - job.cancel() - } catch (t: Throwable) { - logError(t) - } - } - } - - internal suspend fun List.join() { - forEach { job -> - try { - job.join() - } catch (t: Throwable) { - logError(t) - } - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/serializers/UriSerializer.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/serializers/UriSerializer.kt deleted file mode 100644 index 7c73a6889..000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/serializers/UriSerializer.kt +++ /dev/null @@ -1,40 +0,0 @@ -package com.lagradost.cloudstream3.utils.serializers - -import android.net.Uri -import kotlinx.serialization.KSerializer -import kotlinx.serialization.descriptors.PrimitiveKind -import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor -import kotlinx.serialization.descriptors.SerialDescriptor -import kotlinx.serialization.encoding.Decoder -import kotlinx.serialization.encoding.Encoder - -/** - * Custom KSerializer for Android's [Uri] type. - * - * Uri is an Android platform type and cannot be annotated with @Serializable directly. - * Registering it in a SerializersModule globally would require a custom module passed to - * every Json instance, which adds hidden coupling. This serializer is also used sparingly - * across the codebase, so the overhead of a global registration isn't justified. - * Instead, we keep it explicit so that each usage site opts in intentionally and the - * serialization behavior remains visible. - * - * Usage: - * - * @Serializable - * data class MyData( - * @Serializable(with = UriSerializer::class) - * val uri: Uri, - * ) - */ -object UriSerializer : KSerializer { - override val descriptor: SerialDescriptor = - PrimitiveSerialDescriptor("Uri", PrimitiveKind.STRING) - - override fun serialize(encoder: Encoder, value: Uri) { - encoder.encodeString(value.toString()) - } - - override fun deserialize(decoder: Decoder): Uri { - return Uri.parse(decoder.decodeString()) - } -} diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/videoskip/AniSkip.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/videoskip/AniSkip.kt deleted file mode 100644 index 0db90afea..000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/videoskip/AniSkip.kt +++ /dev/null @@ -1,68 +0,0 @@ -package com.lagradost.cloudstream3.utils.videoskip - -import com.fasterxml.jackson.databind.annotation.JsonSerialize -import com.lagradost.cloudstream3.AnimeLoadResponse -import com.lagradost.cloudstream3.LoadResponse -import com.lagradost.cloudstream3.LoadResponse.Companion.getMalId -import com.lagradost.cloudstream3.TvType -import com.lagradost.cloudstream3.app -import com.lagradost.cloudstream3.ui.result.ResultEpisode - -// taken from https://github.com/saikou-app/saikou/blob/3803f8a7a59b826ca193664d46af3a22bbc989f7/app/src/main/java/ani/saikou/others/AniSkip.kt -// the following is GPLv3 code https://github.com/saikou-app/saikou/blob/main/LICENSE.md -class AniSkip : SkipAPI() { - override val name: String = "AniSkip" - override val supportedTypes: Set = setOf(TvType.Anime, TvType.OVA) - - override suspend fun stamps( - data: LoadResponse, - episode: ResultEpisode, - episodeDurationMs: Long - ): List? { - if (data !is AnimeLoadResponse) return null // Filter actual anime - - val malId = data.getMalId()?.toIntOrNull() ?: return null - val url = - "https://api.aniskip.com/v2/skip-times/$malId/${episode.episode}?types[]=ed&types[]=mixed-ed&types[]=mixed-op&types[]=op&types[]=recap&episodeLength=${episodeDurationMs / 1000L}" - - val response = app.get(url).parsed() - - // because it also returns an expected episode length we use that just in case it is mismatched with like 2s next episode will still work - return response.results?.mapNotNull { stamp -> - val skipType = when (stamp.skipType) { - "op" -> SkipType.Opening - "ed" -> SkipType.Ending - "recap" -> SkipType.Recap - "mixed-ed" -> SkipType.MixedEnding - "mixed-op" -> SkipType.MixedOpening - else -> null - } ?: return@mapNotNull null - val end = (stamp.interval.endTime * 1000.0).toLong() - val start = (stamp.interval.startTime * 1000.0).toLong() - SkipStamp( - type = skipType, - startMs = start, - endMs = end, - ) - } - } - - data class AniSkipResponse( - @JsonSerialize val found: Boolean, - @JsonSerialize val results: List?, - @JsonSerialize val message: String?, - @JsonSerialize val statusCode: Int - ) - - data class Stamp( - @JsonSerialize val interval: AniSkipInterval, - @JsonSerialize val skipType: String, - @JsonSerialize val skipId: String, - @JsonSerialize val episodeLength: Double - ) - - data class AniSkipInterval( - @JsonSerialize val startTime: Double, - @JsonSerialize val endTime: Double - ) -} \ No newline at end of file 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 deleted file mode 100644 index f9254576b..000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/videoskip/AnimeSkip.kt +++ /dev/null @@ -1,370 +0,0 @@ -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/IntroDbSkip.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/videoskip/IntroDbSkip.kt deleted file mode 100644 index 869515f43..000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/videoskip/IntroDbSkip.kt +++ /dev/null @@ -1,77 +0,0 @@ -package com.lagradost.cloudstream3.utils.videoskip - -import com.fasterxml.jackson.annotation.JsonProperty -import com.lagradost.cloudstream3.LoadResponse -import com.lagradost.cloudstream3.LoadResponse.Companion.getImdbId -import com.lagradost.cloudstream3.TvType -import com.lagradost.cloudstream3.app -import com.lagradost.cloudstream3.ui.result.ResultEpisode - -class IntroDbSkip : SkipAPI() { - override val name = "IntroDb" - - override val supportedTypes = setOf(TvType.TvSeries, TvType.AsianDrama) - - override suspend fun stamps( - data: LoadResponse, - episode: ResultEpisode, - episodeDurationMs: Long - ): List? { - val season = episode.season ?: return null - val imdbId = data.getImdbId() ?: return null - - val url = - "https://api.introdb.app/segments?imdb_id=$imdbId&season=$season&episode=${episode.episode}" - val response = app.get(url).parsed() - - return listOfNotNull( - response.intro?.let { - val start = it.startMs ?: return@let null - val end = it.endMs ?: return@let null - SkipStamp( - type = SkipType.Opening, - startMs = start, - endMs = end - ) - }, - response.recap?.let { - val start = it.startMs ?: return@let null - val end = it.endMs ?: return@let null - SkipStamp( - type = SkipType.Recap, - startMs = start, - endMs = end - ) - }, - response.outro?.let { - val start = it.startMs ?: return@let null - val end = it.endMs ?: return@let null - SkipStamp( - type = SkipType.Ending, - startMs = start, - endMs = end - ) - } - ) - } - - - data class IntroDbResponse( - @JsonProperty("imdb_id") val imdbId: String?, - val season: Int?, - val episode: Int?, - val intro: Segment?, - val recap: Segment?, - val outro: Segment?, - ) - - data class Segment( - @JsonProperty("start_sec") val startSec: Double?, - @JsonProperty("end_sec") val endSec: Double?, - @JsonProperty("start_ms") val startMs: Long?, - @JsonProperty("end_ms") val endMs: Long?, - val confidence: Double?, - @JsonProperty("submission_count") val submissionCount: Int?, - @JsonProperty("updated_at") val updatedAt: String?, - ) -} \ No newline at end of file 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 deleted file mode 100644 index 60cc3ae1e..000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/videoskip/SkipAPI.kt +++ /dev/null @@ -1,105 +0,0 @@ -package com.lagradost.cloudstream3.utils.videoskip - -import androidx.annotation.StringRes -import com.lagradost.cloudstream3.LoadResponse -import com.lagradost.cloudstream3.R -import com.lagradost.cloudstream3.TvType -import com.lagradost.cloudstream3.mvvm.safeAsync -import com.lagradost.cloudstream3.ui.result.ResultEpisode -import com.lagradost.cloudstream3.utils.txt -import java.util.concurrent.ConcurrentHashMap - - -enum class SkipType(@StringRes val res: Int) { - Opening(R.string.skip_type_op), - Ending(R.string.skip_type_ed), - 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_credits), - Intro(R.string.skip_type_intro), - Preview(R.string.skip_type_preview), -} - -data class SkipStamp( - val type: SkipType, - /** Start position in milliseconds of the skip, where it should start showing up */ - val startMs: Long, - /** End position in milliseconds of the skip, where it will skip to */ - val endMs: Long, - /** Custom visual label instead of using the type. Only use this for content not covered by SkipType */ - val label: String? = null, -) - -data class VideoSkipStamp( - val timestamp: SkipStamp, - val skipToNextEpisode: Boolean, - val source: String, -) { - val uiText = - if (skipToNextEpisode) txt(R.string.next_episode) else - txt( - R.string.skip_type_format, - timestamp.label?.let { txt(it) } ?: txt(timestamp.type.res) - ) -} - -abstract class SkipAPI { - open val name: String = "NONE" - - /** On what types SkipAPI should trigger on */ - abstract val supportedTypes: Set - - /** Get all video skip stamps of the associated episode */ - @Throws - open suspend fun stamps( - data: LoadResponse, - episode: ResultEpisode, - episodeDurationMs: Long, - ): List? { - throw NotImplementedError() - } - - companion object { - private val skipApis: List = listOf(AniSkip(), TheIntroDBSkip(), IntroDbSkip(), AnimeSkip()) - private val cachedStamps = ConcurrentHashMap>() - - /** Get all video timestamps from an episode */ - suspend fun videoStamps( - data: LoadResponse, - episode: ResultEpisode, - episodeDurationMs: Long, - hasNextEpisode: Boolean, - ): List { - cachedStamps[episode.id]?.let { list -> - return list - } - - for (api in skipApis) { - /** Unsupported type, so we do not waste a get call */ - if (!api.supportedTypes.contains(data.type)) { - continue - } - - /** Find first non-empty stamps */ - val stamps = safeAsync { api.stamps(data, episode, episodeDurationMs) } - if (stamps.isNullOrEmpty()) { - continue - } - - return stamps.map { stamp -> - VideoSkipStamp( - timestamp = stamp, - skipToNextEpisode = hasNextEpisode && episodeDurationMs - stamp.endMs < 20_000L, - source = api.name - ) - }.also { stamps -> - /** Put in cache, this is such small data, it should be fine to never clear it */ - cachedStamps[episode.id] = stamps - } - } - return emptyList() - } - } -} - diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/videoskip/TheIntroDBSkip.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/videoskip/TheIntroDBSkip.kt deleted file mode 100644 index cc2661cb0..000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/videoskip/TheIntroDBSkip.kt +++ /dev/null @@ -1,76 +0,0 @@ -package com.lagradost.cloudstream3.utils.videoskip - -import com.fasterxml.jackson.annotation.JsonProperty -import com.lagradost.cloudstream3.LoadResponse -import com.lagradost.cloudstream3.LoadResponse.Companion.getImdbId -import com.lagradost.cloudstream3.LoadResponse.Companion.getTMDbId -import com.lagradost.cloudstream3.LoadResponse.Companion.isMovie -import com.lagradost.cloudstream3.TvType -import com.lagradost.cloudstream3.ui.result.ResultEpisode -import com.lagradost.cloudstream3.app - -/** https://theintrodb.org/docs */ -class TheIntroDBSkip : SkipAPI() { - override val name = "TheIntroDB" - override val supportedTypes = setOf( - TvType.TvSeries, TvType.Cartoon, TvType.Anime, TvType.Movie, - TvType.AsianDrama - ) - - val mainUrl = "https://api.theintrodb.org" - - override suspend fun stamps( - data: LoadResponse, - episode: ResultEpisode, - episodeDurationMs: Long - ): List? { - val idSuffix = - data.getTMDbId()?.let { tmdbId -> "tmdb_id=$tmdbId" } - ?: data.getImdbId()?.let { imdbId -> "imdb_id=$imdbId" } - ?: return null - - val url = if (data.isMovie()) { - "$mainUrl/v2/media?$idSuffix" - } else { - val season = episode.season ?: return null - "$mainUrl/v2/media?$idSuffix&season=$season&episode=${episode.episode}" - } - val root = app.get(url).parsed() - return arrayOf( - root.intro to SkipType.Intro, - root.credits to SkipType.Credits, - root.recap to SkipType.Recap, - root.preview to SkipType.Preview - ).map { (list, type) -> - list.map { stamp -> - SkipStamp( - type, - stamp.startMs ?: 0L, - stamp.endMs ?: episodeDurationMs - ) - } - }.flatten() - } - - data class Root( - @JsonProperty("tmdb_id") - val tmdbId: Long, - @JsonProperty("type") - val type: String, - @JsonProperty("intro") - val intro: List = emptyList(), - @JsonProperty("recap") - val recap: List = emptyList(), - @JsonProperty("credits") - val credits: List = emptyList(), - @JsonProperty("preview") - val preview: List = emptyList(), - ) - - data class Stamp( - @JsonProperty("start_ms") - val startMs: Long?, - @JsonProperty("end_ms") - val endMs: Long?, - ) -} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/widget/FlowLayout.kt b/app/src/main/java/com/lagradost/cloudstream3/widget/FlowLayout.kt index c18ad39c6..624370032 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/widget/FlowLayout.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/widget/FlowLayout.kt @@ -4,7 +4,6 @@ import android.annotation.SuppressLint import android.content.Context import android.util.AttributeSet import android.view.ViewGroup -import androidx.core.content.withStyledAttributes import androidx.core.view.isVisible import androidx.core.view.marginEnd import com.lagradost.cloudstream3.R @@ -20,9 +19,9 @@ class FlowLayout : ViewGroup { @SuppressLint("CustomViewStyleable") internal constructor(c: Context, attrs: AttributeSet?) : super(c, attrs) { - c.withStyledAttributes(attrs, R.styleable.FlowLayout_Layout) { - itemSpacing = getDimensionPixelSize(R.styleable.FlowLayout_Layout_itemSpacing, 0) - } + val t = c.obtainStyledAttributes(attrs, R.styleable.FlowLayout_Layout) + itemSpacing = t.getDimensionPixelSize(R.styleable.FlowLayout_Layout_itemSpacing, 0) + t.recycle() } override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { @@ -105,9 +104,9 @@ class FlowLayout : ViewGroup { @SuppressLint("CustomViewStyleable") internal constructor(c: Context, attrs: AttributeSet?) : super(c, attrs) { - c.withStyledAttributes(attrs, R.styleable.FlowLayout_Layout) { - spacing = 0 - } + val t = c.obtainStyledAttributes(attrs, R.styleable.FlowLayout_Layout) + spacing = 0//t.getDimensionPixelSize(R.styleable.FlowLayout_Layout_itemSpacing, 0); + t.recycle() } internal constructor(width: Int, height: Int) : super(width, height) { diff --git a/app/src/main/res/drawable/animeskip.xml b/app/src/main/res/drawable/animeskip.xml deleted file mode 100644 index 8f1bb3105..000000000 --- a/app/src/main/res/drawable/animeskip.xml +++ /dev/null @@ -1,36 +0,0 @@ - - - - - - - - - - - diff --git a/app/src/main/res/drawable/bg_player_metadata_scrim_netflix.xml b/app/src/main/res/drawable/bg_player_metadata_scrim_netflix.xml deleted file mode 100644 index b4701e42a..000000000 --- a/app/src/main/res/drawable/bg_player_metadata_scrim_netflix.xml +++ /dev/null @@ -1,7 +0,0 @@ - - - \ No newline at end of file diff --git a/app/src/main/res/drawable/bookmark_star_24px.xml b/app/src/main/res/drawable/bookmark_star_24px.xml deleted file mode 100644 index 81b400d92..000000000 --- a/app/src/main/res/drawable/bookmark_star_24px.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/clear_all_24px.xml b/app/src/main/res/drawable/clear_all_24px.xml deleted file mode 100644 index dbbc7dc9f..000000000 --- a/app/src/main/res/drawable/clear_all_24px.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - - - diff --git a/app/src/main/res/drawable/dashed_line_horizontal.xml b/app/src/main/res/drawable/dashed_line_horizontal.xml deleted file mode 100644 index 737ff1959..000000000 --- a/app/src/main/res/drawable/dashed_line_horizontal.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - - - diff --git a/app/src/main/res/drawable/ic_baseline_north_west_24.xml b/app/src/main/res/drawable/ic_baseline_north_west_24.xml deleted file mode 100644 index c46eb4b0c..000000000 --- a/app/src/main/res/drawable/ic_baseline_north_west_24.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_baseline_people_24.xml b/app/src/main/res/drawable/ic_baseline_people_24.xml deleted file mode 100644 index 2e7c9b070..000000000 --- a/app/src/main/res/drawable/ic_baseline_people_24.xml +++ /dev/null @@ -1,15 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/app/src/main/res/drawable/metadata_overlay_icon.xml b/app/src/main/res/drawable/metadata_overlay_icon.xml deleted file mode 100644 index 6d1b6510a..000000000 --- a/app/src/main/res/drawable/metadata_overlay_icon.xml +++ /dev/null @@ -1,36 +0,0 @@ - - - - - - - diff --git a/app/src/main/res/drawable/netflix_download_batch.xml b/app/src/main/res/drawable/netflix_download_batch.xml deleted file mode 100644 index 8ef633fd2..000000000 --- a/app/src/main/res/drawable/netflix_download_batch.xml +++ /dev/null @@ -1,19 +0,0 @@ - - - - - diff --git a/app/src/main/res/drawable/outline_big_15_gray.xml b/app/src/main/res/drawable/outline_big_15_gray.xml deleted file mode 100644 index b94500279..000000000 --- a/app/src/main/res/drawable/outline_big_15_gray.xml +++ /dev/null @@ -1,11 +0,0 @@ - - - - - \ No newline at end of file diff --git a/app/src/main/res/drawable/outline_big_25_gray.xml b/app/src/main/res/drawable/outline_big_25_gray.xml deleted file mode 100644 index ea5f31a1f..000000000 --- a/app/src/main/res/drawable/outline_big_25_gray.xml +++ /dev/null @@ -1,11 +0,0 @@ - - - - - \ No newline at end of file diff --git a/app/src/main/res/drawable/outline_big_35_gray.xml b/app/src/main/res/drawable/outline_big_35_gray.xml deleted file mode 100644 index ab18a1354..000000000 --- a/app/src/main/res/drawable/outline_big_35_gray.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - - - \ No newline at end of file diff --git a/app/src/main/res/drawable/round_keyboard_arrow_up_24.xml b/app/src/main/res/drawable/round_keyboard_arrow_up_24.xml deleted file mode 100644 index d1360f948..000000000 --- a/app/src/main/res/drawable/round_keyboard_arrow_up_24.xml +++ /dev/null @@ -1,13 +0,0 @@ - - - - - diff --git a/app/src/main/res/drawable/sun_7_24.xml b/app/src/main/res/drawable/sun_7_24.xml deleted file mode 100644 index 26e3f43e8..000000000 --- a/app/src/main/res/drawable/sun_7_24.xml +++ /dev/null @@ -1,111 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/drawable/video_outline.xml b/app/src/main/res/drawable/video_outline.xml deleted file mode 100644 index 558c4ec3e..000000000 --- a/app/src/main/res/drawable/video_outline.xml +++ /dev/null @@ -1,4 +0,0 @@ - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/cast_item.xml b/app/src/main/res/layout/cast_item.xml index 4f7bdf74d..99a9750b2 100644 --- a/app/src/main/res/layout/cast_item.xml +++ b/app/src/main/res/layout/cast_item.xml @@ -7,9 +7,9 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_margin="5dp" - android:focusable="true" android:foreground="@drawable/outline_drawable" app:cardBackgroundColor="@color/transparent" + android:focusable="true" app:cardCornerRadius="@dimen/rounded_image_radius" app:cardElevation="0dp"> @@ -25,42 +25,38 @@ android:layout_gravity="center_horizontal"> + + + + + + - - - - - - diff --git a/app/src/main/res/layout/download_queue_item.xml b/app/src/main/res/layout/download_queue_item.xml deleted file mode 100644 index 86562a513..000000000 --- a/app/src/main/res/layout/download_queue_item.xml +++ /dev/null @@ -1,122 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/layout/extra_brightness_overlay.xml b/app/src/main/res/layout/extra_brightness_overlay.xml deleted file mode 100644 index 8f82121bb..000000000 --- a/app/src/main/res/layout/extra_brightness_overlay.xml +++ /dev/null @@ -1,8 +0,0 @@ - - \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_download_queue.xml b/app/src/main/res/layout/fragment_download_queue.xml deleted file mode 100644 index c562940f9..000000000 --- a/app/src/main/res/layout/fragment_download_queue.xml +++ /dev/null @@ -1,64 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/layout/fragment_downloads.xml b/app/src/main/res/layout/fragment_downloads.xml index 96e1e50ab..d6f41d6b0 100644 --- a/app/src/main/res/layout/fragment_downloads.xml +++ b/app/src/main/res/layout/fragment_downloads.xml @@ -74,24 +74,24 @@ + android:foreground="@drawable/outline_drawable" + android:id="@+id/download_appbar" + android:baselineAligned="false" + android:orientation="horizontal" + android:layout_width="match_parent" + android:layout_height="match_parent"> @@ -112,8 +112,8 @@ app:shimmer_base_alpha="0.2" app:shimmer_duration="@integer/loading_time" app:shimmer_highlight_alpha="0.3" - tools:ignore="MissingClass" - tools:visibility="gone"> + tools:visibility="gone" + tools:ignore="MissingClass"> - + android:layout_height="wrap_content"> @@ -249,19 +248,20 @@ + app:tint="?attr/textColor"> + @@ -308,38 +308,9 @@ android:descendantFocusability="afterDescendants" android:nextFocusUp="@id/download_appbar" android:nextFocusLeft="@id/navigation_downloads" - android:nextFocusDown="@id/download_queue_button" android:tag="@string/tv_no_focus_tag" tools:listitem="@layout/download_header_episode" /> - - - - - - - - - + + android:orientation="vertical" + android:layout_gravity="bottom|end"> + android:layout_marginEnd="16dp" + android:tooltipText="@string/open_local_video" + android:layout_gravity="bottom|end" + android:contentDescription="@string/open_local_video" /> + app:icon="@drawable/ic_network_stream" + android:contentDescription="@string/stream" /> \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_home.xml b/app/src/main/res/layout/fragment_home.xml index 99a764dee..77a41b2e2 100644 --- a/app/src/main/res/layout/fragment_home.xml +++ b/app/src/main/res/layout/fragment_home.xml @@ -247,11 +247,4 @@ app:icon="@drawable/ic_baseline_play_arrow_24" tools:ignore="ContentDescription" tools:visibility="visible" /> - - - \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_home_head.xml b/app/src/main/res/layout/fragment_home_head.xml index c57c32cee..e57990dc4 100644 --- a/app/src/main/res/layout/fragment_home_head.xml +++ b/app/src/main/res/layout/fragment_home_head.xml @@ -30,8 +30,8 @@ android:id="@+id/home_padding" android:layout_width="match_parent" android:layout_height="50dp" - android:gravity="center_vertical" android:orientation="horizontal" + android:gravity="center_vertical" android:paddingHorizontal="0dp"> @@ -39,8 +39,8 @@ android:id="@+id/home_search" android:layout_width="0dp" android:layout_height="match_parent" - android:layout_marginEnd="8dp" android:layout_weight="1" + android:layout_marginEnd="8dp" android:editTextColor="@color/white" android:gravity="center_vertical" android:iconifiedByDefault="true" @@ -59,12 +59,12 @@ android:id="@+id/home_head_profile_padding" android:layout_width="50dp" android:layout_height="50dp" + android:gravity="center" + android:orientation="horizontal" android:clickable="true" android:focusable="true" android:foreground="@drawable/rounded_select_ripple" - android:gravity="center" - android:nextFocusLeft="@id/home_search" - android:orientation="horizontal"> + android:nextFocusLeft="@id/home_search"> + app:cardCornerRadius="20dp" + android:foreground="@drawable/outline_drawable_round_20" + android:focusable="true"> @@ -113,43 +114,43 @@ android:gravity="center" android:orientation="horizontal"> - + - + - + @@ -159,20 +160,20 @@ android:id="@+id/alternative_account_padding" android:layout_width="match_parent" android:layout_height="50dp" - android:gravity="end" android:orientation="horizontal" + android:gravity="end" android:paddingHorizontal="0dp"> + android:nextFocusLeft="@id/home_search"> diff --git a/app/src/main/res/layout/fragment_home_tv.xml b/app/src/main/res/layout/fragment_home_tv.xml index d1d5c9e3b..add54cf3c 100644 --- a/app/src/main/res/layout/fragment_home_tv.xml +++ b/app/src/main/res/layout/fragment_home_tv.xml @@ -224,24 +224,6 @@ android:tag="@string/tv_no_focus_tag" app:tint="@color/player_on_button_tv_attr" /> - - - - - @@ -109,23 +108,6 @@ - - + android:orientation="vertical" + android:background="?attr/primaryGrayBackground"> - - - - + diff --git a/app/src/main/res/layout/fragment_result.xml b/app/src/main/res/layout/fragment_result.xml index a5c933d6a..c6de59542 100644 --- a/app/src/main/res/layout/fragment_result.xml +++ b/app/src/main/res/layout/fragment_result.xml @@ -586,81 +586,75 @@ android:paddingVertical="5dp" android:visibility="gone" tools:visibility="visible"> - + android:visibility="visible"/> + android:orientation="horizontal" + android:gravity="center"> - - - - - - - - - - - + + + + + + + - - + tools:visibility="visible" + app:icon="@drawable/cast_ic_mini_controller_skip_next" + android:maxLines="1" + android:ellipsize="end"/> - - + - + - - - + - - \ No newline at end of file + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_search_tv.xml b/app/src/main/res/layout/fragment_search_tv.xml index ed2f3b639..4d45594cf 100644 --- a/app/src/main/res/layout/fragment_search_tv.xml +++ b/app/src/main/res/layout/fragment_search_tv.xml @@ -1,5 +1,5 @@ - - - - - + android:background="?attr/primaryBlackBackground"> - - + \ No newline at end of file + android:descendantFocusability="afterDescendants" + android:nextFocusLeft="@id/navigation_search" + android:nextFocusUp="@id/tvtypes_chips" + android:nextFocusDown="@id/search_clear_call_history" + android:tag = "@string/tv_no_focus_tag" + android:paddingBottom="50dp" + android:visibility="visible" + tools:listitem="@layout/search_history_item" /> + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/main_settings.xml b/app/src/main/res/layout/main_settings.xml index 5c05599e8..4a41759e0 100644 --- a/app/src/main/res/layout/main_settings.xml +++ b/app/src/main/res/layout/main_settings.xml @@ -29,16 +29,16 @@ + android:foreground="@drawable/rounded_outline" + tools:src="@drawable/profile_bg_orange" + android:contentDescription="@string/account"/> @@ -134,8 +134,8 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:padding="10dp" - android:textColor="?attr/textColor" - tools:text="1234567" /> + android:text="@string/commit_hash" + android:textColor="?attr/textColor" /> + tools:text="21/03/2024 09:02 pm"/> + \ No newline at end of file diff --git a/app/src/main/res/layout/player_custom_layout.xml b/app/src/main/res/layout/player_custom_layout.xml index 04a2a1f1f..a70508da9 100644 --- a/app/src/main/res/layout/player_custom_layout.xml +++ b/app/src/main/res/layout/player_custom_layout.xml @@ -7,87 +7,6 @@ android:orientation="vertical" tools:orientation="vertical"> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + tools:progress="30" + style="@android:style/Widget.Material.ProgressBar.Horizontal" /> + tools:progress="0" + style="@android:style/Widget.Material.ProgressBar.Horizontal" /> @@ -351,38 +269,34 @@ android:layout_marginTop="20dp" android:layout_marginEnd="32dp" android:orientation="vertical"> - - - + tools:visibility="visible" + android:layout_gravity="start"/> - - - - - - - - - @@ -1140,34 +1021,34 @@ + android:layout_gravity="end" + android:layout_width="wrap_content" + android:layout_height="match_parent"> + android:layout_width="wrap_content" + android:layout_height="wrap_content"/> + android:descendantFocusability="afterDescendants"> diff --git a/app/src/main/res/layout/player_quality_profile_dialog.xml b/app/src/main/res/layout/player_quality_profile_dialog.xml index c01118b51..7bd7a6802 100644 --- a/app/src/main/res/layout/player_quality_profile_dialog.xml +++ b/app/src/main/res/layout/player_quality_profile_dialog.xml @@ -22,7 +22,7 @@ android:paddingTop="10dp" android:paddingEnd="?android:attr/listPreferredItemPaddingEnd" android:paddingBottom="10dp" - android:text="@string/source_priority" + android:text="@string/profiles" android:textColor="?attr/textColor" android:textSize="20sp" android:textStyle="bold" /> @@ -94,12 +94,6 @@ - - - + - - - - - - + + - - diff --git a/app/src/main/res/layout/player_select_source_priority.xml b/app/src/main/res/layout/player_select_source_priority.xml index 182cd1861..95e00d1f7 100644 --- a/app/src/main/res/layout/player_select_source_priority.xml +++ b/app/src/main/res/layout/player_select_source_priority.xml @@ -91,11 +91,11 @@ android:layout_width="50dp" android:layout_height="50dp" android:background="?attr/selectableItemBackgroundBorderless" - android:contentDescription="@string/help" + android:padding="12dp" android:focusable="true" android:nextFocusLeft="@id/sort_sources" - android:padding="12dp" - android:src="@drawable/baseline_help_outline_24" /> + android:src="@drawable/baseline_help_outline_24" + android:contentDescription="@string/help" /> @@ -142,17 +142,17 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_gravity="center" - android:layout_marginHorizontal="?android:attr/listPreferredItemPaddingStart" android:autofillHints="username" android:inputType="text" android:maxLength="32" + android:layout_marginHorizontal="?android:attr/listPreferredItemPaddingStart" android:paddingStart="?android:attr/listPreferredItemPaddingStart" android:paddingEnd="?android:attr/listPreferredItemPaddingEnd" android:textColor="?attr/textColor" android:textSize="20sp" android:textStyle="bold" - tools:ignore="LabelFor" - tools:text="@string/profile_number" /> + tools:text="@string/profile_number" + tools:ignore="LabelFor" /> diff --git a/app/src/main/res/layout/search_history_footer.xml b/app/src/main/res/layout/search_history_footer.xml deleted file mode 100644 index d8f0d933b..000000000 --- a/app/src/main/res/layout/search_history_footer.xml +++ /dev/null @@ -1,14 +0,0 @@ - - diff --git a/app/src/main/res/layout/search_suggestion_footer.xml b/app/src/main/res/layout/search_suggestion_footer.xml deleted file mode 100644 index 929fd3b04..000000000 --- a/app/src/main/res/layout/search_suggestion_footer.xml +++ /dev/null @@ -1,14 +0,0 @@ - - diff --git a/app/src/main/res/layout/search_suggestion_item.xml b/app/src/main/res/layout/search_suggestion_item.xml deleted file mode 100644 index d07f7b06d..000000000 --- a/app/src/main/res/layout/search_suggestion_item.xml +++ /dev/null @@ -1,43 +0,0 @@ - - - - - - - - - - diff --git a/app/src/main/res/layout/trailer_custom_layout.xml b/app/src/main/res/layout/trailer_custom_layout.xml index 88a318874..b8c5d84a5 100644 --- a/app/src/main/res/layout/trailer_custom_layout.xml +++ b/app/src/main/res/layout/trailer_custom_layout.xml @@ -7,87 +7,6 @@ android:orientation="vertical" tools:orientation="vertical"> - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + \ No newline at end of file diff --git a/app/src/main/res/menu/download_queue.xml b/app/src/main/res/menu/download_queue.xml deleted file mode 100644 index 7406a4555..000000000 --- a/app/src/main/res/menu/download_queue.xml +++ /dev/null @@ -1,10 +0,0 @@ - -

- - \ No newline at end of file diff --git a/app/src/main/res/navigation/mobile_navigation.xml b/app/src/main/res/navigation/mobile_navigation.xml index 27f186a00..784fc515e 100644 --- a/app/src/main/res/navigation/mobile_navigation.xml +++ b/app/src/main/res/navigation/mobile_navigation.xml @@ -184,7 +184,7 @@ app:enterAnim="@anim/enter_anim" app:exitAnim="@anim/exit_anim" app:popEnterAnim="@anim/enter_anim" - app:popExitAnim="@anim/exit_anim"> + app:popExitAnim="@anim/exit_anim" > - - - - - + tools:layout="@layout/fragment_easter_egg_monke" + android:label="EasterEggMonkeFragment" /> diff --git a/app/src/main/res/values-arz/strings.xml b/app/src/main/res/values-arz/strings.xml deleted file mode 100644 index 4a4cb755f..000000000 --- a/app/src/main/res/values-arz/strings.xml +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/app/src/main/res/values-b+af/strings.xml b/app/src/main/res/values-b+af/strings.xml index b81848db6..81d7a96ae 100644 --- a/app/src/main/res/values-b+af/strings.xml +++ b/app/src/main/res/values-b+af/strings.xml @@ -24,7 +24,8 @@ 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 56b71f5a0..7fd3274b9 100644 --- a/app/src/main/res/values-b+am/strings.xml +++ b/app/src/main/res/values-b+am/strings.xml @@ -8,7 +8,8 @@ %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 10ba0a88c..9bc697acf 100644 --- a/app/src/main/res/values-b+apc/strings.xml +++ b/app/src/main/res/values-b+apc/strings.xml @@ -29,7 +29,8 @@ فرجي الـLogcat 🐈 +30 كفي حضر - في أپدايت جديدة! \n%1$s ← %2$s + في أپدايت جديدة! +\n%1$s ← %2$s نزل الترجمات مع الڤيديو عوزو المصادر لَ تنبّشو رجاع @@ -95,7 +96,8 @@ لون الكتيبة مخلص عوز قوة ضوّ الشاشة تبع السيستام بدل من تغميئ الڤيديو - فشل ترجيع النسخة الإحتياطية من ملف \n%s + فشل ترجيع النسخة الإحتياطية من ملف +\n%s مشّي المقطع الدعائي مشّي البث المباشر م لقينا ولا حلقة @@ -164,7 +166,8 @@ طفي الترجمة القصة مستعمل - %dد \nباقي + %dد +\nباقي عم ينعرض حاليًا بلايحة النَطر حالة @@ -191,7 +194,7 @@ في مشكلة بجهاز العرض (Renderer error) العِنوان پروكسي \"گِت هَب\" - فرجي معلومات مشغل الڤيديو + جودة مشغل الڤيديو ملصق الترجمة أوڤا نَزِل من مصادر وجودات مختلفة @@ -358,7 +361,11 @@ العشوائي يللي بعده خيال عم نجدِد المثلثلات يللي مشتركينلها - مبين إنو فيه عنصر متل هيدا موجود بال مكتبة عندكن: \n \n%s \n \nبدكن تزيدو هيدا العنصر بأيّ حال، أو تستبدلوه مع العنصر الموجود، أو تلغو الإجراء؟ + مبين إنو فيه عنصر متل هيدا موجود بال مكتبة عندكن: +\n +\n%s +\n +\nبدكن تزيدو هيدا العنصر بأيّ حال، أو تستبدلوه مع العنصر الموجود، أو تلغو الإجراء؟ نزلت %1$d %2$s معرف مش صالح أفّي %s @@ -366,7 +373,9 @@ حطو الأرقام السرية لـ\"%s\" الطريقة القديمة معلى - \"كلود ستريم\" م بتجي مع مصادر ڤيديوات. لازم تنزلو المصادر من ريپويات. \n \nفيه معلومات على الـ\"ديسكورد\" تبعنا، أو فيكن تنبشو ع معلومات على الإنترنت. + \"كلود ستريم\" م بتجي مع مصادر ڤيديوات. لازم تنزلو المصادر من ريپويات. +\n +\nفيه معلومات على الـ\"ديسكورد\" تبعنا، أو فيكن تنبشو ع معلومات على الإنترنت. زبد تتبع 3G/4G… نفَتح %s @@ -379,7 +388,8 @@ دايمًا كتوب ب أحرف كاپيتال، A بدل a مشغل الڤيديو المفضل 4K - بَلَش تنزيل %1$d %2$s \n… + بَلَش تنزيل %1$d %2$s +\n… الوصف شوف الريپويات تبع مجتمع \"كلاود ستريم\" إنت هلّق بال وضع الآمن @@ -409,7 +419,9 @@ أفّى الإعداد فتت ع أكونت \"%s\" تبعك حدود خطية - في مشكلة بطعمير الـUI. هيدي مشكبة كبيرة. پليز بَلِغ عنّا \n(UI was unable to be created correctly) \n%s + في مشكلة بطعمير الـUI. هيدي مشكبة كبيرة. پليز بَلِغ عنّا +\n(UI was unable to be created correctly) +\n%s عَدِل تجَدَد (من الجديد للقديم) TC @@ -465,7 +477,8 @@ SD الإضافات شيل الإعلانات من الترجمة - رفّكن فاضي ☹ \nفوتو على أكونت فيها رفّ الڤيديوات يللي حضرينها أو زيدو ڤيديوات بال رفّ المحلي. + رفّكن فاضي ☹ +\nفوتو على أكونت فيها رفّ الڤيديوات يللي حضرينها أو زيدو ڤيديوات بال رفّ المحلي. اسم الريپو (مش ضروري) الجودات بيانات مش صالحة @@ -492,7 +505,8 @@ رايتينگ (من الواطي للعالي) فتاح من ملف طفي - لقينا ملف الوضع الآمن! \nمش رح نعوز إضافيات وقت ما ينفَتَح الآپ حتّى ينشال الملف. + لقينا ملف الوضع الآمن! +\nمش رح نعوز إضافيات وقت ما ينفَتَح الآپ حتّى ينشال الملف. مش مغير وقت الترجمة مشكلة مصدر @@ -517,7 +531,7 @@ إضافات م قدرنا نفتح %s رايتينگ: %s - تحزير: \"كلاود ستريم\" مش مسؤولة عن الإضافات المش رسمية، و م بتدعمن أبدًا! + تحزير: \"كلاود ستريم 3\" مش مسؤولة عن الإضافات المش رسمية، و م بتدعمن أبدًا! الحالة محي الريپو مشغل الڤيديو @@ -526,7 +540,10 @@ إنتو أصلًا مصوتين كاميرا م لقينا ولا إضافة بال ريپو - مبين إنو في عنصر متل هيدا موجود بالمكتبة عندكن: \n\"%s\" \n \nبدكن تزيدو هيدا العنصر بأيّ حال، أو تستبدلوّ مع العنصر الموجود، أو تلغو الإجراء؟ + مبين إنو في عنصر متل هيدا موجود بالمكتبة عندكن: +\n\"%s\" +\n +\nبدكن تزيدو هيدا العنصر بأيّ حال، أو تستبدلوّ مع العنصر الموجود، أو تلغو الإجراء؟ رايط مش صالح 1000 مللي ثانية إصدار @@ -545,8 +562,14 @@ /%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 أو أعلى، بينحط تلقائيًا، من دون ما ينعمل لود لكل المصادر! حطو الأرقام السرية الحالية صوت حط كبسة لبرم إتجاه الشاشة @@ -566,21 +589,23 @@ رمز/كلمة مرور للمصادقة فتاح التطبيق باستعمال البصمة، آي دي الوج، پِن، النمط، أو الپاسورد. بعد كذا محاولة فاشلة، هيدا الشباك رح يسكر. بكل بساطة، سكر الآپ ورجاع فتحه حتى تجرب بعد مرة. - %s \nباقي + %s +\nباقي المصادقة البيومترية مش مدعومة ع هالجهاز شيله من المفضل اسم وعنوان الريپو نتسخ! فيه ارور بال وصول ل الكليپ-بورد. پليز جرب مرة أخرى. فيه ارور بال نسخ. پليز نسوخ الـLogcat 🐈 وبعته ل المسؤولين عن دعم الآپ. - هلّق نعمل نسخة احتياطية للداتا تبع \"كلود ستريم\". إذا مابق ينفتح ويمشي الآپ، فيك تعمل كلير للداتا تبعه وترَجع الداتا من النسخة الاحتياطية اللي هلّق عملنالك ياها. \nالاحتمال انو مابق ينفتح الآپ احتمالية زغيرة كتير، بس كل جهاز بيتصرف بشكل مختلف، ونحنا منعتذر إذا سببنا أي إزعاج. + هلّق نعمل نسخة احتياطية للداتا تبع \"كلود ستريم\". إذا مابق ينفتح ويمشي الآپ، فيك تعمل كلير للداتا تبعه وترَجع الداتا من النسخة الاحتياطية اللي هلّق عملنالك ياها. +\nالاحتمال انو مابق ينفتح الآپ احتمالية زغيرة كتير، بس كل جهاز بيتصرف بشكل مختلف، ونحنا منعتذر إذا سببنا أي إزعاج. أوكي وقف اپتميزايشن بطارية جهازك بطارية الآپ اصلًا محطوطة ع «غير مقيد» \"Unrestricted\" م قدرنا نفتح معلومات الآپ تبع \"كلود ستريم\". - موسيقى + موسيقى أوديو بوك - الميديا + الميديا ت تضمن عدم انقطاع التنزيلات والنوتيفيكايشنات للبرامج التلفزيونية يللي مشتركلها، الآپ \"كلود ستريم\" بده إذن ليمشي ب الباكگراوند. ازا كبست أوكي، رح يفتح شباك إذن زغير، كبوس «سماح».\n\nملاحظة إنو هيدا الإذن ما بيعني إنو \"كلود ستريم 3\" رح تستنزف البطارية. ومش رح يشتغل الآپ بال باكگراوند إلّا عند الضرورة، متل لمّا تتلقا نوتيفيكايشن أو تنزل ڤيديو من الريپو الاصلي. ريسات رح ينزل ب %s @@ -609,13 +634,21 @@ نقي الإشيا اللي بدك تمحيها موجود لينحضر بلا إنترنت محي الفايلات - متأكد إنو بدك تمحي هيدي الإشيا ل الأبد؟ \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 صورة زغيرة مع التقريب وال تبعيد بت حط صورة زغير من الڤيديو إنت و عم بت قرب أو ترجع بال ڤيديو بعد مش معمول لود لولا ترجمة @@ -627,8 +660,8 @@ حدد غير محل هيدا الڤيديو تورينت. هيدا بيعني إنه فيه ينعمله تراك، يعني بينعرف شو عم تحضر.\nإزا م بتعرف شو التورينت، م تستعمله. حجم الحفّة - پودكاست - صوت + پودكاست + صوت أرور، مش مدعوم أرور بال إنكودينگ أفّي اللودينگ تلقائيًا @@ -706,33 +739,4 @@ فوق، عال شمال فوق، بال نُص فوق، عال يمين - ليستة التنزيلات - مافي شي عم يتنزّل هلّق. - قوة ضو إضافية - بت حط فلتر للبرايتنس لمّا تعلي قوة الضو ل أكتر من 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 f543136dc..487b29d84 100644 --- a/app/src/main/res/values-b+ar/strings.xml +++ b/app/src/main/res/values-b+ar/strings.xml @@ -12,7 +12,8 @@ سرعة (%.2fx) تقييم: %.1f - يوجد تحديث جديد! \n%1$s -> %2$s + يوجد تحديث جديد! +\n%1$s -> %2$s %d دقيقة CloudStream تشغيل بواسطة CloudStream @@ -175,8 +176,10 @@ إستئناف -٣٠ +٣٠ - سوف يتم الحذف نهائيا %s \nهل أنت متأكد? - %dm \nمتبقية + سوف يتم الحذف نهائيا %s +\nهل أنت متأكد? + %dm +\nمتبقية جاري التنفيذ اكتمل الحالة @@ -244,7 +247,7 @@ تحديث جودة المشاهدة المفضلة (WiFi) أقصى عدد لحروف عنوان مُشغل الفيديو - عرض معلومات المشغل + أبعاد مُشغل الفيديو حجم ذاكرة التخزين المؤقت للفيديو طول التخزين المؤقت التخزين المؤقت للفيديو على القرص @@ -398,7 +401,9 @@ تم تحميل: %d مُعطل %d غير مُحمل: %d - لا يحتوي CloudStream على مواقع مثبتة افتراضيًا. تحتاج إلى تثبيت المواقع من المستودعات. \n \nانضم إلى ديسكورد أو ابحث عبر الإنترنت. + لا يحتوي CloudStream على مواقع مثبتة افتراضيًا. تحتاج إلى تثبيت المواقع من المستودعات. +\n +\nانضم إلى ديسكورد أو ابحث عبر الإنترنت. عرض مستودعات المجتمع قائمة عامة جميع الترجمات حروف كبيرة @@ -449,7 +454,7 @@ مشغل داخلي لم يتم العثور على التطبيق جميع اللغات - الإعتمادات + الإعتمادات ‌تنزيل تحديث التطبيق… ‏تثبيت تحديث التطبيق… %d دقيقة @@ -488,13 +493,15 @@ التقييم (من الأعلى إلى الأدنى) التقييم (من الأدنى إلى الأعلى) الترتيب الأبجدي (من ي إلى أ) - مكتبتك فارغة :( \nقم بتسجيل الدخول على حساب مكتبة أو أضف عروضا إلى مكتبتك المحلية. + مكتبتك فارغة :( +\nقم بتسجيل الدخول على حساب مكتبة أو أضف عروضا إلى مكتبتك المحلية. محدث (من القديم إلى الجديد) فرز حسب افرز فتح بواسطة المكتبة - تم العثور على ملف الوضع الآمن! \nلا يتم تحميل أي ملحقات عند بدء التشغيل حتى تتم إزالة الملف. + تم العثور على ملف الوضع الآمن! +\nلا يتم تحميل أي ملحقات عند بدء التشغيل حتى تتم إزالة الملف. مدة التقديم عنما يكون المشغل مخفيا مدة التقديم - المشغل مخفي تلفزيون أندرويد @@ -526,7 +533,13 @@ تعديل الملفات التعريفية مساعدة - ‎‎هنا يمكنك تغيير طريقة ترتيب المصادر. إذا كان للفيديو أولوية أعلى ، فسيظهر في الأعلى في اختيار المصدر. يمثل مجموع أولوية المصدر وأولوية الجودة أولوية الفيديو. \n \nالمصدر أ: 3 \nالجودة ب: 7 \nسيكون لها أولوية فيديو مجمعة تبلغ 10. \n \nملاحظة: إذا كان المجموع 10 أو أكثر ، فسيتخطى المشغل التحميل تلقائيًا عند تحميل هذا الرابط! + ‎‎هنا يمكنك تغيير طريقة ترتيب المصادر. إذا كان للفيديو أولوية أعلى ، فسيظهر في الأعلى في اختيار المصدر. يمثل مجموع أولوية المصدر وأولوية الجودة أولوية الفيديو. +\n +\nالمصدر أ: 3 +\nالجودة ب: 7 +\nسيكون لها أولوية فيديو مجمعة تبلغ 10. +\n +\nملاحظة: إذا كان المجموع 10 أو أكثر ، فسيتخطى المشغل التحميل تلقائيًا عند تحميل هذا الرابط! النوعيات خلفية الملف الشخصي تعذر إنشاء واجهة المستخدم بشكل صحيح ، وهذا خطأ كبير ويجب الإبلاغ عنه على الفور %s @@ -539,7 +552,11 @@ تمت إزالة %s من المفضلة المفضلة تمت إضافة %s إلى المفضلة - احتمال وجود تكرارات في مكتبتك. \n \n%s \n \nهل تريد الاضافة على اي حال مستبدلاً النسخة الموجودة بالفعل, أم تفضل إلغاء العملية؟ + احتمال وجود تكرارات في مكتبتك. +\n +\n%s +\n +\nهل تريد الاضافة على اي حال مستبدلاً النسخة الموجودة بالفعل, أم تفضل إلغاء العملية؟ احتمال أن يكون موجود بالفعل قفل الحساب اضافة الى المفضلة @@ -552,7 +569,9 @@ إشترك إزالة من المفضلة اختار حساب - يبدو أن هناك عنصرًا مكررًا موجود بالفعل في مكتبتك: \'%s\'. \n \nهل ترغب في إضافة هذا العنصر على أية حال، أو استبدال العنصر الموجود، أو إلغاء الإجراء؟ + يبدو أن هناك عنصرًا مكررًا موجود بالفعل في مكتبتك: \'%s\'. +\n +\nهل ترغب في إضافة هذا العنصر على أية حال، أو استبدال العنصر الموجود، أو إلغاء الإجراء؟ ادخال ال PIN PIN أدخل ال PIN الحالي @@ -580,7 +599,8 @@ مصادقة كلمة المرور/رقم التعريف الشخصي بعد عدة محاولات فاشلة، سيتم إغلاق المطالبة. ما عليك سوى إعادة تشغيل التطبيق للمحاولة مرة أخرى. لقد تم الآن نسخ بيانات CloudStream احتياطيًا. على الرغم من أن احتمال حدوث ذلك منخفض جدًا، إلا أن جميع الأجهزة يمكن أن تتصرف بشكل مختلف. في الحالات النادرة، التي يتم فيها منعك من الوصول إلى التطبيق، قم بمسح بيانات التطبيق بالكامل واستعادتها من نسخة احتياطية. نحن نأسف جدًا لأي إزعاج ناتج عن هذا. - %s \nمتبقي + %s +\nمتبقي المفضلة إزالة من المفضلة اسم و عنوان المخزن @@ -593,8 +613,8 @@ كتاب صوتي حسناً لضمان عدم انقطاع التنزيلات والإشعارات للعروض التلفزيونية التي اشتركت بها ، يحتاج CloudStream إلى إذن للتشغيل في الخلفية. بالضغط على \"موافق\"، سيظهر لك مربع حوار طلب. يُرجى الضغط على \"السماح\".\n\nيرجى ملاحظة أن هذا الإذن لا يعني أن CS3 سيستنزف بطاريتك. سيعمل في الخلفية فقط عند الضرورة، مثلا عند تلقي الإشعارات أو تنزيل مقاطع الفيديو من الإضافات الرسمية. - موسيقى - الوسائط + موسيقى + الوسائط اعادة تعيين قادم خلال %s سيتم إصدار الحلقة %1$d من الموسم %2$d في @@ -622,13 +642,21 @@ الرجاء تحديد العناصر للحذف تحديد الكل حذف الملفات - هل أنت متأكد أنك تريد حذف الحلقات التالية نهائيًا في %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 معاينة شريط البحث تمكين معاينة الصورة المصغرة على شريط البحث لم يتم تحميل أي ترجمات بعد @@ -640,8 +668,8 @@ مخصص هذا الفيديو موجود على شبكة تورنت، مما يعني ان الاستخدام يمكن تعقبه.\nتأكد من فهم شبكات التورنت قبل الاستكمال. حجم الحافة - صوت - بودكاست + صوت + بودكاست خطأ في التكويد خطأ في الدعم تحميل أول ترجمة متاحة @@ -715,45 +743,4 @@ اعلى وسط اعلى يمين شاهد المسلسل كاملاً - سطوع إضافي - تفعيل فلتر السطوع عند تجاوز سطوع الشاشة 100% - تفعيل فلتر السطوع الإضافي - اقتراحات البحث - عرض اقتراحات البحث أثناء الكتابة - مسح الاقتراحات - عرض لوحة فريق التمثيل - تثبيت الإصدار التجريبي - تم تثبيت الإصدار التجريبي بالفعل. - فشل تثبيت الإصدار التجريبي. - نص الحلقة - معلومات الوسائط - اسم المصدر - شريط التنزيلات - لا يوجد تنزيلات قيد الانتظار حاليا. - أولوية المصدر - حَدِّد كيف يجب ترتيب مصادر الفيديو في المُشَّغِل - تنزيل الكل - إلغاء الكل - هل ترغب في تنزيل الحلقة %s ؟ - هل ترغب في إلغاء جميع التنزيلات قيد الانتظار؟ - - %d لا يوجد تنزيل نشط - %d تنزيل واحد نشط - %d تنزيلان نشطان - %d تنزيلات نشطة - %d تنزيل نشط - %d تنزيل نشط - - - %d لا يوجد تنزيلات في قائمة الانتظار - %d تنزيل واحد قيد الانتظار - %d تنزيلان قيد الانتظار - %d تنزيلات قيد الانتظار - %d تنزيل قيد الانتظار - %d تنزيل قيد الانتظار - - عرض واجهة منبثقة للبيانات الوصفية للمشغِّل - مقطع - استعراض - البث قائم diff --git a/app/src/main/res/values-b+ars/strings.xml b/app/src/main/res/values-b+ars/strings.xml index cd3830a01..3104e6a9a 100644 --- a/app/src/main/res/values-b+ars/strings.xml +++ b/app/src/main/res/values-b+ars/strings.xml @@ -35,7 +35,8 @@ توقف التنزيل خطط للمشاهدة إعادة المشاهدة - !تم العثور على تحديث جديد \n%1$s -> %2$s + !تم العثور على تحديث جديد +\n%1$s -> %2$s %.1f:قدر %dاقل كلاودستريم @@ -156,15 +157,23 @@ تم التحديث (من الجديد إلى القديم) تم التحديث (القديم إلى الجديد) أبجديًا (من الألف إلى الياء) - مكتبتك فارغة :( \nقم بتسجيل الدخول إلى حساب المكتبة أو قم بإضافة العروض إلى مكتبتك المحلية. - !تم العثور على ملف الوضع الآمن \n.عدم تحميل أي ملحقات عند بدء التشغيل حتى تتم إزالة الملف + مكتبتك فارغة :( +\nقم بتسجيل الدخول إلى حساب المكتبة أو قم بإضافة العروض إلى مكتبتك المحلية. + !تم العثور على ملف الوضع الآمن +\n.عدم تحميل أي ملحقات عند بدء التشغيل حتى تتم إزالة الملف ارجع تحديث العروض المشتركة الوضع العادي حرر ملفات تعريفية مساعدة - .هنا يمكنك تغيير كيفية ترتيب المصادر. إذا كان للفيديو أولوية أعلى، فسيظهر في مكان أعلى في تحديد المصدر. مجموع أولوية المصدر وأولوية الجودة هو أولوية الفيديو \n \nالمصدر أ: 3 \nالجودة ب: 7 \nستكون أولوية الفيديو المدمجة .10 \n \n!ملاحظة: إذا كان المجموع 10 أو أكثر، فسيقوم اللاعب تلقائيًا بتخطي التحميل عند تحميل هذا الرابط + .هنا يمكنك تغيير كيفية ترتيب المصادر. إذا كان للفيديو أولوية أعلى، فسيظهر في مكان أعلى في تحديد المصدر. مجموع أولوية المصدر وأولوية الجودة هو أولوية الفيديو +\n +\nالمصدر أ: 3 +\nالجودة ب: 7 +\nستكون أولوية الفيديو المدمجة .10 +\n +\n!ملاحظة: إذا كان المجموع 10 أو أكثر، فسيقوم اللاعب تلقائيًا بتخطي التحميل عند تحميل هذا الرابط لقد صوت بالفعل أبجديًا (ياء إلى ألف) ترتيب حسب @@ -218,7 +227,8 @@ قدم وصف يستمر التشغيل في مشغل مصغر فوق التطبيقات الأخرى - نهائيا %sسيؤدي هذا الى حذف \nهل أنت متأكد؟ + نهائيا %sسيؤدي هذا الى حذف +\nهل أنت متأكد؟ الخط حجم الخط زيل @@ -245,7 +255,8 @@ 🐈عرض لوجكات سجل صور في صور - %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 b7efb3341..eb6ad4aa4 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,9 +252,12 @@ পুনৰ আৰম্ভ কৰক -৩০ +৩০ - এইটো স্থায়ীভাৱে %s ডিলিট কৰিব। \nআপুনি নিশ্চিত নেকি? - %dm \nবাকী - %s \nবাকী + এইটো স্থায়ীভাৱে %s ডিলিট কৰিব। +\nআপুনি নিশ্চিত নেকি? + %dm +\nবাকী + %s +\nবাকী চলমান সম্পূৰ্ণ স্থিতি @@ -453,7 +456,9 @@ %d প্লাগইন আপডেট কৰা হ\'ল নিষ্ক্ৰিয় কৰা: %d ডাউনলোড কৰা নহয়: %d - CloudStreamত কোনো চাইট ডিফল্টভাৱে ইনষ্টল হোৱা নাই। আপুনিয়ে ৰিপ\'জিট\'ৰিবোৰৰ পৰা চাইটসমূহ ইনষ্টল কৰিব লাগিব। \n \nআমাৰ Discordত যোগদান কৰক বা অনলাইন বিচাৰক। + CloudStreamত কোনো চাইট ডিফল্টভাৱে ইনষ্টল হোৱা নাই। আপুনিয়ে ৰিপ\'জিট\'ৰিবোৰৰ পৰা চাইটসমূহ ইনষ্টল কৰিব লাগিব। +\n +\nআমাৰ Discordত যোগদান কৰক বা অনলাইন বিচাৰক। সম্প্ৰদায়ৰ ৰিপ\'জিট\'ৰিসমূহ চাওক সকলো চাবটাইটল মুকলি আখৰত সতর্কতা: CloudStream 3 কোৱা নাই যে তৃতীয় পক্ষৰ বৃদ্ধিসমূহ ব্যৱহাৰ কৰিবলৈ আপুনি সম্পূৰ্ণ দায়িত্ব ল\'ব আৰু কোনো সহায় নাপাব! @@ -488,7 +493,7 @@ মিশ্ৰিত সমাপ্তি মিশ্ৰিত উদ্‌ঘাটনী ইতিহাস পৰিস্কাৰ কৰক - স্বীকৃতি + স্বীকৃতি ভূমিকা ইতিহাস উদ্‌ঘাটনী/সমাপ্তিৰ বাবে এৰি দিয়াৰ পপআপ দেখুৱাওক @@ -518,9 +523,11 @@ বৰ্ণানুক্ৰমিক (A ৰ পৰা Z) পুথিভঁৰালী বাছক ইয়াৰ সহায়ত খুলক - আপোনাৰ পুথিভঁৰালী খালি আছে :( \nএখন পুথিভঁৰালী একাউণ্টত লগ ইন কৰক বা স্থানীয় পুথিভঁৰালীত শ্ব\'সমূহ যোগ কৰক। + আপোনাৰ পুথিভঁৰালী খালি আছে :( +\nএখন পুথিভঁৰালী একাউণ্টত লগ ইন কৰক বা স্থানীয় পুথিভঁৰালীত শ্ব\'সমূহ যোগ কৰক। এই তালিকা খালি। অন্য এটি তালিকালৈ সলনি কৰি চাওক। - নিরাপদ ম\'ড ফাইল পোৱা গৈছে! \nফাইল আঁতৰোৱা নোহোৱালৈকে কোনো এক্সটেনশ্যন আৰম্ভ নকৰা হৈছে। + নিরাপদ ম\'ড ফাইল পোৱা গৈছে! +\nফাইল আঁতৰোৱা নোহোৱালৈকে কোনো এক্সটেনশ্যন আৰম্ভ নকৰা হৈছে। ঘূৰাই দিয়া সদস্যতা গ্ৰহণ কৰা শ্ব\'সমূহ আপডেট কৰিছে %s-ত সদস্যতা গ্ৰহণ কৰা হৈছে @@ -532,7 +539,13 @@ সম্পাদনা কৰক প্ৰ\'ফাইলসমূহ সহায় - ইয়াত আপুনি উৎসসমূহ কেনেকৈ ক্ৰম অনুযায়ী চিহ্নিত কৰিব সেই বিষয়ে সলনি কৰিব পাৰে। যদি এখন ভিডিঅ\'ৰ উচ্চ অগ্ৰাধিকাৰ থাকে তেন্তে সেইটো উৎস নিৰ্বাচনৰ সময়ত ওপৰত দেখা যাব। উৎস অগ্ৰাধিকাৰ আৰু গুণ অগ্ৰাধিকাৰৰ মুঠ যোগফল হৈছে ভিডিঅ\'ৰ অগ্ৰাধিকাৰ। \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 আপুনি ইতিমধ্যে ভোট দিছে @@ -543,8 +556,14 @@ প্ৰিয় তালিকাৰ পৰা আঁতৰ কৰক সম্ভাৱ্য নকল বস্ত্ত পোৱা গৈছে সকলো প্ৰতিস্থাপন কৰক - আপোনাৰ পুথিভঁৰালীতে সম্ভাৱ্য নকল বস্তু পোৱা গৈছে: \'%s.\' \n \nআপুনি এই বস্তু যিকোনো হ\'লে যোগ কৰিব বিচাৰেনে, বিদ্যমান বস্তু প্ৰতিস্থাপন কৰিব, বা কাৰ্য বাতিল কৰিব বিচাৰেনে? - আপোনাৰ পুথিভঁৰালীতে সম্ভাৱ্য নকল বস্তুসমূহ পোৱা গৈছে: \n \n%s \n \nআপুনি এই বস্তু যিকোনো হ\'লে যোগ কৰিব বিচাৰেনে, বিদ্যমানবোৰ প্ৰতিস্থাপন কৰিব, বা কাৰ্য বাতিল কৰিব বিচাৰেনে? + আপোনাৰ পুথিভঁৰালীতে সম্ভাৱ্য নকল বস্তু পোৱা গৈছে: \'%s.\' +\n +\nআপুনি এই বস্তু যিকোনো হ\'লে যোগ কৰিব বিচাৰেনে, বিদ্যমান বস্তু প্ৰতিস্থাপন কৰিব, বা কাৰ্য বাতিল কৰিব বিচাৰেনে? + আপোনাৰ পুথিভঁৰালীতে সম্ভাৱ্য নকল বস্তুসমূহ পোৱা গৈছে: +\n +\n%s +\n +\nআপুনি এই বস্তু যিকোনো হ\'লে যোগ কৰিব বিচাৰেনে, বিদ্যমানবোৰ প্ৰতিস্থাপন কৰিব, বা কাৰ্য বাতিল কৰিব বিচাৰেনে? %s ৰ বাবে পিন সন্নিবিষ্ট কৰক বৰ্তমান পিন সন্নিবিষ্ট কৰক প্ৰফাইল লক কৰক @@ -569,7 +588,8 @@ ডাউনলোড এপ্‌ আৰম্ভণিৰ পিছত নতুন আপডেটৰ সন্ধান কৰক। একেই ডেভেলপাৰৰ দ্বাৰা এনিম এপ্‌ - নতুন আপডেট পোৱা গ’ল! \n%1$s -> %2$s + নতুন আপডেট পোৱা গ’ল! +\n%1$s -> %2$s ফিলাৰ CloudStreamৰে প্লে কৰক সন্ধান @@ -589,10 +609,18 @@ প্ৰয়োগ কৰক ফাইলসমূহ ডিলিট কৰক ডিলিট (%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 মুক্তিৰ তাৰিখ (পুৰণাৰ পৰা নতুন) সতৰ্কবাৰ্তা স্থানীয়ভাৱে প্ৰমাণীকৰণ কৰক @@ -628,8 +656,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 b8a1f9e2e..ffbd9d37d 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 4fb7757fd..2c238b968 100644 --- a/app/src/main/res/values-b+bg/strings.xml +++ b/app/src/main/res/values-b+bg/strings.xml @@ -16,7 +16,8 @@ Визуализация на фона Скорост (%.2fx) Оценка: %.1f - Намерена е нова актуализация! \n%1$s -> %2$s + Намерена е нова актуализация! +\n%1$s -> %2$s Шаблон %d мин CloudStream @@ -182,8 +183,10 @@ Продължи -30 30 - Това ще изтрие за постоянно %s \nСигурни ли сте? - %dm \nостава + Това ще изтрие за постоянно %s +\nСигурни ли сте? + %dm +\nостава Продължава Завършен Статус @@ -249,7 +252,7 @@ Актуализация Предпочитано качество за гледане (през WiFi) Максимален брой знаци за заглавие във видеоплейъра - Покажи информация за плейъра + Разделителна способност на видео плейъра Размер на видео буфера Дължина на видео буфера Видео кеш на диск @@ -402,11 +405,13 @@ Деактивирано: %d Не е изтеглено: %d Актуализирани %d плъгини - CloudStream няма инсталирани сайтове по подразбиране. Трябва да инсталирате сайтовете от хранилища. \n \nПрисъединете се към нашия Дискорд или потърсете онлайн. + CloudStream няма инсталирани сайтове по подразбиране. Трябва да инсталирате сайтовете от хранилища. +\n +\nПрисъединете се към нашия Дискорд или потърсете онлайн. Вижте хранилищата на общността Публичен списък Всички субтитри с главни букви - Предупреждение: CloudStream не носи отговорност за използването на трети страни разширения и не предоставя поддръжка за тях! + Предупреждение: CloudStream 3 не носи отговорност за използването на трети страни разширения и не предоставя поддръжка за тях! %s (Деактивиран) Потоци Аудио потоци @@ -446,12 +451,12 @@ Изтегля се актуализация на приложението… Смесено отваряне Смесено затваряне - Кредити + Кредити въведение Изчистване на историята Автоматично инсталиране на всички все още неинсталирани добавки от добавени хранилища. APK Инсталатор - Някои устройства не поддържат новия пакет за инсталиране. Опитайте предпоследената опция, ако актуализациите не се инсталират. + Някои телефони не поддържат новия пакет за инсталиране. Опитайте предпоследената опция, ако актуализациите не се инсталират. Пусни трейлър Връзки Актуализации на приложението @@ -514,10 +519,12 @@ Профил %d По азбучен ред (A до Z) Отваряне с - Вашата библиотека е празна :( \nВпишете се в акаунт с библиотеки или добавете сериали в локалната Ви библиотека. + Вашата библиотека е празна :( +\nВпишете се в акаунт с библиотеки или добавете сериали в локалната Ви библиотека. Използване Епизод %d е публикуван! - Намерен е файл за безопасен режим! \nНяма да се зареждат никакви разширения при стартиране, докато файлът не бъде премахнат. + Намерен е файл за безопасен режим! +\nНяма да се зареждат никакви разширения при стартиране, докато файлът не бъде премахнат. Вече сте гласували Задаване по подразбиране ПИН трябва да е 4 символа @@ -544,11 +551,23 @@ Скрит играч - сума за търсене Сумата за търсене, използвана, когато играчът е скрит Актуализирано (от ново към старо) - Тук можете да промените начина на подреждане на източниците. Ако даден видеоклип има по-висок приоритет, той ще се показва по-високо в избора на източник. Сборът от приоритета на източника и приоритета на качеството е приоритетът на видеото. \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Искате ли все пак да добавите този елемент, да замените съществуващите или да отмените действието? Заключи Профил Вкарай Сегашен ПИН Управлявай Профили @@ -620,14 +639,14 @@ Няма актуализирани плъгини. Are you sure you wСигурни ли сте, че искате трайно да изтриете всички епизоди от тази поредица?\n\n%s Рестартирайте приложението и приемете изскачащото съобщение за Stream Torrent, за да продължите. - Софтуерното декодиране позволява на плейъра да възпроизвежда видео файлове, които не се поддържат от устройството ви, но може да причини забавяне или нестабилно възпроизвеждане при висока резолюция + Софтуерното декодиране позволява на плейъра да възпроизвежда видео файлове, които не се поддържат от телефона ви, но може да причини забавяне или нестабилно възпроизвеждане при висока резолюция Известия на плейъра Епизод (низходящо) Започва процесът на актуализация на плъгините! Зареди първия наличен Няма заредени субтитри още - Звук - Подкаст + Звук + Подкаст Автентикация локално Заключване с биометрия Отвори хранилище @@ -635,7 +654,7 @@ PIN кодът е изтекъл ! CloudStream Уики Удостоверяване с парола/PIN - Медия + Медия Аудиокнига Отхвърли Отключете приложението с отпечатък, Face ID, PIN, шаблон или парола. @@ -663,7 +682,7 @@ Посетете %s на вашия смартфон или компютър и въведете горния код Кодът изтича след %1$dm %2$ds Изберете устройство за излъчване - Музика + Музика Любим За да гарантираме непрекъснати изтегляния и известия за абонирани телевизионни шоута, CloudStream се нуждае от разрешение да работи в фонов режим. Като натиснете ДОБРЕ, ще видите диалогов прозорец с искане. Моля, натиснете \"Позволи\".\n\nМоля, имайте предвид, че това разрешение не означава, че CS3 ще изтощава батерията ви. То ще работи във фонов режим само когато е необходимо, като например при получаване на известия или изтегляне на видеоклипове от официални разширения. Изображение на QR код @@ -681,56 +700,4 @@ Винаги изпращай запитване Задръжте, за да удвоите скоростта Дълго задържане за смяна на скоростта - Опашка за изтегляне - Пусни цялата поредица - В момента няма изтегляния в опашката. - Допълнителна яркост - Активирай филтъра за яркост, когато яркостта на екрана надвиши 100% - Предложения за търсене - Показвай предложения за търсене по време на писане - Изтрий предложения - Покажи панела за излъчване - Инсталирай предварителна версия - Предварителна версия вече е изтеглена. - Неуспешна инсталация на предварителната версия. - Етикет за рейтинг - Текст на епизода - Информация за медията - Приоритет на източника - Определи как източниците на видеото да се сортират в плейъра - Няма акаунт - Редактирай профилната снимка - Въведи линк за профилна снимка - Няма намерен URL - Невалиден URL или снимка - Успешно актуализирана снимка - Маркирай като гледано до този епизод - Премахни маркирането като гледано до този епизод - Презаредено - Презареди доставчика - Име - Име на източника - Резолюция и име - Изтегли всички - Откажи всички - Искаш ли да изтеглиш епизод %s? - Искаш ли да отмениш всички изтегляния в опашката? - Подравняване на субтитрите - Долу вляво - Долу в центъра - Долу вдясно - В средата вляво - В средата в центъра - В средата вдясно - Горе вляво - Горе в центъра - Горе вдясно - - %d активно изтегляне - %d активни изтегляния - - - %d изтегляне в опашката - %d изтегляния в опашката - diff --git a/app/src/main/res/values-b+bn/strings.xml b/app/src/main/res/values-b+bn/strings.xml index adc1b3f19..2e37f43f3 100644 --- a/app/src/main/res/values-b+bn/strings.xml +++ b/app/src/main/res/values-b+bn/strings.xml @@ -15,7 +15,8 @@ ব্যাকগ্রাউন্ড দেখান গতি (%.2f গুণ) মূল্যায়নঃ %.1f - নতুন আপডেট এসেছে! \n%1$s -> %2$s + নতুন আপডেট এসেছে! +\n%1$s -> %2$s ফিলার %d মিনিট ক্লাউডস্ট্রিম @@ -158,7 +159,8 @@ সিনেমা ডিসকর্ডে যোগ দিন টরেন্টস - এটি স্থায়ীভাবে মুছে ফেলা হবে %s \nআপনি কি নিশ্চিত? + এটি স্থায়ীভাবে মুছে ফেলা হবে %s +\nআপনি কি নিশ্চিত? থামুন -৩০ গিটহাব @@ -182,7 +184,8 @@ ব্যবহৃত লাইব্রেরী আমাদের তৈরি ছোট উপন্যাস পড়ার অ্যাপ্লিকেশন - %d মি \nবাকি + %d মি +\nবাকি অন্যান্য চলমান এশিয়ান নাটক @@ -305,7 +308,8 @@ password123 আসছে %s সময়ের মধ্যে বাতিল করুন - %s \nঅবশিষ্ট + %s +\nঅবশিষ্ট লাইভ স্ট্রিম সোর্স সমস্যা রিমোট সমস্যা @@ -346,11 +350,4 @@ অ্যাকাউন্ট প্রস্থান %1$d%2$s - সিজন %1$d পর্ব %2$d প্রকাশিত হবে - %1$dঘন্টা %2$dমিনিট %3$dসেকেন্ড - %1$dমিনিট %2$dসেকেন্ড - %1$dসেকেন্ড - শুরু থেকে চালু করুন - স্পিচ রিকগনিশন উপলব্ধ নেই - কথা বলা শুরু করুন… diff --git a/app/src/main/res/values-b+cs/strings.xml b/app/src/main/res/values-b+cs/strings.xml index 983a03db7..df5895639 100644 --- a/app/src/main/res/values-b+cs/strings.xml +++ b/app/src/main/res/values-b+cs/strings.xml @@ -16,7 +16,8 @@ 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 @@ -171,8 +172,10 @@ 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 @@ -234,7 +237,7 @@ Aktualizovat Upřednostněná kvalita sledování (WiFi) Maximální počet znaků v názvu přehrávače - Zobrazit informace o přehrávači + Rozlišení přehrávače Velikost vyrovnávací paměti videa Délka vyrovnávací paměti videa Mezipaměť videa na disku @@ -413,7 +416,9 @@ 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 @@ -433,7 +438,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… @@ -441,7 +446,8 @@ 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) @@ -449,7 +455,8 @@ 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) @@ -530,7 +537,13 @@ 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ů @@ -540,7 +553,11 @@ %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 @@ -554,7 +571,9 @@ 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 @@ -583,7 +602,8 @@ 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. @@ -592,8 +612,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 @@ -624,12 +644,20 @@ 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 @@ -642,8 +670,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é @@ -721,37 +749,4 @@ Předběžné vydání je již nainstalováno. Nepodařilo se nainstalovat předběžné vydání. Text epizody - Návrhy vyhledávání - Zobrazit návrhy vyhledávání během psaní - Vymazat návrhy - Zobrazit panel vysílání - Extra jas - Povolit filtr jasu při překročení 100 % jasu obrazovky - extra_brightness_enabled - Informace o médiu - Název zdroje - Fronta stahování - Momentálně nemáte žádná stahování ve frontě. - Stáhnout vše - Zrušit vše - Chcete stáhnout epizodu %s? - Chcete zrušit všechna stahování ve frontě? - - %d aktivní stahování - %d aktivní stahování - %d aktivních stahování - %d aktivních stahování - - - %d stahování uloženo do fronty - %d stahování uložena do fronty - %d stahování uloženo do fronty - %d stahování uloženo do fronty - - 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 e674fafd6..d1430c9e5 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,7 +27,8 @@ 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 @@ -187,8 +188,10 @@ 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 @@ -252,7 +255,7 @@ Update Bevorzugte Videoqualität (WLAN) Videoplayertitel max. Zeichen - Zeige Playerinformationen + Videoplayer Auflösung Videopuffergröße Videopufferlänge Video-Cache in Speicher @@ -398,7 +401,9 @@ 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 @@ -479,9 +484,11 @@ 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 @@ -515,7 +522,13 @@ 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 @@ -547,8 +560,14 @@ 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 @@ -568,7 +587,8 @@ 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. @@ -576,9 +596,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. @@ -587,7 +607,7 @@ Sicherheit Konten Repository öffnen - Besuche %s auf dem Smartphone oder Computer und gebe den obenstehenden Code ein + Besuche%s auf dem Smartphone oder Computer und gebe den obenstehenden Code ein PIN-Code vom Gerät nicht abrufbar, versuche lokale Authentifizierung Zur Zeit sind keine Downloads verfügbar. Lokales Video öffnen @@ -610,10 +630,18 @@ 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 @@ -627,7 +655,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) @@ -641,7 +669,7 @@ Plugins aktualisieren %d Plugin(s) erfolgreich aktualisiert! Plugins manuell aktualisieren - Podcast + Podcast Nicht unterstützter Fehler Kodierungsfehler Ep %s @@ -706,33 +734,4 @@ Die Installation der Vorabversion ist fehlgeschlagen. Spiel Mirror ab" Episodentext - Empfehlungen suchen - Beim Tippen Suchempfehlungen anzeigen - Empfehlungen löschen - Zusätzliche Helligkeit - Aktiviere Helligkeitsfilter, wenn 100% Bildschirmhelligkeit überschritten ist - Erhöhte Helligkeit aktiviert - Zeige Cast-Panel - Mediainfo - Quellname - Alle herunterladen - Möchtest du Episode %s herunter laden? - - %d aktiver Download - %d aktive Downloads - - Möchtest du alle Downloads in der Warteschlange abbrechen? - Alles abbrechen - - %d Download in Warteschlange - %d Downloads in Warteschlange - - Download in Warteschlange - Es befinden sich keine Downloads in der Warteschlange. - Quellpriorität - Entscheide, wie Videoquellen im Player sortiert werden sollen - Zeige Player-Metadaten - Video - Vorschau - Live diff --git a/app/src/main/res/values-b+el/strings.xml b/app/src/main/res/values-b+el/strings.xml index a38ee2682..4b671644b 100644 --- a/app/src/main/res/values-b+el/strings.xml +++ b/app/src/main/res/values-b+el/strings.xml @@ -110,7 +110,8 @@ Μπανάνα δόθηκε Ταχύτητα (%.2fx) Βαθμολογία: %.1f - Νέα διαθέσιμη ενημέρωση! \n%1$s -> %2$s + Νέα διαθέσιμη ενημέρωση! +\n%1$s -> %2$s Πατήστε δύο φορές στη μέση για παύση Χρήση φωτεινότητας συστήματος Χρήση φωτεινότητας συστήματος στο ενσωματωμένο πρόγραμμα αναπαραγωγής, αντί εφαρμογής προεπιλεγμένου σκούρου επικαλύμματος @@ -148,8 +149,10 @@ Ακύρωση Παύση Συνέχιση - Αυτό θα διαγράψει μόνιμα το %s \nΕίστε σίγουροι πως θέλετε να προχωρήσετε; - %dm \nαπομένουν + Αυτό θα διαγράψει μόνιμα το %s +\nΕίστε σίγουροι πως θέλετε να προχωρήσετε; + %dm +\nαπομένουν Σε εξέλιξη Κατάσταση Έτος @@ -320,7 +323,9 @@ Απενεργοποιήθηκε: %d Δεν κατέβηκε: %d Ενημερώθηκαν %d πρόσθετα - Το CloudStream δεν έχει προεγκατεστημένους ιστότοπους. Πρέπει να εγκαταστήσετε ιστότοπους μέσω ορισμένων αποθετηρίων. \n \nΒρείτε μας στο Discord ή ψάξτε στο διαδίκτυο. + Το CloudStream δεν έχει προεγκατεστημένους ιστότοπους. Πρέπει να εγκαταστήσετε ιστότοπους μέσω ορισμένων αποθετηρίων. +\n +\nΒρείτε μας στο Discord ή ψάξτε στο διαδίκτυο. Προβολή αποθετηρίων κοινότητας Δημόσια λίστα Κεφαλοποίηση υποτίτλων @@ -384,7 +389,7 @@ Web HDR Ανάμεικτοι τίτλοι αρχής - Εύσημα + Εύσημα Εισαγωγή +30 Ολοκληρώθηκε @@ -482,15 +487,23 @@ Αφαίρεση από παρακολουθημένα Περιηγητής Άνοιγμα με - Η βιβλιοθήκη σας είναι άδεια :( \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 @@ -536,7 +549,9 @@ Εντάξει Απενεργοποιήση της εξοικονόμησης της μπαταρίας Έχετε ήδη ψηφίσει - Φαίνεται πως ένα πιθανό αντίγραφο βρίσκεται στη βιβλιοθήκη σας: \'%s.\' \n \nΘα επιθυμούσατε να το προσθέσετε, να το αντικαταστήσετε, ή να ακυρώσετε την ενέργεια; + Φαίνεται πως ένα πιθανό αντίγραφο βρίσκεται στη βιβλιοθήκη σας: \'%s.\' +\n +\nΘα επιθυμούσατε να το προσθέσετε, να το αντικαταστήσετε, ή να ακυρώσετε την ενέργεια; Εισαγωγή Τρέχον Κωδικού Κλείδωμα Προφίλ Ξεκλείδωμα Cloudstream @@ -563,7 +578,11 @@ Πιθανό αντίγραφο βρέθηκε Προσθήκη Αντικατάσταση - Πιθανά διπλά αρχεία βρέθηκαν στην βιβλιοθήκη: \n \n%s \n \nΘα επιθυμούσατε να προσθέσετε αυτό το αρχείο ούτως η άλλως, να αντικαταστήσετε τα ήδη υπάρχοντα, ή να ακυρώσετε την ενέργεια? + Πιθανά διπλά αρχεία βρέθηκαν στην βιβλιοθήκη: +\n +\n%s +\n +\nΘα επιθυμούσατε να προσθέσετε αυτό το αρχείο ούτως η άλλως, να αντικαταστήσετε τα ήδη υπάρχοντα, ή να ακυρώσετε την ενέργεια? Εισαγωγή Κωδικού για %s Κωδικός Εσφαλμένος Κωδικός. Προσπαθήστε ξανά. @@ -576,7 +595,8 @@ Παράκαμψη απαγόρευσης από raw github URLs χρησιμοποιώντας jsDelivr. Μπορεί να καθυστερήσει τις ενημερώσεις για μερικές μέρες. Εμφάνιση κουμπιού για περιστροφή οθόνης Αγαπημένο - %s \nαπομένουν + %s +\nαπομένουν Βιομετρική αυθεντικοποίηση δεν υποστηρίζεται από τη συσκευή Καστ ταινίας Για να εξασφαλιστούν αδιάκοπες λήψεις και ειδοποιήσεις για αναγραφόμενες τηλεοπτικές εκπομπές, το CloudStream χρειάζεται άδεια για να τρέξει στο παρασκήνιο. Πατώντας OK, θα εμφανιστεί ένας διάλογος αιτήματος. Παρακαλώ πατήστε \\\"Επιτρέπω\\\".\n\nΠαρακαλώ σημειώστε, αυτή η άδεια δεν σημαίνει ότι το CS3 θα αποστραγγίσει την μπαταρία σας. Θα λειτουργεί στο παρασκήνιο μόνο όταν είναι απαραίτητο, όπως κατά τη λήψη ειδοποιήσεων ή τη λήψη βίντεο από επίσημες επεκτάσεις. @@ -584,9 +604,9 @@ Μετά από μερικές αποτυχημένες προσπάθειες, η άμεση θα κλείσει. Απλά επανεκκινήστε την εφαρμογή για να δοκιμάσετε ξανά. Επεξεργασία λογαριασμού Παράλειψη επιλογής λογαριασμού στην εκκίνηση της εφαρμογής - Μουσική + Μουσική Ακουστικό Βιβλίο - Μέσα + Μέσα Επαναφορά Τα δεδομένα σας στο CloudStream έχουν κάνει back up. Αν και η πιθανότητα είναι πολύ χαμηλή, όλες οι συσκευές συμπεριφέρονται διαφορετικά. Στη σπάνια περίπτωση, που απαγορευτεί η πρόσβασή σας από την εφαρμογή, διαγράψτε τα δεδομένα εφαρμογής και επαναφέρετέ τα από ένα ήδη υπάρχον backup. Συγνώμη για οποιαδήποτε ταλαιπωρία. Λογαριασμοί @@ -651,8 +671,8 @@ Άνοιγμα τοπικού βίντεο Κανένα πρόσθετο δεν ενημερώθηκε. Τοποθεσία φακέλου αντιγράφων - Ήχος - Ποντκάστ + Ήχος + Ποντκάστ Σφάλμα κωδικοποίησης Σφάλμα που δεν υποστηρίζεται Φορτώστε το πρώτο διαθέσιμο diff --git a/app/src/main/res/values-b+eo/strings.xml b/app/src/main/res/values-b+eo/strings.xml index 5dea7b24d..f957da076 100644 --- a/app/src/main/res/values-b+eo/strings.xml +++ b/app/src/main/res/values-b+eo/strings.xml @@ -49,7 +49,7 @@ Ĝenroj Ĉiuj lingvoj Serĉi - Kontoj kaj Sekureco + Kontoj GitHub Sezono Epizodo @@ -83,7 +83,8 @@ %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 @@ -126,59 +127,4 @@ Elŝutite Elŝutante Elŝuto Malsukcesite - %1$dh %2$dm %3$ds - %1$dm %2$ds - %1$ds - Sezono %1$d epizodo %2$d publikiĝos en - Legi ekde la komenco - Elsûtovico - La parolrekono ne haveblas - Komencu paroli… - Ne estas elŝuto ĉimomente. - Selekti Ĉion - Malselekti Ĉion - Malfermi lokan videon - Forigi Dosieron - Daŭrigi Elŝuton - Paŭzigi Elŝuton - Pli da informoj - Kaŝi - Filtri Legosignojn - Legosignoj - Nomo kaj URL de la deponejo - Kopiita! - Sciigo de novo epizodo - Serĉi en aliaj kromprogramoj - Montri la rekomendojn - Subtekstaj Agordoj - Kontura Koloro - Fona Koloro - Randa Tipo - Serĉi uzante provizantojn - Serĉi uzante tipojn - Subteksta Lingvo - Pli da informoj - @string/home_play - Priskribo - Neniu Priskribo Trovita - Forigi nigrajn borderaĵojn - Subtekstoj - Serĉaj Sugestoj - Aligi Diskordon - Neniu Ligilo Trovita - %1$s %2$d%3$s - Neniu Sezono - %1$d-%2$d - %1$d %2$s - Neniu Epizodo Trovita - Forigi - Forigi Dosieron - Forigi Dosierojn - Forigi (%1$d | %2$s) - Nuligi - Paŭzigi - Daŭrigi - Daŭro - Sinoptiko - Neniu Subteksto diff --git a/app/src/main/res/values-b+es/array.xml b/app/src/main/res/values-b+es/array.xml index 5456d03e2..121edd95a 100644 --- a/app/src/main/res/values-b+es/array.xml +++ b/app/src/main/res/values-b+es/array.xml @@ -14,19 +14,24 @@ @id/cast_button_type_forward_30_seconds - - @string/source_name + + @string/resolution_and_name + @string/name + @string/resolution_and_title + @string/title @string/resolution - @string/video_info + @string/none - - @string/show_name_key - @string/show_resolution_key - @string/show_media_info_key + + 5 + 4 + 3 + 2 + 1 + 0 - @string/none 16 caracteres diff --git a/app/src/main/res/values-b+es/strings.xml b/app/src/main/res/values-b+es/strings.xml index 167de546d..9748c9d1c 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 @@ -56,7 +56,7 @@ Eliminar Closed Captions (CC) de los subtítulos Cantidad de búsquedas del reproductor (segundos) Use el brillo del sistema en el reproductor de la app en lugar de una superposición oscura - Mostrar la información del reproductor de video + Resolución del reproductor de video Reproductor Iniciar el siguiente episodio cuando el actual termine Omitir Intro @@ -66,7 +66,7 @@ Final Apertura mixta Resumen - Créditos + Créditos Final mixto Póster del episodio Siguiente episodio @@ -80,8 +80,9 @@ Recargar enlaces /?? /%d - Esto eliminará %s permanentemente \nEstá seguro? - ¿Seguro que quieres salir? + Esto eliminará %s permanentemente +\nEstá seguro? + Está seguro que quiere salir? Continuar Descarga Código de idioma (es_ES) Póster principal @@ -103,7 +104,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 @@ -112,7 +113,7 @@ Inicio Buscar Descargas - Ajustes + Configuración Buscar… Buscar en %s… Sin datos @@ -248,7 +249,8 @@ Continuar -30 +30 - %dm \nfaltante + %dm +\nfaltante En curso Completado Estado @@ -270,9 +272,11 @@ Se aplicarán los cambios al reiniciar la App. Reproductor interno Idioma - Tradicional - Instalador de programas - CloudStream no tiene sitios instalados por defecto. Necesitas instalar los sitios desde los repositorios. \n \nÚnete a nuestro Discord o busca en línea. + Legacy (método antiguo) + Instalador de paquetes + CloudStream no tiene sitios instalados por defecto. Necesitas instalar los sitios desde los repositorios. +\n +\nÚnase a nuestro Discord o busque en línea. Advertencia: ¡CloudStream 3 no asume ninguna responsabilidad por el uso de extensiones de terceros y no brinda ningún soporte para ellas! Mostrar actualizaciones de la aplicación Instalador de APK @@ -428,7 +432,7 @@ Hecho Plugin cargado 18+ - Iniciada descarga de %1$d %2$s… + Comenzó la descarga de %1$d %2$s… Descarga por lotes plugin plugins @@ -480,8 +484,10 @@ 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 @@ -506,7 +512,13 @@ 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 @@ -526,7 +538,11 @@ %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 @@ -569,19 +585,20 @@ 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 - Nombre y URL del repositorio + %s +\nrestante + Nombre del repositorio y su URL ¡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. + Para garantizar descargas y notificaciones ininterrumpidas para programas de televisión suscritos, CloudStream necesita permiso para ejecutarse en segundo plano. Al presionar Aceptar, se le dirigirá a información de la aplicación. Presione «Permitir».\n\nTenga en cuenta que este permiso no significa que CS3 agotará su batería. Solo funcionará en segundo plano cuando sea necesario, como cuando reciba notificaciones o descargue vídeos desde extensiones oficiales. Reset Próximamente en %s La temporada %1$d y el episodio %2$d se estrenarán en @@ -593,8 +610,8 @@ Autenticación local Imagen del código QR Descartar - Abrir repositorio - Visita %s en tu smartphone o equipo e introduce el código anterior + Repositorio abierto + Visita %s en tu smartphone o ordenador e introduce el código anterior ¡El código PIN ya ha caducado! El código caduca en %1$d mín y %2$d s No puedo obtener el código PIN del dispositivo; intente con la autenticación local @@ -606,16 +623,24 @@ 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 @@ -627,8 +652,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 @@ -640,14 +665,14 @@ Puntuación (Más alta) Episodio (Descendente) Puntuación (Más baja) - Fecha de emisión (más nueva) + Fecha aérea (más nueva) ¡Iniciando el proceso de actualización del plugin! Actualizar complementos manualmente Notificaciones del reproductor Actualizar complementos - Fecha de emisión (más antigua) + Fecha aérea (más antigua) Puntuar %s - ¡%d complemento(s) actualizados correctamente! + %d complemento(s) actualizados correctamente. No se actualizó ningún complemento. Episodio (Ascendente) Reconocimiento de habla no disponible @@ -660,21 +685,21 @@ Poner todos los subtítulos en negrita Radio del fondo El volumen ha excedido 100% - Desliza hacia arriba otra vez para sobrepasar 100% + Deslice de nuevo hacia arriba para ir más allá del 100% Cuántos elementos diferentes pueden descargarse en paralelo Preguntar siempre Descargas en paralelo - Conexiones simultáneas - Cuántas conexiones simultáneas puede usar cada descarga + Conexiones concurrentes + Cuántas conexiones concurrentes para cada descarga se pueden usar durante un proceso de descarga Ir a Descargas Sin conexión a internet. \n\nConéctese a internet y vuelva a intentarlo, o mire sus descargas mientras está sin conexión. Cambios en los límites de la pantalla - Sobreexploración + Overscan 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 @@ -706,35 +731,4 @@ Medio centro Medio derecha Arriba a la izquierda - Sugerencias de Búsqueda - Mostrar sugerencias de búsqueda mientras escribe - Borrar Sugerencias - Mostrar panel de reparto - Atenuación extra - Active el filtro de atenuación cuando se supere el 100 % de brillo de la pantalla - atenuación_extra_activado - Información del archivo multimedia - Nombre de origen - Cola de descarga - Actualmente no hay descargas encoladas. - Prioridad de origen - Decida como los orígenes del vídeo estarían ordenados en el reproductor - Descargar todo - Cancelar todo - ¿Desea descargar el episodio %s? - ¿Desea cancelar todas las descargas de la cola? - - %d descarga activa - %d descargas activas - %d descargas activas - - - %d descarga encolada - %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 1018e9a82..da6f04d8e 100644 --- a/app/src/main/res/values-b+fa/strings.xml +++ b/app/src/main/res/values-b+fa/strings.xml @@ -113,7 +113,8 @@ در حال تماشا بارگیری‌ها سرعت (%.2f برابر) - بروزرسانی جدید پیدا شد! \n%1$s -> %2$s + بروزرسانی جدید پیدا شد! +\n%1$s -> %2$s پخش فیلم مرورگر پخش قسمت @@ -129,7 +130,8 @@ برای بازنشانی به پیشفرض نگه‌دارید کتابخانه در ادامه - این فرآیند بطور کامل %s را حذف می‌کند \nآیا از این کار اطمینان دارید؟ + این فرآیند بطور کامل %s را حذف می‌کند +\nآیا از این کار اطمینان دارید؟ نام مخزن و نشانی کپی شد! درباره @@ -178,11 +180,13 @@ حذف پرونده نمایش تریلر ها قسمت‌ها - %dد \nباقی‌مانده + %dد +\nباقی‌مانده گیتهاب پنهان کردن ویدیو مشخص شده از نتایج جستجو لغو - %s \nباقی‌مانده + %s +\nباقی‌مانده پیش‌فرض کارتون تورنت @@ -259,7 +263,7 @@ خطای دانلود، دسترسی به حافظه را چک کنید کیفیت کیفیت و عنوان - محتوا + محتوا مقدار جلورفتن پخش‌کننده مخزن پیدا نشد، نشانی را چک کنید و فیلترشکن را امتحان کنید کد زبان (انگلیسی) @@ -316,10 +320,10 @@ انیمه خانگی پخش زنده مثبت ۱۸ سال - موسیقی + موسیقی کتاب صوتی - صدا - پادکست + صدا + پادکست پخش کردن در برنامه دانلود اتوماتیک هیچ آپدیتی پیدا نشد diff --git a/app/src/main/res/values-b+fil/strings.xml b/app/src/main/res/values-b+fil/strings.xml index be54aa959..d4844d1d7 100644 --- a/app/src/main/res/values-b+fil/strings.xml +++ b/app/src/main/res/values-b+fil/strings.xml @@ -52,108 +52,4 @@ Itago ang napiling quality ng video sa mga resulta ng paghahanap Pumili ng mode upang i-filter ang pag-download ng mga plugin Awtomatikong i-install ang lahat ng hindi pa naka-install na plugin mula sa mga idinagdag na repository. - Tauhan: %s - Kabanata %d ay ipapalabas sa - Season %1$d Kabanata %2$d ay ipapalabas sa - %1$da %2$do %3$dm - %1$do %2$dm - %dm - %1$do %2$dm %3$ds - %1$dm %2$ds - %1$ds - Poster ng Kabanata - Susunod na Kahit Ano - Bumalik - Ipatugtog mula sa Simula - Baguhin ang Tagatustos - Background ng Pasilip - Bilis (%.2fx) - Na-rate: %.1f - May nakitang bagong update!\n%1$s -> %2$s - %d min - Ipatugtog sa CloudStream - Bahay - Maghanap - Mga Download - Nakapila na download - Mga pagpipilian - Maghanap… - Maghanap %s… - Magsimulang magsalita… - Walang Data - Iba pang Mga Pagpipilian - Susunod na kabanata - Mga Genre - Ibahagi - Browser - Laktawan ang paglo-load - Naglo-load… - Pinapanood - Nakabinbin - Nakumpleto - Pinaplanong Panoorin - Pinapanood muli - Ipatugtog ang Pelikula - Ipatugtog ang Trailer - Ipatugtog ang Livestream - Ipatugtog ang Buong Serye - Mga Mapagkukunan - Mga saling-teksto - Subukang muli kumonekta… - Bumalik - Ipatugtog ang Kabanata - I-stream ang Torrent - I-download - Na-download - Dina-download - Hininto ang pag-download - Sinimulan ang pag-download - Nabigong ma-download - Kinansela ang pag-download - Natapos ang pag-download - Kasalukuyang walang nakapilang mga download. - Sinimulan ang pag-update - Buksan ang local video - Nagkamali sa paglo-load ng mga link - Ini-load muli ang mga link - Imbakan - Dub - Sub - Burahin ang File - Ipatugtog ang File - Ipagpatuloy ang pag-download - Ihinto ang pag-download - Higit pang impormasyon - Itago - Ipatugtog - Impormasyon - I-filter ang Mga Bookmark - Mga Bookmark - Alisin - Itakda ang kapalaran ng panonood - Ilapat - Ikopya - Isara - Alisin - I-save - Pangalan ng repository at URL - nakopya! - Bagong notipikasyon ng kabanata - Maghanap sa iba pang mga extension - Ipakita ang mga rekomendasyon - Bilis ng Manlalaro - Pagpipilian sa Saling-teksto - Kulay ng Teksto - Kulay ng Balangkas - Kulay ng Likuran - Kulay ng Bintana - Kataasan ng Saling-teksto - Font - Laki ng Font - Maghanap gamit ang mga tagatustos - Maghanap gamit ang mga uri - %d Benenes naibigay sa mga dev - Walang Benenes na binigay - Awtomatikong Piliin ang Wika - Mag-download ng Mga Wika diff --git a/app/src/main/res/values-b+fr/strings.xml b/app/src/main/res/values-b+fr/strings.xml index bf401b20a..32df1d4b7 100644 --- a/app/src/main/res/values-b+fr/strings.xml +++ b/app/src/main/res/values-b+fr/strings.xml @@ -79,7 +79,8 @@ 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 @@ -121,7 +122,8 @@ 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 @@ -135,7 +137,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 @@ -143,7 +145,7 @@ Langues des extensions Médias préférées Auto - Distribution : %s + Distribution : %s %d min Rechercher sur %s… À re-regarder @@ -289,8 +291,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 @@ -300,18 +302,18 @@ 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 Étiquette de qualité - Afficher les informations du lecteur + Résolution du lecteur vidéo Cloner le site Supprimer le site Ajoute un clone à un site déjà existant, avec une URL différente @@ -319,7 +321,8 @@ 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 @@ -360,7 +363,7 @@ NSFW 127.0.0.1 %d / 10 - / ?? + /?? /%d SD UHD @@ -406,14 +409,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 @@ -429,10 +432,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 @@ -445,7 +448,9 @@ 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 @@ -474,7 +479,8 @@ 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 @@ -483,7 +489,8 @@ 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 @@ -508,7 +515,13 @@ 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 @@ -541,14 +554,20 @@ 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 @@ -561,14 +580,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 @@ -581,7 +600,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 @@ -600,10 +619,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) @@ -619,9 +638,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 @@ -635,13 +654,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 @@ -652,7 +671,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 @@ -707,35 +726,4 @@ La version préliminaire est déjà installée. L\'installation de la version préliminaire a échouée. Texte de l\'épisode - Suggestions de recherche - Afficher des suggestions de recherche pendant la saisie - Effacer les suggestions - Luminosité supplémentaire - Activer le filtre de luminosité lorsque la luminosité dépasse 100 % - extra_brightness_enabled - Afficher le panneau de cast - Information du média - Nom de la source - File d\'attente de téléchargements - Il n\'y a actuellement aucun téléchargement en attente. - Priorité de la source - 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 ? - - %d téléchargement actif - %d téléchargements actifs - %d téléchargements actifs - - - %d téléchargement en attente - %d téléchargements en attente - %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 e3775b091..1b8f068e3 100644 --- a/app/src/main/res/values-b+gl/strings.xml +++ b/app/src/main/res/values-b+gl/strings.xml @@ -13,7 +13,8 @@ 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 @@ -185,7 +186,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 @@ -203,7 +204,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 4e013ab36..2c5247238 100644 --- a/app/src/main/res/values-b+hi/strings.xml +++ b/app/src/main/res/values-b+hi/strings.xml @@ -2,7 +2,8 @@ स्पीड (%.2fx) - नया अपडेट आया है! \n%1$s -> %2$s + नया अपडेट आया है! +\n%1$s -> %2$s होम खोजें डाउनलोडस @@ -86,7 +87,8 @@ रद्द करें रोकें फिर से चलाएं - इससे %s स्थायी रूप से हट जाएगा \nक्या आपका निर्णय निश्चित है ? + इससे %s स्थायी रूप से हट जाएगा +\nक्या आपका निर्णय निश्चित है ? अभी चालू है मुकम्मल हुया स्थिति @@ -151,7 +153,11 @@ %d मिनट क्लाउडस्ट्रीम क्लाउडस्ट्रीम के साथ चलाएं - आपकी लाइब्रेरी में संभावित डुप्लिकेट आइटम पाए गए हैं: \n \n%s \n \nक्या आप किसी भी तरह इस आइटम को जोड़ना चाहेंगे, मौजूदा आइटम को बदलना चाहेंगे, या कार्रवाई रद्द करना चाहेंगे? + आपकी लाइब्रेरी में संभावित डुप्लिकेट आइटम पाए गए हैं: +\n +\n%s +\n +\nक्या आप किसी भी तरह इस आइटम को जोड़ना चाहेंगे, मौजूदा आइटम को बदलना चाहेंगे, या कार्रवाई रद्द करना चाहेंगे? %s के लिए पिन दर्ज करें संभावित डुप्लिकेट मिला अपडेट शुरू हुआ @@ -171,7 +177,9 @@ अकाउंट चुनिये लोडिंग स्किप करे लोडिंग… - ऐसा प्रतीत होता है कि संभावित रूप से डुप्लिकेट आइटम आपकी लाइब्रेरी में पहले से मौजूद है: \'%s.\' \n \nक्या आप किसी भी तरह इस आइटम को जोड़ना चाहेंगे, मौजूदा आइटम को बदलना चाहेंगे, या कार्रवाई रद्द करना चाहेंगे? + ऐसा प्रतीत होता है कि संभावित रूप से डुप्लिकेट आइटम आपकी लाइब्रेरी में पहले से मौजूद है: \'%s.\' +\n +\nक्या आप किसी भी तरह इस आइटम को जोड़ना चाहेंगे, मौजूदा आइटम को बदलना चाहेंगे, या कार्रवाई रद्द करना चाहेंगे? पिन दर्ज करें पिन लिंक पुन्ह खुली @@ -246,7 +254,7 @@ अपने वर्तमान प्रकरण की प्रगति को स्वचालित रूप से समकालीन बनाएं एनीमे के लिए पूरक प्रकरण दिखाएं आंकड़े संगृहीत हो गए - संगीत + संगीत यह वीडियो एक टॉरेंट है, इसका मतलब यह है कि आपकी वीडियो गतिविधि पर नजर रखी जा सकती है।\nजारी रखने से पहले सुनिश्चित करें कि आप टोरेंटिंग को समझते है। रोकने के लिए बीच में दो बार टैप करें देखा हुआ प्रोग्रेस अपडेट करें @@ -301,7 +309,7 @@ उपशीर्षक डाउनलोड करें शीर्षक लाइब्रेरी - मीडिया + मीडिया खोज परिणामों में चयनित वीडियो गुणवत्ता छुपाएं %1$d-%2$d @string/home_play @@ -318,8 +326,8 @@ APK इंस्टॉलर कुछ डिवाइस नए पैकेज इंस्टॉलर को सपोर्ट नहीं करते हैं। यदि अपडेट इंस्टॉल नहीं होते हैं, तो पुराने विकल्प को आज़माएं। डिफ़ॉल्ट मान पर रीसेट करें - ऑडियो - पॉडकास्ट + ऑडियो + पॉडकास्ट रेंडरर त्रुटि एन्कोडिंग त्रुटि एरर क्योंकि सपोर्टेड नहीं @@ -410,8 +418,4 @@ जोड़ा गया %s Sync रेटेड - कतार डाउनलोड करें - फिलहाल कोई डाउनलोड कतार में नहीं है। - अतिरिक्त चमक - डिस्प्ले की चमक 100% से अधिक होने पर ब्राइटनेस फ़िल्टर चालू करें diff --git a/app/src/main/res/values-b+hr/strings.xml b/app/src/main/res/values-b+hr/strings.xml index 7927a15c4..47dfa53b5 100644 --- a/app/src/main/res/values-b+hr/strings.xml +++ b/app/src/main/res/values-b+hr/strings.xml @@ -19,7 +19,8 @@ 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 @@ -133,7 +134,7 @@ Količina pomicanja u playeru (sekunde) Dodirni dvaput desnu ili lijevu stranu za pomicanje prema naprijed ili natrag Dodirni dvaput u sredinu za pauziranje - Koristi svjetlinu sustava + Koristi svijetlinu sustava Koristi svjetlinu sustava u playeru aplikacija umjesto tamnog preklopa Ažuriraj napredak gledanja Automatski sinkronizira vaš trenutni napredak u filmu ili epizodi @@ -185,8 +186,10 @@ 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 @@ -254,7 +257,7 @@ Ažuriraj Preferirana kvaliteta gledanja (WiFi) Maksimalni broj znakova u naslovu video playera - Prikaži podatke playera + Rezolucija video playera Veličina međuspremnika videa Duljina međuspremnika videa Predmemorija videa na disku @@ -408,7 +411,9 @@ 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 @@ -431,7 +436,7 @@ Jezik HLS playlista Automatski instaliraj dodatke - Zasluge + Zasluge Automatski instaliraj sve neinstalirane dodatke iz dodanih repozitorija. Preferirani video player Interni player @@ -493,9 +498,11 @@ 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 @@ -534,13 +541,23 @@ 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 @@ -553,7 +570,9 @@ 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 @@ -577,7 +596,8 @@ 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 @@ -586,17 +606,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 @@ -622,14 +642,22 @@ 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 @@ -647,8 +675,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) @@ -712,43 +740,12 @@ Rezolucija i ime Poravnanje titlova Dolje lijevo - Dolje u sredini + Dolje centrirano Dolje desno Sredina lijevo - U sredini - Desno u sredini + Sredina centrirano + Sredina desno Gore lijevo - Gore u sredini + Gore centrirano Gore desno - Dodatna svjetlina - 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 - Izbriši prijedloge - Podaci medija - Ime izvora - Preuzmi sve - Odustani od svega - Red preuzimanja - Sada nema preuzimanja u redu. - - %d aktivno preuzimanje - %d aktivna preuzimanja - %d aktivnih preuzimanja - - - %d preuzimanje u redu čekanja - %d preuzimanja u redu čekanja - %d preuzimanja u redu čekanja - - Prioritet izvora - Odluči kako razvrstati izvor videa u playeru - Ž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 b753eadb2..ae018207b 100644 --- a/app/src/main/res/values-b+hu/strings.xml +++ b/app/src/main/res/values-b+hu/strings.xml @@ -10,7 +10,8 @@ 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 @@ -148,7 +149,8 @@ Ep Nem található epizód Fájl törlése - %dp \nhátra + %dp +\nhátra Időtartam Elérhető Használatban @@ -204,7 +206,8 @@ %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 @@ -320,7 +323,8 @@ 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 @@ -412,7 +416,9 @@ 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 @@ -463,10 +469,11 @@ 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 @@ -529,8 +536,18 @@ 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 @@ -539,7 +556,9 @@ %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 @@ -572,66 +591,4 @@ Elérhető offline megtekintésre Mindet Kiválaszt Mindent Kijelölés Eltávolítása - %1$dh %2$dm %3$ds - %1$dm %2$ds - %1$ds - Letöltési sor - Beszédfelismerés nem elérhető - Kezdjen beszélni… - Teljes sorozat lejátszása - Jelenleg nincs sorban álló letöltés. - Extra fényerő - A fényerő szűrő engedélyezése, ha a kijelző fényereje meghaladja a 100% -ot - extra_brightness_enabled - Keresési javaslatok - Mutasson keresési javaslatok gépelés közben - Javaslatok törlése - Szereplők panel megjelenítése - Telepít kiadás előtti verziót - Az előzetes verzió már telepítve van. - Az előzetes verzió telepítése sikertelen. - 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 - Hangoskönyv - Média - Hang - Podcast - Kódolási hiba - Hiba nem támogatott - Törlés (%1$d | %2$s) - Figyelem - Biztosan véglegesen szeretné törölni a következő tételeket?\n\n%s - Biztosan véglegesen szeretné törölni a következő epizódokat?\n\n%2$s - A következő sorozatok összes epizódját is véglegesen törli:\n\n%s - Lejátszás tükrözve" - Értékelési címke - Epizód szövege - Biztonság - Fiókok - Helyi hitelesítés - Töltse be az első létezőt - QR kód képe - Média információ - Plugin törlése - Mindig kérdezzen - Válassza ki a lejátszó eszközt - Hiba történt a vágólap elérésénél, kérjük, próbálkozzon újra. - Hiba történt a másolás során. Kérjük, másolja a logcat fájlt, és vegye fel a kapcsolatot az alkalmazás ügyfélszolgálatával. - Rendben - Elutasítás - Jelszó/PIN-kód hitelesítés - A biometrikus hitelesítés nem támogatott ezen az eszközön - Az alkalmazás feloldása ujjlenyomat, arcfelismerés, PIN-kód, minta vagy jelszó segítségével. - Néhány sikertelen kísérlet után a parancssor bezárul. Egyszerűen indítsa újra az alkalmazást, és próbálja meg újra. - Visszaállítás - CloudStream Wiki - Látogasson el %s okostelefonján vagy számítógépén, és írja be a fenti kódot - Nem működik az eszköz PIN-kódja, próbálkozzon helyi hitelesítéssel - A PIN-kód most lejárt! - A kód %1$dm %2$ds után lejár - Kiadás dátuma (újabbaktól régebbi felé) - Kiadás dátuma (régitől újig) - A lejátszó vezérlői neveinek elrejtése diff --git a/app/src/main/res/values-b+in/strings.xml b/app/src/main/res/values-b+in/strings.xml index 7d6475f31..8a34ba410 100644 --- a/app/src/main/res/values-b+in/strings.xml +++ b/app/src/main/res/values-b+in/strings.xml @@ -30,15 +30,15 @@ Episode selanjutnya Genre Bagikan - Buka di Peramban + Buka Di Peramban Lewati Pemuatan Memuat… - Sedang Ditonton + Sedang Menonton Ditunda Selesai Dihentikan Ingin Ditonton - Ditonton Ulang + Menonton Ulang Putar Film Stream Torrent Sumber @@ -50,19 +50,19 @@ Unduh Terunduh Mengunduh - Unduhan dijeda - Unduhan dimulai - Unduhan gagal - Unduhan diibatalkan - Unduhan selesai + Unduh Dijeda + Unduh Dimulai + Unduh Gagal + Unduh Dibatalkan + Unduh Selesai Galat Memuat Tautan Penyimpanan Internal Dub Sub Hapus Berkas Putar Berkas - Lanjutkan unduhan - Jeda unduhan + Lanjutkan Unduh + Jeda Unduh Lebih banyak info Sembunyikan Putar @@ -169,8 +169,10 @@ 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 @@ -232,7 +234,7 @@ Update Kualitas nonton yang diinginkan (WiFi) Karakter maksimal judul pemutar video - Tampilkan informasi pemutar + Resolusi pemutar video Ukuran buffer video Panjang buffer video Cache video di disk @@ -388,7 +390,9 @@ %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 @@ -463,7 +467,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? @@ -483,7 +487,8 @@ 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 @@ -495,7 +500,8 @@ 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 @@ -521,7 +527,13 @@ 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 @@ -581,7 +593,8 @@ 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 @@ -592,9 +605,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 @@ -615,7 +628,7 @@ Aktifkan pratinjau gambar mini di bilah pencarian Sembunyikan Nama Kontrol Pemain Buka repositori - Putar dari awal + Mainkan dari Awal Tidak ada unduhan. Buka video lokal Tanggal Rilis (Lama ke Baru) @@ -625,10 +638,18 @@ 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 @@ -641,8 +662,8 @@ Tampilkan dialog sebelum keluar dari aplikasi Jangan Tampilkan Tampilkan - Audio - Podcast + Audio + Podcast Pengkodean error Error yang tidak didukung Muat yang pertama tersedia @@ -720,31 +741,4 @@ Versi pra-rilis sudah terpasang. Gagal memasang versi pra-rilis. Teks Episode - Kecerahan ekstra - Aktifkan filter kecerahan saat kecerahan layar melebihi 100% - extra_brightness_enabled - Hapus saran - Tampilkan panel cast - Info media - Nama sumber - Saran pencarian - Tampilkan saran pencarian saat mengetik - - %d unduhan aktif - - - %d unduhan dalam antrean - - Antrean unduhan - Tidak ada unduhan dalam antrean. - Prioritas sumber - Tentukan bagaimana sumber video harus diurutkan dalam pemutar - Unduh semua - Batalkan semua - 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 c47a84842..c0de82320 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,7 +19,8 @@ Velocità (%.2fx) Valutato: %.1f - Nuovo aggiornamento trovato! \n%1$s -> %2$s + Nuovo aggiornamento trovato! +\n%1$s -> %2$s Filler %d min @@ -82,7 +83,7 @@ Chiudi Cancella Salva - Velocità lettore + Velocità video Impostazioni sottotitoli Colore testo Colore contorno @@ -113,7 +114,7 @@ Nessuna descrizione trovata Mostra Logcat 🐈 Picture-in-Picture - Continua la riproduzione in un lettore in miniatura sopra altre applicazioni + Continua la riproduzione in un player in miniatura sopra altre applicazioni Pulsante di ridimensionamento del video Rimuovi bordi neri Sottotitoli @@ -185,8 +186,10 @@ 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 @@ -253,8 +256,8 @@ Salta questo aggiornamento Aggiorna Qualità di visualizzazione preferita (WiFi) - Limita i caratteri del titolo nel lettore - Mostra informazioni sul lettore + Limita i caratteri del titolo nel player + Risoluzione video player Dimensione cache video Lunghezza buffer video Dimensione cache video su disco @@ -409,7 +412,9 @@ 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 @@ -432,8 +437,8 @@ Lingua Prima installa l\'estensione Playlist HLS - Video lettore preferito - Lettore interno + Video player preferito + Player interno App non trovata Tutte le lingue Salta %s @@ -442,7 +447,7 @@ Riassunto - Crediti + Crediti Cancella cronologia Cronologia @@ -497,18 +502,20 @@ 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. - Intervallo di ricerca utilizzato quando il lettore è nascosto + File \"safe mode\" trovato! +\nAll\'avvio non sarà caricata alcuna estensione finchè il file non verrà rimosso. + Quantità di avanzamento usata quando il player è nascosto TV Android - Intervallo di ricerca utilizzato quando il lettore è visibile - Lettore visibile - Intervallo di ricerca - Lettore nascosto - Intervallo di ricerca + Quantità di avanzamento usata quando il player è visibile + Player visibile - Quantità di secondi da avanzare + Player nascosto - Quantità di secondi da avanzare Registro Avvia Test del provider @@ -527,7 +534,13 @@ 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 @@ -547,7 +560,11 @@ %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 @@ -560,7 +577,9 @@ 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 @@ -590,7 +609,8 @@ 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! @@ -599,10 +619,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 @@ -632,12 +652,20 @@ 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 @@ -649,8 +677,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 @@ -728,35 +756,4 @@ La versione pre-release è già installata. Impossibile installare la versione pre-release. Testo dell\'episodio - Suggerimenti per la ricerca - Mostra suggerimenti di ricerca durante la digitazione - Cancella suggerimenti - Mostra pannello cast - Info sui media - Nome sorgente - Luminosità extra - Attiva il filtro di luminosità quando viene superato il 100% della luminosità dello schermo - extra_brightness_enabled - Coda di download - Al momento non ci sono download in coda. - Scarica tutto - Annulla tutto - Vuoi scaricare l\'episodio %s? - Vuoi annullare tutti i download in coda? - - %d download attivo - %d download attivi - %d download attivi - - - %d download in coda - %d download in coda - %d download in coda - - 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 3b2d0153a..ef4cb9202 100644 --- a/app/src/main/res/values-b+iw/strings.xml +++ b/app/src/main/res/values-b+iw/strings.xml @@ -6,7 +6,8 @@ לשנות ספק מהירות (%.2fx) דירוג: %.1f - נמצא עדכון חדש! \n%1$s -> %2$s + נמצא עדכון חדש! +\n%1$s -> %2$s סינון %d דקות קלאודסטרים @@ -145,8 +146,10 @@ המשך -30 +30 - %dדקות \nנותרו - ‬פעולה זאת תמחק לצמיתות את %s \nהאם אתם בטוחים? + %dדקות +\nנותרו + ‬פעולה זאת תמחק לצמיתות את %s +\nהאם אתם בטוחים? מתמשך משך זמן דירוג @@ -419,11 +422,13 @@ כל %s כבר הורד מחברים שפה - קרדיטים + קרדיטים מיין בחר ספרייה - נראה שהספרייה שלכם ריקה :( \nהתחברו לחשבון ספריה או הוסיפו סדרות לספרייה המקומית שלכם. - קובץ מצב בטוח נמצא! \nלא טוען שום תוספות בהפעלה עד להסרת הקובץ. + נראה שהספרייה שלכם ריקה :( +\nהתחברו לחשבון ספריה או הוסיפו סדרות לספרייה המקומית שלכם. + קובץ מצב בטוח נמצא! +\nלא טוען שום תוספות בהפעלה עד להסרת הקובץ. לא ניתן להתקין את הגרסה החדשה של האפליקציה הורדת אצווה תוסף @@ -439,7 +444,11 @@ הורד: %d מוגבל: %d לא מורד: %d - לקלאודסטרים אין אתרים מותקנים כברירת מחדל. עליכם להתקין את האתרים ממאגרים. \n \nבגלל הסרת DMCA על ידי Sky UK LImited 🤮 אנחנו לא יכולים לקשר את אתר המאגרים באפליקציה. \n \nתצטרפו לדיסקורד שלנו או תחפשו באינטרנט. + לקלאודסטרים אין אתרים מותקנים כברירת מחדל. עליכם להתקין את האתרים ממאגרים. +\n +\nבגלל הסרת DMCA על ידי Sky UK LImited 🤮 אנחנו לא יכולים לקשר את אתר המאגרים באפליקציה. +\n +\nתצטרפו לדיסקורד שלנו או תחפשו באינטרנט. הצג מאגרים קהילתיים רשימה ציבורית לשים את הכתוביות באותיות רישיות @@ -521,7 +530,13 @@ קביעה כברירת מחדל עבר מעקף ספק אינטרנט - כאן ניתן לשנות את סדר המקורות. אם לוידיאו יש עדיפות גבוהה יותר, הוא יופיע גבוה יותר בבחירת המקור. סכום עדיפות המקור ועדיפות האיכות היא עדיפות הוידיאו. \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 3de9a971a..af8f0b54b 100644 --- a/app/src/main/res/values-b+ja/strings.xml +++ b/app/src/main/res/values-b+ja/strings.xml @@ -46,7 +46,7 @@ 全般 動画 プレーヤー - 視聴予定 + 懐う 予告編を再生 エピソード 視聴 @@ -62,7 +62,8 @@ ローディング… ブラウザで開く シーズン - 残り \n%d分 + 残り +\n%d分 再生エピソード ダウンロード済 バックアップ @@ -80,12 +81,13 @@ 主要ポスター 次のランダム 戻り - 評価: %.1f - 新しいアップデートを発見! \n%1$s -> %2$s + 視聴率 %.1f + 新しいアップデートを発見! +\n%1$s -> %2$s %d分 %sを検索… ソース - 番外編 + ろくごうきじ 接続を再試行… 戻り 削除 @@ -269,7 +271,7 @@ hello@world.com 無効化 概要 - 音楽 + 音楽 中央で2回タップして一時停止 %s で再生 アカウントを切り替え @@ -295,8 +297,8 @@ リポジトリ名とURL コピーされました! オーディオブック - メディア - プレイヤー情報を表示 + メディア + ビデオプレーヤーの解像度 プレイヤーに速度オプションを追加 削除する項目を選択 オフラインで視聴可能 @@ -467,7 +469,7 @@ 無効: %d 優先ビデオプレーヤー %s をスキップ - クレジット + クレジット アプリのバッテリー使用はすでに無制限に設定されています 並べ替え 元に戻す @@ -649,8 +651,8 @@ 更新されたプラグインはありません。 プレーヤー通知 バックグラウンドから再生を制御するためのプレーヤー通知 - オーディオ - ポッドキャスト + オーディオ + ポッドキャスト プレーヤー非表示時 - シーク量 エンコーディングエラー サポートされていないエラー @@ -706,27 +708,4 @@ プレリリース版は既にインストールされています。 プレリリース版のインストールに失敗しました。 エピソードのテキスト - 検索候補 - 入力中に検索候補を表示する - 候補を消去 - キャストパネルを表示 - ソース名 - メディア情報 - 追加の輝度設定 - 画面の輝度が100%を超えた場合に輝度フィルターを有効にします - 追加の輝度を有効化 - ダウンロードキュー - 現在、ダウンロードキューは空です。 - すべてダウンロード - すべてキャンセル - エピソード %s をダウンロードしますか? - ダウンロードキューをすべてキャンセルしますか? - - %d 件をダウンロード中 - - - %d 件が待機中 - - ソースの優先順位 - プレイヤーでのビデオソースの並び順を設定します diff --git a/app/src/main/res/values-b+kn/strings.xml b/app/src/main/res/values-b+kn/strings.xml index ba6da787c..22a45b906 100644 --- a/app/src/main/res/values-b+kn/strings.xml +++ b/app/src/main/res/values-b+kn/strings.xml @@ -83,7 +83,8 @@ ಶೇರ್ ಫೈಲ್ ಅಳಿಸಿ ಹೆಚ್ಚಿನ ಮಾಹಿತಿ - ಹೊಸ ಅಪ್ಡೇಟ್ ಬಂದಿದೆ \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 14d327372..90504ec95 100644 --- a/app/src/main/res/values-b+ko/strings.xml +++ b/app/src/main/res/values-b+ko/strings.xml @@ -1,20 +1,21 @@ 출연: %s - 에피소드 %d 공개 예정 + 에피소드 %d이(가) 공개됩니다 포스터 에피소드 포스터 메인 포스터 - 다음 추천 + 다음 랜덤 뒤로가기 소스 변경 - 배경 미리보기 + 미리보기 배경 속도 (%.2fx) 평점: %.1f - 새로운 업데이트! \n%1$s -> %2$s + 새로운 업데이트! +\n%1$s -> %2$s %d분 CloudStream - CloudStream으로 재생 + CloudStream에서 재생 다운로드 설정 @@ -25,14 +26,14 @@ 다음 에피소드 장르 공유 - 브라우저로 열기 + 브라우저에서 열기 브라우저 - 로딩 스킵 + 로딩 건너뛰기 로딩중… 시청 보류 시청 완료 - 시청 포기 + 포기 시청 예정 다시보기 영화 재생 @@ -45,23 +46,23 @@ 에피소드 재생 다운로드 파일 재생 - 다운로드 재개 + 계속 다운로드 다운로드 일시정지 상세 정보 - 숨기기 + 닫기 재생 정보 시청 상태 설정 저장 재생 속도 - 글자 색상 - 윤곽선 색상 - 배경 색상 - 배경 색상 - 윤곽선 유형 + 글자 색깔 + 외곽선 색깔 + 배경 색깔 + 창 색깔 + 가장자리 타입 자막 높이 폰트 - 글자 크기 + 폰트 크기 다운로드됨 다운로드중 다운로드 일시정지 @@ -108,7 +109,7 @@ 플레이어 자막 설정 Chromecast 자막 Chromecast 자막 설정 - 재생 속도 + 배속 모드 스와이프하여 탐색 좌우로 스와이프하여 동영상 위치 제어하기 스와이프하여 설정 변경 @@ -118,12 +119,12 @@ 두 번 탭하여 탐색 두 번 탭하여 일시정지 플레이어 탐색 시간 (초) - 가운데를 두 번 탭하여 일시정지 + 가운데를 두 번 탭하여 일시중지 시스템 밝기 사용 어두운 오버레이 대신 앱 플레이어의 시스템 밝기를 사용합니다 시청 진행 상황 업데이트 현재 에피소드 진행 상황을 자동으로 동기화합니다 - 데이터 복원 + 백업에서 데이터 복원 데이터 백업 파일에서 데이터를 복원하지 못했습니다 %s 저장된 데이터 @@ -134,14 +135,14 @@ 계정 및 보안 소스별로 구분된 검색 결과를 제공합니다 예고편 보기 - Kitsu에서 포스터 가져오기 + Kitsu에서 포스터 보기 검색 결과에서 선택한 동영상 품질 숨기기 플러그인 자동 다운로드 플러그인 자동 업데이트 추가된 저장소에서 아직 설치되지 않은 모든 플러그인을 자동으로 설치합니다. 앱 업데이트 표시 앱을 시작한 후 새 업데이트를 자동으로 검색합니다. - 일부 기기에서는 최신 방식의 설치 프로그램이 작동하지 않을 수 있습니다. 업데이트가 안 된다면 \'기본 방식\' 설정을 사용해 보세요. + 일부 휴대폰은 새 패키지 설치 프로그램을 지원하지 않습니다. 업데이트가 설치되지 않으면 레거시 옵션을 사용해 보세요. 같은 개발자가 만든 라이트 노벨 앱 같은 개발자가 만든 애니메이션 앱 Discord에 참여하기 @@ -149,19 +150,21 @@ 바나나 줌 앱 언어 링크를 찾을 수 없음 - 클립보드에 링크 복사함 + 클립보드에 링크 복사됨 에피소드 재생 기본값으로 재설정 에피소드 %1$d-%2$d - 방영 중 - 완결 + 진행중 + 시청 완료 상태 평점 +30 - %s이(가) 영구적으로 삭제됩니다 \n정말 삭제하시겠습니까? - %d분 \n남음 + %s가 영구 삭제됩니다 +\n정말 삭제하시겠습니까? + %d분 +\n남음 사이트 시간 개요 @@ -177,30 +180,30 @@ 애니 토렌트 Chromecast 미러링 - 앱으로 재생 - %s로 재생 + 앱에서 재생 + %s에서 재생 자동 다운로드 - 다운로드 소스 목록 - 링크 초기화 + 다운로드 미러 + 링크 새로고침 자막 다운로드 - 화질 라벨 - 더빙 라벨 - 자막 라벨 + 화질 탭 + 더빙 탭 + 자막 탭 제목 업데이트 확인 잠금 크기 조정 소스 - 오프닝 스킵 - 다음에 업데이트 + 오프닝 건너뛰기 + 이 업데이트 건너뛰기 선호하는 화질 (WiFi) 선호하는 화질 (모바일 데이터) - 플레이어 내 표시 정보 + 동영상 플레이어 해상도 동영상 버퍼 크기 - 비디오 및 이미지 캐시 삭제 + 동영상 및 이미지 캐시 지우기 DNS over HTTPS GitHub에 연결할 수 없습니다. jsDelivr 프록시를 켜는 중… - jsDelivr를 사용하여 차단된 GitHub 주소를 우회합니다. 단, 업데이트 반영이 며칠 정도 늦어질 수 있습니다. + jsDelivr을 사용하여 GitHub 차단을 우회합니다. 업데이트가 며칠 지연될 수 있습니다. 복제 사이트 사이트 삭제 다른 URL을 사용하여 기존 사이트의 복제본을 추가합니다 @@ -214,10 +217,10 @@ 일반 플레이어 기능 기능 - 확장프로그램 언어 + 소스 언어 앱 레이아웃 선호하는 미디어 - 확장프로그램에서 NSFW 활성화 + 지원되는 공급업체에서 19금 사용 설정 자막 인코딩 소스 소스 테스트 @@ -248,7 +251,7 @@ 전부 최대 최소 - 윤곽선 효과 + 윤곽선 그림자 자막 동기화 1000 ms @@ -274,7 +277,7 @@ 잘못된 데이터 잘못된 URL 오류 - 청각 장애인용 자막 요소 제거 + 자막에서 선택 캡션 제거 선호하는 미디어 언어로 필터링 예고편 다음 @@ -294,15 +297,19 @@ 저장소 삭제 사용하려는 사이트 목록 다운로드 다운로드됨: %d - CloudStream에는 기본적으로 설치된 사이트가 없습니다. 저장소에서 사이트를 설치해야 합니다. \n \nDiscord에 가입하거나 온라인으로 검색해 보세요. + CloudStream에는 기본적으로 설치된 사이트가 없습니다. 저장소에서 사이트를 설치해야 합니다. +\n +\nSky UK Limited의 무분별한 DMCA 게시 중단으로 인해 앱에서 저장소 사이트를 연결 할 수 없습니다. +\n +\nDiscord에 가입하거나 온라인으로 검색해 보세요. 커뮤니티 저장소 보기 공개 목록 - 자막 대문자화 표시 - 경고: CloudStream은 외부 확장 프로그램 사용에 대해 어떠한 책임도 지지 않으며, 관련 기술 지원을 제공하지 않습니다! + 모든 자막 대문자화 + 이 저장소에서 모든 플러그인을 다운로드하시겠습니까?경고: CloudStream 3은 타 사 확장 프로그램 사용에 대해 책임을 지지 않으며 이를 지원하지 않습니다! %s (사용불가) 저장소 추가 - 저장소 이름 (선택 사항) - 저장소 URL 또는 단축 코드 + 저장소 이름 + 저장소 URL 플러그인이 로드됨 플러그인 다운로드 플러그인 삭제됨 @@ -315,16 +322,16 @@ 충돌 정보 보기 언어 에피소드 %d 공개! - Picture-in-picture 모드 + Picture-in-picture 플레이어 크기 조정 버튼 - 미니플레이어를 통해 다른 앱 상단에서 계속 재생합니다 - 레터박스 제거 + 다른 앱 위에 있는 미니어처 플레이어에서 재생을 계속합니다 + 검은색 테두리 제거 오른쪽 또는 왼쪽을 두 번 탭하여 앞뒤로 탐색하기 자막 - 백업 파일을 성공적으로 로드하였습니다 + 로드된 백업 파일 정보 고급 검색 - 설정 프로세스 재실행 + 설정 프로세스 다시 실행 APK 인스톨러 Github 소스 오류 @@ -346,8 +353,8 @@ 실패 평점 평점: %s - 평점 (높은순) - 평점 (낮은순) + 평점 (높음에서 낮음으로) + 평점 (낮음에서 높음으로) 19금 다큐멘터리 라이브 방송 @@ -367,15 +374,15 @@ 애니 OVA 원격 오류 - 다운로드 오류, 저장소 권한을 확인하세요 + 다운로드 오류, 저장 권한 확인 Chromecast 에피소드 예기치 않은 플레이어 오류 다시 표시하지 않음 업데이트를 찾을 수 없음 업데이트 - GitHub 프록시 + raw.githubusercontent.com 프록시 동영상 버퍼 길이 - 디스크 비디오 캐시 + 저장소에 동영상 캐시 Android TV와 같이 메모리가 부족한 디바이스에서 너무 높게 설정하면 충돌이 발생할 수 있습니다. 화면 크기에 맞춤 Android TV와 같이 저장 공간이 부족한 기기에서 너무 높게 설정하면 문제가 발생할 수 있습니다. @@ -390,12 +397,12 @@ 앱 업데이트 확장 기능 로그아웃 - https://example.com + 사이트 URL 비밀번호 계정 사용자 이름 언어 코드 (ko) - 새사이트이름 + 사이트 이름 %1$s %2$s 자막이 %d ms 너무 늦게 표시되는 경우, 사용하세요 자막 지연 없음 @@ -431,16 +438,16 @@ 먼저 확장 프로그램을 설치하세요 앱을 찾을 수 없음 모든 언어 - %s 스킵 + 건너뛰기 %s 오프닝 엔딩 혼합 엔딩 혼합 오프닝 - 크레딧 - 인트로 + 크레딧 + 소개 기록 삭제 기록 - 오프닝/엔딩 시 스킵 팝업 표시 + 오프닝/엔딩 시 건너뛰기 팝업 표시 텍스트가 너무 많습니다. 클립보드에 저장할 수 없습니다. 시청에서 삭제 정말 종료하시겠습니까? @@ -449,18 +456,20 @@ 앱 업데이트 다운로드 중… 앱 업데이트 설치 중… 새 버전의 앱을 설치할 수 없습니다 - 기본 방식 + 레거시 패키지 인스톨러 앱 종료시 업데이트됩니다 정렬 기준 정렬 - 업데이트 (최신순) - 업데이트 (오래된순) + 업데이트됨 (새로움에서 오래된 순) + 업데이트 (오래됨에서 새로운 순) 알파벳순 (A에서 Z) 알파벳순 (Z에서 A) 다음으로 열기 - 라이브러리가 비어 있습니다 :( \n라이브러리 계정으로 로그인하거나 로컬 라이브러리에 프로그램을 추가하세요. - 안전 모드 파일을 찾았습니다! \n파일이 제거될 때까지 시작 시 확장 프로그램을 로드하지 않습니다. + 라이브러리가 비어 있습니다 :( +\n라이브러리 계정으로 로그인하거나 로컬 라이브러리에 프로그램을 추가하세요. + 안전 모드 파일을 찾았습니다! +\n파일이 제거될 때까지 시작 시 확장 프로그램을 로드하지 않습니다. HLS 재생목록 내부 플레이어 선호하는 동영상 플레이어 @@ -476,43 +485,43 @@ @string/home_play 플롯을 찾을 수 없음 설명을 찾을 수 없음 - 로그캣 🐈 보기 + Logcat 🐈 표시 애니메이션용 필러 에피소드 표시 통과 계속 - 플레이어 내 표시 제목의 최대 글자 수 - 플레이어 표시 시 탐색 시간 - 플레이어 표시 중 탐색 간격 - 플레이어 미표시 시 탐색 시간 - 플레이어 미표시 시 탐색 간격 + 동영상 플레이어 제목 최대 글자 수 + 표시된 플레이어 - 빨리 감기 및 되감기 초 + 플레이어가 보일 때 사용되는 탐색량 + 플레이어 숨김 - 빨리 감기 및 되감기 초 + 플레이어가 숨겨져 있을 때 사용되는 탐색량 동작 외형 랜덤 버튼 - 홈페이지 및 라이브러리에서 랜덤 버튼 표시 + 홈페이지에 랜덤 버튼 표시 포스터 아래에 제목을 이동 - 음각 - 양각 + 내려감 + 올라감 다람쥐 헌 쳇바퀴에 타고파 - 불필요한 요소/코드 제거 + 자막에서 부풀림 제거 엑스트라 - https://example.com/example.mp4 + 스트림 링크 트랙 - Referer (선택) + 레퍼러 요약 - 시청 완료로 표시 + 시청함으로 표시 되돌리기 구독한 프로그램 업데이트 구독중 구독 %s 구독 취소 %s 보안 - 계정 + 장부 리포지토리에서 플러그인을 찾을 수 없습니다 - 복사 완료! + 복사됨! 레포지토리 이름 및 URL 본 테스트는 개발자만을 대상으로 하며, 확장자의 작업을 확인하거나 거부하지 않습니다. - CloudStream 위키 - 링크 초기화 완료 + 클라우스스트림 위키 + 다시 기록된 링크 백업 빈도 즐겨찾기 QR 이미지 @@ -522,7 +531,7 @@ 취소 저장소 열기 현재 PIN 입력 - 비디오 방향에 따라 화면 방향을 자동으로 회전합니다 + 비디오 방향에 따라 화면 방향을 자동으로 전환합니다 장치 PIN 코드를 가져올 수 없습니다, 로컬 인증을 시도하세요 PIN 코드가 만료되었습니다! 코드 만료까지 남은 시간: %1$dm %2$ds @@ -537,7 +546,7 @@ 프로필 확인 배터리 최적화 사용 안 함 - 배터리 사용량이 \'제한 없음\'으로 이미 설정되어 있습니다 + 앱 배터리 사용량이 이미 무제한으로 설정되었습니다 CloudStream의 App 정보를 열 수 없습니다. 즐겨찾기에 %s 추가 프로필 %d @@ -552,78 +561,99 @@ 즐겨찾기 해제 잠금 해제 생체 인식으로 잠금 - 음악 + 음악 오디오책 자동 회전 모바일 데이터 사용 불가능 캐스트 장치 선택 - 복사하는 중 오류가 발생했습니다. 로그캣을 복사하고 개발자에게 문의하십시오. + 복사하는 중 오류가 발생했습니다. 로그캣을 복사하고 문의하십시오. 구독 취소 기본값 설정 구독 사용 - 당신의 라이브러리에 이미 잠재적으로 중복된 항목이 존재합니다: \'%s\'. \n \n이 항목을 그래도 추가하시겠습니까, 기존 항목을 교체하시겠습니까, 아니면 작업을 취소하시겠습니까? + 당신의 라이브러리에 이미 잠재적으로 중복된 항목이 존재합니다: \'%s\'. +\n +\n이 항목을 그래도 추가하시겠습니까, 기존 항목을 교체하시겠습니까, 아니면 작업을 취소하시겠습니까? 전부 대체 추가 즐겨찾기에서 %s 제거 - 당신의 라이브러리에 잠재적으로 중복된 항목이 발견되었습니다: \n \n%s \n \n이 항목을 그래도 추가하시겠습니까, 기존 항목을 교체하시겠습니까, 아니면 작업을 취소하시겠습니까? + 당신의 라이브러리에 잠재적으로 중복된 항목이 발견되었습니다: +\n +\n%s +\n +\n이 항목을 그래도 추가하시겠습니까, 기존 항목을 교체하시겠습니까, 아니면 작업을 취소하시겠습니까? 계정 선택 기본 계정 사용 회전 - 화면 방향 전환 버튼 표시 + 화면 방향을 전환할 토글 버튼 표시 계정 관리 프로필 잠금 잘못된 PIN입니다. 다시 시도하세요. 계정 편집 - 미디어 + 미디어 비밀번호/PIN 인증 이 장치에서는 생체 인식이 지원되지 않습니다 지문, 얼굴 ID, PIN, 패턴 또는 비밀번호로 앱을 잠급니다. 여러 번 실패하면 프롬프트가 닫힙니다. 다시 시도하려면 앱을 다시 시작하세요. 재설정 플러그인 다운로드를 필터링할 모드 선택 - CloudStream 데이터 백업이 완료되었습니다. 드문 경우지만, 기기에 따라 앱 접속이 안 되는 오류가 발생할 수 있습니다. 만약 앱이 열리지 않는다면, 앱 데이터를 완전히 삭제(초기화)한 후 이 백업 파일로 복구해 주시기 바랍니다. 이용에 불편을 드려 대단히 죄송합니다. - 스마트폰이나 컴퓨터에서 %s 위의 코드를 입력하세요 - 구독 중인 TV 쇼의 알림을 받고 다운로드를 끊김 없이 완료하려면, CloudStream의 백그라운드 실행 권한이 필요합니다. \'확인\'을 누른 후 나타나는 요청 창에서 \'허용\'을 선택해 주세요.\n\n참고로, 이 권한을 허용한다고 해서 배터리가 계속 소모되는 것은 아닙니다. 알림을 받거나 공식 확장 프로그램에서 영상을 다운로드할 때처럼 꼭 필요한 상황에서만 백그라운드 작업을 수행합니다. - 여기서 소스의 순서를 변경할 수 있습니다. 비디오의 우선 순위가 높은 경우에는 소스 선택 화면에 더 높게 나타납니다. 소스 우선 순위와 품질 우선 순위의 합이 비디오 우선 순위입니다. \n \n참고 A: 3 \n품질 B: 7 \n총 비디오 우선 순위는 10입니다. \n \n참고: 합이 10 이상이면 해당 링크가 로드되면 플레이어는 자동으로 로드를 건너뜁니다! - 시즌 %1$d 에피소드 %2$d 공개 예정 - 다른 확장프로그램에서 검색 + 데이터가 백업되었습니다. 장치에 따라 동작이 다를 수 있으며 앱 접근이 차단될 경우 앱 데이터를 완전히 지우고 백업에서 복원하세요. 이로 인해 발생하는 불편을 사과드립니다. + 스마트폰이나 컴퓨터에서 %s를 방문하여 위의 코드를 입력하세요 + 구독 TV 프로그램에 대한 중단 없는 다운로드 및 알림을 보장하기 위해 CloudStream은 백그라운드에서 실행할 수 있는 권한이 필요합니다. 확인을 누르면 App info로 이동합니다. 거기서 𝘼𝙥𝙥 𝙗𝙖𝙩𝙩𝙚𝙧𝙮 𝙪𝙨𝙖𝙜𝙚로 스크롤하여 배터리 사용량을 𝙐𝙣𝙧𝙚𝙨𝙩𝙧𝙞𝙘𝙩𝙚𝙙로 설정합니다. 이 권한은 CS3가 배터리를 소모한다는 의미가 아닙니다. 알림을 받거나 공식 확장에서 동영상을 다운로드하는 등 필요할 때만 백그라운드에서 작동합니다. 취소를 선택한 경우 나중에 𝙂𝙚𝙣𝙚𝙧𝙖𝙡 𝙎𝙚𝙩𝙩𝙞𝙣𝙜𝙨에서 이 설정을 조정할 수 있습니다. + 여기서 소스의 순서를 변경할 수 있습니다. 비디오의 우선 순위가 높은 경우에는 소스 선택에 더 높게 나타납니다. 소스 우선 순위와 품질 우선 순위의 합이 비디오 우선 순위입니다. +\n +\n참고 A: 3 +\n품질 B: 7 +\n총 비디오 우선 순위는 10입니다. +\n +\n참고: 합이 10 이상이면 해당 링크가 로드되면 플레이어는 자동으로 로드를 건너뜁니다! + 시즌 %1$d 에피소드 %2$d이(가) 출시됩니다 + 다른 확장자에서 검색 새로운 에피소드 알림 - 추천목록 보기 + 권장 사항 표시 플레이어에 속도 옵션을 추가합니다 - %s 후 공개 예정 - %s \n남음 + %s로 출시 예정 + %s +\n남음 잠재적 중복 발견 %s의 PIN 입력 즐겨찾기에서 제거 - Cast 소스 목록 + 캐스트미러 플러그인 삭제 경고 탐색바 미리보기 - 탐색바에서 화면 미리보기 활성화 - 처음부터 재생 + 탐색바에서 미리보기 화면 활성화 + 처음부터 시작 현재 다운로드가 없습니다. 삭제할 항목을 선택하십시오 - 오프라인 시청 가능 + 오프라인 시청가능 모두 선택 모두 선택해제 로컬 비디오 열기 파일 삭제 삭제 (%1$d | %2$s) - 다음 항목을 영구적으로 삭제 하시겠습니까? \n \n%s - 다음 에피소드를 영구적으로 삭제 하시겠습니까? %1$s? \n \n%2$s - 또한 다음 시리즈의 모든 에피소드를 영구적으로 삭제합니다: \n \n%s - 다음 시리즈의 모든 에피소드를 영구적으로 삭제 하시겠습니까? \n \n%s - 공개일 (최신순) - 공개일 (오래된순) - 플레이어 내 버튼명 숨기기 - 이 동영상은 토렌트이므로 시청 활동이 추적될 수 있습니다.\n계속하기 전에 토렌트에 대해 충분히 이해했는지 확인하세요. - 오디오 - 팟캐스트 - 시작하기 … + 다음 항목을 영구적으로 삭제 하시겠습니까?? +\n +\n%s + 다음 에피소드를 영구적으로 삭제 하시겠습니까? %1$s? +\n +\n%2$s + 또한 다음 시리즈의 모든 에피소드를 영구적으로 삭제합니다: +\n +\n%s + 다음 시리즈의 모든 에피소드를 영구적으로 삭제 하시겠습니까?? +\n +\n%s + 출시일 (새로운 것부터 오래된 것) + 출시일 (오래된것부터 새로운것) + 플레이어 컨트롤 이름 숨기기 + 이 동영상은 토렌트이므로 동영상 활동이 추적될 수 있습니다.\n계속하기 전에 토렌트에 대해 충분히 이해했는지 확인하세요. + 오디오 + 팟캐스트 + 음성 시작… 인코딩 오류 - 미지원 오류 + 지원되지 않는 오류 음성 인식 사용 불가 %1$d시간 %2$d분 %3$d초 %1$d분 %2$d초 @@ -632,7 +662,7 @@ 출시 전 버전 설치 출시 전 버전이 이미 설치되어 있습니다. 출시 전 버전 설치 실패. - 재생 소스 목록" + 미러 재생" 평가 라벨 에피소드 본문 사용 가능한 자막 불러오기 @@ -641,24 +671,24 @@ 에피소드 (내림차순) 평가 (높은순) 평가 (낮은순) - 공개일 (최신순) - 공개일 (오래된순) + 방영 날짜 (최신순) + 방영 날짜 (오래된순) 에피소드 %s 평가 %s 날짜 %s 계정 없음 - 자막이 아직 로드되지 않음 + 자막을 아직 불러오지 않았습니다 백업 폴더 위치 커스텀 나가기 전에 확인 - 이 알림을 앱 종료 전에 표시 + 이 문항을 앱에서 나가기 전에 보이기 보이기 보이지 않기 - 윤곽선 굵기 + 테두리 크기 Settings/Providers/Preferred media 에서 토렌트 활성화 진행하려면 앱 재시작 후 토렌트 스트리밍 팝업란의 수락이 필요합니다. 소프트웨어 디코딩 - 소프트웨어 디코딩은 해당 기기에서 지원되지 않는 영상 파일들을 재생할 수 있지만, 높은 화질에서 렉 또는 불안정한 재생을 유발할 수 있습니다. + 소프트웨어 디코딩은 당신의 기기에서 지원되지 않는 영상 파일들을 재생할 수 있지만, 높은 화질에서 렉 또는 불안정한 재생을 유발할 수 있습니다. 볼륨이 100%를 초과하였습니다 100% 너머로 높이려면 한번 더 슬라이드 하십시오 플러그인 업데이트하기 @@ -667,29 +697,29 @@ 성공적으로 %d 플러그인을 업데이트 하였습니다! 업데이트 된 플러그인이 없습니다. 플레이어 알림 - 백그라운드에서 재생을 제어하기 위한 플레이어 알림 - 내장 자막 + 백그라운드에서 재생을 조종할 수 있는 플레이어 알림 + 내장된 온라인 - 자막 굵게 표시 - 자막 기울게 표시 + 모든 자막 굵게 + 모든 자막 기울기 병렬로 다운로드 할 수 있는 아이템의 수 병렬 다운로드 동시 연결수 다운로드 시 각 항목마다 사용할 수 있는 동시 연결의 수 - 다운로드로 이동 + 다운로드로 가기 인터넷 연결 없음.\n\n인터넷에 연결 한 후 재시도 하거나, 혹은 이미 다운로드 된 항목을 재생하십시오. - 화면의 잘림 현상을 방지하기 위해 경계를 조정합니다 + 화면 경계 조정 포스터 크기 변경 포스터 크기 길게 눌러 배속 활성화 - 길게 눌러 2배속 재생 + 길게 눌러 2배속 프로필 사진 변경 프로필 사진 URL 입력 URL을 찾을 수 없습니다 잘못된 URL 혹은 이미지 입니다 이미지 업데이트 성공 - 이 에피소드까지 시청 완료로 표시 - 이 에피소드까지 시청함 표시 제거 + 이 에피소드 까지 봤음 표시 + 이 에피소드 까지 봤음 표시 제거 이름 해상도 및 이름 자막 정렬 @@ -702,35 +732,4 @@ 왼쪽 위 중앙 위 오른쪽 위 - 비디오 소스가 플레이어에서 정렬되는 순서 설정 - 디스플레이 밝기가 100%를 초과하면 밝기 필터를 활성화합니다 - 모든 다운로드 작업을 취소하시겠습니까? - 에피소드를 다운로드 하시겠습니까 %s? - 다운로드 대기열이 비어 있습니다. - - %d 활성 다운로드 - - - %d 다운로드 - - 입력하는 동안 검색어 제안 표시 - 출연진 정보 표시 - 배경 테두리 곡률 - 공급자 새로고침 - 검색어 제안 - 제안 삭제 - 미디어 정보 - 소스 이름 - 최대 밝기 확장 - 다운로드 작업 - 모두 다운로드 - 모두 취소 - 소스 우선순위 - 오버스캔(화면 경계) 설정 - 새로고침 - 최대 밝기 확장 활성화 - 플레이어에 메타데이터 오버레이 표시 - 비디오 - 프리뷰 - 라이브 diff --git a/app/src/main/res/values-b+lt/strings.xml b/app/src/main/res/values-b+lt/strings.xml index db7e994f0..cb2d816f3 100644 --- a/app/src/main/res/values-b+lt/strings.xml +++ b/app/src/main/res/values-b+lt/strings.xml @@ -31,7 +31,8 @@ +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 @@ -87,7 +88,8 @@ 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 @@ -179,7 +181,11 @@ 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 9ab29cd43..18d3177f5 100644 --- a/app/src/main/res/values-b+lv/strings.xml +++ b/app/src/main/res/values-b+lv/strings.xml @@ -2,27 +2,28 @@ Plakāts %1$s Ep %2$d - Lomās: %s + Cast: %s Plakāts Epizodes plakāts Galvenais plakāts - Nākamais nejaušais + Nākamais random Iet atpakaļ Nomainīt dvēju 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 + Claudstream Atskaņo ar cloudstream - Sākums + Mājas Meklēt Meklēt %s… Nav datu Vairāk opcijas Nākamā epizode - Pārlūks + Internets Izlaist ladešanos Lādējas… Skaties @@ -41,14 +42,14 @@ Iet atpakaļ Palaist epizodi Ieladēt - Lejupielāde iepauzēta + Lādēšana pauzēta Lādēšana sakās - Lejupielāde neizdevās - Lejupielāde atcelta - Lejupielāde pabeigta - Atjaunināšana sākta + Ielādēt neizdevās + Ielādēšana atcelta + Pabeidza ieladēt + Atjauninājums sakās Tīkla plūsma - Kļūda, ielādējot saites + Kļūda padejot linkus Iekšējā atmiņa Dub Dzēst datni @@ -77,13 +78,13 @@ Meklēt izmantojot devējus Meklēt izmantojot tipus %d Banāni iedoti veidotājiem - %d. epizode būs pieejama + Episode %d būs izlaista Filtrs - Lejupielādes + Ieladētas Meklēt… - Iestatījumi + Settingi Žanrs - Kopīgot + Dalities Atvērt pārlūkā Ieladēts Lādējas @@ -94,9 +95,9 @@ Iztīrīt Teksta krāsa Automātiski-iestādīt valodu - %1$dst. %2$dmin. - %dmin. - %1$dd. %2$dst. %3$dmin. + %1$dh %2$dm + %dm + %1$dd %2$dh %3$dm Ielādēt valodas Subtitru valoda Tūri lai restartētu uz sākumu @@ -114,7 +115,7 @@ Rādīt Logcat 🐈 Log Bilde bildē - Turpina atskaņošanu miniatūrā atskaņotājā virs citām lietotnēm + Turpina spēlēt mazā lodziņā virs aplikācijām Players izmēra poga Noņemt melnās malas Subtitri @@ -134,7 +135,7 @@ Uzpied divreiz pa labi vai kreisi lai palaistu atpakaļ vai uz priekšu Uzpied divreiz vidū lai pauzētu Lietot sistēmas gaišums - Izmanto sistēmas spilgtumu, nevis atskaņotāja tumšo pārklājumu + Lietot sistēmas gaišumu aplikācijas playerī nevis tumšunu Atjaunināt skatīšanos progresu Automātiski sync savu pašreizējo epizodes progresu Atgūt datus no backupa @@ -159,15 +160,15 @@ Automātiski lejupielādēt papildinājumus Automātiski uzstāda visus vēl neuzstādītos papildinājumus no pievienotajiem repozitorijiem. Rādīt lietotņu atjauninājumus - Automātiski pārbauda atjauninājumus, kad atver lietotni. + Automātiski meklēt jaunus atjauninājumus kad palaiž aplikāciju. Atsākt uzstādīšanas procesu Dažas ierīces neatbalsta jauno pakotnes uzstādītāju. Izmantojiet legacy (veco) uzstādītāju, ja atjauninājumus nevar uzstādīt. - Viegla romānu lietotne no šiem pašiem izstrādātājiem - Anime lietotne no šiem pašiem izstrādātājiem + Noveles aplikācija no šiem izstrādātājiem + Anime aplikāciju no tiem pašiem izstradatājiem Ienāc discordā Iedot banānu izstrādātājiem Iedotie banāni - Lietotnes valoda + Aplikācijas valoda Šim devējam nav Chromecast pieņemšana Nav linku strastu Links kopēts cliobordā @@ -192,20 +193,22 @@ 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 - Vērtējums + gads + Reitings Ilgums - Vietne - Konspekts - ievietots rindā + Saite + Synopsis + Gaida Lietotie - Lietotne + Aplikācija Filmas - Seriāli, raidījumi + Seriāli Animācija Anime Torrenti @@ -216,7 +219,7 @@ NSFW Citi Filmas - Seriāls, raidījums + Sērijas Animācija Anime OVA @@ -232,7 +235,7 @@ Ielādēšanas kļūda, pārbaudi atmiņas atļauju Chromecast epizode Chromecast morror - Atskaņot lietotnē + Palaist aplikācijā Atskaņot uekšā %s Automātiski ielādēt Ielādēt spoguli @@ -274,7 +277,7 @@ Atruna ISP Izlaists Links - Lietotnes atjauninājumi + Aplikācijas atjauninājumus Dublējums Papildinājumi Akcijas @@ -291,7 +294,7 @@ Randomā poga Rādīt izlases pogu Sākums un Bibliotēka sadaļās Papildinājuma valodas - Lietotnes izkārtojums + Aplikācijas izskats Izvēlētā media Iespējot nepiedienīgu, izaicinošu saturu (NSFW) atbalstītajos papildinājumos Subtitru kodējums @@ -342,7 +345,7 @@ Fogts čuhņā mīļi lenc - ģērbj, žvadz, pūkšķ Ielādēti %s Ielādēt no datnes - Lietotnes motīvs + Aplikācijas theme Lejupielādēt no interneta Lejupielādēta datne Galvenais @@ -427,13 +430,13 @@ HLS atskaņošanas saraksts Vēlamais video atskaņotājs Iekšējais atskaņotājs - Lietotne nav atrasta + Aplikācijs nav atrasta Visas valodas Beigas 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 @@ -455,12 +458,13 @@ 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! Apk insteletājs - GitHub + Github Nav subtitru Atskaņot epizodi Iet @@ -494,7 +498,7 @@ Lejupielādējiet to vietņu sarakstu, kuras vēlaties izmantot Vispirms uzstādīt papildinājumu Atvēršana - Ievads + Sākums Izlaist %s Noņemt no skatītajiem Atzīmēt kā skatītu @@ -505,16 +509,16 @@ Abonēto šovu atjaunināšana Abonēts Abonēts %s - %1$d. sezonas %2$d. epizode būs pieejama - %1$dst. %2$dmin. %3$dsek. - %1$dmin. %2$dsek. - %1$dsek. + %1$d. sezona un %2$d. sērija tiks izlaista pēc + %1$dh %2$dm %3$ds + %1$dm %2$ds + %1$ds Atskaņot no sākuma Runas atpazīšana nav pieejama Sāciet runāt… Šis video ir torrenta fails, kas nozīmē, ka jūsu video aktivitātes var izsekot.\nPirms turpināt, pārliecinieties, ka saprotat torrenta failu lietošanu. - Atlasiet vienumus, ko dzēst - Pašlaik nav pieejama neviena lejupielāde. + Atlasiet dzēšamos vienumus + Pašlaik nav lejupielāžu. Pieejams skatīšanai bezsaistē Bezvadu (Wi-Fi) Izmantot @@ -534,14 +538,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 @@ -592,19 +596,4 @@ Padarīt visus subtitrus slīprakstā Pievieno atskaņošanas ātruma izvēli atskaņotājā Dzēst (%1$d | %2$s) - - %d aktīvas lejupielādes - %d aktīva lejupielāde - %d aktīvas lejupielādes - - Lejupielādes rinda - Automātiski pagriezt - Atbloķēt CloudStream - Paroles/PIN autentifikācija - Atskaņot visas epizodes - Vai tiešām vēlaties neatgriezeniski dzēst šīs %1$s epizodes?\n\n%2$s - Jūs arī neatgriezeniski izdzēsīsiet visas šī seriāla, raidījuma epizodes:\n\n%s - Vai tiešām vēlaties neatgriezeniski dzēst visas šī seriāla, raidījuma epizodes?\n\n%s - Pašlaik nav nevienas rindā ievietotas lejupielādes. - Atvērt vietējo video diff --git a/app/src/main/res/values-b+mk/strings.xml b/app/src/main/res/values-b+mk/strings.xml index 4e37afdea..6998c49db 100644 --- a/app/src/main/res/values-b+mk/strings.xml +++ b/app/src/main/res/values-b+mk/strings.xml @@ -126,7 +126,8 @@ Откажи Паузирај Продолжи - Ова трајно ќе го избрише %s \nДали си сигурен? + Ова трајно ќе го избрише %s +\nДали си сигурен? Во тек Изгледанo Статус @@ -244,7 +245,7 @@ TC Претплатен на %s Преводи - Предупредување: CloudStream не презема никаква одговорност за користење на екстензии од трети страни и не обезбедува никаква поддршка за нив! + Предупредување: CloudStream 3 не презема никаква одговорност за користење на екстензии од трети страни и не обезбедува никаква поддршка за нив! Недостасуваат дозволи за складирање. Обиди се повторно. Зачувај Вчитај од датотека @@ -259,7 +260,7 @@ Подреди Внатрешен плеер Резолуција - Кредити + Кредити Пребарај %s… Приклучокот е избришан Статус @@ -416,7 +417,8 @@ Почна да презема %1$d %2$s… Автоматски ажурирања на приклучоци -30 - %dm \nпреостанува + %dm +\nпреостанува Видео кеш на дискот https://example.com/example.mp4 Готово @@ -445,7 +447,7 @@ Грешка при правење резервна копија на %s Сокриј го избраниот квалитет на видеото во резултатите од пребарувањето Некои уреди не го поддржуваат новиот инсталатор на пакети. Испробај ја легаси(старата) опција, ако ажурирањата не се инсталираат. - Прикажи информации за плеерот + Резолуција на видео плеер Големина на видео баферот Распоред Стандардно @@ -565,7 +567,7 @@ Неомилен Омилен Заклучување со биометрика - Музика + Музика Известување за нова епизода Пребарај во други екстензии Прикажи препораки @@ -583,9 +585,10 @@ Отклучи CloudStream Биометриската автентикација не е поддржана на овој уред По неколку неуспешни обиди, известувањето ќе се затвори. Едноставно вклучи ја апликацијата повторно за да се обидеш повторно. - Медиуми + Медиуми Претстои во %s - %s \nпреостанати + %s +\nпреостанати За да се обезбедат непрекинати преземања и известувања за претплатените ТВ серии, CloudStream треба дозвола за работа во позадина. Со притискање на „ОК“, ќе ви биде прикажан дијалог за барање дозвола. Ве молиме, притиснете „Дозволи“.\n\nИмајте предвид дека оваа дозвола не значи дека CS3 ќе ја троши вашата батерија. Ќе работи во позадина само кога е потребно, како на пример при примање известувања или преземање видеа од официјални екстензии. Грешка при пристапот до таблата со исечоци, обиди се повторно. Грешка при копирање, молам копирај го логот и контактирај ја поддршката на апликацијата. @@ -630,8 +633,8 @@ CloudStream Wiki Дали си сигурен дека сакаш трајно да ги избришеш следните епизоди во %1$s?\n\n%2$s Кодот истекува за %1$d минута/и и %2$d секунда/и - Аудио - Поткаст + Аудио + Поткаст Грешка при кодирање на преводот Неподдржана грешка Вчитај прво достапно @@ -705,37 +708,4 @@ Горе во центар Горе на десно Пушти ја целата серија - Редица за преземање - Моментално нема преземања во редицата. - Дополнителна осветленост - Овозможи филтер за осветленост кога ќе се надмине 100% осветленост на екранот - овозможена_дополнителна_осветленост - Предлози за пребарување - Прикажувај предлози за пребарување додека пишуваш - Исчисти предлози - Прикажи преклоп со метаподатоци на плеерот - Прикажи панел за емитување - Инсталирај предиздавачка верзија - Предиздавачката верзија е веќе инсталирана. - Неуспешна инсталација на предиздавачката верзија. - Видео - Текст на епизода - Информации за медиумот - Преглед - Приоритет на извор - Одреди како ќе се подредуваат видео изворите во плеерот - Име на изворот - Преземи сѐ - Откажи сѐ - Дали сакате да ја преземете епизодата %s? - Дали сакате да ги откажете сите преземања во редицата? - - %d активно преземање - %d активни преземања - - - %d преземање во редицата - %d преземања во редицата - - Во живо diff --git a/app/src/main/res/values-b+ml/strings.xml b/app/src/main/res/values-b+ml/strings.xml index c2b25c5ee..d1c9409a3 100644 --- a/app/src/main/res/values-b+ml/strings.xml +++ b/app/src/main/res/values-b+ml/strings.xml @@ -3,7 +3,8 @@ വേഗം (%.2fx) റേറ്റിംഗ്: %.1f - പുതിയ അപ്ഡേറ്റ്! \n%1$s -> %2$s + പുതിയ അപ്ഡേറ്റ്! +\n%1$s -> %2$s ക്ലൗഡ് സ്ട്രീം ഹോം തിരയുക @@ -114,7 +115,8 @@ റദ്ദാക്കുക നിർത്തുക തുടരുക - സ്ഥിരമായി %sനെ ഡിലീറ്റ് ചെയ്യുക \nഉറപ്പാണോ? + സ്ഥിരമായി %sനെ ഡിലീറ്റ് ചെയ്യുക +\nഉറപ്പാണോ? തുടരുന്നു പൂർത്തിയായി അവസ്ഥ @@ -190,7 +192,9 @@ %s ൽ ഫോൻ്റ്‌സ് വെച്ചു കൊണ്ട് ഇംപോർട്ട് ചെയ്യുക പ്രശ്‌നമുണ്ടാക്കുന്ന ഒന്ന് കണ്ടെത്താൻ നിങ്ങളെ സഹായിക്കുന്നതിന് ഒരു ക്രാഷ് കാരണം എല്ലാ വിപുലീകരണങ്ങളും ഓഫാക്കി. പൊതു പട്ടിക - CloudStream-ന് സ്ഥിരസ്ഥിതിയായി സൈറ്റുകളൊന്നും ഇൻസ്റ്റാൾ ചെയ്തിട്ടില്ല. നിങ്ങൾ റിപ്പോസിറ്ററികളിൽ നിന്ന് സൈറ്റുകൾ ഇൻസ്റ്റാൾ ചെയ്യേണ്ടതുണ്ട്. \n \nഞങ്ങളുടെ ഡിസ്കോർഡിൽ ചേരുക അല്ലെങ്കിൽ ഓൺലൈനിൽ തിരയുക. + CloudStream-ന് സ്ഥിരസ്ഥിതിയായി സൈറ്റുകളൊന്നും ഇൻസ്റ്റാൾ ചെയ്തിട്ടില്ല. നിങ്ങൾ റിപ്പോസിറ്ററികളിൽ നിന്ന് സൈറ്റുകൾ ഇൻസ്റ്റാൾ ചെയ്യേണ്ടതുണ്ട്. +\n +\nഞങ്ങളുടെ ഡിസ്കോർഡിൽ ചേരുക അല്ലെങ്കിൽ ഓൺലൈനിൽ തിരയുക. പകർത്തുക എല്ലാ സബ്‌ടൈറ്റിലുകളും വലിയക്ഷരമാക്കുക റെൻഡറർ പിശക് @@ -257,7 +261,8 @@ ഈ ലിസ്റ്റ് ശൂന്യമാണ്. മറ്റൊന്നിലേക്ക് മാറാൻ ശ്രമിക്കുക. ചരിത്രം മായ്ക്കുക ലോഗ്കാറ്റ് കാണിക്കുക 🐈 - നിങ്ങളുടെ ലൈബ്രറി ശൂന്യമാണ് :( \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 a15759939..83492a5ff 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 @@ -25,8 +25,8 @@ Episod %d akan disiarkan dalam Pelakon:%s Mod Selamat Hidup - %1$dh %2$dj %3$dm - %1$dj %2$dm + %1$dd %2$dh %3$dm + %1$dh %2$dm %dm Poster Episod Poster Utama @@ -56,7 +56,8 @@ 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 @@ -284,7 +285,7 @@ /%d Gunakan ini sekiranya sari kata yang dipaparkan awal sebanyak %d HDR - Media + Media Susun atur TV TV Bersiri Tidak dapat papar %s @@ -389,7 +390,7 @@ Berhenti Anda telah mengundi Alih keluar dari Kegemaran - Musik + Musik Pilih benda untuk dibuang Buang sempadan hitam Akaun dan Sekuriti @@ -485,7 +486,7 @@ Kemaskini dan sandaran Ketik dua kali untuk mencari Gunakan kecerahan sistem dalam pemain apl dan bukannya tindanan gelap - %1$dj %2$dm %3$ds + %1$dh %2$dm %3$ds %1$dm %2$ds %1$ds Pengecaman pertuturan tidak tersedia @@ -531,6 +532,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 8f8bf6cd7..ea859ee29 100644 --- a/app/src/main/res/values-b+mt/strings.xml +++ b/app/src/main/res/values-b+mt/strings.xml @@ -18,7 +18,8 @@ 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 d360d095d..4a7a50aa7 100644 --- a/app/src/main/res/values-b+my/strings.xml +++ b/app/src/main/res/values-b+my/strings.xml @@ -11,7 +11,8 @@ နောက်သို့ နောက်ခံပုံရိပ်ကို အကြိုကြည့်ရန် အဆင့်: %.1f - အပ်ဒိတ်အသစ်! \n%1$s -> %2$s + အပ်ဒိတ်အသစ်! +\n%1$s -> %2$s စစ်ထုတ်မှု %d မိနစ် CloudStream @@ -110,8 +111,10 @@ အပိုင်း အပိုင်းများ %1$d-%2$d - ဒါကအပြီးဖျက်ခြင်းဖြစ်ပါသည် %s \nသင်သေချာပါသလား။ - %dမိနစ် \nကျန်ရိှသည် + ဒါကအပြီးဖျက်ခြင်းဖြစ်ပါသည် %s +\nသင်သေချာပါသလား။ + %dမိနစ် +\nကျန်ရိှသည် ထုတ်လွှင့်နေဆဲ ထုတ်လွှင့်မှုပြီးဆုံး အခြေအနေ @@ -333,13 +336,17 @@ အစမှပြန်စ ရောထားသောအဆုံးပိုင်း ရောထားသောအစပိုင်း - ခရက်ဒစ်များ + ခရက်ဒစ်များ အစ သေချာသည် သမားရိုးကျ ထည့်သွင်းသူ ထွက်ချိန်တွင် အက်ပ်ကို အပ်ဒိတ်လုပ်ပါမည် - CloudStream တွင် မူရင်းအတိုင်း ထည့်သွင်းထားသည့်ဆိုက်များ မရှိပါ။ ရီပိုစစ်ထရီများ မှ ဆိုဒ် များကို ထည့်သွင်းရန်လိုအပ်သည်။ \n \nSky UK Limited မှ ဦးနှောက်မဲ့ DMCA ကို ဖယ်ရှားလိုက်ခြင်းကြောင့် 🤮 ကျွန်ုပ်တို့သည် ရီပိုစစ်ထရီဆိုဒ်ကို အက်ပ်တွင် ချိတ်ဆက်၍မရပါ။ \n \nကျွန်ုပ်တို့၏ Discord တွင်ပါဝင်ပါ သို့မဟုတ် အွန်လိုင်းတွင်ရှာဖွေပါ။ + CloudStream တွင် မူရင်းအတိုင်း ထည့်သွင်းထားသည့်ဆိုက်များ မရှိပါ။ ရီပိုစစ်ထရီများ မှ ဆိုဒ် များကို ထည့်သွင်းရန်လိုအပ်သည်။ +\n +\nSky UK Limited မှ ဦးနှောက်မဲ့ DMCA ကို ဖယ်ရှားလိုက်ခြင်းကြောင့် 🤮 ကျွန်ုပ်တို့သည် ရီပိုစစ်ထရီဆိုဒ်ကို အက်ပ်တွင် ချိတ်ဆက်၍မရပါ။ +\n +\nကျွန်ုပ်တို့၏ Discord တွင်ပါဝင်ပါ သို့မဟုတ် အွန်လိုင်းတွင်ရှာဖွေပါ။ အခြားသူများ၏ရီပိုစစ်ထရီများကိုရှာဖွေမည် အသံများ အသံဖိုင်များ @@ -415,7 +422,8 @@ အက်ပ်အပ်ဒိတ်အားဒေါင်းလုဒ်လုပ်နေသည်… အက်ပ်အပ်ဒိတ်အားသွင်းနေသည်… အပ်ဒိတ်ဖြစ်မှု (အသစ် မှ အဟောင်း) - သင့်လိုက်ဘရီသည် ဗလာဖြစ်နေသည် :( \nအကောင့်ဝင်ပါ သို့မဟုတ် သင့်ဖုန်းလိုက်ဘရီတွင် ကြည့်စရာများထည့်ပါ။ + သင့်လိုက်ဘရီသည် ဗလာဖြစ်နေသည် :( +\nအကောင့်ဝင်ပါ သို့မဟုတ် သင့်ဖုန်းလိုက်ဘရီတွင် ကြည့်စရာများထည့်ပါ။ သုံးရန် တည်းဖြတ်ရန် အရည်အသွေးများ @@ -508,7 +516,8 @@ စာရင်းသွင်းပြီး %s စာရင်းသွင်းမှုပယ်ဖျက်ပြီး %s ဤစာရင်းသည် ဗလာဖြစ်နေသည်။ အခြားတစ်ခုသို့ ပြောင်းကြည့်ပါ။ - Safe mode ဖိုင်ကို တွေ့ရှိခဲ့သည်။ \nဖိုင်ကိုမဖယ်ရှားမချင်း စတင်ဖွင့်စတွင် မည်သည့် extension များကိုမျှ မတင်ပါ။ + Safe mode ဖိုင်ကို တွေ့ရှိခဲ့သည်။ +\nဖိုင်ကိုမဖယ်ရှားမချင်း စတင်ဖွင့်စတွင် မည်သည့် extension များကိုမျှ မတင်ပါ။ အပိုင်းသစ် %d ထွက်ပြီ ပရိုဖိုင် %d ဝိုင်ဖိုင် @@ -516,7 +525,13 @@ ပုံသေထားရန် ပရိုဖိုင်များ အကူအညီ - ဤနေရာတွင် သင်သည် အရင်းအမြစ်များကို မည်ကဲ့သို့ အစီအစဥ်ချမည်ကို ပြောင်းလဲနိုင်သည်။ ဗီဒီယိုတစ်ခုတွင် ပိုမိုဦးစားပေးပါက ရင်းမြစ်ရွေးချယ်မှုတွင် ပိုမိုမြင့်မားလာမည်ဖြစ်သည်။ အရင်းအမြစ် ဦးစားပေးနှင့် အရည်အသွေး ဦးစားပေး၏ ပေါင်းစုသည် ဗီဒီယို ဦးစားပေးဖြစ်သည်။ \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 49e5a9350..8a432a505 100644 --- a/app/src/main/res/values-b+ne/strings.xml +++ b/app/src/main/res/values-b+ne/strings.xml @@ -15,7 +15,8 @@ मुख्य पोस्टर %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 3164e6b4e..10588a3fc 100644 --- a/app/src/main/res/values-b+nl/strings.xml +++ b/app/src/main/res/values-b+nl/strings.xml @@ -19,7 +19,8 @@ Snelheid (%.2fx) Beoordeeld: %.1fAls - Nieuwe update gevonden! \n%1$s -> %2$s + Nieuwe update gevonden! +\n%1$s -> %2$s Filler %d min CloudStream @@ -181,8 +182,10 @@ 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 @@ -246,7 +249,7 @@ Update Voorkeurskwaliteit voor kijken (WiFi) Maximaal aantal tekens voor titel van videospeler - Toon spelerinformatie + Videospeler Resolutie Grootte videobuffer Lengte videobuffer Video cache op schijf @@ -286,7 +289,7 @@ Gebruikersnaam hello@Wereld.com 127.0.0.1 - NieuweSiteNaam + MyCoolSite https://voorbeeld.com Taalcode (nl) %1$s %2$s @@ -429,7 +432,7 @@ Repository naam (Optioneel) Plugin Gedownload Mislukt - Omzeil de blokkering van ruwe GitHub-URL’s via jsDelivr. Hierdoor kunnen updates enkele dagen vertraagd zijn. + Omzeilt de blokkering van GitHub met behulp van jsDelivr, waardoor updates enkele dagen vertraging kunnen oplopen. Repository URL of Shortcode Download %1$d %2$s voltooid HLS Afspeellijst @@ -481,7 +484,8 @@ 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 @@ -503,19 +507,24 @@ %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 @@ -527,7 +536,13 @@ 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 @@ -539,7 +554,11 @@ 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 @@ -558,7 +577,9 @@ 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 @@ -595,11 +616,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 @@ -637,44 +658,4 @@ Ga naar %s op je smartphone of computer en voeren de bovenstaande code in PIN-code is nu verlopen! Code verloopt in %1$dm %2$ds - Downloadwachtrij - Er staan momenteel geen downloads in de wachtrij. - Extra helderheid - Schakel het helderheidsfilter in zodra de schermhelderheid boven 100% komt - extra_helderheid_ingeschakeld - Zoeksuggesties - Zoeksuggesties laten zien tijdens het typen - Suggesties verwijderen - Castpaneel weergeven - Pre-releaseversie installeren - Pre-releaseversie is al geïnstaleerd. - Installatie van de pre-release is mislukt. - Schermspiegeling - Spiegeling spelen" - Beoordelingslabel - Afleveringstekst - Deze test is alleen bedoeld voor ontwikkelaars en bevestigt noch ontkent de werking van een extensie. - Lokale authenticatie - Media info - Waarschuwing: Cloudstream is niet verandwoordelijk voor het gebruik van extensies van derden en biedt hier ook geen ondersteuning voor! - Cast apparaat selecteren - Fout bij toegang tot het Klembord, Probeer het opnieuw. - Fout bij het kopiëren. Kopieer alsjeblieft de logcat en neem contact op met de app-ondersteuning. - Afwijzen - Video - Voorbeeld - Bron Prioriteit - Bepaal hoe de videobronnen worden gesorteerd in de speler - Ontgrendel CloudStream - Versleutel met Biometrie - Reset - verschijningsdatum (Nieuw naar Oud) - verschijningsdatum (Oud naar Nieuw) - Verberg de namen van de besturingselementen van de speler - Ondertiteling nog niet geladen - Back-up folder locatie - Aangepast - Bevestig voor afsluiten - Toon dialoogvenster voordat de app wordt afgesloten - Randgrote diff --git a/app/src/main/res/values-b+nn/strings.xml b/app/src/main/res/values-b+nn/strings.xml index b29e142b0..245bf6618 100644 --- a/app/src/main/res/values-b+nn/strings.xml +++ b/app/src/main/res/values-b+nn/strings.xml @@ -15,7 +15,8 @@ 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 @@ -114,8 +115,10 @@ 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 @@ -188,14 +191,4 @@ Bilde i bilde Fortsett å sjå Prøv tilkopling på nytt… - Sesong %1$d Episode %2$d blir sleppt om - Spel av frå start - Nedlastingskø - Semmegjenkjenning er ikkje tilgjengeleg - Snakk no… - Nettlesar - Fjerna - Strøm Torrent - Spel heile serien - Denne filmen er ein Torrent, som betyr at bruken din kan bli spora\nSett deg inn i bruk av Torrent-resursar før du fortsetter. diff --git a/app/src/main/res/values-b+no/strings.xml b/app/src/main/res/values-b+no/strings.xml index 7d309a2f8..374b033c6 100644 --- a/app/src/main/res/values-b+no/strings.xml +++ b/app/src/main/res/values-b+no/strings.xml @@ -12,7 +12,8 @@ Avspillingshastighet (%.2fx) Vurdert: %.1f - Ny oppdatering funnet! \n%1$s -> %2$s + Ny oppdatering funnet! +\n%1$s -> %2$s CloudStream Hjem Søk @@ -134,7 +135,8 @@ Avbryt Stopp Gjenoppta - Dette vil slette %s \nEr du sikker? + Dette vil slette %s +\nEr du sikker? Pågående Fullført Posisjon @@ -351,7 +353,8 @@ %1$d-%2$d Offentlig liste programtillegg - %dm \nigjen + %dm +\nigjen Videooppløsning Synkroniser undertekster Undertekstforsinkelse @@ -370,11 +373,15 @@ 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 @@ -505,8 +512,10 @@ 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 ce7f74290..8c9379f5b 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,7 +84,8 @@ ବ୍ୟାକଅପ୍ ଆଣ୍ଡ୍ରଏଡ୍ ଟିଵି ଅଙ୍ଗଭଙ୍ଗୀ - ନୂଆ ଅଦ୍ୟତନ ମିଳିଲା! \n%1$s -> %2$s + ନୂଆ ଅଦ୍ୟତନ ମିଳିଲା! +\n%1$s -> %2$s ଅଵଧି ଆପ୍ ବ୍ୟାକଅପ୍ ଫାଇଲ୍ ଧାରଣ ହେଲା diff --git a/app/src/main/res/values-b+pl/array.xml b/app/src/main/res/values-b+pl/array.xml index 13297219c..14f377bbf 100644 --- a/app/src/main/res/values-b+pl/array.xml +++ b/app/src/main/res/values-b+pl/array.xml @@ -23,20 +23,24 @@ - - @string/source_name + + @string/resolution_and_name + @string/name + @string/resolution_and_title + @string/title @string/resolution - @string/video_info + @string/none - - @string/show_name_key - @string/show_resolution_key - @string/show_media_info_key + + 5 + 4 + 3 + 2 + 1 + 0 - - - + @string/none 16 znaków diff --git a/app/src/main/res/values-b+pl/strings.xml b/app/src/main/res/values-b+pl/strings.xml index 35367a1b2..92097cddc 100644 --- a/app/src/main/res/values-b+pl/strings.xml +++ b/app/src/main/res/values-b+pl/strings.xml @@ -2,7 +2,8 @@ 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 @@ -176,8 +177,10 @@ 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 @@ -243,7 +246,7 @@ Aktualizacja Domyślna jakość (WiFi) Maksymalna liczba znaków w tytule odtwarzacza - Pokaż informacje o odtwarzaczu + Rozdzielczość odtwarzacza wideo Rozmiar bufora wideo Długość bufora wideo Pamięć podręczna wideo na dysku @@ -382,7 +385,9 @@ 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ą @@ -447,7 +452,7 @@ Opening Ending Mixed opening - Napisy końcowe + Napisy końcowe Intro Mixed ending Pokaż wyskakujące okienka pomijania dla niektórych segmentów @@ -482,9 +487,11 @@ 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 @@ -508,7 +515,13 @@ 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 @@ -528,7 +541,11 @@ 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 @@ -545,7 +562,9 @@ 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 @@ -571,7 +590,8 @@ 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. @@ -579,10 +599,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 @@ -613,11 +633,19 @@ 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 @@ -630,8 +658,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 @@ -693,7 +721,7 @@ Przeładuj dostawcę Odtwarzaj inne źródło" Nazwa - Rozdzielczość i nazwa + Rozdzielczość i nazwa Dolne lewe Wyrównanie napisów Dolne środkowe @@ -709,37 +737,4 @@ Wersja przedpremierowa jest już zainstalowana. Nie udało się zainstalować wersji przedpremierowej. Tekst odcinka - Sugestie wyszukiwania - Pokaż sugestie wyszukiwania podczas pisania - Wyczyść sugestie - Pokaż panel obsady - Nazwa źródła - Informacje o multimediach - Dodatkowa jasność - Włącz filtr jasności, gdy jasność wyświetlacza przekroczy 100% - Włączono dodatkową jasność - Kolejka pobierania - 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? - - %d aktywne pobieranie - %d aktywne pobierania - %d aktywnych pobierań - %d aktywnych pobierań - - - %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 - 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 455478b5a..72e693089 100644 --- a/app/src/main/res/values-b+pt+BR/strings.xml +++ b/app/src/main/res/values-b+pt+BR/strings.xml @@ -180,7 +180,8 @@ Continuar -30 +30 - Isso apagará %s permanentemente \nVocê tem certeza? + Isso apagará %s permanentemente +\nVocê tem certeza? %dm\nrestantes Em andamento Concluído @@ -246,7 +247,7 @@ Atualizar Qualidade preferida de reprodução (Wi-fi) Máximo de caracteres do título de vídeos - Exibir informação do reprodutor + Resolução do player de vídeo Tamanho do buffer do vídeo Duração do buffer do vídeo Cache do vídeo em disco @@ -395,7 +396,9 @@ 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 @@ -438,7 +441,8 @@ 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 @@ -456,8 +460,15 @@ 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 @@ -499,7 +510,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. @@ -542,7 +553,11 @@ 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 @@ -561,7 +576,9 @@ 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 @@ -582,7 +599,8 @@ 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! @@ -594,9 +612,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 @@ -616,7 +634,7 @@ Reproduzir do começo Reprovou alguns testes Excluir plugin - Atualmente não há downloads disponíveis. + Você não baixou nada :/ Ocultar os nomes dos controles do player Abrir arquivo de vídeo Data de lançamento (do novo ao antigo) @@ -645,8 +663,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 @@ -721,35 +739,4 @@ Versão antecipada instalada. Instalar versão antecipada Episódio Text - Fila de download - Não há itens na fila para download. - Brilho extra - Ativar filtro de claridade ao exceder 100% do brilho do display - brilho_extra_ativado - Sugestões de Busca - Exibir sugestões de pesquisa durante a digitação - Limpar Sugestões - Exibir painel de elenco - Informação de mídia - Prioridade de fonte - Decidir como fontes de vídeo devem estar dispostas no reprodutor - Nome da fonte - Baixar tudo - Cancelar tudo - Você deseja baixar o episódio%s? - Você gostaria de cancelar todos os downloads da fila? - - %ddownload ativo - %ddownloads ativos - %ddownloads ativos - - - %d download na sequência - %d downloads na sequência - %d downloads na sequência - - Mostrar sobreposição de metadados do reprodutor - 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 960a91d0d..e7b3623e6 100644 --- a/app/src/main/res/values-b+pt/strings.xml +++ b/app/src/main/res/values-b+pt/strings.xml @@ -18,7 +18,8 @@ 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 @@ -61,7 +62,7 @@ Transmitir Erro a Carregar Links Armazenamento Interno - Dub + Dob Leg Eliminar Ficheiro Reproduzir Ficheiro @@ -100,7 +101,7 @@ Importar fontes colocando em %s Continuar a Assistir Remover - Mais informações + Mais info Uma VPN pode ser necessária para que este fornecedor funcione corretamente Este fornecedor é um torrent, uma VPN é recomendada Metadados não são oferecidos pelo site, o carregamento do vídeo irá falhar se ele não existir no site. @@ -142,7 +143,7 @@ Procurar Contas e segurança Atualizações e cópias de segurança - Informações + Info Procura Avançada Mostra resultados separados por fornecedor Mostrar episódios de enchimento para anime @@ -175,8 +176,10 @@ 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 @@ -239,7 +242,7 @@ Atualizar Qualidade Preferida (WiFi) Máximo de caracteres do título no player de video - Mostrar informações do player de vídeo + Resolução do player de vídeo Tamanho do buffer do vídeo Comprimento do buffer do vídeo Cache do vídeo em disco @@ -318,9 +321,9 @@ Carregar de arquivo Carregar da Internet Arquivo baixado - Principal - Suporte - Plano de fundo + Protagonista + Coadjuvante + Figurante Aleatório Em breve… Imagem de Poster @@ -361,7 +364,9 @@ 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 @@ -405,7 +410,7 @@ Sim Baixando atualização do app… Episódio %d lançado! - Créditos + Créditos Descrição Tamanho Parar @@ -469,8 +474,10 @@ 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 @@ -515,7 +522,13 @@ 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 @@ -523,7 +536,7 @@ Repositório não encontrado, verifique o URL e tente a VPN Você já votou Cancelar Inscrição - Inscrever-se + Subscrever Favoritos A recarregar links Frequência de Backup @@ -547,8 +560,14 @@ 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 @@ -565,7 +584,8 @@ 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 @@ -577,9 +597,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 @@ -591,7 +611,9 @@ 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 @@ -604,9 +626,15 @@ 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 @@ -628,14 +656,14 @@ 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 Reinicie a aplicação e aceite o pop-up do Stream Torrent para continuar. Descodificação por software - Descodificação por software permite que o leitor reproduza ficheiros não suportados pelo seu dispositivo, mas pode resultar numa reprodução desfasada ou instável em altas resoluções. + Descodificação por software permite que o leitor reproduza ficheiros não suportados pelo seu dispositivo, mas pode resultar numa reprodução desfasada ou instável em altas resoluções Incorporada Online Episódio (Ascendente) @@ -686,7 +714,7 @@ Imagem Atualizada com Sucesso Marcar como assistido o episódio Removar marcação de assistido até esse episódio - Recarregar + Recarregado Provedor de Recarregamento Reproduzir do servidor alternativo" Nome @@ -706,35 +734,4 @@ Versão de pré-lançamento já instalada. Falha ao instalar pré-lançamento. Texto do Episódio - Fila de downloads - Não há downloads na fila no momento. - Brilho extra - Ativar filtro de brilho quando o brilho da tela exceder 100% - extra_brightness_enabled - Sugestões de pesquisa - Mostrar sugestões de pesquisa enquanto digita - Limpar Sugestões - Mostrar Painel de Elenco - Informações da mídia - Prioridade da fonte - Decida como as fontes de vídeo devem ser classificadas no player - Nome da fonte - Baixar tudo - Cancelar tudo - Deseja baixar o episódio %s? - Deseja cancelar todos os downloads em fila? - - %d download ativo - %d downloads ativos - %d downloads ativos - - - %d download na fila - %d downloads na fila - %d downloads na fila - - Mostrar sobreposição de metadados do player - Vídeo - Pré-visualização - Ao Vivo diff --git a/app/src/main/res/values-b+qt/strings.xml b/app/src/main/res/values-b+qt/strings.xml index 6bbb0ddba..d60a4e32c 100644 --- a/app/src/main/res/values-b+qt/strings.xml +++ b/app/src/main/res/values-b+qt/strings.xml @@ -185,7 +185,8 @@ 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 @@ -227,7 +228,8 @@ aaaaaaaaaaahhhgh-aooohoooo aau aooooghaao aagh aaaaaaaaaaaa oooh, aaough, ooga oguuu aaaaaaaaaaa ooooooohghh a-a-aaauo - %dmmmmmm.. \naaaaooughugh + %dmmmmmm.. +\naaaaooughugh aooohuohaaaa ooooagh oooooogh-aaaaaogh guuuaaaahhhhhhhaaa @@ -276,7 +278,9 @@ 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 @@ -309,7 +313,13 @@ 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 @@ -398,7 +408,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 @@ -478,7 +488,10 @@ 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 @@ -487,7 +500,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 @@ -510,7 +523,8 @@ 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 @@ -530,13 +544,21 @@ 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 @@ -585,13 +607,15 @@ 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 @@ -599,7 +623,11 @@ 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 b1852640c..dbd607666 100644 --- a/app/src/main/res/values-b+ro/strings.xml +++ b/app/src/main/res/values-b+ro/strings.xml @@ -19,7 +19,8 @@ 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 @@ -176,8 +177,10 @@ 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 @@ -427,7 +430,8 @@ 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) @@ -467,10 +471,13 @@ 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. @@ -515,7 +522,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 @@ -524,7 +531,13 @@ 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 @@ -540,12 +553,18 @@ 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 @@ -591,16 +610,17 @@ 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 @@ -620,8 +640,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 38e576baa..a2d50009d 100644 --- a/app/src/main/res/values-b+ru/strings.xml +++ b/app/src/main/res/values-b+ru/strings.xml @@ -34,7 +34,8 @@ Предпросмотр фона Скорость (%.2fx) Оценили: %.1f - Новое обновление найдено! \n%1$s -> %2$s + Новое обновление найдено! +\n%1$s -> %2$s Заполнитель CloudStream Убрать @@ -51,7 +52,7 @@ Следующая серия Жанры Поделиться - Открыть в Браузере + Открыть в веб обозревателе Пропустить загрузку Просмотр Приостановлено @@ -171,8 +172,10 @@ Продолжить -30 +30 - Это будет удалено безвозвратно%s \nВы уверены? - %d мин. \nосталось + Это будет удалено безвозвратно%s +\nВы уверены? + %d мин. +\nосталось Завершено Год Рейтинг @@ -300,9 +303,9 @@ Приложение не найдено Все языки Вступление - Титры + Титры Отметить как просмотренное - Показывать информацию про видеоплеер + Разрешение видеоплеера Предпочтительное качество видео (WiFi) Максимум символов Длинна буфера @@ -408,7 +411,9 @@ Трейлер %s (отключено) Далее - В CloudStream по умолчанию не установлены сайты. Вам необходимо установить сайты из репозиториев. \n \nПрисоединяйтесь к нашему Discord-серверу или найдите в интернете. + В CloudStream по умолчанию не установлены сайты. Вам необходимо установить сайты из репозиториев. +\n +\nПрисоединяйтесь к нашему Discord-серверу или найдите в интернете. Недопустимые данные Разрешение и название Предыдущий @@ -521,7 +526,13 @@ Вы уже проголосовали Никаких дополнений не обнаружено в источнике Поставить обычный - Здесь вы можете изменить порядок расположения источников. Если видео имеет более высокий приоритет, оно будет отображаться выше в списке источников. Сумма приоритета источника и приоритета качества составляет приоритет видео. \n \nИсточник А: 3 \nКачество Б: 7 \nБудет иметь общий приоритет видео 10. \n \nПРИМЕЧАНИЕ. Если сумма равна 10 или более, плеер автоматически пропустит загрузку при загрузке этой ссылки! + Здесь вы можете изменить порядок расположения источников. Если видео имеет более высокий приоритет, оно будет отображаться выше в списке источников. Сумма приоритета источника и приоритета качества составляет приоритет видео. +\n +\nИсточник А: 3 +\nКачество Б: 7 +\nБудет иметь общий приоритет видео 10. +\n +\nПРИМЕЧАНИЕ. Если сумма равна 10 или более, плеер автоматически пропустит загрузку при загрузке этой ссылки! Ссылки перезагружены Выберите учётную запись %s убрано из любимых @@ -571,11 +582,12 @@ Использование батареи приложением уже настроено на неограниченное Не удаётся открыть информацию о приложении CloudStream. Заблокировать биометрией - Музыка + Музыка Аудиокнига - Медиа + Медиа Разблокируйте приложение с помощью отпечатка пальца, Face ID, ПИН-кода, шаблона и пароля. - %s \nосталось + %s +\nосталось Отключить оптимизацию батареи Аутентификация по паролю/ПИН-коду Биометрическая аутентификация на этом устройстве не поддерживается @@ -605,7 +617,9 @@ Вы уверены, что хотите навсегда удалить все серии в данном сериале? \n \n%s Выберите элементы для удаления Удалить (%1$d | %2$s) - Вы уверены, что хотите навсегда удалить данный объект? \n \n%s + Вы уверены, что хотите навсегда удалить данный объект? +\n +\n%s Невозможно получить ПИН-код устройства, попробуйте локальную аутентификацию Откройте %s на вашем смартфоне или компьютере и введите данный код CloudStream Вики @@ -630,8 +644,8 @@ Ошибка кодировки Не поддерживается Скачать первые доступные - Аудио - Подкаст + Аудио + Подкаст Серия (по возрастанию) Встроенный Из сети @@ -641,7 +655,7 @@ Оценка %s Дата %s Дата выпуска (Новейшие) - Программное декодирование позволяет проигрователю воспроизводить видео, которые не поддерживаются вашим устройством, но может быть с задержками или нестабильным воспроизведением при высоком разрешении. + Программное декодирование позволяет проигрователю воспроизводить видео, которые не поддерживаются вашим устройством, но может быть с задержками или нестабильным воспроизведением при высоком разрешении Программное декодирование Уведомление проигрывателя для управления воспроизведением в фоновом режиме Обновить дополнения @@ -693,50 +707,17 @@ Резолюция и название Выравнивание Субтитров Нижний левый - Нижний центр + Нижний центральный Нижний правый Средний левый Средний центр Средний правый - Верхний слева - Верхний центр - Верхний справа + Вверху слева + Вверху центр + Вверху правый Смотреть полностью Установить предварительный выпуск Предварительный выпуск уже установлен. Не удалось установить предварительный выпуск. Текст эпизода - Поиск предложений - Показывать подсказки поиска при вводе текста - Очистить подсказки - Показать панель приведения - Информация о средствах массовой информации - Имя источника - - %d активная загрузка - %d активные загрузки - %d активных загрузок - %d активных загрузок - - - Запланировано %d загрузка - Запланировано %d загрузки - Запланировано %d загрузок - Запланировано %d загрузок - - Очередь загрузок - В настоящее время нет загрузок в очереди. - Дополнительная яркость - Включать фильтр яркости при превышении 100% яркости - extra_brightness_enabled - Приоритетный источник - Выберите способ сортировки видеоисточников в плеере - Скачать всё - Отменить всё - Вы хотите загрузить эпизод %s? - Вы хотите отменить всё запланированные загрузки? - Показывать наложения метаданных проигрывателя - Видео - Предпросмотр - Прямой эфир diff --git a/app/src/main/res/values-b+sk/strings.xml b/app/src/main/res/values-b+sk/strings.xml index f48b27143..93505971c 100644 --- a/app/src/main/res/values-b+sk/strings.xml +++ b/app/src/main/res/values-b+sk/strings.xml @@ -1,6 +1,7 @@ - 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 @@ -117,7 +118,7 @@ Prehliadač Zhrnutie sa nenašlo Dvojitým ťuknutím pozastaviť - Aktualizácie a Zálohovanie + Aktualizácie a zálohovanie Informácie Rozšírené vyhľadávanie Zobraziť plagáty z Kitsu @@ -125,7 +126,7 @@ Skryť vybranú kvalitu videa vo výsledkoch vyhľadávania Zobraziť výplňovú epizódu pre anime APK inštalátor - Niektoré zariadenia nepodporujú nový inštalátor balíčkov. Ak sa aktualizácie nenainštalujú, skúste použiť staršiu možnosť. + Niektoré telefóny nepodporujú nový inštalátor balíčkov. Ak sa aktualizácie nenainštalujú, skúste použiť staršiu možnosť. Nenáročná aplikácia pre romány od rovnakých vývojárov Jazyk aplikácie Nenašli sa žiadne odkazy @@ -156,7 +157,7 @@ Nastavenia titulkov prehrávača Spustiť ďalšiu epizódu po skončení aktuálnej Chromecast titulky - Rýchlosť prehrávania (Eigengravy režim) + Eigengravy režim Potiahnutím pretočiť Automaticky prehrať ďalšiu epizódu Aktualizovať priebeh sledovania @@ -167,7 +168,7 @@ Knižnica GitHub Hľadať - Účty a Zabezpečenie + Účty Nastavenia Chromecast titulkov Potiahnutím zo strany na stranu môžete ovládať svoju pozíciu vo videu Nepodarilo sa obnoviť dáta zo súboru %s @@ -202,7 +203,7 @@ Rozloženie aplikácie ahoj@svet.sk Úspešné - Meno + MojeSuperMeno Seriály Seriál E @@ -226,7 +227,7 @@ Prehrať v aplikácii %1$d-%2$d Spôsobuje zlyhania, ak je nastavená príliš vysoko na zariadeniach s nízkou pamäťou, ako je Android TV. - GitHub Proxy + raw.githubusercontent.com Proxy Trvanie Aplikácia /%d @@ -241,7 +242,7 @@ %d / 10 Priblížiť Torrenty - Informácie o prehrávači + Rozlíšenie prehrávača Umiestniť názov pod plagát Preferovaná kvalita sledovania (WiFi) Rozšírenia @@ -259,11 +260,11 @@ Žiadna sezóna Epizóda Znova načítať odkazy - Jazyky rozšírenia + Jazyky poskytovateľa Spustiť Živé prenosy Stiahnuť titulky - Povoliť NSFW u podporovaných rozšírení + Povoliť NSFW u podporovaných poskytovateľov Obchádzanie ISP Prepnúť UI prvky na plagáte Rozloženie @@ -289,7 +290,7 @@ Vzhľad %1$s %2$d%3$s Obchádza blokovanie GitHubu pomocou jsDelivr. Môže spôsobiť oneskorenie aktualizácií o niekoľko dní. - Zobraziť náhodné tlačidlo na domovskej stránke a Knižnici + Zobraziť náhodné tlačidlo na domovskej stránke Odhlásiť sa Aktualizovať Stránka @@ -304,7 +305,7 @@ Klonovať stránku OVA Filmy - https://example.com + príklad.sk Vyrovnávacia pamäť Nepodarilo sa pripojiť na GitHub. Zapína sa proxy jsDelivr… Nenašli sa žiadne epizódy @@ -327,12 +328,14 @@ Poskytovatelia TV rozloženie Kód jazyka (sk) - NovyNazovWebu + MôjSuperWeb %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 @@ -358,7 +361,9 @@ 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 @@ -377,8 +382,8 @@ Pozadie Hotovo Kreslený - Hudba - Médiá + Hudba + Médiá Kontá Bezpečnosť Normálne @@ -448,28 +453,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 - Poradie sťahovania - %1$ds - %1$dm %2$ds - Rozpoznanie reči nie je k dispozícii - Začnite hovoriť… - Momentálne nie sú žiadne sťahovania v poradí. - Vyhľadávanie v iných rozšíreniach - Extra Jas - Spustiť filter jasu, keď je prekročený 100% jas displeja - extra_jas_zapnuty - Návrhy vyhľadávania - Zobraziť návrhy vyhľadávania pri písaní - Vyčistiť Návrhy - Nainštalujte pre-release verziu - Pre-release je už nainštalovaný. - Nepodarilo sa nainštalovať pre-release. - Nepodporovaná chyba - Text Epizódy - Lokálna verifikácia diff --git a/app/src/main/res/values-b+so/strings.xml b/app/src/main/res/values-b+so/strings.xml index 0eb787311..09499af00 100644 --- a/app/src/main/res/values-b+so/strings.xml +++ b/app/src/main/res/values-b+so/strings.xml @@ -27,7 +27,8 @@ %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 @@ -182,9 +183,11 @@ 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 @@ -424,7 +427,11 @@ 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 @@ -464,5 +471,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 52d4e1d05..75c7efda4 100644 --- a/app/src/main/res/values-b+sv/strings.xml +++ b/app/src/main/res/values-b+sv/strings.xml @@ -2,7 +2,8 @@ Betygsatt: %.1f Hastighet (%.2fx) - Ny uppdatering hittad! \n%1$s -> %2$s + Ny uppdatering hittad! +\n%1$s -> %2$s CloudStream Hem Sök @@ -44,7 +45,7 @@ @string/result_poster_img_des Spela upp Info - Nästa slumpvis + Nästa Byt leverantör Filtrera bokmärken Bokmärken @@ -116,7 +117,8 @@ 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 @@ -229,7 +231,8 @@ %1$d %2$s %1$s %2$d%3$s -30 - %dm \nåterstår + %dm +\nåterstår NSFW OVA Torrent @@ -245,7 +248,7 @@ Lagringsbehörigheter saknas. Var vänlig försök igen. Inga episoder hittade Visa bilder från Kitsu - Vissa enheter stöjder inte den nya paketinstallatören. Prova alternativet för äldre versioner om uppdateringarna inte installeras. + Vissa telefoner stöder inte den nya paketinstallatören. Prova alternativet för äldre versioner om uppdateringarna inte installeras. APK-installatör Importera typsnitt genom att placera filerna i %s Automatiskt uppdatera antalet episoder sedda @@ -267,7 +270,7 @@ Kvalitetsetikett Titel Växla UI-element på affisch - Visa videospelarens information + Videospelarens upplösning Historik Markera som sedd Inställningar för Chromecast-undertexter @@ -418,7 +421,7 @@ Nätverksflöde Databasens namn (valfritt) All %s har redan laddats ner - Varning: CloudStream tar inget ansvar för att använda tredjepartstillägg och ger inget stöd för dem! + Varning: CloudStream 3 tar inget ansvar för att använda tredjepartstillägg och ger inget stöd för dem! Felsäkert läge på Starta om appen för att se ändringar. Intern spelare @@ -481,12 +484,16 @@ 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 @@ -542,7 +549,7 @@ Ladda ner listan över webbplatser du vill använda %s (Inaktiverad) Beskrivning - Eftertexter + Eftertexter Introduktion Favoriter Ange standard @@ -551,9 +558,21 @@ 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 @@ -568,10 +587,11 @@ 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. + 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 till App info. Där bläddrar du till appens batterianvändning och ställer in batterianvändningen på obegränsad. Observera 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. Om du väljer att avbryta kan du ändra denna inställning senare i allmänna inställningar. Din CloudStream-data har säkerhetskopierats nu. Även om möjligheten till detta är mycket liten, kan alla enheter bete sig olika. I det sällsynta fallet att du blir utelåst från att komma åt appen, rensa appdata helt och återställ från en säkerhetskopia. Vi ber om ursäkt för eventuella besvär som detta uppstår. Ljudbok Det gick inte att komma åt urklipp. Försök igen. @@ -579,11 +599,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 @@ -597,7 +617,9 @@ 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 @@ -611,9 +633,15 @@ 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 @@ -624,15 +652,15 @@ Visa 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 + 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 Mjukvaruavkodning - Starta om appen och acceptera popup-fönstret för Stream Torrent för att fortsätta. + Ogiltig torrent Aktivera torrent i Inställningar/Leverantörer/Föredragen media Avkodningsfel Ladda in första möjliga - Visa inte + Dölj Kantstorlek Egen Stöds ej @@ -649,90 +677,4 @@ Betyg%s Uppdatera Plugins Gå till Hämtade filer - Mängden av olika föremål som kan bli nedladdat i parallell - Parallel nedladdningar - Samtidiga anslutningar - Mängden av samtidiga anslutningar varje nedladdning kan använda - Ingen anslutning. \n\nAnslut till ett nätverk och försök igen. Du kan titta på dina nedladdningar medan du inte är ansluten. - Ändrar skärmens gränser - Överskanning - Ändrar storleken på affischer - Affischstorlek - LångPress Hastighet Växling - Tryck ner för 2x hastighet - Redigera Profil Bild - Ange URL för Profil Bilden - Inget URL hittades - Ogiltig URL eller Bild - Bilden ändrades - Markera som \"sett fram\" till detta avsnitt - Ta bort \"sett fram\" till detta avsnitt - Omladdad - Ladda om leverantören - Namn - Källnamn - Upplösning och Namn - Ladda ned alla - Avbryt alla - Vill du ladda ned avsnitt %s? - Vill du avbryta alla köade hämtningar? - Justera undertexter - Nederst till vänster - Nederst i mitten - Nederst till höger - Mitten vänster - I mitten - Mitten till höger - Övre vänster - Övre mitten - Övre höger - - %d aktiv nedladdning - %d aktiva nedladdningar - - - %d nedladdning i kö - %d nedladdningar i kö - - %1$dh %2$dm %3$ds - %1$dm %2$ds - %1$ds - Nedladdningskö - Spela hela serierna - Det finns inga nedladdningar i kö just nu. - Extra ljusstyrka - Aktivera ljusstyrkefiltret när ljusstyrkan överstiger 100% - extra_brightness_enabled - Sökförslag - Visa sökförslag medan du skriver - Rensa förslag - Visa skådespelarpanelen - Installera förhandsversionen - Förhandsversionen är redan installerad. - Misslyckades med att installera förhandsversionen. - Spela mirror" - Graderingstitel - Avsnitt Text - Media Info - Fråga alltid - Sändningsdatum (nyast) - Sändningsdatum (äldst) - Källprioritet - Välj hur videokällor ska sorteras i spelaren - Inget konto - Dra uppåt igen för att gå över 100% - Startar plugin uppdateringsprocessen! - %d plugin(s) har uppdaterats! - Inga plugins blev uppdaterade. - Spelarnotiser - Spelarnotisen för att styra uppspelningen i bakgrunden - Inbäddad - Ansluten - Gör alla undertexter fetstilta - 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 4cde39d8f..e223f6c60 100644 --- a/app/src/main/res/values-b+ta/strings.xml +++ b/app/src/main/res/values-b+ta/strings.xml @@ -278,7 +278,7 @@ தற்குறிப்பு -30 ஒளிதோற்றம் - பிளேயர் தகவலைக் காட்டு + வீடியோ பிளேயர் தீர்மானம் வீடியோ இடையக அளவு நகலி தளம் அறிவிலிமையம் பதிலாள் @@ -286,7 +286,7 @@ களஞ்சியம் கிடைக்கவில்லை, முகவரி ஐ சரிபார்த்து VPN ஐ முயற்சிக்கவும் தொகுதி பதிவிறக்கம் சொருகு - எச்சரிக்கை: மூன்றாம் தரப்பு நீட்டிப்புகளைப் பயன்படுத்துவதற்கு CloudStream எந்தப் பொறுப்பையும் ஏற்காது, அவற்றிற்கு எந்த ஆதரவையும் வழங்காது! + எச்சரிக்கை: கிளவுட்ச்ட்ரீம் 3 மூன்றாம் தரப்பு நீட்டிப்புகளைப் பயன்படுத்துவதற்கான எந்தப் பொறுப்பையும் ஏற்காது, அவர்களுக்கு எந்த ஆதரவையும் வழங்காது! மொழி திரும்பவும் %s இலிருந்து குழுவிலகப்பட்டது @@ -358,7 +358,8 @@ முன்மாதிரி தளவமைப்பு பதிவிறக்கம் செய்யப்பட்ட கோப்பு பகுத்தல் - உங்கள் நூலகம் காலியாக உள்ளது :( \n நூலகக் கணக்கில் உள்நுழைக அல்லது உங்கள் உள்ளக நூலகத்தில் காட்சிகளைச் சேர்க்கவும். + உங்கள் நூலகம் காலியாக உள்ளது :( +\n நூலகக் கணக்கில் உள்நுழைக அல்லது உங்கள் உள்ளக நூலகத்தில் காட்சிகளைச் சேர்க்கவும். குழுவிலகவும் சுயவிவரங்கள் முள் 4 எழுத்துகளாக இருக்க வேண்டும் @@ -536,7 +537,7 @@ உள் வீரர் திறப்பு கலப்பு திறப்பு - வரவு + வரவு வரலாறு சரி கிளவுட்ச்ட்ரீமின் பயன்பாட்டுத் தகவலைத் திறக்க முடியவில்லை. @@ -548,13 +549,13 @@ வீடியோ நோக்குநிலையின் அடிப்படையில் திரை நோக்குநிலையின் தானியங்கி மாறுவதை இயக்கவும் ஆட்டோ சுழலும் பிடித்த - இசை + இசை ஓவா தரமான சிட்டை புதுப்பிப்பு விடுபதிகை விரலிடைத் தோல் - சில சாதனங்கள் புதிய தொகுப்பு நிறுவியை ஆதரிக்கவில்லை. புதுப்பிப்புகள் நிறுவப்படவில்லை என்றால், மரபு விருப்பத்தை முயற்சிக்கவும். + சில தொலைபேசிகள் புதிய தொகுப்பு நிறுவியை ஆதரிக்கவில்லை. புதுப்பிப்புகள் நிறுவப்படாவிட்டால் மரபு விருப்பத்தை முயற்சிக்கவும். சந்தா தொலைக்காட்சி நிகழ்ச்சிகளுக்கான தடையற்ற பதிவிறக்கங்கள் மற்றும் அறிவிப்புகளை உறுதிப்படுத்த, கிளவுட்ச்ட்ரீம் பின்னணியில் இயங்க இசைவு தேவை. சரி என்பதை அழுத்துவதன் மூலம், உங்களுக்கு கோரிக்கை உரையாடல் காண்பிக்கப்படும். தயவுசெய்து \'இசைவு\' என்பதை அழுத்தவும். \n\nதயவுசெய்து கவனிக்கவும், இந்த இசைவு CS3 உங்கள் பேட்டரியை வெளியேற்றும் என்று அர்த்தமல்ல. அறிவிப்புகளைப் பெறும்போது அல்லது உத்தியோகபூர்வ நீட்டிப்புகளிலிருந்து வீடியோக்களைப் பதிவிறக்குவது போன்ற பின்னணியில் மட்டுமே இது செயல்படும். பயன்பாட்டு பேட்டரி பயன்பாடு ஏற்கனவே கட்டுப்பாடற்றதாக அமைக்கப்பட்டுள்ளது பயன்பாட்டு புதுப்பிப்பை நிறுவுகிறது… @@ -565,7 +566,8 @@ மதிப்பீடு (குறைந்த முதல் உயர் வரை) புதுப்பிக்கப்பட்டது (பழையது புதியது) இந்த பட்டியல் காலியாக உள்ளது. இன்னொரு இடத்திற்கு மாற முயற்சிக்கவும். - பாதுகாப்பான பயன்முறை கோப்பு கிடைத்தது! \n கோப்பு அகற்றப்படும் வரை தொடக்கத்தில் எந்த நீட்டிப்புகளையும் ஏற்றவில்லை. + பாதுகாப்பான பயன்முறை கோப்பு கிடைத்தது! +\n கோப்பு அகற்றப்படும் வரை தொடக்கத்தில் எந்த நீட்டிப்புகளையும் ஏற்றவில்லை. சந்தா காட்சிகளைப் புதுப்பித்தல் சந்தா எபிசோட் %d வெளியானது! @@ -573,7 +575,7 @@ இயல்புநிலையை அமைக்கவும் ஆதாரங்கள் எவ்வாறு உத்தரவிடப்படுகின்றன என்பதை இங்கே மாற்றலாம். ஒரு வீடியோவுக்கு அதிக முன்னுரிமை இருந்தால், அது மூல தேர்வில் அதிகமாகத் தோன்றும். மூல முன்னுரிமையின் தொகை மற்றும் தரமான முன்னுரிமை ஆகியவை வீடியோ முன்னுரிமை. \n\n சான்று A: 3 \n தகுதி பி: 7 \n 10 இன் ஒருங்கிணைந்த வீடியோ முன்னுரிமை இருக்கும். \n\n குறிப்பு: தொகை 10 அல்லது அதற்கு மேற்பட்டதாக இருந்தால், அந்த இணைப்பு ஏற்றப்படும்போது பிளேயர் தானாகவே ஏற்றுவதைத் தவிர்க்கும்! உங்கள் கிளவுட்ச்ட்ரீம் தரவு இப்போது காப்புப் பிரதி எடுக்கப்பட்டுள்ளது. இதன் சாத்தியம் மிகக் குறைவு என்றாலும், எல்லா சாதனங்களும் வித்தியாசமாக நடந்து கொள்ளலாம். அரிய விசயத்தில், பயன்பாட்டை அணுகுவதிலிருந்து நீங்கள் பூட்டப்படுகிறீர்கள், பயன்பாட்டு தரவை முழுவதுமாக அழித்து, காப்புப்பிரதியிலிருந்து மீட்டெடுக்கவும். இதிலிருந்து எழும் ஏதேனும் சிரமத்திற்கு நாங்கள் மிகவும் வருந்துகிறோம். - ஊடகம் + ஊடகம் கணக்குகள் எச்சரிக்கை தற்போது பதிவிறக்கங்கள் எதுவும் இல்லை. @@ -629,8 +631,8 @@ பின்வரும் தொடரில் உள்ள அனைத்து அத்தியாயங்களையும் நீங்கள் நிரந்தரமாக நீக்குவீர்கள்:\n\n %s பேச்சு ஏற்பு கிடைக்கவில்லை பேசத் தொடங்குங்கள்… - ஆடியோ - போட்காச்ட் + ஆடியோ + போட்காச்ட் குறியீட்டு பிழை ஆதரிக்கப்படாத பிழை முதலில் கிடைக்கிறது @@ -646,7 +648,7 @@ அமைப்புகள்/வழங்குநர்கள்/விருப்பமான ஊடகங்களில் டொரெண்டை இயக்கவும் பயன்பாட்டை மறுதொடக்கம் செய்து, தொடர ச்ட்ரீம் டொரண்ட் பாப்-அப் ஏற்றுக்கொள்ளுங்கள். மென்பொருள் டிகோடிங் - மென்பொருள் டிகோடிங் உங்கள் சாதனத்தால் ஆதரிக்கப்படாத வீடியோ கோப்புகளை பிளேயருக்கு இயக்க உதவுகிறது, ஆனால் உயர் தெளிவுத்திறனில் பின்னடைவு அல்லது நிலையற்ற பிளேபேக்கை ஏற்படுத்தலாம். + மென்பொருள் டிகோடிங் உங்கள் தொலைபேசியால் ஆதரிக்கப்படாத வீடியோ கோப்புகளை இயக்க பிளேயருக்கு உதவுகிறது, ஆனால் உயர் தெளிவுத்திறனில் பின்னடைவு அல்லது நிலையற்ற பின்னணியை ஏற்படுத்தக்கூடும் தொகுதி 100% ஐ தாண்டியுள்ளது 100% க்கு அப்பால் செல்ல மீண்டும் சறுக்கவும் செருகுநிரல்களைப் புதுப்பிக்கவும் @@ -674,61 +676,4 @@ %1$d மணி %2$d நிமிடம் %3$d விநாடி %1$d நிமிடம் %2$d விநாடி %1$d விநாடி - பதிவிறக்க வரிசை - முழு தொடரையும் விளையாடு - தற்போது வரிசைப்படுத்தப்பட்ட பதிவிறக்கங்கள் எதுவும் இல்லை. - கூடுதல் ஒளி - 100% காட்சி பிரகாசத்தை மீறும் போது பிரகாச வடிப்பானை இயக்கவும் - கூடுதல்_பிரகாசம்_செயல்படுத்தப்பட்டது - தேடல் பரிந்துரைகள் - தட்டச்சு செய்யும் போது தேடல் பரிந்துரைகளைக் காட்டு - தெளிவான பரிந்துரைகள் - காச்ட் பேனலைக் காட்டு - வெளியீட்டிற்கு முந்தைய பதிப்பை நிறுவவும் - முன் வெளியீடு ஏற்கனவே நிறுவப்பட்டுள்ளது. - முன் வெளியீட்டை நிறுவுவதில் தோல்வி. - கண்ணாடியை விளையாடு" - மதிப்பீடு சிட்டை - எபிசோட் உரை - மீடியா செய்தி - எப்போதும் கேளுங்கள் - மூல முன்னுரிமை - பிளேயரில் வீடியோ ஆதாரங்கள் எவ்வாறு வரிசைப்படுத்தப்பட வேண்டும் என்பதைத் தீர்மானிக்கவும் - கணக்கு இல்லை - LongPress விரைவு மாற்று - 2x வேகத்தைப் பெற அழுத்திப் பிடிக்கவும் - சுயவிவரப் படத்தைத் திருத்து - சுயவிவரப் பட முகவரி ஐ உள்ளிடவும் - முகவரி இல்லை - தவறான முகவரி அல்லது படம் - படம் வெற்றிகரமாக புதுப்பிக்கப்பட்டது - இந்த எபிசோட் வரை பார்த்ததாகக் குறிக்கவும் - இந்த எபிசோட் வரை பார்த்ததை அகற்று - மீண்டும் ஏற்றப்பட்டது - மறுஏற்றம் வழங்குநர் - பெயர் - மூலப் பெயர் - தீர்மானம் மற்றும் பெயர் - அனைத்தையும் பதிவிறக்கவும் - அனைத்தையும் ரத்து வெற்றி - அத்தியாயம் %s ஐ பதிவிறக்க விரும்புகிறீர்களா? - வரிசைப்படுத்தப்பட்ட அனைத்து பதிவிறக்கங்களையும் ரத்துசெய்ய விரும்புகிறீர்களா? - வசன சீரமைப்பு - கீழே இடது - கீழ் நடுவண் - கீழ் வலது - நடுத்தர இடது - நடுத்தர நடுவண் - நடுத்தர வலது - மேல் இடது - மேல் நடுவண் - மேல் வலது - - %d செயலில் பதிவிறக்கம் - %d செயலில் உள்ள பதிவிறக்கங்கள் - - - %d பதிவிறக்கம் வரிசையில் உள்ளது - %d பதிவிறக்கங்கள் வரிசையில் உள்ளன - diff --git a/app/src/main/res/values-b+tl/strings.xml b/app/src/main/res/values-b+tl/strings.xml index 4ed229ca7..4050ddbd7 100644 --- a/app/src/main/res/values-b+tl/strings.xml +++ b/app/src/main/res/values-b+tl/strings.xml @@ -15,7 +15,8 @@ Bilis (%.2fx) Rated: %.1f - Bagong update! \n%1$s -> %2$s + Bagong update! +\n%1$s -> %2$s CloudStream Home Maghanap @@ -137,7 +138,8 @@ 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/array.xml b/app/src/main/res/values-b+tr/array.xml index 5a7901ea9..725836123 100644 --- a/app/src/main/res/values-b+tr/array.xml +++ b/app/src/main/res/values-b+tr/array.xml @@ -37,19 +37,24 @@ 8 - - @string/source_name + + @string/resolution_and_name + @string/name + @string/resolution_and_title + @string/title @string/resolution - @string/video_info + @string/none - - @string/show_name_key - @string/show_resolution_key - @string/show_media_info_key + + 5 + 4 + 3 + 2 + 1 + 0 - @string/none 16 karakter diff --git a/app/src/main/res/values-b+tr/strings.xml b/app/src/main/res/values-b+tr/strings.xml index d711505a8..b3fc671b9 100644 --- a/app/src/main/res/values-b+tr/strings.xml +++ b/app/src/main/res/values-b+tr/strings.xml @@ -2,24 +2,25 @@ %1$s B. %2$d - Oyuncular: %s - %d. Bölüm şu tarihte yayınlanacak - %1$dg %2$dsa %3$ddk - %1$dsa %2$ddk - %ddk + Cast: %s + Bölüm %d şu tarihte yayınlanacak + %1$dg %2$ds %3$dd + %1$ds %2$dd + %dd Afiş Afiş Bölüm Afişi Ana Afiş Sonraki Rastgele - Geri dön + Geri git Sağlayıcıyı Değiştir Arkaplanı Önizle 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 @@ -186,7 +187,8 @@ Sürdür -30 +30 - %s tamamen silinecek \nEmin misiniz? + %s tamamen silinecek +\nEmin misiniz? %dd \nkaldı Devam ediyor Tamamlandı @@ -255,7 +257,7 @@ Güncelle Tercih edilen görüntü kalitesi (WiFi) Video oynatıcı başlığı karakter üst sınırı - Oynatıcı bilgisini göster + Oynatıcı çözünürlüğü Video arabellek boyutu Video arabellek uzunluğu Diskteki video önbelleği @@ -410,11 +412,13 @@ Devre dışı: %d İndirilmeyen: %d %d eklentiler güncellendi - CloudStream, varsayılan olarak hiçbir site ile gelmez. Siteleri depolardan kurmanız gerekir. \n \nDiscord\'umuza katılın veya internet üzerinden arama yapın. + CloudStream, varsayılan olarak hiçbir site ile gelmez. Siteleri depolardan kurmanız gerekir. +\n +\nDiscord\'umuza katılın veya çevrimiçi arama yapın. Topluluk depolarını görüntüle Herkese açık liste Tüm altyazılar büyük harf - Uyarı: CloudStream 3.taraf uzantıların kullanımı için herhangi bir sorumluluk kabul etmez ve bunlar için herhangi bir destek sağlamaz! + Uyarı: CloudStream 3, üçüncü taraf uzantıların kullanımı için herhangi bir sorumluluk kabul etmez ve bunlar için herhangi bir destek sağlamaz! %s (Devre dışı) Parçalar Ses parçaları @@ -467,7 +471,7 @@ Uygulama güncellemesi indiriliyor… Uygulamanın yeni sürümü kurulamadı Geçmişi temizle - Paket Kurucu + PaketKurucu Eski Hareketler Tüm Diller @@ -475,7 +479,7 @@ İzlenenlerden kaldır Karışık son Karışık başlangıç - Katkıda Bulunanlar + Katkıda Bulunanlar Giriş Eklenti İndirildi Eylemler @@ -483,8 +487,10 @@ Ç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) @@ -527,7 +533,13 @@ 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 @@ -536,7 +548,11 @@ 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 @@ -556,10 +572,12 @@ Favorilerden çıkar Hesap Seç Devre dışı bırak - Depo bulunamadı, bağlantı adresini kontrol edin veya VPN ile deneyin + Depo bulunamadı, bağlantı adresini kontrol edin ve VPN 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 @@ -591,14 +609,14 @@ 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 %s içinde yaklaşıyor - %1$d. Sezon %2$d. Bölüm şu tarihte yayınlanacak + Sezon %1$d Bölüm %2$d tarihinde yayınlanacak Yansıtılacak cihaz seç Ekran yansıtma CloudStream bilgi @@ -621,12 +639,20 @@ 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 @@ -644,8 +670,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ü @@ -698,7 +724,7 @@ URL bulunamadı Geçersiz Bağlantı veya Görsel Görsel Başarıyla Güncellendi - Şu kaynaktan oynat" + Ekranda oynat" Bu bölüme kadar izlenmiş olarak işaretle Bu bölümü izlenmemiş olarak işaretle Yeniden yükle @@ -720,33 +746,4 @@ Ön sürüm zaten indirilmiş. Ön sürüm indirmesi başarısız oldu. Bölüm Başlığı - Arama Önerileri - Yazarken arama önerilerini göster - Önerileri Temizle - Yayın panelini göster - Ekstra parlaklık - Görüntü parlaklığı %100\'ü geçerse parlaklık filtresini aktifleştir - Ekstra parlaklık aktifleştirildi - Medya bilgisi - Kaynağın adı - İndirme kuyruğu - Şu anda bekleyen indirme yok. - Tümünü indir - Tümünü iptal et - %s numaralı bölümü indirmek istiyor musunuz? - Kuyruktaki tüm indirmeleri iptal etmek istiyor musunuz? - - %d aktif indirme - %d aktif indirmeler - - - %d indirme kuyrukta - %d indirilecekler kuyruğa alındı - - Ö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 ba46736ac..5ece00de5 100644 --- a/app/src/main/res/values-b+uk/strings.xml +++ b/app/src/main/res/values-b+uk/strings.xml @@ -1,20 +1,20 @@ Плакат - Постер епізоду + Плакат епізоду Завантаження скасовано Змінити постачальника Назад Рейтинг: %.1f - У ролях: %s - %d епізод вийде через + Актори: %s + Епізод %d вийде через Плакат - %1$s Еп %2$d - %1$d дн %2$d год %3$d хв - %1$d год %2$d хв - %d хв - Головний постер - Наступне випадкове + %1$s Еп. %2$d + %1$dд %2$dгод %3$dхв + %1$dгод %2$dхв + %dхв + Головний плакат + Наступний випадковий Попередній перегляд тла Швидкість (%.2fx) Знайдено нове оновлення!\n%1$s –> %2$s @@ -34,12 +34,12 @@ Завершено Заплановано Покинуто - Відтворити фільм - Відтворити трейлер - Транслювати торрент + Переглянути фільм + Переглянути трейлер + Переглянути торент Повторити з’єднання… Назад - Відтворити епізод + Переглянути епізод Завантажено Завантаження Завантаження завершено @@ -57,9 +57,9 @@ Скопіювати Закрити Зберегти - Швидкість програвача + Швидкість плеєра Колір вікна - Тип межі + Тип обведення Шрифт Розмір шрифту Пошук за постачальниками @@ -68,7 +68,7 @@ Автовибір мови Завантажити мови Мова субтитрів - Утримуйте, щоби скинути до типових + Утримуйте, щоби скинути до типових налаштувань Імпортуйте шрифти, помістивши їх до %s Продовжити перегляд Вилучити @@ -78,7 +78,7 @@ Сюжет не знайдено Опис не знайдено Показати Logcat 🐈 - Продовжувати відтворення в мініатюрному програвачі поверх інших застосунків + Продовжувати відтворення в малому програвачі поверх інших застосунків Прибрати чорні смуги Субтитри Субтитри Chromecast @@ -89,15 +89,15 @@ Відтворювати наступний епізод після закінчення поточного Головна CloudStream - Філлер + Заповнювач Відтворити в CloudStream Мережева трансляція Переглядаю Поділитися Відкладено - Передивляюся + Повторно переглядаю Завантажити - Відтворити трансляцію + Переглянути трансляцію Джерела Субтитри Внутрішнє сховище @@ -109,7 +109,7 @@ Призупинити завантаження Переглянути файл Докладніше - Фільтрувати закладки + Фільтр закладок Очистити Налаштування субтитрів Колір тла @@ -118,7 +118,7 @@ Колір обведення Автовідтворення наступного епізоду Проведіть збоку в бік, щоби керувати часом відтворення у відео - Дано %d бананів розробникам + %d бананів надано розробникам Кнопка зміни розміру програвача @string/home_play Для коректної роботи цього постачальника може знадобитися VPN @@ -128,7 +128,7 @@ Провести, щоби перемотати Натиснути двічі, щоби перемотати Натиснути двічі, щоби призупинити - Крок перемотування (Секунди) + Крок перемотування (у секундах) Натисніть двічі посередині, щоби призупинити відтворення Використовувати системну яскравість Оновлювати прогрес перегляду @@ -143,24 +143,24 @@ Подробиці Розширений пошук Показувати результати пошуку, розділені за постачальниками - Показувати філлери для аніме + Показувати наповнювачі для аніме Показувати трейлери Приховати вибрану якість відео у результатах пошуку Автозавантаження розширень Показувати оновлення застосунку - Налаштувати повторно процес встановлення - Встановлювач APK + Налаштувати повторно + Установлювач APK Github Застосунок для ранобе від тих самих розробників Застосунок для аніме від тих самих розробників - Дати бананів розробникам + Дати банан розробникам Мова застосунку Цей постачальник не має підтримування Chromecast Посилань не знайдено Переглянути епізод Скинути до типових значень - Немає сезону - Епізоди + Немає сезона + Епізодів %1$d %2$s С Е @@ -170,8 +170,8 @@ Відновити -30 Це назавжди видалить %s\nВи впевнені? - %d хв\nзалишилося - Виходить + %dхв\nзалишилося + Триває Завершено Рейтинг Тривалість @@ -184,14 +184,14 @@ Телесеріали Мультфільми Аніме - ОВА + OVA Азіатські драми Прямі трансляції Інші Серіал Мультфільм Аніме - Документалка + Документальний фільм Азіатська драма Пряма трансляція Відео @@ -208,7 +208,7 @@ Пропустити ОП Не показувати знову Оновити - Бажана якість перегляду (Wi-Fi) + Бажана якість перегляду (WiFi) Заголовок Перемкнути елементи інтерфейсу на плакаті Оновлення не знайдено @@ -220,12 +220,12 @@ Немає дозволу на зберігання. Спробуйте ще раз. Показувати плакати від Kitsu Автооновлення розширень - Автоматично встановлювати всі розширення, які ще не встановлено, з доданих сховищ. + Автоматично встановлювати всі розширення, які ще не встановлено, з доданих репозиторіїв. Автоматично перевіряти нові оновлення після запуску застосунку. - Посилання скопійовано до буфера обміну - Деякі пристрої не підтримують новий встановлювач пакетів. Спробуйте старий варіант, якщо оновлення не встановлюються. + Покликання скопійовано до буфера обміну + Деякі пристрої не підтримують новий інсталятор пакетів. Спробуйте старий варіант, якщо оновлення не встановлюються. Приєднуйтеся до Discord - Дано банани + Дано бананів Рік +30 %1$s %2$d%3$s @@ -239,17 +239,17 @@ Змінити розмір Стислий зміст Фільми - Перезавантажити посилання - Документалки + Перезавантажити покликання + Документальні фільми NSFW Фільм - ОВА + OVA Торент Мітка якості NSFW Несподівана помилка програвача Помилка завантаження, перевірте дозвіл на зберігання - Епізод через Chromecast + Дивитися через Chromecast Мітка субтитрів Джерело Завантажити субтитри @@ -257,17 +257,17 @@ Пропустити це оновлення Усе На весь екран - Розтягнути + Заповнити Збільшити Доріжки Оновлення застосунку Кеш Жести - Функції програвача + Особливості програвача Субтитри Типово Вигляд - Функції + Особливості Загальні Випадкова кнопка Показувати кнопку випадкового вибору на головній сторінці та бібліотеці @@ -276,13 +276,13 @@ Бажані медіа Автоматично Макет телевізора - Макет телефона + Макет телефону Макет емулятора Основний колір Тема застосунку Розташування назви плаката Розмістити назву під плакатом - password123 + Пароль123 Імʼя користувача hello@world.com НоваНазваСайту @@ -321,7 +321,7 @@ Web Зображення плаката Програвач - Роздільність та заголовок + Роздільна здатність та заголовок Недійсний ID Недійсна URL-адреса Резервне копіювання @@ -332,7 +332,7 @@ Видалити сайт URL-адреса сервера NGINX Покликання - Показувати інформацію про програвач + Роздільність відеопрогравача Довжина буфера відео Очистити кеш відео та зображень Спричиняє збої, якщо встановлено занадто високе значення на пристроях із малим об’ємом пам’яті, таких як Android TV. @@ -341,21 +341,21 @@ DNS через HTTPS Шлях завантаження Додайте двійника наявного сайту, з іншою URL-адресою - Показувати аніме з дубляжем / із субтитрами + Показувати мітку Дубляж/Субтитри для аніме Застереження Розширення Дії 127.0.0.1 Макет Кодування субтитрів - Увімкнути NSFW у підтримуваних розширеннях + Увімкнути NSFW вміст на підтримуваних розширеннях Макет Постачальники https://example.com - %1$s %2$s + %2$s %1$s Опущені обліковий запис - Створити обліковий запис + Створити Додано %s /?? Рейтинг @@ -375,7 +375,7 @@ Фільтрувати за бажаною мовою медіа 4K Заголовок - Роздільність + Роздільна здатність Помилка Трейлер Додатково @@ -388,7 +388,7 @@ TS TC Вилучати роздуття субтитрів - Джерело переходу (необов’язково) + Referer (необов’язково) Далі Переглядайте відео на цих мовах Пропустити налаштування @@ -404,17 +404,17 @@ Почалося завантаження %1$d %2$s… Завантажено %1$d %2$s Усі %s вже завантажено - Завантажити пакунком + Завантажити пакунки розширення - розширення - Видалити сховище + розширень + Видалити репозиторій Завантажте список сайтів, які ви хочете використовувати Завантажено: %d Вимкнено: %d Не завантажено: %d Оновлено %d розширень - Типово у CloudStream немає жодного встановленого сайту. Вам потрібно встановити сайти зі сховищ.\n\nПриєднуйтеся до нашого Discord або шукайте в інтернеті. - Переглянути сховища спільноти + Типово у CloudStream немає жодного встановленого сайту. Вам потрібно встановити сайти з репозиторіїв.\n\nПриєднуйтеся до нашого Discord або шукайте в інтернеті. + Переглянути репозиторії спільноти Публічний список Усі субтитри великими літерами %s (вимкнено) @@ -432,10 +432,10 @@ Список відтворення HLS Вбудований програвач Ендінґ - Підсумок + Коротке повторення Пропустити %s Змішаний ендінґ - Подяки + Подяки Опенінґ Вступ Очистити історію @@ -449,9 +449,9 @@ Установлення оновлення застосунку… Не вдалося встановити нову версію застосунку Застарілий - Встановлювач пакунків + Установлювач пакунків Застосунок буде оновлено після виходу - Це також призведе до видалення всіх розширень сховища + Це також призведе до видалення всіх розширень репозиторію Усі мови Назад Змініть вигляд застосунку відповідно до вашого пристрою @@ -478,19 +478,19 @@ Ваша бібліотека порожня :(\nУвійдіть в обліковий запис бібліотеки або додайте щось до вашої локальної бібліотеки. Алфавітом (від Я до А) Оберіть бібліотеку - Відкрити з + Відкрити Браузер Цей список порожній. Спробуйте перейти до іншого. Файл безпечного режиму знайдено!\nРозширення не завантажуватимуться під час запуску, доки файл не буде видалено. Android TV - Прогрвач приховано: крок перемотування - Програвач показано: крок перемотування + Прогрвач приховано – крок перемотування + Програвач показано – крок перемотування Крок перемотування, який використовується, коли програвач видимий - Крок перемотування, який використовується, коли програвач прихований + Крок перемотування, який використовується, коли плеєр прихований Провалено Пройдено Перезапустити - Лог + Журнал Відновити Зупинити Перевірка постачальників @@ -500,15 +500,15 @@ Ви відписалися від %s Епізод %d випущено! Повернути - Проксі GitHub + GitHub проксі Не вдалось отримати доступ до GitHub. Увімкнення проксі-сервера jsDelivr… Обходи ISP - Обхід блокування прямих посилань GitHub за допомогою jsDelivr. Може спричинити затримку оновлень на кілька днів. + Обхід блокування GitHub за допомогою jsDelivr. Можлива затримка оновлень на кілька днів. Бажана якість перегляду (мобільні дані) - Встановити типові + Змінити на типові Профілі Довідка - Тут можна змінити порядок джерел. Відео з вищим пріоритетом з’являтиметься вище в списку джерел. Сума пріоритету джерела та пріоритету якості утворює пріоритет відео.\n\nДжерело А: 3\nЯкість Б: 7\nЗагальний пріоритет відео дорівнюватиме 10.\n\nПРИМІТКА: Якщо сума пріоритетів дорівнюватиме 10 або більше, програвач автоматично пропустить завантаження цього посилання! + Тут можна змінити порядок джерел. Відео з вищим пріоритетом з’являтиметься вище в списку джерел. Сума пріоритету джерела та пріоритету якості утворює пріоритет відео.\n\nДжерело А: 3\nЯкість Б: 7\nЗагальний пріоритет відео дорівнюватиме 10.\n\nПРИМІТКА: Якщо сума пріоритетів дорівнюватиме 10 або більше, програвач автоматично пропустить завантаження цього покликання! Профіль %d Wi-Fi Мобільні дані @@ -523,9 +523,9 @@ Не знайдено жодного розширення в репозиторії Ви вже проголосували Частота резервного копіювання - %s вилучено з вподобаних - Вподобані - %s додано до вподобаних + %s вилучено з обраного + Обране + %s додано до обраного У вашій бібліотеці виявлено можливі дублікати:\n\n%s\n\nУсе одно хочете додати цей елемент, замінити наявні чи скасувати дію? Знайдено можливий дублікат Заблокувати профіль @@ -539,7 +539,7 @@ Підписатися Вилучити з обраного Оберіть обліковий запис - Схоже, що у вашій бібліотеці вже є можливий дублікат: \'%s.\'\n\nУсе одно хочете додати цей елемент, замінити наявний чи скасувати дію? + Схоже, що у вашій бібліотеці вже є можливий дублікат: «%s.»\n\nУсе одно хочете додати цей елемент, замінити наявний чи скасувати дію? Уведіть PIN-код PIN-код Уведіть поточний PIN-код @@ -551,7 +551,7 @@ Редагувати обліковий запис Показувати кнопку перемикання орієнтації екрана Обернути - Посилання перезавантажені + Покликання перезавантажено Автообертання Увімкнути автоматичну зміну орієнтації екрана відповідно до відео Додати налаштування швидкості до програвача @@ -560,7 +560,7 @@ Показати рекомендації Ця перевірка лише для розробників і не підтверджує або заперечує роботу жодного розширення. Сповіщення про новий епізод - Автентифікація за паролем / PIN-кодом + Автентифікація за паролем/PIN-кодом Розблокуйте CloudStream Біометричне блокування Розблоковуйте застосунок за допомогою відбитка пальця, Face ID, PIN-коду, графічного ключа або пароля. @@ -568,8 +568,8 @@ Біометрична автентифікація не підтримується на цьому пристрої Після кількох невдалих спроб вікно запиту зникне. Перезапустіть застосунок, щоби спробувати ще раз. %s\nзалишилося - Вилучити з вподобаного - Додати до вподобаного + Вилучити з обраного + Додати до обраного скопійовано! Назва репозиторію та URL Помилка копіювання, скопіюйте logcat та зверніться до служби підтримки застосунку. @@ -580,22 +580,22 @@ Споживання батареї застосунком уже змінено на необмежене Не вдається відкрити подробиці про застосунок CloudStream. Аудіокнига - Музика - Медіа + Музика + Медіа Скинути Наступний через %s - %1$d сезон %2$d епізод вийде через + Сезон %1$d епізод %2$d вийде через Оберіть пристрій для трансляції Трансляція через дзеркало - CloudStream Wiki + Довідник CloudStream Безпека Облікові записи Зображення QR-коду - Відкрити сховище - Відвідайте %s на своєму смартфоні або комп’ютері та введіть вищевказаний код + Відкрити репозиторій + Відвідайте %s на своєму смартфоні або комп\'ютері та введіть вищевказаний код Не вдається отримати PIN-код пристрою, спробуйте локальну автентифікацію - Термін дії PIN-коду закінчився! - Термін дії коду закінчується через %1$d хв %2$d с + PIN-код застарів! + Термін дії коду закінчується через %1$dхв %2$dс Локальна автентифікація Відхилити Відтворити з початку @@ -608,11 +608,11 @@ Датою випуску (від старих до нових) Оберіть елементи для видалення Обрати все - Зняти виділення + Зняти вибір Видалити (%1$d | %2$s) - Ви впевнені, що хочете назавжди видалити такі епізоди в %1$s?\n\n%2$s + Ви впевнені, що хочете назавжди видалити такі епізоди «%1$s»?\n\n%2$s Ви також назавжди видалите всі епізоди в такому серіалі:\n\n%s - Доступно для перегляду поза мережею + Доступно для перегляду в автономному режимі Видалити файли Ви впевнені, що хочете назавжди видалити такі елементи?\n\n%s Ви впевнені, що хочете назавжди видалити всі епізоди в такому серіалі?\n\n%s @@ -620,20 +620,20 @@ Увімкнути мініатюру попереднього перегляду на шкалі перегляду Субтитри ще не завантажено Підтвердіть перед виходом - Показати + Показувати Показувати діалог перед виходом із застосунку Не показувати Розташування теки для резервних копій Власний - Це відео – торрент, це означає, що ваша відеоактивність може відстежуватися.\nПереконайтеся, що розумієте, що таке торрент, перед тим як продовжити. + Це відео – торент, це означає, що ваша діяльність у відео може відстежуватися.\nПереконайтеся, що розумієте, що таке торент, перш ніж продовжити. Розмір обведення - Аудіо - Подкаст + Аудіо + Подкаст Непідтримувана помилка Помилка кодування - Завантажити перший доступний - Увімкніть торент в Налаштування - > Постачальники - > Бажані медіа - Перезапустіть застосунок та прийміть спливне вікно «Транслювати торрент», щоби продовжити. + Завантажити перші доступні + Увімкніть торент у Налаштування/Постачальники/Бажані медіа + Перезапустіть застосунок та прийміть спливне вікно Stream Torrent, щоби продовжити. Програмне декодування Програмне декодування дозволяє плеєру відтворювати відеофайли, які не підтримуються вашим пристроєм, але може спричинити затримки або нестабільне відтворення у високій роздільній здатності. Датою виходу (найновіша) @@ -653,11 +653,11 @@ Сповіщення програвача для керування відтворенням у фоновому режимі Сповіщення програвача Розпізнавання мовлення недоступне - Почніть говорити… + Говоріть… Вбудовані Мережеві Радіус тла - Зробити всі субтитри потовщеним + Зробити всі субтитри жирним Зробити всі субтитри курсивом Гучність перевищила 100% Ще раз проведіть угору, щоби перевищити 100% @@ -665,18 +665,18 @@ Змінює межі екрана Обрізання зображення Перейти до завантажень - Немає з’єднання з мережею. \n\nПерепід’єднайтеся до мережі та спробуйте ще раз або перегляньте завантажені відео локально. + Немає підключення до Інтернету.\n\nБудь ласка, підключіться до Інтернету та спробуйте ще раз або перегляньте завантажені відео офлайн. Скільки різних елементів можна завантажити паралельно Паралельних завантажень Скільки одночасних з’єднань може використовувати кожне завантаження Зміна розміру плакатів Розмір постера - Завжди запитувати + Завжди запитуйте Змінювати швидкість при утриманні Утримуйте, щоб отримати 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с Мітка рейтингу Немає облікового запису Редагувати зображення профілю @@ -687,56 +687,23 @@ Позначити як переглянуте до цього епізоду Вилучити переглянуті до цього епізоду Перезавантажено - Перезавантажити постачальника - Переглянути в дзеркалі" - Назва - Роздільність та назва + Постачальник послуг поповнення рахунку + Грати в дзеркало" + Ім\'я + Роздільна здатність та назва Вирівнювання субтитрів Внизу ліворуч Внизу по центру Внизу праворуч - Посередині зліва - Посередині в центрі - Посередині справа + Середній лівий + Середній центр + Середній правий Угорі ліворуч - Угорі в центрі + Верхній центр Угорі праворуч - Відтворити весь серіал + Відтворити повну серію Встановити передрелізну версію - Передрелізна версія вже встановлена. - Не вдалося встановити передрелізну версію. + Попередня версія вже встановлена. + Не вдалося встановити попередню версію. Текст епізоду - Пропозиції пошуку - Показувати підказки пошуку під час введення тексту - Очистити пропозиції - Додаткова яскравість - Увімкнути фільтр яскравості при перевищенні 100% яскравості дисплея - extra_brightness_enabled - Показати панель трансляції - Інформація про медіа - Назва джерела - Черга завантаження - Наразі немає завантажень у черзі. - Завантажити все - Скасувати все - Бажаєте завантажити %s епізод? - Бажаєте скасувати всі завантаження в черзі? - - %d активне завантаження - %d активні завантаження - %d активних завантажень - %d активних завантажень - - - %d завантаження в черзі - %d завантаження в черзі - %d завантажень у черзі - %d завантажень у черзі - - Пріоритетне джерело - Виберіть спосіб сортування джерел відео у програвачі - Показувати накладання метаданих програвача - Відео - Передперегляд - Наживо diff --git a/app/src/main/res/values-b+ur/strings.xml b/app/src/main/res/values-b+ur/strings.xml index 94728e2f8..5f6d8aa14 100644 --- a/app/src/main/res/values-b+ur/strings.xml +++ b/app/src/main/res/values-b+ur/strings.xml @@ -10,7 +10,8 @@ ذریعہ تبدیل کریں پس منظر کا دیکھنا درجہ بندی: %.1f - نیا update آگیا ہے! \n%1$s -> %2$s + نیا update آگیا ہے! +\n%1$s -> %2$s بھرنے والا %d منٹ %1$d دن %2$d گھنٹے %3$d منٹ @@ -188,8 +189,10 @@ از سر نو شروع کریں -30 +30 - یہ مستقل طور پر حذف ہوجائے گا %s \nتمھيں يقين ہے? - %dm \nباقی + یہ مستقل طور پر حذف ہوجائے گا %s +\nتمھيں يقين ہے? + %dm +\nباقی احوال مکمل حالت @@ -343,7 +346,7 @@ %d / 10 اٹھایا اگر سب ٹائٹلز %d ms بہت جلد دکھائے جائیں تو اسے استعمال کریں - کریڈٹس + کریڈٹس اضافی مرکزی ترتیب @@ -438,7 +441,8 @@ پلیئردکھایا گیا - Seek Amount پلیئر کے نظر آنے پر استعمال کی جانے والی Seek Amount پلیئر کے چھپنے پر استعمال ہونے والی seek amount - سیف موڈ فائل مل گئی! \nفائل کو ہٹانے تک اسٹارٹ اپ پر کوئی ایکسٹینشن لوڈ نہیں کرنا۔ + سیف موڈ فائل مل گئی! +\nفائل کو ہٹانے تک اسٹارٹ اپ پر کوئی ایکسٹینشن لوڈ نہیں کرنا۔ شروع کریں ناکام کامیاب ہو گیا @@ -450,7 +454,8 @@ آئی ایس پی بائی پاسز %s کو سبسکرائب کیا Bypass blocking of raw github URLs using jsDelivr. اپ ڈیٹس میں کچھ دنوں کی تاخیر ہو سکتی ہے - آپ کی لائبریری خالی ہے:( \nلائبریری اکاؤنٹ میں لاگ ان کریں یا اپنی مقامی لائبریری میں شوز شامل کریں۔ + آپ کی لائبریری خالی ہے:( +\nلائبریری اکاؤنٹ میں لاگ ان کریں یا اپنی مقامی لائبریری میں شوز شامل کریں۔ غلط URL براؤزر ویب @@ -499,7 +504,9 @@ سب ٹائٹلز سے بند کیپشنز کو ہٹا دیں اپنے آلے کے مطابق ایپ کی شکل تبدیل کریں اگلے - CloudStream میں بذریعہ ڈیفالٹ کوئی سائٹ انسٹال نہیں ہے۔ آپ کو ریپوزٹری سے سائٹس انسٹال کرنے کی ضرورت ہے۔ \n \nہمارے Discord میں شامل ہوں یا آن لائن تلاش کریں۔ + CloudStream میں بذریعہ ڈیفالٹ کوئی سائٹ انسٹال نہیں ہے۔ آپ کو ریپوزٹری سے سائٹس انسٹال کرنے کی ضرورت ہے۔ +\n +\nہمارے Discord میں شامل ہوں یا آن لائن تلاش کریں۔ تمام ایکسٹینشنز کریش کی وجہ سے آف کر دی گئیں تاکہ آپ کو پریشانی کا باعث تلاش کرنے میں مدد مل سکے۔ پہلے ایکسٹینشن انسٹال کریں بہت زیادہ متن۔ کلپ بورڈ میں محفوظ کرنے سے قاصر۔ @@ -521,7 +528,13 @@ آپ نے پہلے ہی ووٹ دیا ہے مخزن میں کوئی پلگ انز نہیں ملا ترجیحی تعین کریں - یہاں آپ تبدیلی کرسکتے ہیں کہ سورسز کو کس طرح کی ترتیب دی جائے۔ اگر ایک ویڈیو کی زیادہ پرائیورٹی ہوتی ہے تو یہ سورس کی انتخاب میں زیادہ اوپر آئے گی۔ سورس کی پرائیورٹی اور کوالٹی کی پرائیورٹی کا مجموعہ ویڈیو کی پرائیورٹی ہوتی ہے۔ \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 کو پسندیدہ سے ختم کیا گیا @@ -548,8 +561,14 @@ جمع کریں سب کو بدل دیں بدل دیں - آپ کی لائبریری میں ممکنہ ڈپلیکیٹ آئٹمز مل گئے ہیں: \n \n%s \n \nکیا آپ بہرحال اس آئٹم کو شامل کرنا چاہیں گے، موجودہ کو تبدیل کرنا چاہیں گے، یا کارروائی کو منسوخ کریں گے؟ - ایسا معلوم ہوتا ہے کہ آپ کی لائبریری میں ممکنہ طور پر ڈپلیکیٹ آئٹم پہلے سے موجود ہے: \'%s۔\' \n \nکیا آپ بہرحال اس آئٹم کو شامل کرنا چاہیں گے، موجودہ کو تبدیل کرنا چاہیں گے، یا کارروائی کو منسوخ کریں گے؟ + آپ کی لائبریری میں ممکنہ ڈپلیکیٹ آئٹمز مل گئے ہیں: +\n +\n%s +\n +\nکیا آپ بہرحال اس آئٹم کو شامل کرنا چاہیں گے، موجودہ کو تبدیل کرنا چاہیں گے، یا کارروائی کو منسوخ کریں گے؟ + ایسا معلوم ہوتا ہے کہ آپ کی لائبریری میں ممکنہ طور پر ڈپلیکیٹ آئٹم پہلے سے موجود ہے: \'%s۔\' +\n +\nکیا آپ بہرحال اس آئٹم کو شامل کرنا چاہیں گے، موجودہ کو تبدیل کرنا چاہیں گے، یا کارروائی کو منسوخ کریں گے؟ پروفائل لاک کریں آغاز پر اکاؤنٹ کا انتخاب چھوڑ دیں ویڈیو واقفیت کی بنیاد پر اسکرین کی سمت بندی کی خودکار سوئچنگ کو فعال کریں @@ -557,7 +576,8 @@ کاپی کر لیا! ذخیرہ کا نام اور URL %s میں آنے والا ہے - %s \nباقی + %s +\nباقی کلپ بورڈ تک رسائی میں خرابی، براہ کرم دوبارہ کوشش کریں۔ کاپی کرنے میں خرابی، براہ کرم logcat کاپی کریں اور ایپ سپورٹ سے رابطہ کریں۔ سبسکرائب شدہ ٹی وی شوز کے لیے بلاتعطل ڈاؤن لوڈز اور اطلاعات کو یقینی بنانے کے لیے، CloudStream کو پس منظر میں چلنے کی اجازت درکار ہے۔ OK دبانے سے، آپ کو App info پر بھیج دیا جائے گا۔ وہاں اسکرول کریں 𝘼𝙥𝙥 𝙗𝙖𝙩𝙩𝙚𝙧𝙮 𝙪𝙨𝙖𝙜𝙚 اور battery usage کو 𝙐𝙣𝙧𝙚𝙨𝙩𝙧𝙞𝙘𝙩𝙚𝙙 کردیں، اس اجازت کا مطلب یہ نہیں ہے کہ CS3 آپ کی بیٹری ختم کردے گا۔ یہ صرف ضرورت پڑنے پر پس منظر میں کام کرے گا، جیسے کہ جب اطلاعات موصول ہوں یا آفیشل ایکسٹینشنز سے ویڈیوز ڈاؤن لوڈ کریں۔ اگر آپ منسوخ کرنے کا انتخاب کرتے ہیں، تو آپ اس ترتیب کو بعد میں 𝙂𝙚𝙣𝙚𝙧𝙖𝙡 𝙎𝙚𝙩𝙩𝙞𝙣𝙜𝙨 میں ایڈجسٹ کر سکتے ہیں۔ @@ -571,9 +591,9 @@ Battery Optimization کو غیر فعال کریں اپپ battery usage پہلے سے ہی unrestricted ہے پسندیدہ کریں - موسیقی + موسیقی آڈیو بک - میڈیا + میڈیا انلاک CloudStream بایومیٹرکس کے ساتھ لاک کریں بایومیٹرک تصدیق اس ڈیوائس پر سپپورٹڈ نہیں ہے @@ -597,7 +617,7 @@ لوکل ویڈیو کھولیں فائلز حذف کریں انتباہ - آواز + آواز تاریخ %s اپنے اسمارٹ فون یا کمپیوٹر پر یہ %s وزٹ کریں اور مندرجہ بالا کوڈ ڈالیں diff --git a/app/src/main/res/values-b+vi/array.xml b/app/src/main/res/values-b+vi/array.xml index 848ff014b..1189fd18d 100644 --- a/app/src/main/res/values-b+vi/array.xml +++ b/app/src/main/res/values-b+vi/array.xml @@ -15,19 +15,24 @@ - - @string/source_name + + @string/resolution_and_name + @string/name + @string/resolution_and_title + @string/title @string/resolution - @string/video_info + @string/none - - @string/show_name_key - @string/show_resolution_key - @string/show_media_info_key + + 5 + 4 + 3 + 2 + 1 + 0 - @string/none 16 ký tự diff --git a/app/src/main/res/values-b+vi/strings.xml b/app/src/main/res/values-b+vi/strings.xml index 952bafee7..b26c715f3 100644 --- a/app/src/main/res/values-b+vi/strings.xml +++ b/app/src/main/res/values-b+vi/strings.xml @@ -5,30 +5,31 @@ %1$s Tập %2$d Diễn viên: %s Tập %d sẽ ra mắt sau - %1$d ngày %2$d giờ %3$d phút - %1$d giờ %2$d phút - %d phút + %1$dng %2$dg %3$dph + %1$dgi %2$dph + %dm Poster - Poster - Poster tập phim - Poster chính - Tập tiếp theo ngẫu nhiên - Thoát - Thay đổi Nguồn phim - Xem trước hình nền + Ảnh bìa + Episode Poster + Main Poster + Next Random + Quay trở lại + Change Provider + Preview Background 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 - Tải xuống - Cài đặt + Mở với CloudStream + Trang Chủ + Tìm Kiếm + Tải Về + Cài Đặt Tìm kiếm… Tìm kiếm %s… Không có dữ liệu @@ -37,170 +38,172 @@ Thể loại Chia sẻ Mở bằng trình duyệt - Bỏ tải + Bỏ qua Đang tải… Đang xem Đang chờ Đã xem - Bỏ xem + Bỏ qua Xem sau Xem lại - Phát - Phát Trực tiếp - Phát Torrent - Nguồn + Xem Ngay + Phát trực tiếp + Xem Torrent + Nguồn Phim Phụ đề Thử kết nối lại… Quay lại - Phát Tập phim + Xem Tập Phim Tải xuống - Đã tải xuống - Đang 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 - Tải xuống thành công + Đã tải + Đang tải + Tạm dừng + Đã bắt đầu tải + Tải lỗi + Đã hủy + Tải thành công Luồng mạng - Lỗi tải liên kết + Lỗi khi tải liên kết Bộ nhớ trong Lồng tiếng Phụ đề Xóa Tệp - Phát Tệp - Tiếp tục tải xuống - Tạm dừng tải xuống + Xem Tệp + Tiếp tục tải + Tạm dừng tải Thông tin thêm Ẩn - Phát + Xem ngay Thông tin - Lọc danh sách của tôi - Danh sách của tôi + Lọc theo danh sách đã lưu + Danh sách đã lưu Xóa Đặt trạng thái xem Áp dụng Sao chép Đóng - Xóa + Huỷ bỏ Lưu Tốc độ phát - Cài đặt phụ đề + Cài đặt hiển thị phụ đề Màu chữ - Màu viền + Màu viền chữ Màu nền Màu cửa sổ Kiểu viền - Độ nâng phụ đề + Độ nâng Kiểu chữ Kích thước chữ Tìm kiếm theo nguồn phim Tìm kiếm theo thể loại - %d lượt ủng hộ đã gửi đến nhà phát triển - Không có lượt ủng hộ đã nhận + %d lời cảm ơn đã được gửi tặng nhà phát triển + Hãy tặng cho nhà phát triển một lời cảm ơn Tự động chọn ngôn ngữ - Ngôn ngữ tải xuống + Ngôn ngữ khi tải xuống Ngôn ngữ phụ đề - Nhấn giữ để đặt lại mặc định + Giữ để làm mới toàn bộ Thêm phông chữ tại %s Tiếp tục xem - Xóa + Loại bỏ Thông tin thêm @string/home_play - Có thể cần dùng VPN để nguồn này hoạt động đúng - Nguồn này là một torrent, khuyến nghị dùng VPN + Bạn có thể sẽ cần sử dụng VPN để xem phim này + Phim này được chiếu dưới dạng Torrent. Hãy sử dụng VPN để xem Thông tin phim - Không tìm thấy nội dung - Không tìm thấy thông tin chi tiết + Đang cập nhật + Không tìm thấy thông tin Hiển thị Logcat 🐈 - Hình trong hình - Tiếp tục phát trong trình phát thu nhỏ trên các ứng dụng khác - Nút thay đổi kích thước trình phát - Xóa bỏ các viền đen + Chế độ cửa sổ nhỏ + Tiếp tục xem phim khi thoát ứng dụng hoặc khi tìm kiếm + Bật nút thu phóng khi xem + Xóa khoảng đen của phim Phụ đề Cài đặt phụ đề Phụ đề Chromecast Cài đặt phụ đề Chromecast Tốc độ phát - Vuốt để tua - Vuốt ngang qua lại để tua video - Vuốt để thay đổi cài đặt - 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 + Vuốt để tua nhanh + Vuốt sang trái hoặc phải để tua video + Vuốt để chỉnh độ sáng và âm lượng + Vuốt lên hoặc vuốt xuống ở hai bên để điều chỉnh độ sáng và â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 hai lần để tua - Nhấn hai lần để tạm dừng + Nhấn 2 lần để tua + Nhấn 2 lần để tạm dừng Thời lượng tua (Giây) - Nhấn hai lần vào cạnh trái hoặc phải để tua về trước hoặc sau + Nhấn 2 lần vào bên trái hoặc bên phải màn hình để tua 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 + Sử dụng độ sáng hệ thống trong trình phát ứng dụng Cập nhật tiến trình xem Tự động đồng bộ tiến trình hiện tại của bạn Khôi phục dữ liệu từ bản sao lưu Sao lưu dữ liệu - Đã tải tệp sao lưu + Đã tải dữ liệu sao lưu Không thể khôi phục dữ liệu từ %s - Dữ liệu đã lưu - Thiếu quyền truy cập bộ nhớ. Vui lòng thử lại. + Sao lưu dữ liệu thành công + Thiếu quyền truy cập bộ nhớ, hãy thử lại. Lỗi khi sao lưu %s Tìm kiếm Tài khoản và Bảo mật Cập nhật và Sao lưu Thông tin Tìm kiếm nâng cao - Cung cấp cho bạn kết quả tìm kiếm được phân loại theo từng nguồn phim + Cho phép tìm kiếm theo bộ lọc từng nhà cung cấp Hiển thị tập phụ cho anime Hiển thị trailer Hiển thị poster từ Kitsu Ẩn chất lượng video trong kết quả tìm kiếm - Tự động cập nhật tiện ích mở rộng + Tự động cập nhật plugin Hiển thị thông báo cập nhật ứng dụng Tự động tìm kiếm bản cập nhật mới sau khi khởi động ứng dụng. Github Ứng dụng đọc tiểu thuyết của cùng nhà phát triển Ứng dụng xem Anime của cùng nhà phát triển Tham gia cộng đồng trên Discord - Gửi ủng hộ tới nhà phát triển - Gửi ủng hộ + Gửi lời cảm ơn tới nhà phát triển + Gửi lời cảm ơn Ngôn ngữ ứng dụng Nguồn phim này chưa hỗ trợ Chromecast Không tìm thấy liên kết - Đã sao chép liên kết vào bảng nhớ tạm - Phát Tập phim - Đặt lại giá trị mặc định + Đã sao chép liên kết vào bộ nhớ tạm + Xem Phim + Thiết lập lại giá trị mặc định Mùa Không có mùa nào Tập Tập %1$d-%2$d %1$d %2$s - Mùa - Tập + M + T Không có tập nào Xóa Tệp Xóa Hủy bỏ - Tạm dừng - Tiếp tục + Tạm Dừng + Tiếp Tục -30 +30 - %s sẽ bị xoá vĩnh viễn \nBạn có chắc chắn không? - %d phút\ncòn lại + %s sẽ bị xoá vĩnh viễn +\nBạn có chắc chắn muốn xóa? + %dm +\ncòn lại Đang chiếu - Hoàn thành + Hoàn Thành Trạng Thái Năm - Đánh giá - Thời lượng + Đánh Giá + Thời Lượng Nguồn - Tóm tắt phim + Thông tin Hàng chờ Không có phụ đề - Mặc định + Mặc Định Còn trống Đã sử dụng - Ứng dụng + App Phim Lẻ Phim Bộ @@ -226,60 +229,60 @@ NSFW Video Lỗi nguồn phim - Lỗi nguồn từ xa - Lỗi kết xuất - 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ớ - Truyền tập phim - Truyền nguồn thay thế - Phát bằng ứng dụng - Phát bằng %s + Lỗi kết nối tới máy chủ + Không thể render + Đã có lỗi xảy ra. Vui lòng thử lại sau + Lỗi tải xuống. Hãy kiểm tra quyền truy cập bộ nhớ của ứng dụng + Tập Chromecast + Chiếu Chromecast + Xem với trình phát mặc định + Xem với trình phát %s Tự động tải xuống - Tải xuống nguồn thay thế - Tải lại các liên kết - Tải xuống phụ đề + Nguồn tải xuống + Lấy link mới nhất + Tải phụ đề Nhãn chất lượng phim Nhãn lồng tiếng Nhãn phụ đề - Tên phim - Hiển thị các thông tin trên poster + Tiêu đề + Thay đổi giao diện trên poster Bạn đang dùng phiên bản mới nhất Kiểm tra cập nhật Khóa - Thu phóng - Nguồn - Bỏ qua Giới thiệu + Thu Phóng + Nguồn & Phụ đề + Bỏ qua OP Không hiện lại Bỏ qua bản cập nhật này Cập nhật Chất lượng xem ưu tiên (WiFi) - Số ký tự tối đa tiêu đề trình phát - Hiển thị thông tin trình phát + Kí tự tối đa trên tiêu đề + Nội dung trình phát video Kích thước bộ nhớ đệm video Thời lượng bộ nhớ đệm - Bộ nhớ đệm video trên thiết bị + Lưu bộ nhớ đệm video trên ổ cứng Xoá bộ nhớ đệm hình ảnh và video - Sẽ gây lỗi nếu đặt quá cao trên thiết bị có bộ nhớ thấp như Android TV. + Sẽ gây lỗi nếu đặt quá cao trên máy có dung lượng ram thấp như Android TV. Sẽ gây lỗi nếu đặt quá cao trên máy có dung lượng lưu trữ thấp như Android TV. DNS over HTTPS Rất hữu ích để bỏ chặn ISP - Bản sao trang web + Sao chép trang web Xoá trang web - Thêm bản sao của trang hiện có bằng một URL khác + Thêm bản sao của một trang web, với một địa chỉ khác Đường dẫn tải xuống Địa chỉ máy chủ Nginx - Hiển thị Anime Lồng tiếng/Phụ đề + Hiển thị nhãn Phụ đề hoặc Thuyết minh Vừa màn hình Kéo dãn Phóng to - Tuyên bố miễn trừ trách nhiệm + Disclaimer Tổng quan Nút ngẫu nhiên Hiện nút ngẫu nhiên trên Trang chủ và Thư viện Ngôn ngữ mở rộng - Bố cục ứng dụng + Giao diện App Thể loại ưu tiên - Kích hoạt NSFW trên các Tiện ích mở rộng được hỗ trợ + Kích hoạt NSFW trên các tiện ích mở rộng được hỗ trợ Mã hoá phụ đề Nguồn phim Giao diện @@ -288,15 +291,15 @@ Giao diện điện thoại Giao diện giả lập Màu chính - Chủ đề ứng dụng - Vị trí tên phim - Đặt tên phim dưới poster + Chủ đề App + Vị trí tiêu đề + Đặt tiêu đề dưới poster Mật khẩu - Tên người dùng + Tài khoản Email 127.0.0.1 - Tên trang mới + Tên mới https://example.com Mã ngôn ngữ (vi) %1$s %2$s @@ -313,10 +316,10 @@ %d / 10 /?? /%d - %s đã xác thực - Không thể đăng nhập tại %s + Đã xác thực %s + Không thể xác thực %s - Không có + Mặc định Bình thường Tất cả Tối đa @@ -326,26 +329,26 @@ Đổ bóng Nâng Chỉnh phụ đề - 1000 ms + 1000ms Độ trễ phụ đề - Dùng nếu phụ đề bị nhanh %d ms - Dùng nếu phụ đề bị trễ %d ms - Không điều chỉnh + Dùng nếu phụ đề bị nhanh %dms + Dùng nếu phụ đề bị trễ %dms + Không chỉnh - Phụ đề của bạn sẽ trông tương tự như thế này + Xem trước mẫu phụ đề Được đề xuất Đã tải %s - Chọn từ tệp + Chọn từ máy Chọn từ Internet - Tệp đã tải xuống + Tệp đã tải Vai chính Vai phụ - Diễn viên quần chúng + Lý lịch Nguồn Ngẫu nhiên Sắp có… @@ -365,18 +368,18 @@ HDR SDR Web - Ảnh Poster + Ảnh áp phích Trình phát - Độ phân giải và Tên - Tên + Độ phân giải và Tên nguồn + Tên nguồn Độ phân giải Id không hợp lệ Lỗi dữ liệu Lỗi đường dẫn Lỗi - Xóa bỏ chú thích chi tiết trong phụ đề - Xóa bỏ nội dung thừa trong phụ đề - Lọc theo ngôn ngữ ưu tiên + Xoá phụ đề đã dùng + Loại bỏ mã hoá phụ đề + Lọc theo ngôn ngữ media được chuộng hơn Thêm Trailer https://example.com/example.mp4 @@ -384,66 +387,68 @@ Tiếp theo Xem video bằng các ngôn ngữ này Trước đó - Bỏ qua thiết lập lần đầu - Thay đổi giao diện phù hợp với thiết bị của bạn + Bỏ qua cài đặt setup + Chọn giao diện phù hợp với thiết bị của bạn Bạn muốn xem gì Hoàn tất Tiện ích mở rộng - 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á + Thêm kho lưu trữ + Tên kho lưu trữ (Tùy chọn) + Đường dẫn kho lưu trữ hoặc Mã ngắn + Đã tải plugin + Plugin đã xoá Không tải được %s 18+ Đã bắt đầu tải xuống %1$d %2$s… Đã tải xuống %1$d %2$s Toàn bộ %s đã được tải xuống - 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 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 + Tải hàng loạt + plugin + plugin + Tất cả plugin của kho lưu trữ sẽ bị xoá + Xoá kho lưu trữ + Tải nguồn phim bạn muốn dùng + Đã tải: %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 các kho nguồn phim của cộng đồng + Không tải: %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 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) - Âm thanh & video + Cảnh báo: CloudStream 3 không chịu trách nhiệm về các tiện ích bên thứ ba và không cung cấp bất kỳ sự hỗ trợ nào! + %s (Đã vô hiệu hoá) + Âm thanh & Chất lượng Âm thanh - Video - Khởi động lại ứng dụng để thấy các thay đổi. + Chất lượng Video + Áp dụng khi khởi động lại ứng dụng. 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. + Đã xảy ra sự cố và chúng tôi đã tự động tắt tất cả các tiện ích mở rộng, hãy tìm và xóa tiện ích mở rộng đang gây ra sự cố. Xem thông tin sự cố Lịch sử Đánh dấu là đã xem - 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 thể cài đặt. + Tự động tải xuống plugin + Làm lại tiến trình cài đặt + Bộ cài APK + Một số thiết bị không hỗ trợ trình cài đặt gói mới. Hãy thử tùy chọn cũ nếu các bản cập nhật không 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 nguồn phim đã thêm. - Cập nhật đã bắt đầu + Xem Trailer + Tự động cài đặt tất cả plugin chưa được cài đặt từ những kho lưu trữ đã thêm vào. + Bắt đầu cập nhật Liên kết Danh sách HLS Trình phát ưu tiên - Trình phát tích hợp + Trình phát mặc định Đánh giá: %s Không Phiên bản Tác giả Cập nhật ứng dụng Sao lưu - Tiện ích mở rộng + Tiện ích Hành động - Bộ nhớ đệm + Cache Cử chỉ Tính năng trình phát Phụ đề @@ -457,43 +462,44 @@ Kích thước Hỗ trợ Ngôn ngữ - Cài đặt tiện ích mở rộng trước + Cài đặt tiện ích trước Không thấy ứng dụng Tất cả ngôn ngữ - Bỏ qua %s + Tua %s Mở đầu Kết thúc - Điểm lại nội dung - Kết thúc hỗn hợp - Mở đầu hỗn hợp - Danh đề + Tóm tắt + Các kết thúc hỗn hợp + Các mở đầu hỗn hợp + Danh đề Giới thiệu Xoá lịch sử - Hiển thị cửa sổ bật lên của bỏ qua giới thiệu cho mở đầu/kết thúc - Văn bản quá dài. Không thể lưu vào bảng nhớ tạm. + Hiển thị nút tua nhanh cho mở đầu/kết thúc + Văn bản quá dài. Không thể lưu vào khay nhớ tạm. Xoá khỏi đã xem Bạn có chắc muốn thoát? - Đang tải xuống bản cập nhật… + Đang tải bản cập nhật… Đang cài bản cập nhật… Không thể cài đặt phiên bản mới - Ứng dụng sẽ được cập nhật sau khi thoát + Ứng dụng sẽ được cập nhật khi thoát Thư viện Trình duyệt - Tiện ích mở rộng đã tải xuống - Chế độ tương thích cũ + Plugin đã tải + Legacy Đã 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ộ. - 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 + 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ở với + Thông tin dữ liệu không được cung cấp bởi trang web, video sẽ không tìm thấy link nếu nó không tồn tại trên trang web. + PackageInstaller Sắp xếp Xếp hạng (Cao đến Thấp) Xếp hạng (Thấp đến Cao) Chữ cái (Z đến A) Sắp xếp theo - Danh sách này trống. Vui lòng thử chuyển sang danh sách khác. + Danh sách này trống, hãy thử chuyển sang danh sách khác. Chữ cái (A đến Z) Chọn Thư viện Nhật ký @@ -505,21 +511,22 @@ GitHub Proxy Không thể kết nối được tới GitHub. Đang bật jsDelivr proxy… Android TV - Phát từ đầu + Khởi động lại Đã đăng kí %s Tập %d đã ra mắt! Đã đăng kí - Dừng - Bỏ chặn nhà mạng + Dừng lại + Bỏ qua ISP Đã 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. - Hoàn tác + 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. + Đảo ngược lại Đ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. - Lượng thời gian tua được dùng khi trình phát đang bị ẩn - Thời lượng tua khi trình phát đang ẩn - Lượng thời gian tua được dùng khi trình phát đang hiển thị - Thời lượng tua khi trình phát đang hiện + Bỏ qua chặn đường link GitHub bằng cách dùng jsDelivr. Có thể gây ra việc cập nhật bị chậm vài ngày. + Lượng tua thêm được sử dụng khi trình phát ẩn + Lượng tua thêm + Lượng tua thêm được sử dụng khi trình phát hiện lên + Lượng tua thêm Hồ sơ %d Dữ liệu di động Đặt mặc định @@ -529,92 +536,105 @@ 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! - Chất lượng + 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! + Các chất lượng Bạn đã bình chọn - 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 + Vô hiệu hoá + Không tìm thấy tiện ích, hãy kiểm tra URL và thử lại với VPN + Không tìm thấy plugin 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 + Chọn chế độ để lọc plugin tải xuống + %s đã loại bỏ 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ơ Thêm vào mục yêu thích Thay thế tất cả - Mã PIN không đúng. Vui lòng thử lại. + Mã PIN không chính xác. Vui lòng thử lại. Hủy đăng ký Mã PIN phải có 4 ký tự Thay thế Thêm vào Đăng ký - Xóa khỏi mục yêu thích - 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 + Loại bỏ khỏi mục yêu thích + Chọn một tài khoản + 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 PIN PIN Nhập mã PIN hiện tại Đã đăng nhập với tư cách %s Nhập mã PIN cho %s - Sử dụng Hồ sơ mặc định - Bỏ qua lựa chọn hồ sơ lúc khởi động - Quản lý hồ sơ - Chỉnh sửa hồ sơ + Sử dụng tài khoản mặc định + Bỏ qua lựa chọn tài khoản khi khởi động + Quản lý tài khoản + Chỉnh sửa tài khoản Tải lại liên kết - Tìm kiếm trong tiện ích mở rộng khác + Tìm kiếm tiện ích khác Hiển thị đề xuất - Kiểm tra tất cả Tiện ích mở rộng + Kiểm tra nguồn phim Xoay Thông báo tập mới - Thêm tùy chọn tốc độ phát trong trình phát + Chỉnh tốc độ trong trình phát Hiển thị nút xoay màn hình - Bật tự động xoay màn hình theo hướng của video + Kích hoạt chế độ xoay màn hình tự động Tự động xoay đã sao chép! - Lỗi truy cập Bảng nhớ tạm, Vui lòng thử lại. - Lỗi sao chép, Vui lòng sao chép logcat và liên hệ hỗ trợ ứng dụng. + Vấn đề truy cập Bảng ghi tạm, Hãy thử lại. + Lỗi sao chép, Hãy sao chép logcat và liên hệ hỗ trợ ứng dụng. Yêu thích OK - Tắt tối ưu hóa pin - Không thể mở thông tin ứng dụng CloudStream. - Bỏ yêu thích + Vô hiệu Tối ưu pin + Không thể mở thông tin ứng dụng của CloudStream. + Không 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 + Khóa với sinh trắc học + %s +\ncòn lại Xác thực bằng sinh trắc học không được hỗ trợ trên thiết bị này - Xác thực bằng Mật khẩu/PIN + Mật khẩu/PIN Xác thực Dữ liệu CloudStream của bạn hiện đã được sao lưu. Mặc dù khả năng xảy ra điều này là rất thấp nhưng tất cả các thiết bị đều có thể hoạt động khác nhau. Trong trường hợp hiếm gặp là bạn bị khóa truy cập ứng dụng, hãy xóa hoàn toàn dữ liệu ứng dụng và khôi phục từ bản sao lưu. Chúng tôi rất xin lỗi vì bất kỳ sự bất tiện nào phát sinh từ việc này. Mở khóa ứng dụng bằng Vân tay, Khuôn mặt, PIN, Hình vẽ và Mật khẩu. - 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. + Màn hình bị đóng sau nhiều lần thử thất bại. Hãy khởi động lại ứng dụng. + 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ỳ nguồn phim 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 nguồn phim + Phương tiện + Tên và URL kho lưu trữ Đặ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 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ị. Hãy 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 ra mắt sau %s + Sắp tới sau %s Chọn thiết bị truyền Bảo mật Tài khoản Mã QR Bỏ qua - Mở kho nguồn phim + Mở tiện ích mở rộng 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! - Mã sẽ hết hạn trong %1$d phút %2$d giây - 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. + Mã sẽ hết hạn trong %1$dm %2$ds + Không lấy được mã PIN, hãy thử xác thực cục bộ + Hiện không có bản tải xuống nào. Xác thực cục bộ - Truyền nguồn thay thế - Phát từ đầu + Phản chiếu màn hình + Xem từ đầu Mở video có sẵn Cảnh báo Ngày phát hành (Mới đến Cũ) @@ -624,18 +644,26 @@ 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ũ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 - Xóa tiện ích mở rộng + 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 sau: +\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 plugin 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 - Bật xem trước hình thu nhỏ trên thanh tua - Xem trước trên thanh tua - Chưa tải phụ đề nào + Ẩn tên các nút điều khiển + Bật chế độ xem trước hình thu nhỏ trên seekbar + Xem trước Seekbar + Chưa tải phụ đề Xác nhận trước khi thoát - Hiển thị hộp thoại xác nhận trước khi thoát ứng dụng + Hiện hộp thoại xác nhận thoát ứng dụng Không hiển thị Hiển thị Vị trí thư mục sao lưu @@ -644,12 +672,12 @@ Video này là Torrent, điều này có nghĩa là hoạt động video của bạn có thể được theo dõi.\nHãy đảm bảo rằng bạn hiểu về Torrent trước khi tiếp tục. 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. - Giải mã phần mềm - 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. + 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 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) @@ -657,16 +685,16 @@ Xếp hạng %s Ngày %s Tập %s - Cập nhật tiện ích mở rộng - Không có tiện ích mở rộng nào được cập nhật. - Ngày phát sóng (Cũ nhất) - Đã cập nhật thành công %d tiện ích mở rộng! + Cập nhật plugin + Không có plugin nào được cập nhật. + Ngày phát hành (Cũ nhất) + Đã cập nhật thành công %d plugin! Xếp hạng (Cao nhất) - Cập nhật tiện ích mở rộng thủ công + Cập nhật plugin thủ công Tập (Giảm dần) - Bắt đầu quá trình cập nhật tiện ích mở rộng! + Bắt đầu quá trình cập nhật plugin! Thông báo trình phát - Điều khiển phát lại trong nền bằng thông báo của trình phát + Thông báo trình phát để điều khiển phát lại từ nền Bắt đầu nói… Tìm kiếm giọng nói không khả dụng Trực tuyến @@ -679,33 +707,33 @@ Luôn hỏi Nhãn đánh giá Số lượng mục khác nhau có thể tải xuống cùng lúc - Tải xuống đồng thời + Tải xuống song song Kết nối đồng thời - Số lượng kết nối đồng thời mà mỗi lần tải xuống có thể sử dụng - Đến mục Tải xuống + Số lượng kết nối đồng thời có thể sử dụng cho mỗi lượt tải + Đế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 - Quét chồng lấn - Thay đổi kích thước poster + Vượt khung + Thay đổi kích thước của hình poster Kích thước poster Tăng tốc độ phát khi nhấn giữ Nhấn giữ để tăng tốc độ phát 2x - %1$d giờ %2$d phút %3$d giây - %1$d phút %2$d giây - %1$d giây + %1$dh %2$dm %3$ds + %1$dm %2$ds + %1$ds Không có tài khoản - Chỉnh sửa ảnh Hồ sơ - Nhập URL ảnh Hồ sơ - Không tìm thấy URL - URL hoặc ảnh không hợp lệ - Đã cập nhật ảnh thành công + Đổi hình đại điện + Nhập url hình đại diện + Không tìm thấy url + URL hoặc hình không hợp lệ + Tải hình lên thành công Đánh dấu là đã xem đến tập này - Xóa đã xem đến tập này + Xóa những tập đã xem đến tập này Đã tải lại - Tải lại nguồn phim + Tải lại nguồn phát Tên Độ phân giải và tên - Phát nguồn thay thế" + Xem phản chiếu" Căn chỉnh phụ đề Dưới trái Dưới giữa @@ -717,35 +745,4 @@ Giữa trái Giữa phải Phát trọn bộ loạt phim - Gợi ý tìm kiếm - Hiển thị gợi ý tìm kiếm khi đang nhập - Xóa gợi ý - Cài đặt phiên bản phát hành trước - Bản phát hành trước đã được cài đặt. - Cài đặt bản phát hành trước thất bại. - 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 dàn diễn viên - Thông tin video - Độ sáng bổ sung - Tên nguồn - Hàng đợi tải xuống - Không có tải xuống đang chờ nào. - Quyết định cách sắp xếp các nguồn video trong trình phát - Ưu tiên nguồn - Tải xuống tất cả - Hủy tất cả - Bạn có muốn tải xuống tập %s không? - Bạn có muốn hủy tất cả tải xuống đang chờ không? - - %d đang tải xuống - - - %d tải xuống đang chờ - - Đã 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 cfd8adf05..7dc4b48f2 100644 --- a/app/src/main/res/values-b+zh+TW/strings.xml +++ b/app/src/main/res/values-b+zh+TW/strings.xml @@ -19,7 +19,8 @@ 速度(%.2fx) 評分:%.1f - 發現新版本! \n%1$s -> %2$s + 發現新版本! +\n%1$s -> %2$s 填充 %d 分鐘 CloudStream @@ -154,7 +155,7 @@ 顯示預告片 顯示來自 Kitsu 的封面 在搜尋結果中隱藏所選的影片畫質 - 自動更新外掛 + 自動更新外掛程式 顯示應用程式更新 啟動應用程式後自動搜尋更新。 Github @@ -186,8 +187,10 @@ 繼續 -30 +30 - 這將永遠刪除 %s \n你確定嗎? - 剩下 \n%d 分鐘 + 這將永遠刪除 %s +\n你確定嗎? + 剩下 +\n%d 分鐘 連載中 已完結 狀態 @@ -255,7 +258,7 @@ 更新 偏好播放畫質 (WiFi) 影片播放器標題最大字數 - 顯示播放器資訊 + 影片播放器標題 影片緩衝大小 影片緩衝長度 磁碟上的影片快取 @@ -394,34 +397,36 @@ 資源庫名稱(選填) 資源庫 URL 或簡碼 外掛程式已載入 - 外掛已刪除 + 外掛程式已刪除 無法載入 %s 18+ 開始下載 %1$d %2$s … 已下載 %1$d %2$s 全部 %s 已經下載 批次下載 - 外掛 - 外掛 - 這也將刪除所有在資源庫中的外掛 + 外掛程式 + 外掛程式 + 這也將刪除所有在資源庫中的外掛程式 刪除資源庫 下載你所需的片源 已下載:%d 已停用:%d 未下載:%d 已更新 %d 外掛程式 - CloudStream 預設沒有安裝網站。你需要從儲存庫安裝網站。 \n \n加入我們的 Discord 或在網路上搜尋。 + CloudStream 預設沒有安裝網站。你需要從儲存庫安裝網站。 +\n +\n加入我們的 Discord 或在網路上搜尋。 查看 公開清單 字幕全大寫 - 警告:CloudStream 不對使用第三方擴充功能承擔任何責任,也不提供任何支援! + 警告:CloudStream 3 不對使用第三方擴充功能承擔任何責任,也不提供任何支援! %s (停用) 軌道 音頻軌道 影片軌道 重新啟動應用程式以查看變更。 安全模式已啟用 - 由於程式當掉,為了協助您找到導致問題的程式,所有外掛皆已關閉。 + 由於程式崩潰,所有外掛程式皆已關閉,以協助您找到導致問題的程式。 查看程式崩潰資訊 評分:%s 簡介 @@ -431,7 +436,7 @@ 作者 類型 語言 - 請先安裝外掛 + 請先安裝外掛程式 HLS 播放清單 偏好影片播放器 內部播放器 @@ -443,17 +448,17 @@ 前情回顧 混合片尾 混合片頭 - 致謝名單 + 致謝名單 介紹 清除歷史紀錄 歷史紀錄 - 自動下載外掛 + 自動下載外掛程式 你確定要離開? 從新增的資源庫自動安裝所有尚未安裝的外掛程式。 在開始/結束顯示跳過彈出視窗 無法安裝新版本的應用程式 APK 安裝器 - 部分裝置不支援新的軟體安裝程式。 如果未安裝更新,請嘗試使用舊版選項。 + 有些手機不支援新的軟體包安裝程式。 如果未安裝更新,請嘗試使用舊版選項。 文字太多。 無法儲存到剪貼簿。 正在下載應用程式更新… @@ -498,7 +503,8 @@ 按字母順序(A 到 Z) 按字母順序(Z 到 A) 選擇媒體庫 - 找到安全模式檔案! \n在刪除此檔案之前,將不會在啟動時載入任何擴充功能。 + 找到安全模式檔案! +\n在刪除此檔案之前,將不會在啟動時載入任何擴充功能。 日誌 失敗 通過 @@ -516,7 +522,8 @@ 還原 無法存取 GitHub。 正在開啟 jsDelivr proxy… 使用 jsDelivr 繞過直接使用 GitHub 網址時的存取封鎖。 可能導致更新延遲數天。 - 您的媒體庫是空的 :( \n登入媒體庫帳號或將節目新增到您本機的媒體庫。 + 您的媒體庫是空的 :( +\n登入媒體庫帳號或將節目新增到您本機的媒體庫。 您的媒體庫是空的。可嘗試以不同的帳號登入。 正在更新訂閱節目 備份頻率 @@ -524,7 +531,11 @@ 我的最愛 %s 已加入我的最愛 以 %s 的身分登入 - 您的媒體庫中似乎有多個重覆的項目: \n \n%s \n \n您要強制加入、取代已有項目、還是取消操作? + 您的媒體庫中似乎有多個重覆的項目: +\n +\n%s +\n +\n您要強制加入、取代已有項目、還是取消操作? 輸入 %s 的 PIN 碼 行動數據 找到可能重覆的項目 @@ -556,11 +567,19 @@ 找不到資源庫,請檢查網址與 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 碼 顯示切換畫面方向的按鈕 選擇篩選外掛程式下載的模式 @@ -575,16 +594,17 @@ 使用生物辨識技術鎖定 應用程式電池使用已設定為無限制 解除鎖定 CloudStream - 媒體 + 媒體 重置 顯示推薦 在播放器中新增速度選項 即將在 %s 推出 - %s \n剩餘 + %s +\n剩餘 測試所有擴充功能 停用電池優化 有聲書 - 音樂 + 音樂 第 %1$d 季第 %2$d 集即將發佈於 在其他擴充功能中搜尋 新集數通知 @@ -599,7 +619,7 @@ 無法開啟 CloudStream 的應用程式資訊頁面。 您的 CloudStream 資料已完成備份。儘管可能性非常低,但因不同裝置的行為都有所不同,在極少數情況下,您可能會無法存取本應用程式。此時請完全清除本應用程式的資料,再使用已有的備份進行還原。若因此造成任何不便,我們深感抱歉。 此測試是供開發人員參考,而不是用以驗證任何擴充功能的正常運作與否。 - 為了確保已訂閱的電視節目的不間斷下載與通知,CloudStream 需要取得在背景執行的權限。若點選「確定」,將顯示「請求權限」,請選擇「允許」。\n\n請注意,取得此權限並不表示本程式會明顯增加電池用量,而是只在必要時在背景執行,例如取得通知或使用擴充功能下載影片。 + 為了確保下載與通知已訂閱的電視節目的不間斷,CloudStream 需要取得在背景執行的權限。若點選「確定」,將移至「應用程式資訊」,請找到「應用程式電池使用」並將電池用量設置為「無限制」。請注意,取得此權限並不表示 CS3 會明顯增加電池用量,而是只在必要時在背景執行,例如取得通知或使用官方擴充功能下載影片時。若選擇「取消」,您可以稍後在「一般設定」中調整此設定。 CloudStream Wiki 此裝置不支援生物特徵認證 無法取得裝置 PIN 碼,請嘗試本機驗證 @@ -641,110 +661,11 @@ 尚未載入字幕 此影片是 Torrent,這意味著你的影片活動可以被追蹤。\n在繼續之前,請確保你瞭解 Torrenting。 subs_edge_size - 音樂 + 音樂 編碼錯誤 - 因為不支援造成錯誤 - 播客 + 因為不支持造成的錯誤 + 播客 軟體解碼 - 重開程式並「同意」線上播放 Torrent 視窗。 + 不接受的種子 載入第一個可用的 - %1$d小時 %2$d分鐘 %3$d秒 - %1$d分鐘 %2$d秒 - %1$d秒 - 下載佇列 - 不支援語音辨識 - 開始說話…… - 播放全劇 - 目前佇列中無下載影片。 - 額外亮度 - 超過100%亮度時開啟亮度調整 - 已開啟額外亮度 - 搜尋建議 - 輸入時顯示搜尋建議 - 清除建議 - 顯示投影控制板 - 安裝提前發行版 - 已安裝提前發行版。 - 提前發行版安裝失敗。 - 鏡像播放" - 評分標籤 - 影劇文本 - 媒體資訊 - 總是詢問 - 成功更新了 %d 外掛! - 沒有需要更新的外掛。 - 播放器通知 - 控制背景播放的通知 - 內嵌 - 線上 - 所有字幕變為粗體 - 所有字幕變為斜體 - 背景寬度 - 允許同時下載幾個項目 - 同時下載 - 同時連接量 - 下載時,每個下載可同時使用的連接量 - 前往下載 - 無網際網路。\n\n請連接網路後重試,或者以離線模式觀看已下載項目。 - 更改螢幕邊界 - 放大符合螢幕 - 更改圖片尺寸 - 圖片尺寸 - 長按以切換速度 - 2 倍速(按住) - 編輯帳戶圖片 - 輸入需使用的網址 URL 以更改帳戶圖片 - 未找到該網址 - 無效網址或圖片 - 已成功更新圖片 - 將這集及之前集數標示為「已觀看」 - 移除至此前所有集數「已觀看」狀態 - 已重新載入 - 重新載入來源 - 名稱 - 來源名 - 解析度與名稱 - 全部下載 - 全部取消 - 需要下載第 %s 集嗎? - 需取消佇列中的所有下載嗎? - 字幕對齊 - 左下 - 底部置中 - 右下 - 左中 - 正中 - 右中 - 左上 - 頂部置中 - 右上 - - %d 個正在下載 - - - 佇列中有 %d 個下載 - - 集數(由舊至新) - 集數(由新至舊) - 評分(最高) - 評分(最低) - 播放日期(最新) - 播放日期(最舊) - 第 %s 集 - 評分 %s - 日期 %s - 讓音量超過 100%(再次上滑) - 更新外掛 - 手動更新外掛 - 正在更新外掛! - 來源優先順序 - 決定影片來源的排列順序 - 帳戶不存在 - 在「設定/影片來源/首選媒體」中啟用 Torrent下載 - 軟體解碼使程式可以播放裝置不支援的影片,但可能導致播放高解析的影片時的延遲或不穩定。 - 音量已超過 100% - 顯示播放器元資料遮罩層 - 影片 - 預覽 - 播放中 diff --git a/app/src/main/res/values-b+zh/strings.xml b/app/src/main/res/values-b+zh/strings.xml index 56ec8f43e..3c5a78d08 100644 --- a/app/src/main/res/values-b+zh/strings.xml +++ b/app/src/main/res/values-b+zh/strings.xml @@ -1,7 +1,7 @@ - %1$s Ep %2$d + %1$s 共 %2$d 集 演员:%s 第 %d 集将发布于 %1$dd %2$dh %3$dm @@ -19,7 +19,8 @@ 速度(%.2fx) 评分:%.1f - 发现新版本! \n%1$s -> %2$s + 发现新版本! +\n%1$s -> %2$s 填充 %d 分钟 CloudStream @@ -187,8 +188,10 @@ 继续 -30 +30 - 这将永久删除 %s \n您确定吗? - %d 分钟 \n剩余 + 这将永久删除 %s +\n您确定吗? + %d 分钟 +\n剩余 连载中 已完结 状态 @@ -256,7 +259,7 @@ 更新 首选播放画质(WiFi) 视频播放器标题最多字符 - 显示播放器信息 + 视频播放器标题 视频缓冲大小 视频缓冲长度 视频缓存存储 @@ -411,7 +414,9 @@ 已禁用:%d 未下载:%d 已更新 %d 插件 - CloudStream 默认不安装片源。您需要从仓库中安装片源。 \n \n加入我们的 Discord 或在网上搜索。 + CloudStream 默认不安装片源。您需要从仓库中安装片源。 +\n +\n加入我们的 Discord 或在网上搜索。 查看社区仓库 公开列表 字幕全大写 @@ -444,7 +449,7 @@ 前情回顾 混合片尾 混合片头 - 致谢名单 + 致谢名单 介绍 清除历史记录 历史记录 @@ -481,7 +486,8 @@ 应用退出后将会更新 插件已下载 从已观看中移除 - 发现安全模式文件! \n启动时不加载任何扩展,直到文件被删除。 + 发现安全模式文件! +\n启动时不加载任何扩展,直到文件被删除。 浏览器 排序方式 @@ -494,7 +500,8 @@ 字母排序(从 Z 到 A) 选择库 打开方式 - 您的库是空的 :( \n登录库账户或添加节目到您的本地库。 + 您的库是空的 :( +\n登录库账户或添加节目到您的本地库。 此列表是空的,请尝试切换到另一个。 播放器显示 - 快进快退秒数 播放器可见时使用的快进快退秒数 @@ -527,7 +534,13 @@ 配置文件 帮助 移动流量 - 在这里,您可以更改源的排序方式。如果视频具有更高的优先级,它将在源选择中显示得更高。源优先级和质量优先级的总和就是视频优先级。 \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 @@ -557,8 +570,14 @@ 您已投票 %s已添加到收藏夹 %s已从收藏夹中删除 - 您的资料库中似乎已经存在一个可能相同的项目:\'%s.\' \n \n您想添加该项目、替换现有项目还是取消操作? - 在您的资料库中发现了潜在的重复项目: \n \n%s \n \n您想添加此项目、替换现有项目还是取消操作? + 您的资料库中似乎已经存在一个可能相同的项目:\'%s.\' +\n +\n您想添加该项目、替换现有项目还是取消操作? + 在您的资料库中发现了潜在的重复项目: +\n +\n%s +\n +\n您想添加此项目、替换现有项目还是取消操作? 确认PIN 输入来自 %s 的 PIN 码 锁定个人资料 @@ -578,15 +597,16 @@ 解锁 CloudStream 使用生物识别技术锁定 密码或 PIN 验证 - %s \n剩余 + %s +\n剩余 测试所有扩展 已复制! 访问剪贴板出错,请重试。 应用程序电池使用量已设置为不受限制 有声书 - 媒体 + 媒体 禁用电池最佳化 - 音乐 + 音乐 无法打开 CloudStream 的应用程序信息。 使用指纹、面部 ID、PIN 码、图案和密码解锁应用程序。 此测试仅适用于开发人员,不会验证或否认任何扩展的工作。 @@ -624,10 +644,18 @@ 全不选 删除文件 删除 (%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 码现已过期! @@ -641,8 +669,8 @@ 这是个 Torrent 的视频,这意味着您的视频活动可以被追踪。\n请确认您了解 Torrenting 后,再继续。 备份文件夹位置 边缘大小 - 音频 - 播客 + 音频 + 播客 编码错误 不受支持的错误 加载第一个可用的字幕 @@ -720,31 +748,4 @@ 已安装预发行版。 安装预发行版失败。 剧集文本 - 搜索建议 - 输入时显示搜索建议 - 清除建议 - 显示投屏面板 - 媒体信息 - 源名称 - 额外亮度 - 超过 100% 亮度时启用亮度过滤器 - 下载队列 - 队列中当前无下载。 - 全部下载 - 全部取消 - 要下载第 %s 集吗? - 要取消队列中的所有下载吗? - - %d 个活跃下载 - - - 队列中有 %d 个下载 - - 源优先级 - 确定在播放器中如何排列视频源的顺序 - 已启用额外亮度 - 显示播放器元数据遮罩层 - 视频 - 预览 - 播放中 diff --git a/app/src/main/res/values-be/strings.xml b/app/src/main/res/values-be/strings.xml index 3d9200faf..64eeaffae 100644 --- a/app/src/main/res/values-be/strings.xml +++ b/app/src/main/res/values-be/strings.xml @@ -12,7 +12,7 @@ %1$d с Плакат Плакат - Плакат серыі + Плакат эпізода Асноўны плакат Наступны выпадковы Назад @@ -34,7 +34,7 @@ Шукаць па %s… Распазнаванне маўлення недаступна Пачніце размаўляць… - Няма дадзеных + Няма даных Больш параметраў Наступная серыя Жанры @@ -128,7 +128,7 @@ \@string/home_play Для карэктнай працы гэтага пастаўшчыка можа спатрэбіцца VPN Гэты пастаўшчык — Torrent, рэкамендуецца VPN - Вэб-сайт не пастаўляе метададзеных, загрузіць відэа не ўдасца, калі на сайце яго няма. + Сайт не пастаўляе метаданых, загрузіць відэа не ўдасца, калі на сайце яго няма. Апісанне Сюжэту не знойдзена Апісання не знойдзена @@ -159,12 +159,12 @@ Выкарыстоўваць сістэмную яркасць у прайгравальніку замест цёмнага накладання Абнаўляць працэс прагляду Аўтаматычна сінхранізаваць прагрэс бягучай серыі - Аднавіць дадзеныя з рэзервовай копіі - Рэзервовае капіраванне дадзеных + Аднавіць даныя з рэзервовай копіі + Рэзервовае капіраванне даных Частата рэзервовага капіравання Загружаны файл рэзервовай копіі - Не ўдалося аднавіць дадзеныя з файла %s - Дадзеныя захаваны + Не ўдалося аднавіць даныя з файла %s + Даныя захаваны Няма дазволу да сховішча. Паспрабуйце ізноў. Памылка пры рэзервовым капіраванні %s Пошук @@ -174,569 +174,13 @@ Звесткі Пашыраны пошук Паказвае пошукавыя вынікі, падзеленыя па пастаўшчыкам - Паказваць серыі-запаўняльнікі для анімэ + Паказваць серыі-запаўнялнікі для анімэ Паказваць трэйлеры Паказваць плакаты з Kitsu Схаваць выбраную якасць відэа з вынікаў пошуку - Аўтаматычнае абнаўленне ўбудоў - Аўтаматычная спампоўка ўбудоў + Аўтаматычнае абнаўленне пашырэнняў + Аўтаматычная спампоўка ўбудоваў Некаторыя прылады не падтрымліваюць новы ўсталёўшчык пакетаў. Калі абнаўленні не ўсталёўваюцца, паспрабуйце ранейшую версію. Github - Выберыце рэжым фільтравання спампоўвання убудоў - Прапановы пошуку - Паказваць прапановы пошуку падчас уводу тэксту - Ачысціць прапановы - Паказваць склад акцёраў - Аўтаматычна ўсталёўваць усе яшчэ не ўсталяваныя ўбудовы з даданых рэпазіторыяў. - Паказваць абнаўленні праграмы - Аўтаматычна правяраць на новыя абнаўленні пасля адкрыцця праграмы. - Паўтарыць наладжванне - Усталяваць перадфінальную версію - Перадфінальная версія ўжо ўсталявана. - Не ўдалося ўсталяваць перадфінальную версію. - Усталёўшчык APK - Лёгкая праграма для раманаў ад тых жа распрацоўшчыкаў - Праграма для анімэ ад тых жа распрацоўшчыкаў - Далучайцеся да Discord - Даць распрацоўшчыкам бенен - Дадзена бененаў - Мова праграмы - У гэтага пастаўшчыка няма падтрымкі Chromecast - Спасылак не знойдзена - Спасылка скапіравана да буфера абмену - Прайграць серыю - Скінуць да пачатковага значэння - Сезон - %1$s %2$d%3$s - Сезона няма - Серыя - Серый - %1$d-%2$d - %1$d %2$s - Наступны праз %s - Сез - Сер - Серый не знойдзена - Выдаліць - Выдаліць файл - Выдаліць файлы - Выдаліць (%1$d | %2$s) - Скасаваць - Прыпыніць - Пачаць - Няўдала - Пройдзена - Увага - Узнавіць - -30 - +30 - Гэта выдаліць %s назаўсёды\nВы ўпэўнены? - Вы ўпэўнены, што хочаце назаўсёды выдаліць наступныя элементы?\n\n%s - Вы ўпэўнены, што хочаце назаўсёды выдаліць наступныя серыі «%1$s»?\n\n%2$s - Вы таксама назаўсёды выдаліце ўсе серыі гэтага серыяла:\n\n%s - Вы ўпэўнены, што хочаце назаўсёды выдаліце ўсе серыі гэтага серыяла:\n\n%s - %dхв\nзасталося - %s\nзасталося - Бягучы - Завершана - Стан - Год - Рэйтынг - Працягласць - Вэб-сайт - Сціслы агляд - у чарзе - Субцітраў няма - Прадвызначаны - Свабодна - Ужыта - Праграма - Фільмы - Тэлесерыялы - Мультфільмы - Анімэ - Torrents - Дакументальныя фільмы - OVA - Азіяцкія драмы - Прамыя трансляцыі - NSFW - Іншыя - Фільм - Серыял - Мультфільм - Анімэ - OVA - Torrent - Дакументальны фільм - Азіяцкая драма - Прамая трансляцыя - NSFW - Відэа - Музыка - Аўдыякніга - Медыя - Аўдыя - Падкаст - Памылка крыніцы - Памылка аддаленага элемента - Памылка паказу - Памылка кадзіравання - Непадтрыманая памылка - Нечаканая памылка прайгравальніка - Памылка спампоўвання, праверце дазвол на сховішча - Глядзець праз Chromecast - Люстэрка Chromecast - Дадатковая яркасць - Уключыць фільтр яркасці калі яркасць дысплэя больш за 100% - extra_brightness_enabled - Трансляцыя праз люстэрку - Глядзець у праграме - Глядзець праз люстэрку" - Глядзець у %s - Аўтаспампоўка - Спампаваць люстэрку - Абнавіць спасылкі - Спампаваць субцітры - Метка якасці - Метка дубляжу - Метка субцітраў - Метка рэйтынгу - Загаловак - Тэкст серыі - Пераключэнне элементаў інтэрфейсу на плакаце - Абнаўленняў не знойдзена - Праверыць на абнаўленні - Блакіроўка - Змена памеру - Крыніца - Прапусціць ОП - Не паказваць зноў - Прапусціць гэта абнаўленне - Абнавіць - Прыярытэтная якасць прагляду (WiFi) - Прыярытэтная якасць прагляду (Мабільная перадача дадзеных) - Максімальная колькасць сімвалаў у загалоўку праглядальніка - Паказваць інфармацыю ў прайгравальніку - Памер буфера відэа - Даўжыня буфера відэа - Кэш відэа на дыску - Ачысціць кэш відэа і відарысаў - Прайгравальнік паказаны — крок перамоткі - Крок перамоткі калі прайгравальнік бачны - Прайгравальнік схаваны — крок перамоткі - Крок перамоткі калі прайгравальнік схаваны - Выклікае збоі пры высокіх значэннях на прыладах з маленькім аб\'ёмам памяці, такіх як Android TV. - Выклікае праблемы пры высокіх значэннях на прыладах з маленькім аб\'ёмам сховішча, такіх як Android TV. - DNS праз HTTPS - Карысна для абходу блакіровак - Проксі GitHub - Не ўдалося дасягнуць GitHub. Уключэнне проксі jsDelivr… - Абыходзьце блакіроўкі спасылак GitHub з дапамогай jsDelivr. Можа затрымаць абнаўленні на некалькі дзён. - Кланіраваць вэб-сайт - Выдаліць вэб-сайт - Дадайце копію існуючага вэб-сайта з іншым URL-адрасам - Шлях спампоўкі - URL-адрас сервера NGINX - Паказваць метку «Дубляж»/«Субцітры» для анімэ - Умясціць у экран - Запоўніць - Маштабаваць - Адмова ад адказнасці - Абходы ISP - Спасылкі - Абнаўленні праграмы - Рэзервовае капіраванне - Пашырэнні - Дзеянні - Кэш - Android TV - Жэсты - Бяспека - Уліковыя запісы - Функцыі прайгравальніка - Субцітры - Макет - Прадвызначэнні - Выгляд - Функцыі - Агульныя - Кнопка «Выпадковае» - Паказваць кнопку «Выпадковае» на дамашняй старонцы і ў бібліятэцы - Мовы пашырэнняў - Макет праграмы - Прыярытэтнае медыя - Уключыць NSFW на пашырэннях, дзе гэта падтрымліваецца - Кадзіраванне субцітраў - Пастаўшчыкі - Праверка пастаўшчыкоў - Праверыць усе пашырэнні - Гэта праверка прызначана толькі для распрацоўшчыкаў і не вызначае работу абы-якога пашырэння. - Макет - Аўтаматычны - Макет тэлевізара - Макет тэлефона - Макет эмулятара - Асноўны колер - Тэма праграмы - Размяшчэнне загалоўка плаката - Размяшчаць назву пад плакатам - parol123 - Імя карыстальніка - vitaju@sviet.com - 127.0.0.1 - НоваяНазваСайта - https://pryklad.by - Код мовы (be) - %2$s%1$s - уліковы запіс - Выйсці - Увайсці - Лакальная праверка сапраўднасці - Змяніць ўліковы запіс - Дадаць уліковы запіс - Стварыць уліковы запіс - Дадаць адсочванне - Дададзена %s - Сінхранізаваць - Ацэнка - %d / 10 - /?? - /%d - %s праверана на сапраўднасць - Не ўдалося ўвайсці ў %s - Выключыць - Нічога - Звычайны - Усё - Макс. - Мін. - Контур - Заніжаныя - Цень - Паднятыя - Сінхранізаваць субцітры - 1000 мс - Затрымка субцітраў - Выкарыстоўвайце, калі субцітры паяўляюцца на %d мс раней - Выкарыстоўвайце, калі субцітры паяўляюцца на %d мс пазней - Без затрымкі субцітраў - У цэху з’яўляецца чорт, кроўю і пугай фарбуе свежы шампіньён - Рэкамендавана - Загружана %s - Загрузіць з файла - Спампаваць з інтэрнэту - Спампаваць першыя даступныя - Спампаваны файл - Галоўны - Другасны - Фонавы - Крыніца - Выпадковае - Скора… - Cam - Cam - Cam - HQ - HD - TS - TC - Blu-ray - WP - DVD - 4K - SD - UHD - HDR - SDR - Web - Відарыс плаката - Відарыс QR-кода - Прайгравальнік - Раздзяляльнасць і загаловак - Загаловак - Раздзяляльнасць - Звесткі пра медыя - Памылковы ID - Памылковыя дадзеныя - Памылковы URL-адрэс - Памылка - Прыбраць схаваныя цітры з субцітраў - Прыбраць раздуванне з субцітраў - Фільтраваць па прыярытэтнай мове медыя - Дадатковае - Трэйлер - https://pryklad.by/pryklad.mp4 - Referer (неабавязкова) - Далей - Праглядвайце відэа на гэтых мовах - Назад - Прапусціць наладжванне - Змяніце выгляд праграмы пад вашу прыладу - Што хочаце ўбачыць - Гатова - Пашырэнні - Дадаць рэпазіторый - Назва рэпазіторыя (неабавязкова) - URL-адрас рэпазіторыя або кароткі код - Убудова загружана - Убудова спампавана - Убудова выдалена - Не ўдалося загрузіць %s - 18+ - Пачатак спампоўвання %1$d%2$s… - %1$d%2$s спампавана - Усе %s ужо спампаваныя - У рэпазіторыі не знойдзена ўбудоў - Рэпазіторый не знойдзены, праверце URL-адрас і паспрабуйце VPN - Пакетная спампоўка - убудова - убудовы - Гэта таксама прывядзе да выдалення ўсіх убудоў рэпазіторыя - Выдаліць рэпазіторый - Выдаліць убудову - Спампуйце спіс вэб-сайтаў, якімі вы хочаце карыстацца - Спампавана: %d - Выключана: %d - Не спампавана: %d - Абноўлена %d убудоў - Прадвызначана на CloudStream няма ўсталяваных вэб-сайтаў. Вам трэба ўсталяваць вэб-сайты з рэпазіторыяў. \n \nДалучыцеся да нашага сервера Discord або пашукайце ў сетцы. - Праглядзець рэпазіторыі ад супольнасці - Публічны спіс - Усе субцітры верхнім рэгістрам - Увага: CloudStream не нясе адказнасці за выкарыстанне старонніх пашырэнняў і не пастаўляе для іх ніякай падтрымцы! - %s (выключана) - Трэкі - Аўдыятрэкі - Відэатрэкі - Перазапусціце праграму, каб убачыць змены. - Перазапусціць - Спыніць - Бяспечны рэжым уключаны - Усе пашырэнні былі выключаны праз збой, каб вы змаглі знайсці, якое з іх выклікае праблемы. - Праглядзець звесткі пра збой - Рэйтынг: %s - Апісанне - Версія - Стан - Памер - Аўтары - Падтрымлівае - Мова - Спачатку ўсталюйце пашырэнне - Плэй-ліст HLS - Прыярытэтны прайгравальнік - Убудаваны прайгравальнік - Заўсёды пытацца - Выбраць прыладу для трансляцыі - Праграмы не знойдзена - Усе мовы - Прапусціць %s - Опенінг - Заканчэнне - Зводка - Змешанае заканчэнне - Змешаны опенінг - Удзельнікі - Застаўка - Ачысціць гісторыю - Гісторыя - Паказваць усплывальнае акно для пропуску опенінга/заканчэння - Надта шмат тэксту. Не ўдалося захаваць да буфера абмену. - Памылка доступу да буфера абмену, паспрабуйце яшчэ раз. - Памылка пры капіраванні, скапіруйце logcat і звярніцеся ў падтрымку. - Пазначыць як прагледжанае - Прыбраць з прагледжанага - Вы ўпэўнены, што хочаце выйсці? - Так - Не - ОК - Адхіліць - Адкрыць рэпазіторый - Выключыць аптымізацыю батарэі - Каб забяспечыць бесперапынныя спампоўкі і апавяшчэнні аб тэлеперадачах, на якія вы падпісаны, CloudStream патрэбны дазвол на выкананне ў фонавым рэжыме. Па націсканні «ОК», вам пакажацца дыялогавае акно. Націсніце «Дазволіць».\n\nЗвярніце ўвагу — гэта не значыць, што CS3 будзе садзіць вашу батарэю. Праграма будзе працаваць у фоне толькі калі патрэбна, напрыклад пры атрыманні апавяшчэнняў або спампоўванні відэа з афіцыйных пашырэнняў. - Выкарыстанне батарэі ўжо выстаўлена як неабмежаванае - Не ўдалося адкрыць звесткі пра праграму CloudStream. - Спампоўванне абнаўлення праграмы… - Усталяванне абнаўлення праграмы… - Не ўдалося ўсталяваць новую версію праграмы - Састарэлая версія - Усталёўшчык пакетаў - Праграма будзе абноўлена пасля выхаду - Сартаваць па - Сартаваць - Рэйтынгу (ад высокага да нізкага) - Рэйтынгу (ад нізкага да высокага) - Абнаўленню (ад новага да старога) - Абнаўленню (ад старога да новага) - Алфавіту (ад А да Я) - Алфавіту (ад Я да А) - Серыі (па ўзрастанні) - Серыі (па ўбыванні) - Рэйтынгу (найвышэйшы) - Рэйтынгу (найніжэйшы) - Даце паказу (найноўшыя) - Даце паказу (найстарэйшыя) - Сер. %s - Рэйтынг %s - Дата %s - Выбраць бібліятэку - Адкрыць праз - Ваша бібліятэка пустая :( \nУвайдзіце ва ўліковы запіс з бібліятэкай або дадайце праграмы да вашай лакальнай бібліятэкі. - Гэты спіс пусты. Паспрабуйце пераключыцца на іншы. - Знойдзены файл бяспечнага рэжыму! \nПашырэнні не будуць загружацца на запуску, пакуль файл не будзе выдалены. - Вярнуць - Ідзе абнаўленне падпісак - Вы падпісаны - Вы падпісаліся на %s - Вы адпісаліся ад %s - Выпушчана серыя %d! - Падпісацца - Адпісацца - Профіль %d - Wi-Fi - Мабільная перадача дадзеных - Выбраць як прадвызначаны - Выкарыстоўваць - Рэдагаваць - Профілі - Даведка - Тут можна змяніць парадак крыніц. Калі ў відэа большы прыярытэт, яно будзе паказвацца вышэй пры выбары крыніцы. Прыярытэтам відэа з\'яўляецца сума прыярытэту крыніцы і прыярытэту якасці.\n\nКрыніца А: 3\nЯкасць Б: 7\nПрыярытэт відэа будзе 10.\n\nЗАЎВАГА: Калі сума раўняецца 10 ці болей прайгравальнік аўтаматычна прапусціць загрузку для гэтай спасылцы! - Якасці - Фон профілю - Не ўдалося карэктна стварыць інтэрфейс, гэта СУР\'ЁЗНАЯ ХІБА, пра якую варта неадкладна паведаміць %s - Вы ўжо прагаласавалі - Абраныя - %s дададзена ў абранае - %s прыбрана з абранага - Дадаць у абранае - Прыбраць з абранага - Знойдзены магчымы дублікат - Дадаць - Замяніць - Замяніць усё - Падобна на тое, што ў вашай бібліятэцы ёсць дублікат гэтага элемента: ‹%s.›\n\nЦі хочаце вы ўсё роўна яго дадаць, замяніць ужо існуючы, або скасаваць дзеянне? - У вашай бібліятэцы знойдзены магчымыя дублікаты: \n\n%s \n\nЦі хочаце вы ўсё роўна яго дадаць, замяніць ужо існуючыя, або скасаваць дзеянне? - Увядзіце PIN-код - Увядзіце PIN-код для %s - Увядзіце бягучы PIN-код - Заблакіраваць профіль - PIN-код - Няправільны PIN-код. Паспрабуйце яшчэ раз. - PIN-код павінны быць з 4 сімвалаў - Выберыце ўліковы запіс - Без ўліковага запісу - Кіраванне ўліковымі запісамі - Рэдагаваць уліковы запіс - Вы ўвайшлі як %s - Прапускаць выбар уліковага запісу пры запуске - Выкарыстоўваць прадвызначаны ўліковы запіс - Павярнуць - Паказваць кнопку пераключэння арыентацыі экрана - Уключыць аўтаматычнае пераключэнне арыентацыі экрана ў залежнасці ад арыентацыі відэа - Аўтапаварот - Дадаць у абраныя - Выдаліць з абранага - Разблакіруйце CloudStream - Блакіроўка біяметрыяй - Праверка сапраўднасці паролем/PIN-кодам - Праверка сапраўднасці біяметрыяй не падтрымліваецца на гэтай прыладзе - Разблакіруйце праграму адбіткам пальца, Face ID, PIN-кодам, узорам разблакіроўкі або паролем. - Праз некалькі няўдалых спроб акно з запытам закрыецца. Проста перазапусціце праграму, каб паўтарыць спробу. - Вашы дадзеныя CloudStream былібылі толькі што зарэзерваваныя. Нягледзячы на тое, што магчымасць вельмі маленькая, усе прылады могуць паводзіць сябе па-рознаму. У рэдкасным выпадку, калі вы страціце доступ да праграмы, поўнасцю ачысціце даныя і аднавіце іх праз рэзервовую копію. Выбачайце за любую нязручнасць, якая можа з гэтага атрымацца. - Скінуць - CloudStream-Вікі - Наведайце %s на вашым смартфоне або камп\'ютары і ўвядзіце код вышэй - Не ўдалося атрымаць PIN-код прылады, паспрабуйце лакальную праверку сапраўднасці - PIN-код састарэў! - Код мінуе праз %1$dхв %2$dс - Даце выпуску (ад новага да старога) - Даце выпуску (ад старога да новага) - Схаваць назвы элементаў кіравання прайгравальніка - Перадпрагляд на шкале часу - Уключыць мініяцюру папярэдняга прагляду на шкале часу - Субцітраў яшчэ не загружана - Размяшчэнне папкі з рэзервовымі копіямі - Уласнае - Пацвярджэнне перад выхадам - Паказваць дыялог перад выхадам з праграмы - Паказваць - Не паказваць - Памер краёў - Уключыце Torrent у Наладах/Пастаўшчыкі/Прыярытэтнае медыя - Перазапусціце праграму і прыміце ўсплывальнае акно «Трансліраваць Torrent», каб працягнуць. - Праграмная дэкадзіроўка - Праграмная дэкадзіроўка дазваляе прайгравальніку паказваць відэафайлы, якія не падтрымліваюцца на вашай прыладзе, але можа выклікаць затрымкі або рабіць прайграванне нестабільным на высокіх раздзяляльнасцях. - Гучнасць перавысіла 100% - Правядзіце пальцам уверх яшчэ раз, каб зрабіць гучнасць больш за 100% - Абнавіць убудовы - Абнавіць убудовы ўручную - Пачынаецца абнаўленне ўбудоў! - Абноўлена %d убудова(ы/ў)! - Ніводнай убудовы не было абноўлена. - Апавяшчэнні прайгравальніка - Апавяшчэнне прайгравальніка для кіравання прайграваннем у фонавым рэжыме - Убудаваны - Сеткавы - Зрабіць усе субцітры тоўстымі - Зрабіць усе субцітры курсіўнымі - Радыус фону - Колькі разных элементаў можна спампоўваць паралельна - Паралельныя спампоўкі - Адначасовыя злучэнні - Колькі адначасовых злучэнняў можа выкарыстоўваць кожнае спампоўванне - Перайсці ў спампоўкі - Інтэрнет-злучэнне адсутнічае.\n\nЗлучыцеся з інтэрнэтам і паспрабуйце яшчэ раз, або паглядзіце свае спампоўкі, пакуль вы па-за сеткай. - Змяняе краі экрана - Абрэзка відарыса - Змяняе памер плакатаў - Памер плаката - Пераключэнне хуткасці пры доўгім націсканні - Утрымлівайце, каб пераключыцца на 2-кратную хуткасць - Рэдагаваць відарыс профілю - Увядзіце URL-адрас відарыса профілю - URL-адрас не знойдзены - Памылковы URL-адрас або відарыс - Відарыс абноўлены - Пазначыць прагледжаным усё да гэтай серыі - Прыбраць пазнаку «Прагледжана» з усіх серый да гэтай - Перазагружана - Перазагрузіць пастаўшчыка - Назва - Назва крыніцы - Раздзяляльнасць і назва - Выраўноўванне - Знізу злева - Знізу па цэнтру - Знізу справа - У сярэдзіне злева - У сярэдзіне па цэнтру - У сярэдзіне справа - Зверху злева - Зверху па цэнтру - Зверху справа - Чагра спампоўванняў - У чарзе пакуль што няма спампоўванняў. - Прыярытэт крыніц - Выберыце, як сартаваць крыніцы відэа ў прайгравальніку - Спампаваць усё - Скасаваць усё - Спампаваць %s серыю? - Скасаваць усе спампоўванні ў чарзе? - - %d актыўнае спампоўванне - %d актыўных спампоўвання - %d актыўных спампоўванняў - %d актыўных спампоўванняў - - - %d спампоўванне ў чарзе - %d спампоўвання ў чарзе - %d спампоўванняў у чарзе - %d спампоўванняў у чарзе - - Паказваць накладанне з метададзенымі ў прайгравальніку - Відэа - Перадпрагляд - Ужывую + Выберыце рэжым фільтравання спампоўвання убудоваў diff --git a/app/src/main/res/values-ca/strings.xml b/app/src/main/res/values-ca/strings.xml index 495eb5e3c..10f61e40e 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 deleted file mode 100644 index a13f87bfa..000000000 --- a/app/src/main/res/values-sq/strings.xml +++ /dev/null @@ -1,738 +0,0 @@ - - - %1$s Ep %2$d - Kasti: %s - Episodi %d do të publikohet në - Sezoni %1$d Episodi %2$d do të publikohet në - %1$dd %2$do %3$dm - %1$do %2$dm - %dm - %1$do %2$dm %3$ds - %1$dm %2$ds - %1$ds - Posteri - Posteri - Posteri i episodit - Posteri kryesor - Tjetër rastësore - Kthehu prapa - Luaj nga fillimi - Ndrysho ofruesin - Parashikim sfondi - Shpejtësia (%.2fx) - Vlerësuar: %.1f - U gjet update i ri!\n%1$s -> %2$s - Filler - %d min - CloudStream - Luaj me CloudStream - Kryefaqja - Kërko - Shkarkimet - Radhë shkarkimesh - Cilësimet - Kërko… - Kërko %s… - Njohja e të folurit nuk është e disponueshme - Fillo të flasësh… - Nuk ka të dhëna - Më shumë opsione - Episodi i radhës - Zhanri - Shpërndaje - Hap në Browser - Browser - Anashkalo ngarkimin - Duke u ngarkuar… - Duke parë - Në pritje - Përfunduar - Braktisur - Planifikoni të shikoni - Duke rishikuar - Luaj Filmin - Luaj Trailerin - Luaj Transmetimin Live - Transmeto Torrent - Luaj serinë e plotë - Kjo video është një Torrent, kjo do të thotë që aktiviteti juaj i videos mund të gjurmohet.\nSigurohuni që kuptoni Torrent-in para se të vazhdoni. - Burime - Titrat - Ripovo lidhjen… - Kthehu mbrapa - Luaj Episodin - Shkarko - U shkarkua - Duke u shkarkuar - Shkarkimi u ndërpre - Shkarkimi filloi - Shkarkimi dështoi - Shkarkimi u anulua - Shkarkimi u krye - Zgjidhni artikuj për të fshirë - Momentalisht nuk ka shkarkime. - Momentalisht nuk ka shkarkime në radhë. - E disponueshme për shikim offline - Zgjidh të gjitha - Çzgjidh të gjitha - Përditësimi filloi - Transmetim në rrjet - Hap video lokale - Gabim gjatë ngarkimit të lidhjeve - Lidhjet u ringarkuan - Memorie e brendshme - Dublim - Titra - Fshi skedarin - Luaj skedarin - Rifillo shkarkimin - Ndalo shkarkimin - Më shumë info - Fsheh - Luaj - Informacion - Filtro të ruajturat - Të ruajturat - Hiq - Vendos statusin e shikueshmërisë - Apliko - Kopjo - Mbyll - Fshi - Ruaj - Emri i repository-t dhe URL - U kopjua! - Njoftimi i episodeve të reja - Kërko në shtesat e tjera - Shfaq rekomandime - Shpejtësia e luajtësit - Cilësimet e titrave - Ngjyra e tekstit - Ngjyra e konturit - Ngjyra e sfondit - Ngjyra e faqes - Lloji i konturit - Lartësia e titrave - Stili i shkrimit - Madhësia e shkrimit - Kërko duke përdorur ofruesit - Kërko duke përdorur tipet - %d Banane të dhëna per developer-at - Asnjë Banane e dhënë - Zgjedhje automatike e gjuhës - Shkarko gjuhët - Gjuha e titrave - Mbaje shtupur për ti rikthyer në gjendjen fillestare - Importo stilin e shkrimit duke i vendosur në %s - Vazhdo shikimin - Hiq - Më shumë informacion - \@string/home_play - Një VPN mund të nevojitet që ky ofrues të funksionojë në rregull - Ky ofrues është Torrent, rekomandohet një VPN - Metadata nuk ofrohet nga kjo faqe, ngarkimi i videos do të dështojë nëse nuk ekziston në këtë faqe. - Përshkrimi - Skenari nuk u gjet - Përshkrimi nuk u gjet - Shfaq regjistrin Logcat 🐈 - Rregjistër - Imazh-brenda-imazhit - Vazhdo shikimin në një luajtës të vogël mbi aplikacionet e tjera - Butoni për ndryshimin e madhësisë së luajtësit - Hiq kufijtë e errët - Titrat - Cilësimet e titrave të luajtësit - Titrat e Chromecast - Cilësimet e titrave të Chromecast - Shpejtësia e rikthimit - Shto një opsion shpejtësie në luajtës - Rrëshqit për të kaluar - Rrëshqit nga njëra anë në tjetrën për të lëvizur pozicionin e shikimit në video - Rrëshqit per te ndryshuar cilësimet - Rrëshqit lart ose poshtë në të djathtë ose të majtë për të ndryshuar nivelin e ndriçimit dhe volumin - Luaj episodin tjetër automatikisht - Luaj episodin tjetër kur episodi aktual mbaron - Shtyp dy herë për të kaluar - Shtyp dy herë për të ndaluar - Sasia e kalimit të luajtësit (sekonda) - Shtyp dy herë në të djathtë ose të majtë për ta kaluar para ose mbrapa - Shtyp dy herë në mes për të ndaluar - Përdor nivelin e ndriçimit të sitemit - Përdor ndriçimin e sistemit në luajtësin e aplikacionit në vend të një mbivendosjeje të errët - Ndriçim ekstra - Aktivizo filtrin e ndriçimit kur ndriçimi i ekranit kalon 100% - extra_brightness_enabled - Përditëso progresin e shikimit - Sinkronizo automatikisht progresin e episodit aktual - Rikthe të dhënat nga kopja rezervë - Të dhënat e kopjes rezervë - Shpeshtësia e kopjimit rezervë - Kopja rezervë u ngarkua - Dështoi rikthimi i të dhënave nga skedari %s - Të dhënat u ruajtën - Lejet për ruajtje mungojnë. Ju lutem provoni përsëri. - Gabim gjatë kopjimit rezervë të %s - Kërko - Bilblioteka - Llogaritë dhe Siguria - Përditësime dhe Kopje Rezervë - Informacion - Kërkim i avancuar - Tregon rezultatet e kërkimit të ndara sipas ofruesit - Kërkime të sugjeruara - Trego sugjerimet ndërkohë që shkruani - Pastro sugjerimet - Trego episodin Filler për anime - Shfaq informacionin e Metadata-s mbi video - Shfaq trailerat - Shfaq posterat nga Kitsu - Shfaq panelin e aktorëve - Mos shfaq cilësinë e videos së përzgjedhur në rezultatet e kërkimit - Përditësim automatik i shtesave - Shkarkim automatik i shtesave - Zgjidh modalitetin për të filtruar shtesat që shkarkohen - Instalo automatikisht të gjitha shtesat që janë shtuar dhe ende nuk janë instaluar nga repository-t. - Shfaq përditësimet e aplikacionit - Kërko automatikisht për përditësime të reja kur hapet aplikacioni. - Ribëj procesin e konfigurimit - Instalo versione beta - Versioni beta është i instaluar. - Dështoi instalimi i versionit beta. - Instaluesi i APK-ve - Disa pajisje nuk mbështesin instaluesin e ri të paketës. Provoni opsionin e vjetër nëse përditësimet nuk instalohen. - Github - Aplikacioni Light Novel nga të njëjtit developer-a - Aplikacioni i Anime-ve nga të njëjtit developer-a - Na u bashko në Discord - Jep një banane për developer-at - Banane të dhëna - Gjuha e aplikacionit - Ky ofrues nuk suporton Chromecast - Nuk u gjetën lidhje - Lidhja u kopjua në kujtesën e përkohshme - Luaj Episodin - Rikthe në vlerën fillestare - Sezoni - %1$s %2$d%3$s - Asnje sezon - Episodi - Episodet - %1$d-%2$d - %1$d %2$s - Vazhdon në %s - S - E - Nuk u gjet asnjë episod - Fshij - Fshi skedarin - Fshi skedarët - Fshij (%1$d | %2$s) - Anulo - Ndalo - Fillo - Dështoi - Kaloi - Paralajmërim - Vazhdo - -30 - +30 - Kjo do të fshijë përgjithmonë %s\nJeni të sigurt? - Jeni të sigurt që dëshironi të fshini përgjithmonë artikujt e mëposhtëm?\n\n%s - Jeni të sigurt që dëshironi të fshini përgjithmonë episodet e mëposhtme në %1$s?\n\n%2$s - Do të fshini gjithashtu përgjithmonë të gjitha episodet në seritë e mëposhtme:\n\n%s - Jeni të sigurt që dëshironi të fshini përfundimisht të gjitha episodet në serinë e mëposhtme?\n\n%s - %dm\ntë mbetura - %s\ntë mbetura - Në vazhdim - Përfunduar - Statusi - Viti - Vlerësimi - Kohëzgjatja - Faqja - Sinopsisi - në rradhë - Pa titra - Parazgjedhur - Falas - Përdorur - Aplikacion - Filmat - Seriale televizive - Vizatimore - Anime - Torrents - Dokumentarë - OVA - Drama aziatike - Transmetime live - NSFW - Tjera - Film - Seri - Vizatimorë - Anime - OVA - Torrent - Dokumentar - Dramë aziatike - Transmetim live - NSFW - Video - Muzikë - Libër audio - Media - Audio - Podkast - Gabim në burim - Gabim në distancë - Gabim në renderer - Gabim në enkodim - Gabim i pasuportueshëm - Gabim i papritur i luajtësit - Gabim në shkarkim, kontrolloni lejet e memories - Episod Chromecast-i - Pasqyrim Chromecast - Pasqyrim Cast - Luaj në aplikacion - Luaj pasqyrimin" - Luaj në %s - Shkarko automatikisht - Pasqyrim shkarkimi - Ringarko lidhjet - Shkarko titrat - Etiketa e cilësisë - Etiketa e dublimit - Etiketa e titrave - Etiketa e vlerësimit - Titulli - Teksti i episodit - Zgjidh elementët e ndërfaqes mbi poster - Nuk u gjet asnjë përditësim - Kontrollo për përditësime - Çelës - Ndrysho madhësinë - Burimi - Kalo Intron - Mos e shfaq përsëri - Anashkalo këtë përditësim - Përditëso - Cilësia e preferuar e shikimit (WiFi) - Cilësia e preferuar e shikimit (Mobile Data) - Numri maksimal i karaktereve të titullit - Shfaq informacionin e luajtësit - Madhësia e bufferit të videos - Gjatësia e bufferit të videos - Video cache në disk - Pastro cache-në e videove dhe imazheve - Sasia e kalimit - Luajtësi i dukshëm - Sasia e kalimit kur luajtësi është i dukshëm - Sasia e kalimit - Luajtësi i fshehur - Sasia e kalimit kur luajtësi është i fshehur - Shkakton keqfunksionim nëse vendoset shumë lartë në pajisje me memorie të ulët, si Android TV. - Shkakton probleme nëse vendoset shumë lartë në pajisje me memorie të ulët, si Android TV. - DNS në vend të HTTPS - E dobishme për të anashkaluar bllokimet e ISP-së - GitHub Proxy - Nuk mund të arrihej GitHub. Po aktivizohet proxy jsDelivr… - Anashkalon bllokimin e URL-ve të GitHub duke përdorur jsDelivr. Mund të shkaktojë vonesa të disa ditëve në përditësime. - Klono faqen - Fshi faqen - Shto një klonim të një faqeje ekzistuese, me një URL tjetër - Destinacioni i shkarkimit - URL e serverit NGINX - Shfaq Anime të Dubluar/me Titra - Përshtat me ekranin - Shtrij - Zmadho - Mohim përgjegjësie - Tejkalues ISP-je - Lidhjet - Përditësimet e aplikacionit - Kopja rezervë - Shtesat - Veprimet - Cache - Android TV - Gjeste - Siguria - Llogaritë - Veçoritë e luajtësit - Titrat - Paraqitja - Parazgjedhjet - Pamja - Veçoritë - Të përgjithshme - Buton i rastësishëm - Shfaq butonin e rastësishëm në Faqen kryesore dhe Bibliotekë - Zgjidh Bibliotekën - Biblioteka juaj është bosh :(\nHyni në një llogari biblioteke ose shtoni shfaqje në bibliotekën tuaj lokale. - Gjuha e shtesave - Paraqitja e aplikacionit - Media e preferuar - Aktivizo NSFW për shtesat që e suportojnë - Enkodimi i titrave - Ofruesit - Testim i ofruesve - Testo të gjitha shtesat - Ky test është vetëm për developer-a dhe nuk verifikon apo mohon funksionimin e asnjë shtese. - Paraqitja - Automatikisht - Paraqitja për TV - Paraqitja për Celular - Paraqitja për emulator - Ngjyra kryesore - Pamja e aplikacionit - Vendodhja e titullit te posterit - Vendos titullin poshte posterit - Fjalëkalimi123 - Emri i përdoruesit - përshëndetje@shqipëri.com - 127.0.0.1 - EmriRiFaqes - https://shembull.com - Kodi i gjuhës (al) - %1$s %2$s - llogaria - Çkycu - Hyr - Autentifikohu lokalisht - Ndrysho llogari - Shto llogari - Krijo një llogari - Shto gjurmim - U shtua %s - Rifresko - Vlerësuar - %d / 10 - /?? - /%d - %s u autentifikua - Nuk mund të hysh në %s - Çaktivizuar - Asnjë - Normal - Të gjitha - Maksimumi - Minimumi - Kontur - Të zhytura - Hije - Të ngritura - Sinkronizo titrat - 1000 ms - Vonesa e titrave - Përdore nëse titrat shfaqen %d ms më herët - Përdore nëse titrat shfaqen %d ms më vonë - Pa vonesë titrash - Dhelpra e shpejtë ngjyra kafe kërcen mbi qenin dembel - E rekomanduar - U ngarkua %s - Ngarko nga skedari - Ngarko nga interneti - Ngarko të parën e disponueshme - Skedar i shkarkuar - Kryesor - Mbështetës - Figurant - Burimi - Rastësor - Së shpejti… - Kamera - Kamera - Kamera - HQ - HD - TS - TC - Blu-ray - WP - DVD - 4K - SD - UHD - HDR - SDR - Web - Imazhi i posterit - Imazhi i kodit QR - Luajtësi - Rezolucioni dhe titulli - Titulli - Rezolucioni - Informacion i medias - ID e pavlefshme - Të dhëna të pavlefshme - URL e pavlefshme - Gabim - Hiq titrat e mbyllura - Hiq të tepërtat nga titrat - Filtro sipas gjuhës së preferuar të medias - Ekstra - Traileri - https://shembull.com/shembull.mp4 - Referues (opsional) - Tjetër - Shiko videot në këto gjuhë - E mëparshme - Kalo konfigurimin - Ndrysho pamjen e aplikacionit për tu përshtatur me pajisjen tuaj - Çfarë dëshironi të shihni - U krye - Shtesat - Shto repository - Emri i repository-t (Opsional) - URL-ja ose kodi i shkurtër i repository-t - Shtesa u ngarkua - Shtesa u shkarkua - Shtesa u fshi - Nuk mund të ngarkohej %s - 18+ - Filloi shkarkimi i %1$d %2$s… - U shkarkuan %1$d %2$s - 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/array.xml b/app/src/main/res/values/array.xml index d14dca768..83b6afe56 100644 --- a/app/src/main/res/values/array.xml +++ b/app/src/main/res/values/array.xml @@ -54,19 +54,24 @@ @string/nsfw - - @string/source_name + + @string/resolution_and_name + @string/name + @string/resolution_and_title + @string/title @string/resolution - @string/video_info + @string/none - - @string/show_name_key - @string/show_resolution_key - @string/show_media_info_key + + 5 + 4 + 3 + 2 + 1 + 0 - @string/none 16 characters @@ -231,16 +236,14 @@ @string/automatic - HW+SW - SW+HW - HW + @string/yes + @string/no - -1 - 0 - 2 - 1 + -1 + 0 + 1 diff --git a/app/src/main/res/values/donottranslate-strings.xml b/app/src/main/res/values/donottranslate-strings.xml index 6a4c82713..5f2186fae 100644 --- a/app/src/main/res/values/donottranslate-strings.xml +++ b/app/src/main/res/values/donottranslate-strings.xml @@ -19,7 +19,7 @@ quality_pref_mobile_data_key player_default_key prefer_limit_title_key - prefer_limit_show_player_info + prefer_limit_title_rez_key apk_installer_key video_buffer_size_key video_buffer_length_key @@ -29,7 +29,6 @@ swipe_enabled_key playback_speed_enabled_key player_resize_enabled_key - player_source_priority_key pip_enabled_key double_tap_enabled_key double_tap_pause_enabled_key @@ -40,10 +39,8 @@ autoplay_next_key display_sub_key show_fillers_key - show_player_metadata_key show_trailers_key show_kitsu_posters_key - show_cast_in_details_key random_button_key provider_lang_key dns_key @@ -97,10 +94,8 @@ anilist_key simkl_key mal_key - kitsu_key opensubtitles_key subdl_key - animeskip_key pref_category_security_key pref_category_gestures_key @@ -133,11 +128,6 @@ @string/action_default @string/action_default - show_name - show_resolution - show_media_info - prefer_title_limit - Any legal issues regarding the content on this application should be taken up with the actual file hosts and providers themselves as we are not affiliated with them. diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 31cf951cf..8ad0ec423 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -32,7 +32,6 @@ Home Search Downloads - Download queue Settings Search… Search %s… @@ -75,7 +74,6 @@ Download Done Select Items to Delete There are currently no downloads. - There are currently no queued downloads. Available for watching offline Select All Deselect All @@ -166,9 +164,6 @@ Use system brightness in the app player instead of a dark overlay - Extra brightness - Enable brightness filter when 100% display brightness is exceeded - extra_brightness_enabled Update watch progress Automatically sync your current episode progress Restore data from backup @@ -186,14 +181,9 @@ Info Advanced Search Gives you the search results separated by provider - Search Suggestions - Show search suggestions while typing - Clear Suggestions Show filler episode for anime - Show Player Metadata Overlay Show trailers Show posters from Kitsu - Show cast panel Hide selected video quality in search results Automatic plugin updates Automatically download plugins @@ -202,9 +192,9 @@ Show app updates Automatically search for new updates after starting the app. Redo setup process - Install pre-release version - Pre-release is already installed. - Failed to install pre-release. + Install pre-release version + Pre-release is already installed. + Failed to install pre-release. APK Installer Some devices do not support the new package installer. Try the legacy option if updates do not install. Github @@ -288,12 +278,11 @@ Livestream NSFW Video - Music + Music Audio Book - Media - Audio - Podcast - Video + Media + Audio + Podcast Source error Remote error Renderer error @@ -330,7 +319,7 @@ Preferred watch quality (WiFi) Preferred watch quality (Mobile Data) Video player title max chars - Show player information + Video player resolution Video buffer size Video buffer length Video cache on disk @@ -477,7 +466,6 @@ Resolution and title Title Resolution - Media Info Invalid ID Invalid data Invalid URL @@ -560,8 +548,7 @@ Recap Mixed ending Mixed opening - Credits - Preview + Credits Intro Clear history History @@ -627,8 +614,6 @@ Set default Use Edit - Source priority - Decide how video sources should be sorted in the player Profiles Help @@ -758,12 +743,7 @@ Reloaded Reload Provider Name - Source Name Resolution and name - Download all - Cancel all - Do you want to download episode %s? - Do you want to cancel all queued downloads? Subtitle Alignment Bottom left Bottom center @@ -774,14 +754,4 @@ Top left Top center Top right - - %d active download - %d active downloads - - - %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 580090318..bbef5f05b 100644 --- a/app/src/main/res/xml/settings_account.xml +++ b/app/src/main/res/xml/settings_account.xml @@ -9,10 +9,6 @@ android:icon="@drawable/mal_logo" android:key="@string/mal_key" /> - - @@ -28,9 +24,6 @@ - - - - - - Unit, - callback: (ExtractorLink) -> Unit, - ) { - val videoId = extractYouTubeId(url) - val watchUrl = "$mainUrl/watch?v=$videoId" - - val info = StreamInfo.getInfo(watchUrl) - val isLive = info.streamType == StreamType.LIVE_STREAM - || info.streamType == StreamType.AUDIO_LIVE_STREAM - || info.streamType == StreamType.POST_LIVE_STREAM - || info.streamType == StreamType.POST_LIVE_AUDIO_STREAM - - if (isLive && info.hlsUrl != null) { - callback( - newExtractorLink( - source = name, - name = "YouTube Live", - url = info.hlsUrl - ) { - type = ExtractorLinkType.M3U8 - } - ) - } else { - processVideo(info, subtitleCallback, callback) - } - } - - private suspend fun processVideo( - info: StreamInfo, - subtitleCallback: (SubtitleFile) -> Unit, - callback: (ExtractorLink) -> Unit, - ): Boolean { - val videoStreams = info.videoOnlyStreams.orEmpty() - if (videoStreams.isEmpty()) return false - - val audioStreams = info.audioStreams.orEmpty() - videoStreams.forEach { video -> - callback( - newExtractorLink( - source = name, - name = "YouTube ${normalizeCodec(video.codec)}", - url = video.content - ) { - quality = video.height - audioTracks = audioStreams.map { newAudioFile(it.content) } - } - ) - } - - info.subtitles.forEach { subtitle -> - subtitleCallback( - newSubtitleFile( - lang = subtitle.displayLanguageName - ?: subtitle.languageTag - ?: "Unknown", - url = subtitle.content - ) - ) - } - - return true - } - - private fun extractYouTubeId(url: String): String { - val regex = Regex( - "(?:youtu\\.be/|youtube(?:-nocookie)?\\.com/(?:.*v=|v/|u/\\w/|embed/|shorts/|live/))([\\w-]{11})" - ) - - return regex.find(url)?.groupValues?.get(1) - ?: throw IllegalArgumentException("Invalid YouTube URL: $url") - } - - private fun normalizeCodec(codec: String?): String { - if (codec.isNullOrBlank()) return "" - val c = codec.lowercase() - return when { - c.startsWith("av01") -> "AV1" - c.startsWith("vp9") -> "VP9" - c.startsWith("avc1") || c.startsWith("h264") -> "H264" - c.startsWith("hev1") || c.startsWith("hvc1") || c.startsWith("hevc") -> "H265" - else -> codec.substringBefore('.').uppercase() - } - } -} diff --git a/library/src/androidMain/kotlin/com/lagradost/cloudstream3/network/WebViewResolver.android.kt b/library/src/androidMain/kotlin/com/lagradost/cloudstream3/network/WebViewResolver.android.kt index 2f9c9b628..a99d0a16a 100644 --- a/library/src/androidMain/kotlin/com/lagradost/cloudstream3/network/WebViewResolver.android.kt +++ b/library/src/androidMain/kotlin/com/lagradost/cloudstream3/network/WebViewResolver.android.kt @@ -3,6 +3,8 @@ package com.lagradost.cloudstream3.network import android.annotation.SuppressLint import android.content.Context import android.net.http.SslError +import android.os.Handler +import android.os.Looper import android.webkit.* import com.lagradost.api.Log import com.lagradost.api.getContext @@ -10,18 +12,16 @@ import com.lagradost.cloudstream3.app import com.lagradost.cloudstream3.mvvm.debugException import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.mvvm.safe -import com.lagradost.cloudstream3.utils.Coroutines.atomicListOf import com.lagradost.cloudstream3.utils.Coroutines.main import com.lagradost.cloudstream3.utils.Coroutines.mainWork -import com.lagradost.cloudstream3.utils.Coroutines.runOnMainThread +import com.lagradost.cloudstream3.utils.Coroutines.threadSafeListOf import com.lagradost.nicehttp.requestCreator -import io.ktor.http.Url -import io.ktor.http.decodeURLPart import kotlinx.coroutines.delay import kotlinx.coroutines.runBlocking import okhttp3.Interceptor import okhttp3.Request import okhttp3.Response +import java.net.URI /** * When used as Interceptor additionalUrls cannot be returned, use WebViewResolver(...).resolveUsingWebView(...) @@ -120,9 +120,11 @@ actual class WebViewResolver actual constructor( } var fixedRequest: Request? = null - val extraRequestList = atomicListOf() + val extraRequestList = threadSafeListOf() main { + // Useful for debugging + WebView.setWebContentsDebuggingEnabled(true) try { webView = WebView( (getContext() as? Context) @@ -150,7 +152,8 @@ actual class WebViewResolver actual constructor( Log.i(TAG, "Loading WebView URL: $webViewUrl") if (script != null) { - runOnMainThread { + val handler = Handler(Looper.getMainLooper()) + handler.post { view.evaluateJavascript(script) { scriptCallback?.invoke(it) } } @@ -212,7 +215,7 @@ actual class WebViewResolver actual constructor( * */ return@runBlocking try { when { - blacklistedFiles.any { Url(webViewUrl).encodedPath.decodeURLPart().contains(it) } || webViewUrl.endsWith( + blacklistedFiles.any { URI(webViewUrl).path.contains(it) } || webViewUrl.endsWith( "/favicon.ico" ) -> WebResourceResponse( "image/png", 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 048e7fc02..48a709eb4 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,13 +2,10 @@ package com.lagradost.cloudstream3.utils import android.os.Handler import android.os.Looper -import androidx.annotation.AnyThread -import androidx.annotation.MainThread -@AnyThread -actual fun runOnMainThreadNative(@MainThread work: () -> Unit) { +actual fun runOnMainThreadNative(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 ffc0a938d..2f196af3e 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/MainAPI.kt +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/MainAPI.kt @@ -17,34 +17,21 @@ import com.lagradost.cloudstream3.syncproviders.SyncIdName import com.lagradost.cloudstream3.utils.* import com.lagradost.cloudstream3.utils.AppUtils.toJson import com.lagradost.cloudstream3.utils.AppUtils.tryParseJson -import com.lagradost.cloudstream3.utils.Coroutines.atomicListOf import com.lagradost.cloudstream3.utils.Coroutines.mainWork +import com.lagradost.cloudstream3.utils.Coroutines.threadSafeListOf import com.lagradost.cloudstream3.utils.SubtitleHelper.fromCodeToLangTagIETF import com.lagradost.cloudstream3.utils.SubtitleHelper.fromLanguageToTagIETF import com.lagradost.nicehttp.RequestBodyTypes -import io.ktor.http.Url -import io.ktor.http.URLBuilder -import io.ktor.http.encodedPath -import io.ktor.http.takeFrom import okhttp3.Interceptor import okhttp3.MediaType.Companion.toMediaTypeOrNull import okhttp3.RequestBody.Companion.toRequestBody -import kotlinx.datetime.LocalDate -import kotlinx.datetime.LocalTime -import kotlinx.datetime.TimeZone -import kotlinx.datetime.atStartOfDayIn -import kotlinx.datetime.format.DateTimeComponents -import kotlinx.datetime.format.FormatStringsInDatetimeFormats -import kotlinx.datetime.format.byUnicodePattern -import kotlinx.datetime.format.char -import kotlinx.datetime.format.parse -import kotlinx.datetime.toInstant -import kotlinx.serialization.json.Json +import java.net.URI +import java.text.SimpleDateFormat +import java.util.* import kotlin.io.encoding.Base64 +import kotlin.io.encoding.ExperimentalEncodingApi import kotlin.math.absoluteValue import kotlin.math.roundToInt -import kotlin.time.Clock -import kotlin.time.Instant /** * API available only on prerelease builds. @@ -59,22 +46,6 @@ import kotlin.time.Instant ) annotation class Prerelease -@Retention(AnnotationRetention.BINARY) // This is only an IDE hint, and will not be used in the runtime -@RequiresOptIn( - message = "This API is marked as internal and should not be used by extensions. " + - "Using it could cause catastrophic build or runtime errors and may " + - "be changed or removed at any time.", - level = RequiresOptIn.Level.ERROR -) -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 @@ -87,27 +58,20 @@ const val USER_AGENT = class ErrorLoadingException(message: String? = null) : Exception(message) //val baseHeader = mapOf("User-Agent" to USER_AGENT) - -@Prerelease -val json = Json { - encodeDefaults = true - explicitNulls = false - ignoreUnknownKeys = true -} - val mapper = JsonMapper.builder().addModule(kotlinModule()) .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false).build()!! object APIHolder { - val unixTimeMS: Long - get() = Clock.System.now().toEpochMilliseconds() val unixTime: Long - get() = unixTimeMS / 1000L + get() = System.currentTimeMillis() / 1000L + val unixTimeMS: Long + get() = System.currentTimeMillis() - val allProviders = atomicListOf() + // ConcurrentModificationException is possible!!! + val allProviders = threadSafeListOf() fun initAll() { - allProviders.withLock { + synchronized(allProviders) { for (api in allProviders) { api.init() } @@ -117,28 +81,28 @@ object APIHolder { /** String extension function to Capitalize first char of string.*/ fun String.capitalize(): String { - return this.replaceFirstChar { if (it.isLowerCase()) it.titlecase() else it.toString() } + return this.replaceFirstChar { if (it.isLowerCase()) it.titlecase(Locale.getDefault()) else it.toString() } } - var apis: AtomicList = atomicListOf() + var apis: List = threadSafeListOf() var apiMap: Map? = null fun addPluginMapping(plugin: MainAPI) { - apis.withLock { + synchronized(apis) { apis = apis + plugin } initMap(true) } fun removePluginMapping(plugin: MainAPI) { - apis.withLock { + synchronized(apis) { apis = apis.filter { it != plugin } } initMap(true) } private fun initMap(forcedUpdate: Boolean = false) { - apis.withLock { + synchronized(apis) { if (apiMap == null || forcedUpdate) apiMap = apis.mapIndexed { index, api -> api.name to index }.toMap() } @@ -146,21 +110,24 @@ object APIHolder { fun getApiFromNameNull(apiName: String?): MainAPI? { if (apiName == null) return null - return allProviders.withLock { + synchronized(allProviders) { initMap() - apis.withLock { - apiMap?.get(apiName)?.let { apis.getOrNull(it) } + synchronized(apis) { + return apiMap?.get(apiName)?.let { apis.getOrNull(it) } // Leave the ?. null check, it can crash regardless - ?: allProviders.firstOrNull { it.name == apiName } + ?: allProviders.firstOrNull { it.name == apiName } } } } fun getApiFromUrlNull(url: String?): MainAPI? { if (url == null) return null - return allProviders.withLock { - allProviders.firstOrNull { url.startsWith(it.mainUrl) } + synchronized(allProviders) { + allProviders.forEach { api -> + if (url.startsWith(api.mainUrl)) return api + } } + return null } /** @@ -178,9 +145,9 @@ object APIHolder { // To get the key suspend fun getCaptchaToken(url: String, key: String, referer: String? = null): String? { try { - val _url = Url(url) + val uri = URI.create(url) val domain = base64Encode( - (_url.protocol.name + "://" + _url.host + ":443").encodeToByteArray(), + (uri.scheme + "://" + uri.host + ":443").encodeToByteArray(), ).replace("\n", "").replace("=", ".") val vToken = @@ -265,7 +232,6 @@ object APIHolder { Tracker( res.idMal, - null, res.id.toString(), res.coverImage?.extraLarge ?: res.coverImage?.large, res.bannerImage @@ -489,7 +455,7 @@ abstract class MainAPI { } fun init() { - overrideData?.get(this::class.simpleName)?.let { data -> + overrideData?.get(this.javaClass.simpleName)?.let { data -> overrideWithNewData(data) } } @@ -703,22 +669,17 @@ abstract class MainAPI { } } +/** Might need a different implementation for desktop*/ fun base64Decode(string: String): String { - // ISO-8859-1 decoding: each byte maps directly to its Unicode code point (0-255), - // so we mask each byte to unsigned and convert to the corresponding Char manually. - // decodeToString() can't be used here as it assumes UTF-8. - val bytes = base64DecodeArray(string) - return buildString(bytes.size) { - for (b in bytes) { - append((b.toInt() and 0xFF).toChar()) - } - } + return String(base64DecodeArray(string), Charsets.ISO_8859_1) } +@OptIn(ExperimentalEncodingApi::class) fun base64DecodeArray(string: String): ByteArray { return Base64.decode(string) } +@OptIn(ExperimentalEncodingApi::class) fun base64Encode(array: ByteArray): String { return Base64.encode(array) } @@ -777,7 +738,7 @@ fun capitalizeStringNullable(str: String?): String? { if (str == null) return null return try { - str.replaceFirstChar(Char::titlecase) + str.replaceFirstChar { if (it.isLowerCase()) it.titlecase(Locale.getDefault()) else it.toString() } } catch (e: Exception) { str } @@ -786,7 +747,7 @@ fun capitalizeStringNullable(str: String?): String? { fun fixTitle(str: String): String { return str.split(" ").joinToString(" ") { it.lowercase() - .replaceFirstChar(Char::titlecase) + .replaceFirstChar { char -> if (char.isLowerCase()) char.titlecase(Locale.getDefault()) else it } } } @@ -1086,8 +1047,6 @@ enum class TvType(value: Int?) { Audio(16), Podcast(17), - @Prerelease - Video(18), } enum class AutoDownloadMode(val value: Int) { @@ -1103,15 +1062,14 @@ 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, Video returns true otherwise returns false. + * @return If the type is AnimeMovie, Live, Movie, Torrent returns true otherwise returns false. * */ fun TvType.isMovieType(): Boolean { return when (this) { TvType.AnimeMovie, TvType.Live, TvType.Movie, - TvType.Torrent, - TvType.Video -> true + TvType.Torrent -> true else -> false } @@ -1191,6 +1149,7 @@ suspend fun newSubtitleFile( * @property headers Optional headers for the audio file request. * @see newAudioFile * */ +@Prerelease @ConsistentCopyVisibility data class AudioFile internal constructor( var url: String, @@ -1202,6 +1161,7 @@ data class AudioFile internal constructor( * @param initializer Lambda to configure additional properties like headers. * @return Configured AudioFile instance. * */ +@Prerelease suspend fun newAudioFile( url: String, initializer: suspend AudioFile.() -> Unit = { } @@ -1330,23 +1290,23 @@ fun getQualityFromString(string: String?): SearchQuality? { * ``` */ fun MainAPI.updateUrl(url: String): String { - return try { - val original = Url(url) - val updated = Url(mainUrl) + try { + val original = URI(url) + val updated = URI(mainUrl) - URLBuilder().apply { - takeFrom(updated) - user = original.user - password = original.password - encodedPath = original.encodedPath - fragment = original.fragment - - parameters.clear() - parameters.appendAll(original.parameters) - }.buildString() + // URI(String scheme, String userInfo, String host, int port, String path, String query, String fragment) + return URI( + updated.scheme, + original.userInfo, + updated.host, + updated.port, + original.path, + original.query, + original.fragment + ).toString() } catch (t: Throwable) { logError(t) - url + return url } } @@ -1510,7 +1470,7 @@ constructor( override var posterUrl: String? = null, var year: Int? = null, - var dubStatus: MutableSet? = null, + var dubStatus: EnumSet? = null, var otherName: String? = null, var episodes: MutableMap = mutableMapOf(), @@ -1519,10 +1479,46 @@ constructor( override var quality: SearchQuality? = null, override var posterHeaders: Map? = null, override var score: Score? = null, -) : SearchResponse +) : SearchResponse { + @Suppress("DEPRECATION_ERROR") + @Deprecated( + "Use newAnimeSearchResponse", + level = DeprecationLevel.ERROR + ) + constructor( + name: String, + url: String, + apiName: String, + type: TvType? = null, + + posterUrl: String? = null, + year: Int? = null, + dubStatus: EnumSet? = null, + + otherName: String? = null, + episodes: MutableMap = mutableMapOf(), + + id: Int? = null, + quality: SearchQuality? = null, + posterHeaders: Map? = null, + ) : this( + name, + url, + apiName, + type, + posterUrl, + year, + dubStatus, + otherName, + episodes, + id, + quality, + posterHeaders, null + ) +} fun AnimeSearchResponse.addDubStatus(status: DubStatus, episodes: Int? = null) { - this.dubStatus = dubStatus?.also { it.add(status) } ?: mutableSetOf(status) + this.dubStatus = dubStatus?.also { it.add(status) } ?: EnumSet.of(status) if (this.type?.isMovieType() != true) if (episodes != null && episodes > 0) this.episodes[status] = episodes @@ -1780,6 +1776,7 @@ interface LoadResponse { var posterHeaders: Map? var backgroundPosterUrl: String? + @Prerelease var logoUrl: String? var contentRating: String? @@ -1800,8 +1797,6 @@ interface LoadResponse { companion object { var malIdPrefix = "" //malApi.idPrefix - - var kitsuIdPrefix = "" //kitsuApi.idPrefix var aniListIdPrefix = "" //aniListApi.idPrefix var simklIdPrefix = "" //simklApi.idPrefix var isTrailersEnabled = true @@ -1862,9 +1857,6 @@ interface LoadResponse { return this.syncData[malIdPrefix] } - fun LoadResponse.getKitsuId(): String? { - return this.syncData[kitsuIdPrefix] - } fun LoadResponse.getAniListId(): String? { return this.syncData[aniListIdPrefix] } @@ -1886,10 +1878,6 @@ interface LoadResponse { this.addSimklId(SimklSyncServices.Mal, id.toString()) } - fun LoadResponse.addKitsuId(id: Int?) { - this.syncData[kitsuIdPrefix] = (id ?: return).toString() - } - fun LoadResponse.addAniListId(id: Int?) { this.syncData[aniListIdPrefix] = (id ?: return).toString() this.addSimklId(SimklSyncServices.AniList, id.toString()) @@ -2151,7 +2139,6 @@ fun TvType.getFolderPrefix(): String { TvType.Podcast -> "Podcasts" TvType.Torrent -> "Torrents" TvType.TvSeries -> "TVSeries" - TvType.Video -> "Videos" } } @@ -2521,45 +2508,15 @@ constructor( get() = score?.toInt(100) } -@OptIn(FormatStringsInDatetimeFormats::class) fun Episode.addDate(date: String?, format: String = "yyyy-MM-dd") { - if (date == null) return - this.date = runCatching { - // First try standard ISO 8601 (e.g. "2026-01-01T12:30:00.000Z", "2026-05-17T14:35+02:00") - runCatching { Instant.parse(date).toEpochMilliseconds() } - .getOrElse { - val fmt = DateTimeComponents.Format { byUnicodePattern(format) } - val components = DateTimeComponents.parse(date, fmt) - /** - * Try multiple conversions in order of precision for non-ISO-8601 formats, - * since the date string may or may not include time and/or timezone offset: - * 1. If the custom format produced a UTC offset (e.g. "2026-05-17 14:35+02:00"), use it directly - * 2. If it has time but no offset (e.g. "2026-05-17 14:35"), fall back to device timezone - * 3. If it's date-only (e.g. "2026-05-17"), use start of day in device timezone - */ - runCatching { components.toInstantUsingOffset().toEpochMilliseconds() } - .recoverCatching { components.toLocalDateTime().toInstant(TimeZone.currentSystemDefault()).toEpochMilliseconds() } - .getOrElse { components.toLocalDate().atStartOfDayIn(TimeZone.currentSystemDefault()).toEpochMilliseconds() } - } - }.onFailure { logError(it) }.getOrNull() + try { + this.date = SimpleDateFormat(format, Locale.getDefault()).parse(date ?: return)?.time + } catch (e: Exception) { + logError(e) + } } -@Prerelease -fun Episode.addDate(date: LocalDate?) { - this.date = date?.atStartOfDayIn(TimeZone.currentSystemDefault())?.toEpochMilliseconds() -} - -@Prerelease -fun Episode.addDate(date: Instant?) { - this.date = date?.toEpochMilliseconds() -} - -// Deprecate after next stable -/* @Deprecated( - message = "Use addDate with LocalDate, Instant, or String instead.", - level = DeprecationLevel.WARNING, -) */ -fun Episode.addDate(date: java.util.Date?) { +fun Episode.addDate(date: Date?) { this.date = date?.time } @@ -2696,27 +2653,6 @@ fun fetchUrls(text: String?): List { return linkRegex.findAll(text).map { it.value.trim().removeSurrounding("\"") }.toList() } -@Prerelease -fun isUpcoming(dateString: String?): Boolean { - return runCatching { - val fmt = DateTimeComponents.Format { - year(); char('-'); monthNumber(); char('-'); day() - } - val components = DateTimeComponents.parse(dateString ?: return false, fmt) - /** - * Try multiple conversions in order of precision, since the date string format - * may or may not include time and/or timezone offset information: - * 1. If the string has a UTC offset (e.g. "2026-05-17T14:35+02:00"), use it directly - * 2. If it has time but no offset (e.g. "2026-05-17T14:35"), fall back to device timezone - * 3. If it's date-only (e.g. "2026-05-17"), use start of day in device timezone - */ - val instant = runCatching { components.toInstantUsingOffset() } - .recoverCatching { components.toLocalDateTime().toInstant(TimeZone.currentSystemDefault()) } - .getOrElse { components.toLocalDate().atStartOfDayIn(TimeZone.currentSystemDefault()) } - Clock.System.now() < instant - }.onFailure { logError(it) }.getOrElse { false } -} - @Deprecated( "toRatingInt() is deprecated. Use new score API instead.", level = DeprecationLevel.ERROR @@ -2726,7 +2662,6 @@ fun String?.toRatingInt(): Int? = data class Tracker( val malId: Int? = null, - val kitsuId: String? = null, val aniId: String? = null, val image: String? = null, val cover: String? = null, diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/MainActivity.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/MainActivity.kt index 127b075da..6502cc831 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/MainActivity.kt +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/MainActivity.kt @@ -1,40 +1,35 @@ package com.lagradost.cloudstream3 -import com.lagradost.cloudstream3.utils.AppUtils.parseJson -import com.lagradost.cloudstream3.utils.AppUtils.toJson +import com.fasterxml.jackson.databind.DeserializationFeature +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import com.lagradost.nicehttp.Requests import com.lagradost.nicehttp.ResponseParser import kotlin.reflect.KClass // Short name for requests client to make it nicer to use -private val jsonResponseParser = object : ResponseParser { + +var app = Requests(responseParser = object : ResponseParser { + val mapper: ObjectMapper = jacksonObjectMapper().configure( + DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, + false + ) + override fun parse(text: String, kClass: KClass): T { - return parseJson(text, kClass) + return mapper.readValue(text, kClass.java) } override fun parseSafe(text: String, kClass: KClass): T? { return try { - parse(text, kClass) - } catch (_: Exception) { + mapper.readValue(text, kClass.java) + } catch (e: Exception) { null } } override fun writeValueAsString(obj: Any): String { - return obj.toJson() + return mapper.writeValueAsString(obj) } -} - -/** 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 = jsonResponseParser).apply { +}).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 = jsonResponseParser).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/AStreamHub.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/AStreamHub.kt new file mode 100644 index 000000000..7575a6347 --- /dev/null +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/AStreamHub.kt @@ -0,0 +1,41 @@ +package com.lagradost.cloudstream3.extractors + +import com.lagradost.api.Log +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.newExtractorLink + +open class AStreamHub : ExtractorApi() { + override val name = "AStreamHub" + override val mainUrl = "https://astreamhub.com" + override val requiresReferer = true + + override suspend fun getUrl(url: String, referer: String?): List { + val sources = mutableListOf() + app.get(url).document.selectFirst("body > script")?.data()?.let { script -> + Log.i("Dev", "script => $script") + if (script.isNotBlank()) { + val m3link = "(?<=file:)(.*)(?=,)".toRegex().find(script) + ?.groupValues?.get(0)?.trim()?.trim('"') ?: "" + Log.i("Dev", "m3link => $m3link") + if (m3link.isNotBlank()) { + sources.add( + newExtractorLink( + name = name, + source = name, + url = m3link, + type = ExtractorLinkType.M3U8 + ) { + this.quality = Qualities.Unknown.value + this.referer = referer ?: url + } + ) + } + } + } + return sources + } +} \ No newline at end of file diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/AsianLoad.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/AsianLoad.kt new file mode 100644 index 000000000..70e869f55 --- /dev/null +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/AsianLoad.kt @@ -0,0 +1,50 @@ +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 + +class Vidnest : AsianLoad() { + override var name = "Vidnest" + override var mainUrl = "https://vidnest.io" +} + +open class AsianLoad : ExtractorApi() { + override var name = "AsianLoad" + override var mainUrl = "https://asianhdplay.pro" + 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 + } + } +} diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Bigwarp.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Bigwarp.kt new file mode 100644 index 000000000..50a68c62f --- /dev/null +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Bigwarp.kt @@ -0,0 +1,51 @@ +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/BullStream.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/BullStream.kt new file mode 100644 index 000000000..71fa7066b --- /dev/null +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/BullStream.kt @@ -0,0 +1,29 @@ +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 + +open class BullStream : ExtractorApi() { + override val name = "BullStream" + override val mainUrl = "https://bullstream.xyz" + override val requiresReferer = false + val regex = Regex("(?<=sniff\\()(.*)(?=\\)\\);)") + + override suspend fun getUrl(url: String, referer: String?): List? { + val data = regex.find(app.get(url).text)?.value + ?.replace("\"", "") + ?.split(",") + ?: return null + + val m3u8 = "$mainUrl/m3u8/${data[1]}/${data[2]}/master.txt?s=1&cache=${data[4]}" + //println("shiv : $m3u8") + return M3u8Helper.generateM3u8( + name, + m3u8, + url, + headers = mapOf("referer" to url, "accept" to "*/*") + ) + } +} \ No newline at end of file diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/ByseSX.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/ByseSX.kt index b29d29f5d..34ad4fad0 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/ByseSX.kt +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/ByseSX.kt @@ -1,6 +1,7 @@ package com.lagradost.cloudstream3.extractors import com.fasterxml.jackson.annotation.JsonProperty +import com.lagradost.cloudstream3.Prerelease import com.lagradost.cloudstream3.SubtitleFile import com.lagradost.cloudstream3.app import com.lagradost.cloudstream3.base64DecodeArray @@ -8,32 +9,31 @@ 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 -import io.ktor.http.Url -import io.ktor.http.decodeURLPart +import java.net.URI +import java.nio.charset.StandardCharsets import javax.crypto.Cipher import javax.crypto.spec.GCMParameterSpec import javax.crypto.spec.SecretKeySpec +@Prerelease class Bysezejataos : ByseSX() { override var name = "Bysezejataos" override var mainUrl = "https://bysezejataos.com" } +@Prerelease class ByseBuho : ByseSX() { override var name = "ByseBuho" override var mainUrl = "https://bysebuho.com" } +@Prerelease class ByseVepoin : ByseSX() { override var name = "ByseVepoin" override var mainUrl = "https://bysevepoin.com" } -class ByseQekaho : ByseSX() { - override var name = "ByseQekaho" - override var mainUrl = "https://byseqekaho.com" -} - +@Prerelease open class ByseSX : ExtractorApi() { override var name = "Byse" override var mainUrl = "https://byse.sx" @@ -46,11 +46,11 @@ open class ByseSX : ExtractorApi() { } private fun getBaseUrl(url: String): String { - return Url(url).let { "${it.protocol.name}://${it.host}" } + return URI(url).let { "${it.scheme}://${it.host}" } } private fun getCodeFromUrl(url: String): String { - val path = Url(url).encodedPath.decodeURLPart() + val path = URI(url).path ?: "" return path.trimEnd('/').substringAfterLast('/') } @@ -94,7 +94,7 @@ open class ByseSX : ExtractorApi() { cipher.init(Cipher.DECRYPT_MODE, secretKey, spec) val plainBytes = cipher.doFinal(cipherBytes) - var jsonStr = plainBytes.decodeToString() + var jsonStr = String(plainBytes, StandardCharsets.UTF_8) if (jsonStr.startsWith("\uFEFF")) jsonStr = jsonStr.substring(1) diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/ByteShare.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/ByteShare.kt new file mode 100644 index 000000000..ca9504d2c --- /dev/null +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/ByteShare.kt @@ -0,0 +1,21 @@ +package com.lagradost.cloudstream3.extractors + +import com.lagradost.cloudstream3.utils.* + +open class ByteShare : ExtractorApi() { + override val name = "ByteShare" + override val mainUrl = "https://byteshare.to" + override val requiresReferer = false + + override suspend fun getUrl(url: String, referer: String?): List { + val sources = mutableListOf() + sources.add( + newExtractorLink( + source = name, + name = name, + url = url.replace("/embed/", "/download/"), + ) + ) + return sources + } +} diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Cda.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Cda.kt index 5c9f58efc..fc155bdd9 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Cda.kt +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Cda.kt @@ -6,14 +6,15 @@ import com.lagradost.cloudstream3.utils.AppUtils.tryParseJson import com.lagradost.cloudstream3.utils.ExtractorApi import com.lagradost.cloudstream3.utils.ExtractorLink import com.lagradost.cloudstream3.utils.Qualities -import com.lagradost.cloudstream3.utils.StringUtils.decodeUrl import com.lagradost.cloudstream3.utils.newExtractorLink +import java.net.URLDecoder open class Cda : ExtractorApi() { override var mainUrl = "https://ebd.cda.pl" override var name = "Cda" override val requiresReferer = false + override suspend fun getUrl(url: String, referer: String?): List? { val mediaId = url .split("/").last() @@ -64,10 +65,10 @@ open class Cda : ExtractorApi() { .replace("_QWE", "") .replace("_Q5", "") .replace("_IKSDE", "") - a = a.decodeUrl() + a = URLDecoder.decode(a, "UTF-8") a = a.map { char -> if (char.code in 33..126) { - return@map (33 + (char.code + 14) % 94).toChar().toString() + return@map String.format("%c", 33 + (char.code + 14) % 94) } else { return@map char } diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/CineMMRedirect.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/CineMMRedirect.kt index a85aff8d4..62c450073 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/CineMMRedirect.kt +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/CineMMRedirect.kt @@ -4,7 +4,7 @@ import com.lagradost.cloudstream3.SubtitleFile import com.lagradost.cloudstream3.utils.ExtractorApi import com.lagradost.cloudstream3.utils.ExtractorLink import com.lagradost.cloudstream3.utils.loadExtractor -import io.ktor.http.Url +import okhttp3.HttpUrl.Companion.toHttpUrl // deobfuscated from https://hglink.to/main.js?v=1.1.3 using https://deobfuscate.io/ private val mirrors = arrayOf( @@ -90,7 +90,7 @@ abstract class CineMMRedirect : ExtractorApi() { subtitleCallback: (SubtitleFile) -> Unit, callback: (ExtractorLink) -> Unit ) { - val videoId = Url(url).encodedPath + val videoId = url.toHttpUrl().encodedPath val mirror = mirrors.random() // re-use existing extractors by calling the ExtractorApi @@ -98,4 +98,4 @@ abstract class CineMMRedirect : ExtractorApi() { val mirrorUrlWithVideoId = "https://$mirror$videoId" loadExtractor(mirrorUrlWithVideoId, referer, subtitleCallback, callback) } -} +} \ No newline at end of file diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/CloudMailRuExtractor.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/CloudMailRuExtractor.kt index 7f6d98049..3c79baf3a 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/CloudMailRuExtractor.kt +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/CloudMailRuExtractor.kt @@ -27,7 +27,7 @@ open class CloudMailRu : ExtractorApi() { "Origin" to mainUrl, "User-Agent" to USER_AGENT, ) - val vidId = url.substringAfter("public/").encodeToByteArray() + val vidId = url.substringAfter("public/").toByteArray() val vidIdEnc = base64Encode(vidId) val videoReq = app.get(url, headers=headers).text val regex = Regex(pattern = "videowl_view\":\\{\"count\":\"1\",\"url\":\"([^\"]*)\"\\}", options = setOf(RegexOption.IGNORE_CASE)) diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Dailymotion.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Dailymotion.kt index 4732cafcf..db6db39d5 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Dailymotion.kt +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Dailymotion.kt @@ -7,8 +7,9 @@ import com.lagradost.cloudstream3.newSubtitleFile import com.lagradost.cloudstream3.utils.ExtractorApi import com.lagradost.cloudstream3.utils.ExtractorLink import com.lagradost.cloudstream3.utils.M3u8Helper.Companion.generateM3u8 -import io.ktor.http.Url -import io.ktor.http.decodeURLPart +import java.net.URI + + class Geodailymotion : Dailymotion() { override val name = "GeoDailymotion" @@ -56,6 +57,7 @@ open class Dailymotion : ExtractorApi() { } } + private fun getEmbedUrl(url: String): String? { if (url.contains("/embed/") || url.contains("/video/")) return url if (url.contains("geo.dailymotion.com")) { @@ -65,8 +67,9 @@ open class Dailymotion : ExtractorApi() { return null } + private fun getVideoId(url: String): String? { - val path = Url(url).encodedPath.decodeURLPart() + val path = URI(url).path val id = path.substringAfter("/video/") return if (id.matches(videoIdRegex)) id else null } @@ -79,6 +82,7 @@ open class Dailymotion : ExtractorApi() { return generateM3u8(name, streamLink, "").forEach(callback) } + data class MetaData( val qualities: Map>?, val subtitles: SubtitlesWrapper? @@ -98,4 +102,5 @@ open class Dailymotion : ExtractorApi() { val label: String, val urls: List ) + } \ No newline at end of file diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/DoodExtractor.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/DoodExtractor.kt index bce017276..a686cdd83 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/DoodExtractor.kt +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/DoodExtractor.kt @@ -1,21 +1,14 @@ package com.lagradost.cloudstream3.extractors -import com.lagradost.cloudstream3.Prerelease 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.INFER_TYPE import com.lagradost.cloudstream3.utils.getQualityFromName import com.lagradost.cloudstream3.utils.newExtractorLink -import io.ktor.http.Url - -class Doodspro : DoodLaExtractor() { - override var mainUrl = "https://doods.pro" -} - -class Dsvplay : DoodLaExtractor() { - override var mainUrl = "https://dsvplay.com" -} +import java.net.URI +import kotlin.random.Random class D0000d : DoodLaExtractor() { override var mainUrl = "https://d0000d.com" @@ -80,19 +73,10 @@ class Ds2video : DoodLaExtractor() { override var mainUrl = "https://ds2video.com" } -class Vide0Net: DoodLaExtractor() { - override var mainUrl = "https://vide0.net" -} - class MyVidPlay : DoodLaExtractor() { override var mainUrl = "https://myvidplay.com" } -@Prerelease -class Playmogo : DoodLaExtractor() { - override var mainUrl = "https://playmogo.com" -} - open class DoodLaExtractor : ExtractorApi() { override var name = "DoodStream" override var mainUrl = "https://dood.la" @@ -138,6 +122,8 @@ open class DoodLaExtractor : ExtractorApi() { } private fun getBaseUrl(url: String): String { - return Url(url).let { "${it.protocol.name}://${it.host}" } + return URI(url).let { + "${it.scheme}://${it.host}" + } } } diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/EPlay.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/EPlay.kt new file mode 100644 index 000000000..146859cf4 --- /dev/null +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/EPlay.kt @@ -0,0 +1,27 @@ +package com.lagradost.cloudstream3.extractors + +import com.fasterxml.jackson.annotation.JsonProperty +import com.lagradost.cloudstream3.app +import com.lagradost.cloudstream3.utils.* +import com.lagradost.cloudstream3.utils.AppUtils.tryParseJson + +open class EPlayExtractor : ExtractorApi() { + override var name = "EPlay" + override var mainUrl = "https://eplayvid.net" + override val requiresReferer = true + + override suspend fun getUrl(url: String, referer: String?): List? { + val response = app.get(url).document + val trueUrl = response.select("source").attr("src") + return listOf( + newExtractorLink( + this.name, + this.name, + trueUrl, + ) { + this.referer = mainUrl + this.quality = getQualityFromName("") // this needs to be auto + } + ) + } +} 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 94ddaf61e..e8f8c49ac 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Fastream.kt +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Fastream.kt @@ -1,44 +1,54 @@ 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 com.lagradost.cloudstream3.utils.getPacked +import org.jsoup.nodes.Document -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?, - subtitleCallback: (SubtitleFile) -> Unit, - callback: (ExtractorLink) -> Unit - ) { + override suspend fun getUrl(url: String, referer: String?): List { + val sources = ArrayList() val idregex = Regex("emb.html\\?(.*)=") - val response = if (url.contains(Regex("(emb.html.*fastream)"))) { + if (url.contains(Regex("(emb.html.*fastream)"))) { val id = idregex.find(url)?.destructured?.component1() ?: "" - app.post( - "$mainUrl/dl", allowRedirects = false, + val response = app.post("https://fastream.to/dl", allowRedirects = false, data = mapOf( "op" to "embed", "file_code" to id, "auto" to "1" ) ).document - } 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) - } + getstream(response, sources) } + 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 index 7756f7290..7baa62710 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Filegram.kt +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Filegram.kt @@ -3,9 +3,10 @@ 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.M3u8Helper +import com.lagradost.cloudstream3.utils.fixUrl import com.lagradost.cloudstream3.utils.getAndUnpack import org.jsoup.nodes.Element @@ -34,8 +35,15 @@ open class Filegram : ExtractorApi() { val doc = app.get(getEmbedUrl(url), referer = referer).document val unpackedJs = unpackJs(doc).toString() - - JwPlayerHelper.extractStreamLinks(unpackedJs, name, mainUrl, callback, subtitleCallback, headers = header) + val videoUrl = Regex("""file:\s*"([^"]+\.m3u8[^"]*)"""").find(unpackedJs)?.groupValues?.get(1) + if (videoUrl != null) { + M3u8Helper.generateM3u8( + this.name, + fixUrl(videoUrl), + "$mainUrl/", + headers = header + ).forEach(callback) + } } private fun unpackJs(script: Element): String? { 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 ad4def1de..6c10a92d9 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Filemoon.kt +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Filemoon.kt @@ -3,7 +3,6 @@ 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 @@ -55,16 +54,18 @@ open class FilemoonV2 : ExtractorApi() { ?.data().orEmpty() val unpackedScript = JsUnpacker(fallbackScriptData).unpack() - val linkFound = JwPlayerHelper.extractStreamLinks( - unpackedScript.orEmpty(), - name, - mainUrl, - callback, - subtitleCallback, - defaultHeaders - ) + val videoUrl = unpackedScript?.let { + Regex("""sources:\[\{file:"(.*?)"""").find(it)?.groupValues?.get(1) + } - if (!linkFound) { + if (!videoUrl.isNullOrEmpty()) { + M3u8Helper.generateM3u8( + name, + videoUrl, + mainUrl, + headers = defaultHeaders + ).forEach(callback) + } else { Log.d("FilemoonV2", "No iframe and no video URL found in script fallback.") } return @@ -80,15 +81,18 @@ open class FilemoonV2 : ExtractorApi() { val unpackedScript = JsUnpacker(iframeScriptData).unpack() - val linkFound = JwPlayerHelper.extractStreamLinks( - unpackedScript.orEmpty(), - name, - mainUrl, - callback, - subtitleCallback - ) + val videoUrl = unpackedScript?.let { + Regex("""sources:\[\{file:"(.*?)"""").find(it)?.groupValues?.get(1) + } - if (!linkFound) { + if (!videoUrl.isNullOrEmpty()) { + M3u8Helper.generateM3u8( + name, + videoUrl, + mainUrl, + headers = defaultHeaders + ).forEach(callback) + } else { // 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 51e127e3f..4c5352dd9 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Filesim.kt +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Filesim.kt @@ -4,12 +4,9 @@ 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() { - override var mainUrl = "https://multimoviesshg.com" -} + class Guccihide : Filesim() { override val name = "Guccihide" @@ -79,9 +76,17 @@ open class Filesim : ExtractorApi() { pageResponse.document.selectFirst("script:containsData(sources:)")?.data() } - val linkFound = JwPlayerHelper.extractStreamLinks(scriptData.orEmpty(), name, mainUrl, callback, subtitleCallback) + val m3u8Url = scriptData?.let { + Regex("""file:\s*"(.*?m3u8.*?)"""").find(it)?.groupValues?.getOrNull(1) + } - if (!linkFound) { + if (!m3u8Url.isNullOrEmpty()) { + M3u8Helper.generateM3u8( + name, + m3u8Url, + mainUrl + ).forEach(callback) + } else { // Fallback using WebViewResolver val resolver = WebViewResolver( interceptUrl = Regex("""(m3u8|master\.txt)"""), diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Flyfile.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Flyfile.kt deleted file mode 100644 index eb6d474a5..000000000 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Flyfile.kt +++ /dev/null @@ -1,48 +0,0 @@ -package com.lagradost.cloudstream3.extractors - -import com.lagradost.cloudstream3.Prerelease -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.ExtractorLinkType -import com.lagradost.cloudstream3.utils.newExtractorLink -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable - -@Prerelease -open class Flyfile : ExtractorApi() { - override val name: String = "FlyFile" - override val mainUrl: String = "https://flyfile.app" - open val apiUrl: String = "https://api.flyfile.app" - override val requiresReferer: Boolean = false - - override suspend fun getUrl( - url: String, - referer: String?, - subtitleCallback: (SubtitleFile) -> Unit, - callback: (ExtractorLink) -> Unit - ) { - val videoId = url.substringAfterLast("/") - val videoInfo = app.get("$apiUrl/api/streaming/assign/$videoId") - .parsed() - - val streamUrl = "${videoInfo.url}/hls/${videoInfo.token}/master.m3u8" - callback.invoke( - newExtractorLink( - source = name, - name = name, - url = streamUrl, - type = ExtractorLinkType.M3U8 - ) - ) - } - - @Serializable - private data class StreamInfo( - @SerialName("url") - val url: String, - @SerialName("token") - val token: String - ) -} \ No newline at end of file diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/GDMirrorbot.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/GDMirrorbot.kt index ba297067e..095add00d 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/GDMirrorbot.kt +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/GDMirrorbot.kt @@ -8,7 +8,7 @@ import com.lagradost.cloudstream3.base64Decode import com.lagradost.cloudstream3.utils.ExtractorApi import com.lagradost.cloudstream3.utils.ExtractorLink import com.lagradost.cloudstream3.utils.loadExtractor -import io.ktor.http.Url +import java.net.URI class Techinmind: GDMirrorbot() { override var name = "Techinmind Cloud AIO" @@ -38,13 +38,7 @@ open class GDMirrorbot : ExtractorApi() { val hostUrl = baseUrl?.let { getBaseUrl(it) } if (finalId != null && myKey != null) { - val apiUrl = if (url.contains("/tv/")) { - val season = Regex("""/tv/\d+/(\d+)/""").find(url)?.groupValues?.get(1) ?: "1" - val episode = Regex("""/tv/\d+/\d+/(\d+)""").find(url)?.groupValues?.get(1) ?: "1" - "$mainUrl/myseriesapi?tmdbid=$finalId&season=$season&epname=$episode&key=$myKey" - } else { - "$mainUrl/mymovieapi?$idType=$finalId&key=$myKey" - } + val apiUrl = "$mainUrl/mymovieapi?$idType=$finalId&key=$myKey" pageText = app.get(apiUrl).text } @@ -103,7 +97,7 @@ open class GDMirrorbot : ExtractorApi() { } private fun getBaseUrl(url: String): String { - return Url(url).let { "${it.protocol.name}://${it.host}" } + return URI(url).let { "${it.scheme}://${it.host}" } } } diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/GMPlayer.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/GMPlayer.kt new file mode 100644 index 000000000..7aa4ed53f --- /dev/null +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/GMPlayer.kt @@ -0,0 +1,47 @@ +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.newExtractorLink + +open class GMPlayer : ExtractorApi() { + override val name = "GM Player" + override val mainUrl = "https://gmplayer.xyz" + override val requiresReferer = true + + override suspend fun getUrl(url: String, referer: String?): List? { + val ref = referer ?: return null + val id = url.substringAfter("/video/").substringBefore("/") + + val m3u8 = app.post( + "$mainUrl/player/index.php?data=$id&do=getVideo", + mapOf( + "accept" to "*/*", + "referer" to ref, + "x-requested-with" to "XMLHttpRequest", + "origin" to mainUrl + ), + data = mapOf("hash" to id, "r" to ref) + ).parsed().videoSource ?: return null + + return listOf( + newExtractorLink( + source = this.name, + name = this.name, + url = m3u8, + type = ExtractorLinkType.M3U8 + ) { + this.referer = ref + this.quality = Qualities.Unknown.value + this.headers = mapOf("accept" to "*/*") + } + ) + } + + private data class GmResponse( + val videoSource: String? = null + ) +} \ No newline at end of file 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 85212e6bb..7e00dbf95 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/GamoVideo.kt +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/GamoVideo.kt @@ -1,10 +1,7 @@ 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.utils.* open class GamoVideo : ExtractorApi() { @@ -14,13 +11,21 @@ open class GamoVideo : ExtractorApi() { override suspend fun getUrl( url: String, - 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) - } + 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 + } + ) + } } } diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Gdriveplayer.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Gdriveplayer.kt index 5fc55aac6..61c22e929 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Gdriveplayer.kt +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Gdriveplayer.kt @@ -82,7 +82,7 @@ open class Gdriveplayer : ExtractorApi() { val password = Regex("null,['|\"](\\w+)['|\"]").first(eval) ?.split(Regex("\\D+")) ?.joinToString("") { - it.toInt().toChar().toString() + Char(it.toInt()).toString() }.let { Regex("var pass = \"(\\S+?)\"").first(it ?: return)?.toByteArray() } ?: throw ErrorLoadingException("can't find password") val decryptedData = cryptoAESHandler(data, password, false, "AES/CBC/NoPadding")?.let { getAndUnpack(it) }?.replace("\\", "") @@ -125,4 +125,4 @@ open class Gdriveplayer : ExtractorApi() { @JsonProperty("label") val label: String ) -} +} \ No newline at end of file diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Gofile.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Gofile.kt index 2b42e42c7..defd6f698 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Gofile.kt +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Gofile.kt @@ -5,10 +5,8 @@ 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.ExtractorLinkType import com.lagradost.cloudstream3.utils.Qualities import com.lagradost.cloudstream3.utils.newExtractorLink -import kotlin.math.round open class Gofile : ExtractorApi() { override val name = "Gofile" @@ -22,45 +20,27 @@ open class Gofile : ExtractorApi() { subtitleCallback: (SubtitleFile) -> Unit, callback: (ExtractorLink) -> Unit ) { - val id = Regex("/(?:\\?c=|d/)([\\da-zA-Z-]+)").find(url)?.groupValues?.get(1) ?: return - - val token = app.post( - "$mainApi/accounts", - ).parsedSafe()?.data?.token ?: return - - val globalRes = app.get("$mainUrl/dist/js/config.js").text - val wt = Regex("""appdata\.wt\s*=\s*[\"']([^\"']+)[\"']""").find(globalRes)?.groupValues?.get(1) ?: return - - val headers = mapOf( - "Authorization" to "Bearer $token", - "X-Website-Token" to wt - ) - - val parsedResponse = app.get( - "$mainApi/contents/$id?contentFilter=&page=1&pageSize=1000&sortField=name&sortDirection=1", - headers = headers - ).parsedSafe() - - val childrenMap = parsedResponse?.data?.children ?: return - - for ((_, file) in childrenMap) { - if (file.link.isNullOrEmpty() || file.type != "file") continue - val fileName = file.name ?: "" - val size = file.size ?: 0L - val formattedSize = formatBytes(size) - - callback.invoke( - newExtractorLink( - "Gofile", - "[Gofile] $fileName [$formattedSize]", - file.link, - ExtractorLinkType.VIDEO - ) { - this.quality = getQuality(fileName) - this.headers = mapOf("Cookie" to "accountToken=$token") - } - ) + val id = Regex("/(?:\\?c=|d/)([\\da-zA-Z-]+)").find(url)?.groupValues?.get(1) + val token = app.get("$mainApi/createAccount").parsedSafe()?.data?.get("token") + val websiteToken = app.get("$mainUrl/dist/js/alljs.js").text.let { + Regex("fetchData.wt\\s*=\\s*\"([^\"]+)").find(it)?.groupValues?.get(1) } + app.get("$mainApi/getContent?contentId=$id&token=$token&wt=$websiteToken") + .parsedSafe()?.data?.contents?.forEach { + callback.invoke( + newExtractorLink( + this.name, + this.name, + it.value["link"] ?: return, + ) { + this.quality = getQuality(it.value["name"]) + this.headers = mapOf( + "Cookie" to "accountToken=$token" + ) + } + ) + } + } private fun getQuality(str: String?): Int { @@ -68,42 +48,16 @@ open class Gofile : ExtractorApi() { ?: Qualities.Unknown.value } - private fun roundTo2Decimals(value: Double): String { - val rounded = round(value * 100) / 100.0 - val intPart = rounded.toLong() - val decPart = round((rounded - intPart) * 100).toLong() - return "$intPart.${decPart.toString().padStart(2, '0')}" - } - - private fun formatBytes(bytes: Long): String { - val mb = 1024L * 1024 - val gb = mb * 1024 - return when { - bytes < gb -> "${roundTo2Decimals(bytes.toDouble() / mb)} MB" - else -> "${roundTo2Decimals(bytes.toDouble() / gb)} GB" - } - } - - data class AccountResponse( - @JsonProperty("data") val data: AccountData? = null + data class Account( + @JsonProperty("data") val data: HashMap? = null, ) - data class AccountData( - @JsonProperty("token") val token: String? = null + data class Data( + @JsonProperty("contents") val contents: HashMap>? = null, ) - data class GofileResponse( - @JsonProperty("data") val data: GofileData? = null + data class Source( + @JsonProperty("data") val data: Data? = null, ) - data class GofileData( - @JsonProperty("children") val children: Map? = null - ) - - data class GofileFile( - @JsonProperty("type") val type: String? = null, - @JsonProperty("name") val name: String? = null, - @JsonProperty("link") val link: String? = null, - @JsonProperty("size") val size: Long? = 0L - ) } diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/GuardareStream.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/GuardareStream.kt new file mode 100644 index 000000000..c8eccd988 --- /dev/null +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/GuardareStream.kt @@ -0,0 +1,89 @@ +package com.lagradost.cloudstream3.extractors + +import com.fasterxml.jackson.annotation.JsonProperty +import com.lagradost.cloudstream3.SubtitleFile +import com.lagradost.cloudstream3.newSubtitleFile +import com.lagradost.cloudstream3.utils.ExtractorLink +import com.lagradost.cloudstream3.app +import com.lagradost.cloudstream3.utils.* + +class Vanfem : GuardareStream() { + override var name = "Vanfem" + override var mainUrl = "https://vanfem.com/" +} + +class CineGrabber : GuardareStream() { + override var name = "CineGrabber" + override var mainUrl = "https://cinegrabber.com" +} + +open class GuardareStream : ExtractorApi() { + override var name = "Guardare" + override var mainUrl = "https://guardare.stream" + override val requiresReferer = false + + data class GuardareJsonData( + @JsonProperty("data") val data: List, + @JsonProperty("captions") val captions: List?, + ) + + data class GuardareData( + @JsonProperty("file") val file: String, + @JsonProperty("label") val label: String, + @JsonProperty("type") val type: String + ) + + + // https://cinegrabber.com/asset/userdata/224879/caption/gqdmzh-71ez76z8/876438.srt + data class GuardareCaptions( + @JsonProperty("id") val id: String, + @JsonProperty("hash") val hash: String, + @JsonProperty("language") val language: String?, + @JsonProperty("extension") val extension: String + ) { + fun getUrl(mainUrl: String, userId: String): String { + return "$mainUrl/asset/userdata/$userId/caption/$hash/$id.$extension" + } + } + + override suspend fun getUrl( + url: String, + referer: String?, + subtitleCallback: (SubtitleFile) -> Unit, + callback: (ExtractorLink) -> Unit + ) { + val response = + app.post(url.replace("/v/", "/api/source/"), data = mapOf("d" to mainUrl)).text + + val jsonVideoData = AppUtils.parseJson(response) + jsonVideoData.data.forEach { + callback.invoke( + newExtractorLink( + this.name, + this.name, + it.file + ".${it.type}", + ) { + this.referer = mainUrl + this.quality = it.label.filter { it.isDigit() }.toInt() + } + ) + } + + if (!jsonVideoData.captions.isNullOrEmpty()){ + val iframe = app.get(url) + // var USER_ID = '224879'; + val userIdRegex = Regex("""USER_ID.*?(\d+)""") + val userId = userIdRegex.find(iframe.text)?.groupValues?.getOrNull(1) ?: return + jsonVideoData.captions.forEach { + if (it == null) return@forEach + val subUrl = it.getUrl(mainUrl, userId) + subtitleCallback.invoke( + newSubtitleFile( + it.language ?: "", + subUrl + ) + ) + } + } + } +} \ No newline at end of file diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/HDMomPlayerExtractor.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/HDMomPlayerExtractor.kt index 1ccd3e4d5..ea6fba73b 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/HDMomPlayerExtractor.kt +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/HDMomPlayerExtractor.kt @@ -2,12 +2,13 @@ package com.lagradost.cloudstream3.extractors -import com.fasterxml.jackson.annotation.JsonProperty import com.lagradost.api.Log import com.lagradost.cloudstream3.* -import com.lagradost.cloudstream3.extractors.helper.AesHelper import com.lagradost.cloudstream3.utils.* -import com.lagradost.cloudstream3.utils.AppUtils.parseJson +import com.lagradost.cloudstream3.extractors.helper.AesHelper +import com.fasterxml.jackson.annotation.JsonProperty +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import com.fasterxml.jackson.module.kotlin.readValue open class HDMomPlayer : ExtractorApi() { override val name = "HDMomPlayer" @@ -23,7 +24,7 @@ open class HDMomPlayer : ExtractorApi() { if (bePlayer != null) { val bePlayerPass = bePlayer.get(1) val bePlayerData = bePlayer.get(2) - val encrypted = AesHelper.cryptoAESHandler(bePlayerData, bePlayerPass.encodeToByteArray(), false)?.replace("\\", "") ?: throw ErrorLoadingException("failed to decrypt") + val encrypted = AesHelper.cryptoAESHandler(bePlayerData, bePlayerPass.toByteArray(), false)?.replace("\\", "") ?: throw ErrorLoadingException("failed to decrypt") m3uLink = Regex("""video_location\":\"([^\"]+)""").find(encrypted)?.groupValues?.get(1) } else { @@ -31,7 +32,7 @@ open class HDMomPlayer : ExtractorApi() { val trackStr = Regex("""tracks:\[([^\]]+)""").find(iSource)?.groupValues?.get(1) if (trackStr != null) { - val tracks:List = parseJson>("[${trackStr}]") + val tracks:List = jacksonObjectMapper().readValue("[${trackStr}]") for (track in tracks) { if (track.file == null || track.label == null) continue @@ -67,4 +68,4 @@ open class HDMomPlayer : ExtractorApi() { @JsonProperty("language") val language: String?, @JsonProperty("default") val default: String? ) -} +} \ No newline at end of file diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/HubCloud.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/HubCloud.kt index a974df15c..d8a3fb1ec 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/HubCloud.kt +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/HubCloud.kt @@ -1,6 +1,7 @@ package com.lagradost.cloudstream3.extractors import com.lagradost.api.Log +import com.lagradost.cloudstream3.Prerelease import com.lagradost.cloudstream3.SubtitleFile import com.lagradost.cloudstream3.amap import com.lagradost.cloudstream3.app @@ -9,8 +10,9 @@ import com.lagradost.cloudstream3.utils.ExtractorLink import com.lagradost.cloudstream3.utils.Qualities import com.lagradost.cloudstream3.utils.loadExtractor import com.lagradost.cloudstream3.utils.newExtractorLink -import io.ktor.http.Url +import java.net.URI +@Prerelease class HubCloud : ExtractorApi() { override val name = "Hub-Cloud" override val mainUrl = "https://hubcloud.*" @@ -24,7 +26,7 @@ class HubCloud : ExtractorApi() { ) { val tag = "HubCloud" val realUrl = url.takeIf { - try { Url(it); true } catch (e: Exception) { Log.e(tag, "Invalid URL: ${e.message}"); false } + try { URI(it).toURL(); true } catch (e: Exception) { Log.e(tag, "Invalid URL: ${e.message}"); false } } ?: return val baseUrl=getBaseUrl(realUrl) @@ -146,7 +148,42 @@ class HubCloud : ExtractorApi() { ) { this.quality = quality } ) } - + + text.contains("10Gbps", ignoreCase = true) -> { + var currentLink = link + var redirectUrl: String? + var redirectCount = 0 + val maxRedirects = 3 + + while (redirectCount < maxRedirects) { + val response = app.get(currentLink, allowRedirects = false) + redirectUrl = response.headers["location"] + + if (redirectUrl == null) { + Log.e(tag, "10Gbps: No redirect") + return@amap + } + + if ("link=" in redirectUrl) { + val finalLink = redirectUrl.substringAfter("link=") + callback.invoke( + newExtractorLink( + "10Gbps [Download]", + "10Gbps [Download] $labelExtras", + finalLink + ) { this.quality = quality } + ) + return@amap + } + + currentLink = redirectUrl + redirectCount++ + } + + Log.e(tag, "10Gbps: Redirect limit reached ($maxRedirects)") + return@amap + } + else -> { loadExtractor(link, "", subtitleCallback, callback) } @@ -161,7 +198,7 @@ class HubCloud : ExtractorApi() { private fun getBaseUrl(url: String): String { return try { - Url(url).let { "${it.protocol.name}://${it.host}" } + URI(url).let { "${it.scheme}://${it.host}" } } catch (_: Exception) { "" } @@ -206,4 +243,4 @@ class HubCloud : ExtractorApi() { parts.takeLast(3).joinToString(".") } } -} +} \ No newline at end of file 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 8f8a0c0ce..8a56783b1 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.lagradost.cloudstream3.SubtitleFile +import com.fasterxml.jackson.annotation.JsonProperty 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,22 +39,64 @@ open class Hxfile : ExtractorApi() { override val requiresReferer = false open val redirect = true - override suspend fun getUrl( - url: String, - referer: String?, - subtitleCallback: (SubtitleFile) -> Unit, - callback: (ExtractorLink) -> Unit - ) { + override suspend fun getUrl(url: String, referer: String?): List? { + val sources = mutableListOf() val document = app.get(url, allowRedirects = redirect, referer = referer).document with(document) { this.select("script").map { script -> - 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) + 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 } } } + 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/InternetArchive.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/InternetArchive.kt index 1ac6c789c..40d817e99 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/InternetArchive.kt +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/InternetArchive.kt @@ -7,7 +7,7 @@ import com.lagradost.cloudstream3.newSubtitleFile import com.lagradost.cloudstream3.utils.ExtractorApi import com.lagradost.cloudstream3.utils.ExtractorLink import com.lagradost.cloudstream3.utils.Qualities -import com.lagradost.cloudstream3.utils.StringUtils.decodeUrl +import com.lagradost.cloudstream3.utils.StringUtils.decodeUri import com.lagradost.cloudstream3.utils.newExtractorLink import org.jsoup.nodes.Document @@ -96,7 +96,7 @@ open class InternetArchive : ExtractorApi() { if (mediaUrl.isNotEmpty()) { val name = if (mediaUrl.count() > 1) { val fileExtension = mediaUrl.substringAfterLast(".") - val fileNameCleaned = fileName.decodeUrl().substringBeforeLast('.') + val fileNameCleaned = fileName.decodeUri().substringBeforeLast('.') "$fileNameCleaned ($fileExtension)" } else this.name callback( 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 324640355..e744fdb39 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/JWPlayer.kt +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/JWPlayer.kt @@ -1,10 +1,13 @@ package com.lagradost.cloudstream3.extractors -import com.lagradost.cloudstream3.SubtitleFile +import com.fasterxml.jackson.annotation.JsonProperty import com.lagradost.cloudstream3.app -import com.lagradost.cloudstream3.extractors.helper.JwPlayerHelper +import com.lagradost.cloudstream3.utils.AppUtils.tryParseJson 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" @@ -31,36 +34,50 @@ 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?, - 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) + 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 } + + 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/Jawcloud.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Jawcloud.kt new file mode 100644 index 000000000..203a266c1 --- /dev/null +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Jawcloud.kt @@ -0,0 +1,27 @@ +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 + + +open class Jawcloud : ExtractorApi() { + override var name = "Jawcloud" + override var mainUrl = "https://jawcloud.co" + override val requiresReferer = false + + override suspend fun getUrl(url: String, referer: String?): List? { + val doc = app.get(url).document + val urlString = doc.select("html body div source").attr("src") + val sources = mutableListOf() + if (urlString.contains("m3u8")) + M3u8Helper.generateM3u8( + name, + urlString, + url, + headers = app.get(url).headers.toMap() + ).forEach { link -> sources.add(link) } + return sources + } +} \ 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 896228b51..f64863a9f 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Jeniusplay.kt +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Jeniusplay.kt @@ -3,12 +3,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.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 +import com.lagradost.cloudstream3.newSubtitleFile +import com.lagradost.cloudstream3.utils.* +import com.lagradost.cloudstream3.utils.AppUtils.tryParseJson open class Jeniusplay : ExtractorApi() { override val name = "Jeniusplay" @@ -37,17 +34,40 @@ open class Jeniusplay : ExtractorApi() { url, ).forEach(callback) + document.select("script").map { script -> - if (getPacked(script.data()) != null) { - val unpacked = getAndUnpack(script.data()) - JwPlayerHelper.extractStreamLinks(unpacked, name, mainUrl, callback, subtitleCallback) + 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 + ) + ) + } } } } + 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 dec679594..c130ca2de 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/LuluStream.kt +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/LuluStream.kt @@ -1,15 +1,12 @@ 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 - - -class Luluvdoo : LuluStream() { - override var mainUrl = "https://luluvdoo.com" -} +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 Lulustream1 : LuluStream() { override val name = "Lulustream" @@ -45,7 +42,18 @@ open class LuluStream : ExtractorApi() { ).document post.selectFirst("script:containsData(vplayer)")?.data() ?.let { script -> - JwPlayerHelper.extractStreamLinks(script, name, mainUrl, callback, subtitleCallback) + Regex("file:\"(.*)\"").find(script)?.groupValues?.get(1)?.let { link -> + callback( + newExtractorLink( + name, + name, + link, + ) { + this.referer = mainUrl + this.quality = Qualities.P1080.value + } + ) + } } } } \ 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 new file mode 100644 index 000000000..702501a1e --- /dev/null +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Minoplres.kt @@ -0,0 +1,38 @@ +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/MixDrop.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/MixDrop.kt index 75ac299d8..746dc7eb3 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/MixDrop.kt +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/MixDrop.kt @@ -3,22 +3,6 @@ package com.lagradost.cloudstream3.extractors import com.lagradost.cloudstream3.app import com.lagradost.cloudstream3.utils.* -class MixDropPs : MixDrop() { - override var mainUrl = "https://mixdrop.ps" -} - -class Mdy : MixDrop() { - override var mainUrl = "https://mdy48tn97.com" -} - -class MxDropTo : MixDrop() { - override var mainUrl = "https://mxdrop.to" -} - -class MixDropSi : MixDrop() { - override var mainUrl = "https://mixdrop.si" -} - class MixDropBz : MixDrop(){ override var mainUrl = "https://mixdrop.bz" } @@ -63,4 +47,4 @@ open class MixDrop : ExtractorApi() { } return null } -} +} \ No newline at end of file diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/MultiQuality.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/MultiQuality.kt new file mode 100644 index 000000000..802d9ea3a --- /dev/null +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/MultiQuality.kt @@ -0,0 +1,63 @@ +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/Mvidoo.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Mvidoo.kt index 84b818723..76f14d33b 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Mvidoo.kt +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Mvidoo.kt @@ -14,10 +14,11 @@ open class Mvidoo : ExtractorApi() { private fun String.decodeHex(): String { require(length % 2 == 0) { "Must have an even length" } - return chunked(2) - .map { it.toInt(16).toByte() } - .toByteArray() - .decodeToString() + return String( + chunked(2) + .map { it.toInt(16).toByte() } + .toByteArray() + ) } override suspend fun getUrl( diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/OdnoklassnikiExtractor.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/OdnoklassnikiExtractor.kt index 69c3b7759..8af77c1df 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/OdnoklassnikiExtractor.kt +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/OdnoklassnikiExtractor.kt @@ -32,7 +32,7 @@ open class Odnoklassniki : ExtractorApi() { val embedUrl = url.replace("/video/","/videoembed/") val videoReq = app.get(embedUrl, headers=headers).text.replace("\\"", "\"").replace("\\\\", "\\") .replace(Regex("\\\\u([0-9A-Fa-f]{4})")) { matchResult -> - matchResult.groupValues[1].toInt(16).toChar().toString() + Integer.parseInt(matchResult.groupValues[1], 16).toChar().toString() } val videosStr = Regex(""""videos":(\[[^]]*])""").find(videoReq)?.groupValues?.get(1) ?: throw ErrorLoadingException("Video not found") val videos = AppUtils.tryParseJson>(videosStr) ?: throw ErrorLoadingException("Video not found") diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/OkRuExtractor.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/OkRuExtractor.kt index d9803fa3e..f5f258cfe 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/OkRuExtractor.kt +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/OkRuExtractor.kt @@ -2,6 +2,8 @@ package com.lagradost.cloudstream3.extractors +import com.lagradost.cloudstream3.Prerelease + open class OkRuSSL : Odnoklassniki() { override var name = "OkRuSSL" override var mainUrl = "https://ok.ru" @@ -12,10 +14,12 @@ open class OkRuHTTP : Odnoklassniki() { override var mainUrl = "http://ok.ru" } +@Prerelease class OkRuSSLMobile : OkRuSSL() { override var mainUrl = "https://m.ok.ru" } +@Prerelease class OkRuHTTPMobile : OkRuHTTP() { override var mainUrl = "http://m.ok.ru" } diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Okrulink.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Okrulink.kt new file mode 100644 index 000000000..2c4701855 --- /dev/null +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Okrulink.kt @@ -0,0 +1,37 @@ +package com.lagradost.cloudstream3.extractors + +import com.fasterxml.jackson.annotation.JsonProperty +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 + +data class Okrulinkdata ( + @JsonProperty("status" ) var status : String? = null, + @JsonProperty("url" ) var url : String? = null +) + +open class Okrulink: ExtractorApi() { + override var mainUrl = "https://okru.link" + override var name = "Okrulink" + override val requiresReferer = false + + override suspend fun getUrl(url: String, referer: String?): List { + val sources = mutableListOf() + val key = url.substringAfter("html?t=") + val request = app.post("https://apizz.okru.link/decoding", allowRedirects = false, + data = mapOf("video" to key) + ).parsedSafe() + if (request?.url != null) { + sources.add( + newExtractorLink( + name, + name, + request.url!! + ) + ) + } + return sources + } +} diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Pelisplus.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Pelisplus.kt new file mode 100644 index 000000000..e2588feb6 --- /dev/null +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Pelisplus.kt @@ -0,0 +1,101 @@ +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/PixelDrainExtractor.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/PixelDrainExtractor.kt index d97e1ca06..3426289f8 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/PixelDrainExtractor.kt +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/PixelDrainExtractor.kt @@ -5,6 +5,7 @@ package com.lagradost.cloudstream3.extractors import com.lagradost.cloudstream3.* import com.lagradost.cloudstream3.utils.* +@Prerelease class PixelDrainDev : PixelDrain() { override var mainUrl = "https://pixeldrain.dev" } diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Rabbitstream.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Rabbitstream.kt index 3f7408eb3..98598dd28 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Rabbitstream.kt +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Rabbitstream.kt @@ -12,6 +12,7 @@ import com.lagradost.cloudstream3.utils.AppUtils.parseJson import com.lagradost.cloudstream3.utils.ExtractorApi import com.lagradost.cloudstream3.utils.ExtractorLink import com.lagradost.cloudstream3.utils.M3u8Helper +import java.nio.charset.StandardCharsets import java.security.MessageDigest import javax.crypto.Cipher import javax.crypto.spec.IvParameterSpec @@ -170,7 +171,7 @@ open class Rabbitstream : ExtractorApi() { IvParameterSpec(decryptionKey.copyOfRange(32, decryptionKey.size)) ) val decryptedData = aesCBC?.doFinal(encrypted) ?: throw ErrorLoadingException("Cipher not found") - return decryptedData.decodeToString() + return String(decryptedData, StandardCharsets.UTF_8) } data class Tracks( diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/RapidVidExtractor.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/RapidVidExtractor.kt index 822a5eed7..9654e5f38 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/RapidVidExtractor.kt +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/RapidVidExtractor.kt @@ -35,14 +35,14 @@ open class RapidVid : ExtractorApi() { if (extractedValue != null) { val bytes = extractedValue.split("\\x").filter { it.isNotEmpty() }.map { it.toInt(16).toByte() }.toByteArray() - decoded = bytes.decodeToString() + decoded = String(bytes, Charsets.UTF_8) } else { val evalJWSsetup = Regex("""\};\s*(eval\(function[\s\S]*?)var played = \d+;""").find(videoReq)?.groupValues?.get(1) ?: throw ErrorLoadingException("File not found") val JWSsetup = getAndUnpack(getAndUnpack(evalJWSsetup)).replace("\\\\", "\\") extractedValue = Regex("""file":"(.*)","label""").find(JWSsetup)?.groupValues?.get(1)?.replace("\\\\x", "") val bytes = extractedValue?.chunked(2)?.map { it.toInt(16).toByte() }?.toByteArray() - decoded = bytes?.decodeToString() ?: throw ErrorLoadingException("File not found") + decoded = bytes?.toString(Charsets.UTF_8) ?: throw ErrorLoadingException("File not found") } callback.invoke( diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Solidfiles.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Solidfiles.kt new file mode 100644 index 000000000..9c6fd614e --- /dev/null +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Solidfiles.kt @@ -0,0 +1,47 @@ +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.getQualityFromName +import com.lagradost.cloudstream3.utils.newExtractorLink + + +open class Solidfiles : ExtractorApi() { + override val name = "Solidfiles" + override val mainUrl = "https://www.solidfiles.com" + override val requiresReferer = false + + override suspend fun getUrl(url: String, referer: String?): List { + val sources = mutableListOf() + with(app.get(url).document) { + this.select("script").map { script -> + if (script.data().contains("\"streamUrl\":")) { + val data = script.data().substringAfter("constant('viewerOptions', {").substringBefore("});") + val source = tryParseJson("{$data}") + val quality = Regex("\\d{3,4}p").find(source!!.nodeName)?.groupValues?.get(0) + sources.add( + newExtractorLink( + name, + name, + source.streamUrl, + ) { + this.referer = url + this.quality = getQualityFromName(quality) + } + ) + } + } + } + return sources + } + + + private data class ResponseSource( + @JsonProperty("streamUrl") val streamUrl: String, + @JsonProperty("nodeName") val nodeName: String + ) + +} \ No newline at end of file diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/StreamTape.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/StreamTape.kt index 211b5ecf9..47fb96a89 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/StreamTape.kt +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/StreamTape.kt @@ -7,10 +7,6 @@ import com.lagradost.cloudstream3.utils.Qualities import com.lagradost.cloudstream3.utils.newExtractorLink import org.mozilla.javascript.Context -class Watchadsontape : StreamTape() { - override var mainUrl = "https://watchadsontape.com" -} - class StreamTapeNet : StreamTape() { override var mainUrl = "https://streamtape.net" } 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 58aa25c8c..db883d6af 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/StreamWishExtractor.kt +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/StreamWishExtractor.kt @@ -1,11 +1,9 @@ package com.lagradost.cloudstream3.extractors import com.lagradost.api.Log -import com.lagradost.cloudstream3.Prerelease 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 @@ -13,6 +11,8 @@ import com.lagradost.cloudstream3.utils.getAndUnpack 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" @@ -28,12 +28,6 @@ class Ewish : StreamWishExtractor() { override val mainUrl = "https://embedwish.com" } -@Prerelease -class Hgcloudto : StreamWishExtractor() { - override val name = "Hgcloud" - override val mainUrl = "https://Hgcloud.to" -} - class WishembedPro : StreamWishExtractor() { override val name = "Wishembed" override val mainUrl = "https://wishembed.pro" @@ -186,9 +180,18 @@ open class StreamWishExtractor : ExtractorApi() { else -> pageResponse.document.selectFirst("script:containsData(sources:)")?.data() } - val linkFound = JwPlayerHelper.extractStreamLinks(playerScriptData.orEmpty(), name, mainUrl, callback, subtitleCallback, headers) + val directStreamUrl = playerScriptData?.let { + Regex("""file:\s*"(.*?m3u8.*?)"""").find(it)?.groupValues?.getOrNull(1) + } - if (!linkFound) { + if (!directStreamUrl.isNullOrEmpty()) { + M3u8Helper.generateM3u8( + name, + directStreamUrl, + mainUrl, + headers = headers + ).forEach(callback) + } else { val webViewM3u8Resolver = WebViewResolver( interceptUrl = Regex("""txt|m3u8"""), additionalUrls = listOf(Regex("""txt|m3u8""")), diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Streamhub.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Streamhub.kt index 2cb9a5e5d..611711e39 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Streamhub.kt +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Streamhub.kt @@ -8,6 +8,7 @@ import com.lagradost.cloudstream3.utils.INFER_TYPE import com.lagradost.cloudstream3.utils.JsUnpacker import com.lagradost.cloudstream3.utils.Qualities import com.lagradost.cloudstream3.utils.newExtractorLink +import java.net.URI open class Streamhub : ExtractorApi() { override var mainUrl = "https://streamhub.to" 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 b7f618e95..7fafe05be 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/StreamoUpload.kt +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/StreamoUpload.kt @@ -1,30 +1,42 @@ package com.lagradost.cloudstream3.extractors -import com.lagradost.cloudstream3.SubtitleFile +import com.fasterxml.jackson.annotation.JsonProperty import com.lagradost.cloudstream3.app -import com.lagradost.cloudstream3.extractors.helper.JwPlayerHelper +import com.lagradost.cloudstream3.utils.AppUtils.tryParseJson import com.lagradost.cloudstream3.utils.ExtractorApi import com.lagradost.cloudstream3.utils.ExtractorLink import com.lagradost.cloudstream3.utils.getAndUnpack -import com.lagradost.cloudstream3.utils.getPacked +import com.lagradost.cloudstream3.utils.Qualities +import com.lagradost.cloudstream3.utils.M3u8Helper 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?, - subtitleCallback: (SubtitleFile) -> Unit, - callback: (ExtractorLink) -> Unit - ) { + override suspend fun getUrl(url: String, referer: String?): List { + val sources = mutableListOf() val response = app.get(url, referer = referer) - response.document.select("script").map { script -> - if (getPacked(script.data()) != null) { + val scriptElements = response.document.select("script").map { script -> + if (script.data().contains("eval(function(p,a,c,k,e,d)")) { val data = getAndUnpack(script.data()) - JwPlayerHelper.extractStreamLinks(data, name, mainUrl, callback, subtitleCallback) + .substringAfter("sources:[") + .substringBefore("],") + .replace("file", "\"file\"") + .trim() + tryParseJson(data)?.let { + M3u8Helper.generateM3u8( + name, + it.file, + "$mainUrl/", + ).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/Streamplay.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Streamplay.kt index 9886300aa..98481970b 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Streamplay.kt +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Streamplay.kt @@ -7,7 +7,7 @@ import com.lagradost.cloudstream3.SubtitleFile import com.lagradost.cloudstream3.app import com.lagradost.cloudstream3.utils.* import com.lagradost.cloudstream3.utils.AppUtils.tryParseJson -import io.ktor.http.Url +import java.net.URI open class Streamplay : ExtractorApi() { override val name = "Streamplay" @@ -22,7 +22,9 @@ open class Streamplay : ExtractorApi() { ) { val request = app.get(url, referer = referer) val redirectUrl = request.url - val mainServer = Url(redirectUrl).let { "${it.protocol.name}://${it.host}" } + val mainServer = URI(redirectUrl).let { + "${it.scheme}://${it.host}" + } val key = redirectUrl.substringAfter("embed-").substringBefore(".html") val token = request.document.select("script").find { it.data().contains("sitekey:") }?.data() @@ -77,4 +79,4 @@ open class Streamplay : ExtractorApi() { @JsonProperty("label") val label: String? = null, ) -} +} \ No newline at end of file diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Streamup.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Streamup.kt index ea85a005e..e9898c48e 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Streamup.kt +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Streamup.kt @@ -3,28 +3,15 @@ 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.ExtractorApi import com.lagradost.cloudstream3.utils.ExtractorLink import com.lagradost.cloudstream3.utils.ExtractorLinkType import com.lagradost.cloudstream3.utils.newExtractorLink -class Streamix(): Streamup() { - override val name: String = "Streamix" - override val mainUrl = "https://streamix.so" -} - -class Vidara(): Streamup() { - override val name: String = "Vidara" - override val mainUrl = "https://vidara.to" - override val apiPath: String = "/api/stream" -} - open class Streamup() : ExtractorApi() { override val name: String = "Streamup" override val mainUrl: String = "https://strmup.to" override val requiresReferer: Boolean = false - open val apiPath: String = "/ajax/stream" override suspend fun getUrl( url: String, @@ -33,7 +20,7 @@ open class Streamup() : ExtractorApi() { callback: (ExtractorLink) -> Unit ) { val fileCode = url.substringAfterLast("/") - val fileInfo = app.get("$mainUrl$apiPath?filecode=$fileCode") + val fileInfo = app.get("$mainUrl/ajax/stream?filecode=$fileCode") .parsed() callback.invoke( @@ -44,12 +31,6 @@ open class Streamup() : ExtractorApi() { type = ExtractorLinkType.M3U8 ) ) - - fileInfo.subtitles?.forEach { subtitle -> - subtitleCallback.invoke( - newSubtitleFile(subtitle.language, subtitle.filePath) - ) - } } private data class StreamUpFileInfo( @@ -57,13 +38,7 @@ open class Streamup() : ExtractorApi() { val thumbnail: String, @JsonProperty("streaming_url") val streamingUrl: String, - val subtitles: List? + // subtitles seems to always be empty + // val subtitles: List ) - - private data class StreamUpSubtitle( - @JsonProperty("file_path") - val filePath: String, - @JsonProperty("language") - val language: String, - ) -} +} \ No newline at end of file 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 5e47dd2de..e70cae6bd 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Supervideo.kt +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Supervideo.kt @@ -1,27 +1,42 @@ 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.fasterxml.jackson.annotation.JsonProperty import com.lagradost.cloudstream3.utils.ExtractorLink -import com.lagradost.cloudstream3.utils.JsUnpacker +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, +) 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?, - subtitleCallback: (SubtitleFile) -> Unit, - callback: (ExtractorLink) -> Unit - ) { + override suspend fun getUrl(url: String, referer: String?): List? { + val extractedLinksList: MutableList = mutableListOf() val response = app.get(url).text val jstounpack = Regex("eval((.|\\n)*?)").find(response)?.groups?.get(1)?.value - val unpacked = JsUnpacker(jstounpack).unpack() - - JwPlayerHelper.extractStreamLinks(unpacked.orEmpty(), name, mainUrl, callback, subtitleCallback) + 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 } } \ No newline at end of file diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Tomatomatela.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Tomatomatela.kt new file mode 100644 index 000000000..b8d131ad9 --- /dev/null +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Tomatomatela.kt @@ -0,0 +1,61 @@ +package com.lagradost.cloudstream3.extractors + +import com.lagradost.cloudstream3.utils.* +import com.lagradost.cloudstream3.app +import com.fasterxml.jackson.annotation.JsonProperty +import com.fasterxml.jackson.module.kotlin.readValue +import com.lagradost.cloudstream3.USER_AGENT +import com.lagradost.cloudstream3.mapper +import com.lagradost.cloudstream3.utils.AppUtils.parseJson + + +class Cinestart: Tomatomatela() { + override var name: String = "Cinestart" + override val mainUrl: String = "https://cinestart.net" + override val details = "vr.php?v=" +} + +class TomatomatelalClub: Tomatomatela() { + override var name: String = "Tomatomatela" + override val mainUrl: String = "https://tomatomatela.club" +} + +open class Tomatomatela : ExtractorApi() { + override var name = "Tomatomatela" + override val mainUrl = "https://tomatomatela.com" + override val requiresReferer = false + private data class Tomato ( + @JsonProperty("status") val status: Int, + @JsonProperty("file") val file: String? + ) + open val details = "details.php?v=" + open val embeddetails = "/embed.html#" + override suspend fun getUrl(url: String, referer: String?): List? { + val link = url.replace("$mainUrl$embeddetails","$mainUrl/$details") + val sources = ArrayList() + val server = app.get(link, allowRedirects = false, + headers = mapOf( + "User-Agent" to USER_AGENT, + "Accept" to "application/json, text/javascript, */*; q=0.01", + "Accept-Language" to "en-US,en;q=0.5", + "X-Requested-With" to "XMLHttpRequest", + "DNT" to "1", + "Connection" to "keep-alive", + "Sec-Fetch-Dest" to "empty", + "Sec-Fetch-Mode" to "cors", + "Sec-Fetch-Site" to "same-origin" + + ) + ).parsedSafe() + if (server?.file != null) { + sources.add( + newExtractorLink( + name, + name, + server.file, + ) + ) + } + return sources + } +} \ 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 b72213e66..91150992b 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,17 +19,12 @@ open class Up4Stream : ExtractorApi() { override var mainUrl = "https://up4stream.com" override val requiresReferer = true - override suspend fun getUrl( - url: String, - referer: String?, - subtitleCallback: (SubtitleFile) -> Unit, - callback: (ExtractorLink) -> Unit - ) { + override suspend fun getUrl(url: String, referer: String?): List? { 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 + val redirectForm = redirectResponse.document.selectFirst("form[method=POST]") ?: return null val redirectUrl = fixUrl(redirectForm.attr("action")) val redirectParams = redirectForm.select("input[type=hidden]").associate { input -> input.attr("name") to input.attr("value") @@ -47,7 +42,19 @@ open class Up4Stream : ExtractorApi() { } JsUnpacker(extractedpack).unpack()?.let { unPacked -> - JwPlayerHelper.extractStreamLinks(unPacked, name, mainUrl, callback, subtitleCallback) + 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 + } + ) + } } + return null } } \ No newline at end of file diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Uqload.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Uqload.kt index 803973ef4..a267c87ee 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Uqload.kt +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Uqload.kt @@ -2,12 +2,6 @@ package com.lagradost.cloudstream3.extractors import com.lagradost.cloudstream3.utils.* import com.lagradost.cloudstream3.app -import com.lagradost.cloudstream3.SubtitleFile -import com.lagradost.cloudstream3.USER_AGENT -import com.lagradost.cloudstream3.newSubtitleFile -import com.lagradost.cloudstream3.utils.* - -// import android.util.Log class Uqload1 : Uqload() { override var mainUrl = "https://uqload.com" @@ -17,40 +11,27 @@ class Uqload2 : Uqload() { override var mainUrl = "https://uqload.co" } -class Uqloadcx : Uqload() { - override var mainUrl = "https://uqload.cx" -} - -class Uqloadbz : Uqload() { - override var mainUrl = "https://uqload.bz" -} - open class Uqload : ExtractorApi() { - override var name: String = "Uqload" - override var mainUrl: String = "https://www.uqload.com" + override val name: String = "Uqload" + override val mainUrl: String = "https://www.uqload.com" + private val srcRegex = Regex("""sources:.\[(.*?)\]""") // would be possible to use the parse and find src attribute override val requiresReferer = true - private val srcRegex = Regex("""sources:.*"(.*?)".*""") // would be possible to use the parse and find src attribute - override suspend fun getUrl( - url: String, - referer: String?, - subtitleCallback: (SubtitleFile) -> Unit, - callback: (ExtractorLink) -> Unit - ) { + override suspend fun getUrl(url: String, referer: String?): List? { with(app.get(url)) { // raised error ERROR_CODE_PARSING_CONTAINER_UNSUPPORTED (3003) is due to the response: "error_nofile" - srcRegex.find(this.text)?.groupValues?.get(1)?.let { link -> - // Log.d("CS3debugUQload","decoded URL: $link") - callback.invoke( + srcRegex.find(this.text)?.groupValues?.get(1)?.replace("\"", "")?.let { link -> + return listOf( newExtractorLink( - source = name, - name = name, - url = link + name, + name, + link ) { - this.referer = "$mainUrl/" + this.referer = url } ) } } + return null } } diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Userload.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Userload.kt index 08dcb634e..582be8afb 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Userload.kt +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Userload.kt @@ -6,6 +6,8 @@ import com.lagradost.cloudstream3.utils.* import org.mozilla.javascript.Context import org.mozilla.javascript.EvaluatorException import org.mozilla.javascript.Scriptable +import java.util.* + open class Userload : ExtractorApi() { override var name = "Userload" @@ -14,7 +16,7 @@ open class Userload : ExtractorApi() { private fun splitInput(input: String): List { var counter = 0 - val array = mutableListOf() + val array = ArrayList() var buffer = "" for (c in input) { when (c) { @@ -69,7 +71,7 @@ open class Userload : ExtractorApi() { } var txtresult = "" subchar.forEach{ - txtresult = txtresult.plus(it.toInt(8).toChar()) + txtresult = txtresult.plus(Char(it.toInt(8))) } val val1 = Regex(""""morocco="((.|\\n)*?)"&mycountry="""").find(txtresult)?.groups?.get(1)?.value.toString().drop(1).dropLast(1) val val2 = txtresult.substringAfter("""&mycountry="+""").substringBefore(")") diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Vicloud.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Vicloud.kt index 6b82ee454..974549fcb 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Vicloud.kt +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Vicloud.kt @@ -1,7 +1,6 @@ package com.lagradost.cloudstream3.extractors import com.fasterxml.jackson.annotation.JsonProperty -import com.lagradost.cloudstream3.APIHolder.unixTimeMS import com.lagradost.cloudstream3.SubtitleFile import com.lagradost.cloudstream3.app import com.lagradost.cloudstream3.utils.ExtractorApi @@ -22,7 +21,7 @@ open class Vicloud : ExtractorApi() { ) { val id = Regex("\"apiQuery\":\"(.*?)\"").find(app.get(url).text)?.groupValues?.getOrNull(1) app.get( - "$mainUrl/api/?$id=&_=$unixTimeMS", + "$mainUrl/api/?$id=&_=${System.currentTimeMillis()}", headers = mapOf( "X-Requested-With" to "XMLHttpRequest" ), 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 849b2b6d9..cce5b01b3 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/VidHidePro.kt +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/VidHidePro.kt @@ -3,19 +3,8 @@ 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 com.lagradost.cloudstream3.utils.getPacked - -class Ryderjet: VidHidePro() { - override var mainUrl = "https://ryderjet.com" -} - -class VidHideHub : VidHidePro() { - override var mainUrl = "https://vidhidehub.com" -} +import com.lagradost.cloudstream3.utils.* +import com.lagradost.cloudstream3.utils.M3u8Helper.Companion.generateM3u8 class VidHidePro1 : VidHidePro() { override var mainUrl = "https://filelions.live" @@ -77,12 +66,24 @@ open class VidHidePro : ExtractorApi() { val response = app.get(getEmbedUrl(url), referer = referer) val script = if (!getPacked(response.text).isNullOrEmpty()) { - getAndUnpack(response.text) + var result = getAndUnpack(response.text) + if(result.contains("var links")){ + result = result.substringAfter("var links") + } + result } else { response.document.selectFirst("script:containsData(sources:)")?.data() } ?: return - JwPlayerHelper.extractStreamLinks(script, name, mainUrl, callback, subtitleCallback, headers) + // 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) + } } private fun getEmbedUrl(url: String): String { diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/VidMoxyExtractor.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/VidMoxyExtractor.kt index a23f6b683..36acf7f7a 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/VidMoxyExtractor.kt +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/VidMoxyExtractor.kt @@ -35,14 +35,14 @@ open class VidMoxy : ExtractorApi() { if (extractedValue != null) { val bytes = extractedValue.split("\\x").filter { it.isNotEmpty() }.map { it.toInt(16).toByte() }.toByteArray() - decoded = bytes.decodeToString() + decoded = String(bytes, Charsets.UTF_8) } else { val evaljwSetup = Regex("""\};\s*(eval\(function[\s\S]*?)var played = \d+;""").find(videoReq)?.groupValues?.get(1) ?: throw ErrorLoadingException("File not found") val jwSetup = getAndUnpack(getAndUnpack(evaljwSetup)).replace("\\\\", "\\") extractedValue = Regex("""file":"(.*)","label""").find(jwSetup)?.groupValues?.get(1)?.replace("\\\\x", "") val bytes = extractedValue?.chunked(2)?.map { it.toInt(16).toByte() }?.toByteArray() - decoded = bytes?.decodeToString() ?: throw ErrorLoadingException("File not found") + decoded = bytes?.toString(Charsets.UTF_8) ?: throw ErrorLoadingException("File not found") } callback.invoke( diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/VidStack.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/VidStack.kt index 63ceb1f3d..846fd851d 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/VidStack.kt +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/VidStack.kt @@ -10,7 +10,7 @@ import com.lagradost.cloudstream3.utils.ExtractorLinkType import com.lagradost.cloudstream3.utils.Qualities import com.lagradost.cloudstream3.utils.fixUrl import com.lagradost.cloudstream3.utils.newExtractorLink -import io.ktor.http.Url +import java.net.URI import javax.crypto.Cipher import javax.crypto.spec.IvParameterSpec import javax.crypto.spec.SecretKeySpec @@ -84,7 +84,7 @@ open class VidStack : ExtractorApi() { private fun getBaseUrl(url: String): String { return try { - Url(url).let { "${it.protocol.name}://${it.host}" } + URI(url).let { "${it.scheme}://${it.host}" } } catch (e: Exception) { Log.e("Vidstack", "getBaseUrl fallback: ${e.message}") mainUrl diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Videa.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Videa.kt deleted file mode 100644 index 59d7a7f2e..000000000 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Videa.kt +++ /dev/null @@ -1,216 +0,0 @@ -// Adapted for CloudStream - taken from https://github.com/vargalex/ResolveURL/blob/fix/videa-resolver-add-cookie/script.module.resolveurl/lib/resolveurl/plugins/videa.py -package com.lagradost.cloudstream3.extractors - -import com.lagradost.cloudstream3.app -import com.lagradost.cloudstream3.SubtitleFile -import com.lagradost.cloudstream3.utils.* -import com.lagradost.cloudstream3.base64DecodeArray - -/** - * Extractor for Videa.hu video hosting service - * Handles encrypted XML responses and redirect chains - */ -class Videa : ExtractorApi() { - override val name = "Videa" - override val mainUrl = "https://videa.hu" - override val requiresReferer = false - - private val videaSecret = "xHb0ZvME5q8CBcoQi6AngerDu3FGO9fkUlwPmLVY_RTzj2hJIS4NasXWKy1td7p" - - override suspend fun getUrl( - url: String, - referer: String?, - subtitleCallback: (SubtitleFile) -> Unit, - callback: (ExtractorLink) -> Unit - ) { - var currentUrl = url - var key = "" - // Handle redirect loop until we get valid XML - val visitedUrls = mutableSetOf() - var count = 10 - while (!visitedUrls.contains(currentUrl) && count > 0) { - visitedUrls += currentUrl - count -= 1 - - val webUrl = getXmlUrl(currentUrl) { cookie -> /* no-op, cookie not used */ } ?: return - val response = app.get(webUrl) - val body = response.body - val rawBytes = body.bytes() - body.close() - - // Check if response starts with XML declaration - val isXml = rawBytes.size >= 5 && - rawBytes[0] == 0x3C.toByte() && // '<' - rawBytes[1] == 0x3F.toByte() && // '?' - rawBytes[2] == 0x78.toByte() && // 'x' - rawBytes[3] == 0x6D.toByte() && // 'm' - rawBytes[4] == 0x6C.toByte() // 'l' - - val videaXml = if (isXml) { - rawBytes.decodeToString() - } else { - // Handle encrypted XML response - val xsHeader = response.headers["X-Videa-Xs"] ?: return - key += xsHeader - rc4DecryptBytes(rawBytes, key) - } - - // Check for redirect in XML error - val redirectMatch = """(.*)""".toRegex().find(videaXml) - - if (redirectMatch != null && redirectMatch.groupValues[1] != currentUrl) { - currentUrl = redirectMatch.groupValues[1] - } else { - parseVideoSources(videaXml, callback) - break - } - } - } - - private suspend fun getXmlUrl(url: String, cookieCallback: (String) -> Unit = {}): String? { - val response = app.get(url) - val size = response.size - /* OOM Protection */ - if(size != null && size > 5_000_000) { - // You tried to use a video here - return null - } - val html = response.text - - // Extract sl cookie if present - response.headers["Set-Cookie"]?.let { cookieHeader -> - """sl=([^;]+)""".toRegex().find(cookieHeader)?.let { - cookieCallback(it.value) - } - } - - // Determine if this is a player URL or needs iframe extraction - val playerUrl = if ("/player" in url) { - url - } else { - val iframeMatch = """ - """sl=([^;]+)""".toRegex().find(cookieHeader)?.let { - cookieCallback(it.value) - } - } - - // Extract nonce and generate tokens - val nonceMatch = """_xt\s*=\s*"([^"]+)""".toRegex().find(playerHtml) ?: return null - val (s, t, key) = generateTokens(nonceMatch.groupValues[1]) - - // Extract video parameter - val videoParam = when { - "f=" in playerUrl -> "f=" + playerUrl.substringAfter("f=").substringBefore("&") - "v=" in playerUrl -> "v=" + playerUrl.substringAfter("v=").substringBefore("&") - else -> return null - } - - return "$mainUrl/player/xml?platform=desktop&$videoParam&_s=$s&_t=$t" - } - - private fun generateTokens(nonce: String): Triple { - val lo = nonce.take(32) - val s = nonce.substring(32) - var result = "" - - for (i in 0 until 32) { - val index = videaSecret.indexOf(lo[i]) - 31 - result += s[i - index] - } - - // Generate random seed - val chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" - val randomSeed = (1..8).map { chars.random() }.joinToString("") - - val key = result.substring(16) + randomSeed - return Triple(randomSeed, result.take(16), key) - } - - private suspend fun parseVideoSources(xml: String, callback: (ExtractorLink) -> Unit) { - val sourceRegex = """video_source\s*name="([^"]+)".*exp="([^"]+)"[^>]*>([^<]+)""".toRegex() - val sources = sourceRegex.findAll(xml).toList() - - for (sourceMatch in sources) { - val sourceName = sourceMatch.groupValues[1] - val exp = sourceMatch.groupValues[2] - var sourceUrl = sourceMatch.groupValues[3] - - // Add https if needed - if (sourceUrl.startsWith("//")) { - sourceUrl = "https:$sourceUrl" - } - - // Extract hash for this source - val hashMatch = """([^<]+)<""".toRegex().find(xml) - - hashMatch?.let { match -> - val hash = match.groupValues[1] - val finalUrl = "$sourceUrl?md5=$hash&expires=$exp".replace("&", "&") - - callback( - newExtractorLink( - name, - "$sourceName - $name", - finalUrl, - ExtractorLinkType.VIDEO - ) { - this.quality = Qualities.Unknown.value - this.referer = mainUrl - } - ) - } - } - } - - private fun rc4DecryptBytes(encryptedBytes: ByteArray, key: String): String { - // Check if data is Base64 encoded - val isBase64 = encryptedBytes.all { byte -> - val char = byte.toInt() and 0xFF - char in 32..126 || char == 10 || char == 13 - } - - val actualEncryptedBytes = if (isBase64) { - val base64String = encryptedBytes.decodeToString() - .replace("\r", "") - .replace("\n", "") - .replace(" ", "") - .trim() - base64DecodeArray(base64String) - } else { - encryptedBytes - } - - val keyBytes = key.encodeToByteArray() - - // RC4 key-scheduling algorithm (KSA) - val s = IntArray(256) { it } - var j = 0 - for (i in 0..255) { - j = (j + s[i] + (keyBytes[i % keyBytes.size].toInt() and 0xFF)) % 256 - s[i] = s[j].also { s[j] = s[i] } - } - - // RC4 pseudo-random generation algorithm (PRGA) - var i = 0 - j = 0 - val result = ByteArray(actualEncryptedBytes.size) - for (k in actualEncryptedBytes.indices) { - i = (i + 1) % 256 - j = (j + s[i]) % 256 - s[i] = s[j].also { s[j] = s[i] } - val keyStreamByte = s[(s[i] + s[j]) % 256] - result[k] = ((actualEncryptedBytes[k].toInt() and 0xFF) xor keyStreamByte).toByte() - } - - return result.decodeToString() - } -} diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/VideoSeyredExtractor.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/VideoSeyredExtractor.kt index bc94ae0cc..583d92322 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/VideoSeyredExtractor.kt +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/VideoSeyredExtractor.kt @@ -2,11 +2,12 @@ package com.lagradost.cloudstream3.extractors -import com.fasterxml.jackson.annotation.JsonProperty import com.lagradost.api.Log import com.lagradost.cloudstream3.* import com.lagradost.cloudstream3.utils.* -import com.lagradost.cloudstream3.utils.AppUtils.tryParseJson +import com.fasterxml.jackson.annotation.JsonProperty +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import com.fasterxml.jackson.module.kotlin.readValue open class VideoSeyred : ExtractorApi() { override val name = "VideoSeyred" @@ -19,7 +20,7 @@ open class VideoSeyred : ExtractorApi() { val videoUrl = "${mainUrl}/playlist/${videoId}.json" val responseRaw = app.get(videoUrl) - val responseList: List = tryParseJson>(responseRaw.text) ?: throw ErrorLoadingException("VideoSeyred") + val responseList:List = jacksonObjectMapper().readValue(responseRaw.text) ?: throw ErrorLoadingException("VideoSeyred") val response = responseList[0] for (track in response.tracks) { @@ -67,4 +68,4 @@ open class VideoSeyred : ExtractorApi() { @JsonProperty("label") val label: String? = null, @JsonProperty("default") val default: String? = null ) -} +} \ No newline at end of file diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/VideoVard.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/VideoVard.kt new file mode 100644 index 000000000..30a1d8fe6 --- /dev/null +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/VideoVard.kt @@ -0,0 +1,271 @@ +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.Companion.generateM3u8 +import kotlinx.coroutines.delay +import java.math.BigInteger + +class VideovardSX : WcoStream() { + override var mainUrl = "https://videovard.sx" +} + +open class VideoVard : ExtractorApi() { + override var name = "Videovard" // Cause works for animekisa and wco + override var mainUrl = "https://videovard.to" + override val requiresReferer = false + + //The following code was extracted from https://github.com/saikou-app/saikou/blob/main/app/src/main/java/ani/saikou/parsers/anime/extractors/VideoVard.kt + override suspend fun getUrl(url: String, referer: String?): List { + val id = url.substringAfter("e/").substringBefore("/") + val sources = mutableListOf() + val hash = app.get("$mainUrl/api/make/download/$id").parsed() + delay(11_000) + val resm3u8 = app.post( + "$mainUrl/api/player/setup", + mapOf("Referer" to "$mainUrl/"), + data = mapOf( + "cmd" to "get_stream", + "file_code" to id, + "hash" to hash.hash!! + ) + ).parsed() + val m3u8 = decode(resm3u8.src!!, resm3u8.seed) + sources.addAll( + generateM3u8( + name, + m3u8, + mainUrl, + headers = mapOf("Referer" to mainUrl) + ) + ) + return sources + } + + companion object { + private val big0 = 0.toBigInteger() + private val big3 = 3.toBigInteger() + private val big4 = 4.toBigInteger() + private val big15 = 15.toBigInteger() + private val big16 = 16.toBigInteger() + private val big255 = 255.toBigInteger() + + private fun decode(dataFile: String, seed: String): String { + val dataSeed = replace(seed) + val newDataSeed = binaryDigest(dataSeed) + val newDataFile = bytes2blocks(ascii2bytes(dataFile)) + var list = listOf(1633837924, 1650680933).map { it.toBigInteger() } + val xorList = mutableListOf() + for (i in newDataFile.indices step 2) { + val temp = newDataFile.slice(i..i + 1) + xorList += xorBlocks(list, tearDecode(temp, newDataSeed)) + list = temp + } + + val result = replace(unPad(blocks2bytes(xorList)).map { it.toInt().toChar() }.joinToString("")) + return padLastChars(result) + } + + private fun binaryDigest(input: String): List { + val keys = listOf(1633837924, 1650680933, 1667523942, 1684366951).map { it.toBigInteger() } + var list1 = keys.slice(0..1) + var list2 = list1 + val blocks = bytes2blocks(digestPad(input)) + + for (i in blocks.indices step 4) { + list1 = tearCode(xorBlocks(blocks.slice(i..i + 1), list1), keys).toMutableList() + list2 = tearCode(xorBlocks(blocks.slice(i + 2..i + 3), list2), keys).toMutableList() + + val temp = list1[0] + list1[0] = list1[1] + list1[1] = list2[0] + list2[0] = list2[1] + list2[1] = temp + } + + return listOf(list1[0], list1[1], list2[0], list2[1]) + } + + private fun tearDecode(a90: List, a91: List): MutableList { + var (a95, a96) = a90 + + var a97 = (-957401312).toBigInteger() + for (_i in 0 until 32) { + a96 -= ((((a95 shl 4) xor rShift(a95, 5)) + a95) xor (a97 + a91[rShift(a97, 11).and(3.toBigInteger()).toInt()])) + a97 += 1640531527.toBigInteger() + a95 -= ((((a96 shl 4) xor rShift(a96, 5)) + a96) xor (a97 + a91[a97.and(3.toBigInteger()).toInt()])) + + } + + return mutableListOf(a95, a96) + } + + private fun digestPad(string: String): List { + val empList = mutableListOf() + val length = string.length + val extra = big15 - (length.toBigInteger() % big16) + empList.add(extra) + for (i in 0 until length) { + empList.add(string[i].code.toBigInteger()) + } + for (i in 0 until extra.toInt()) { + empList.add(big0) + } + + return empList + } + + private fun bytes2blocks(a22: List): List { + val empList = mutableListOf() + val length = a22.size + var listIndex = 0 + + for (i in 0 until length) { + val subIndex = i % 4 + val shiftedByte = a22[i] shl (3 - subIndex) * 8 + + if (subIndex == 0) { + empList.add(shiftedByte) + } else { + empList[listIndex] = empList[listIndex] or shiftedByte + } + + if (subIndex == 3) listIndex += 1 + } + + return empList + } + + private fun blocks2bytes(inp: List): List { + val tempList = mutableListOf() + inp.indices.forEach { i -> + tempList += (big255 and rShift(inp[i], 24)) + tempList += (big255 and rShift(inp[i], 16)) + tempList += (big255 and rShift(inp[i], 8)) + tempList += (big255 and inp[i]) + } + return tempList + } + + private fun unPad(a46: List): List { + val evenOdd = a46[0].toInt().mod(2) + return (1 until (a46.size - evenOdd)).map { + a46[it] + } + } + + private fun xorBlocks(a76: List, a77: List): List { + return listOf(a76[0] xor a77[0], a76[1] xor a77[1]) + } + + private fun rShift(input: BigInteger, by: Int): BigInteger { + return (input.mod(4294967296.toBigInteger()) shr by) + } + + private fun tearCode(list1: List, list2: List): MutableList { + var a1 = list1[0] + var a2 = list1[1] + var temp = big0 + + for (_i in 0 until 32) { + a1 += (a2 shl 4 xor rShift(a2, 5)) + a2 xor temp + list2[(temp and big3).toInt()] + temp -= 1640531527.toBigInteger() + a2 += (a1 shl 4 xor rShift(a1, 5)) + a1 xor temp + list2[(rShift(temp, 11) and big3).toInt()] + } + return mutableListOf(a1, a2) + } + + private fun ascii2bytes(input: String): List { + val abc = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_" + val abcMap = abc.mapIndexed { i, c -> c to i.toBigInteger() }.toMap() + var index = -1 + val length = input.length + var listIndex = 0 + val bytes = mutableListOf() + + while (true) { + for (i in input) { + if (abc.contains(i)) { + index++ + break + } + } + + bytes.add((abcMap[input.getOrNull(index)?:return bytes]!! * big4)) + + while (true) { + index++ + if (abc.contains(input[index])) { + break + } + } + + var temp = abcMap[input[index]]!! + + bytes[listIndex] = bytes[listIndex] or rShift(temp, 4) + listIndex++ + temp = (big15.and(temp)) + + if ((temp == big0) && (index == (length - 1))) return bytes + + bytes.add((temp * big4 * big4)) + + while (true) { + index++ + if (index >= length) return bytes + if (abc.contains(input[index])) break + } + + temp = abcMap[input[index]]!! + bytes[listIndex] = bytes[listIndex] or rShift(temp, 2) + listIndex++ + temp = (big3 and temp) + if ((temp == big0) && (index == (length - 1))) { + return bytes + } + bytes.add((temp shl 6)) + for (i in input) { + index++ + if (abc.contains(input[index])) { + break + } + } + bytes[listIndex] = bytes[listIndex] or abcMap[input[index]]!! + listIndex++ + } + } + + private fun replace(a: String): String { + val map = mapOf( + '0' to '5', + '1' to '6', + '2' to '7', + '5' to '0', + '6' to '1', + '7' to '2' + ) + var b = "" + a.forEach { + b += if (map.containsKey(it)) map[it] else it + } + return b + } + + private fun padLastChars(input:String):String{ + return if(input.reversed()[3].isDigit()) input + else input.dropLast(4) + } + + private data class HashResponse( + val hash: String? = null, + val version:String? = null + ) + + private data class SetupResponse( + val seed: String, + val src: String?=null, + val link:String?=null + ) + } +} \ No newline at end of file diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Vidguard.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Vidguard.kt new file mode 100644 index 000000000..03a16c0a7 --- /dev/null +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Vidguard.kt @@ -0,0 +1,139 @@ +package com.lagradost.cloudstream3.extractors + +import com.lagradost.api.Log +import com.lagradost.cloudstream3.SubtitleFile +import com.lagradost.cloudstream3.app +import com.lagradost.cloudstream3.utils.AppUtils +import com.lagradost.cloudstream3.utils.ExtractorApi +import com.lagradost.cloudstream3.utils.ExtractorLink +import com.lagradost.cloudstream3.utils.INFER_TYPE +import com.lagradost.cloudstream3.utils.Qualities +import com.lagradost.cloudstream3.utils.newExtractorLink +import org.mozilla.javascript.Context +import org.mozilla.javascript.NativeJSON +import org.mozilla.javascript.NativeObject +import org.mozilla.javascript.Scriptable +import kotlin.io.encoding.Base64 +import kotlin.io.encoding.ExperimentalEncodingApi + +class Vidguardto1 : Vidguardto() { + override val mainUrl = "https://bembed.net" +} + +class Vidguardto2 : Vidguardto() { + override val mainUrl = "https://listeamed.net" +} + +class Vidguardto3 : Vidguardto() { + override val mainUrl = "https://vgfplay.com" +} + +open class Vidguardto : ExtractorApi() { + override val name = "Vidguard" + override val mainUrl = "https://vidguard.to" + override val requiresReferer = false + + override suspend fun getUrl( + url: String, + referer: String?, + subtitleCallback: (SubtitleFile) -> Unit, + callback: (ExtractorLink) -> Unit + ) { + val res = app.get(getEmbedUrl(url)) + val resc = res.document.select("script:containsData(eval)").firstOrNull()?.data() + resc?.let { + val jsonStr2 = AppUtils.parseJson(runJS2(it)) + val watchlink = sigDecode(jsonStr2.stream) + + callback.invoke( + newExtractorLink( + this.name, + name, + watchlink, + ) { + this.referer = mainUrl + } + ) + } + } + + @OptIn(ExperimentalEncodingApi::class) + private fun sigDecode(url: String): String { + val sig = url.split("sig=")[1].split("&")[0] + val t = sig.chunked(2) + .joinToString("") { (Integer.parseInt(it, 16) xor 2).toChar().toString() } + .let { + val padding = when (it.length % 4) { + 2 -> "==" + 3 -> "=" + else -> "" + } + String(Base64.decode((it + padding).toByteArray(Charsets.UTF_8))) + } + .dropLast(5) + .reversed() + .toCharArray() + .apply { + for (i in indices step 2) { + if (i + 1 < size) { + this[i] = this[i + 1].also { this[i + 1] = this[i] } + } + } + } + .concatToString() + .dropLast(5) + return url.replace(sig, t) + } + + private fun runJS2(hideMyHtmlContent: String): String { + var result = "" + val r = Runnable { + val rhino = Context.enter() + rhino.initSafeStandardObjects() + rhino.setInterpretedMode(true) + val scope: Scriptable = rhino.initSafeStandardObjects() + scope.put("window", scope, scope) + try { + rhino.evaluateString( + scope, + hideMyHtmlContent, + "JavaScript", + 1, + null + ) + val svgObject = scope.get("svg", scope) + result = if (svgObject is NativeObject) { + NativeJSON.stringify( + Context.getCurrentContext(), + scope, + svgObject, + null, + null + ).toString() + } else { + Context.toString(svgObject) + } + } catch (e: Exception) { + Log.e("runJS", "Error executing JavaScript: ${e.message}") + } finally { + Context.exit() + } + } + val t = Thread(ThreadGroup("A"), r, "thread_rhino", 8 * 1024 * 1024) // Increase stack size to 8MB + t.start() + t.join() + t.interrupt() + return result + } + + private fun getEmbedUrl(url: String): String { + return url.takeIf { it.contains("/d/") || it.contains("/v/") } + ?.replace("/d/", "/e/")?.replace("/v/", "/e/") ?: url + } + + data class SvgObject( + val stream: String, + val hash: String + ) + +} 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 11927c507..f966a2f5d 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Vidmoly.kt +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Vidmoly.kt @@ -1,50 +1,86 @@ 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.extractors.helper.JwPlayerHelper -import com.lagradost.cloudstream3.utils.ExtractorApi -import com.lagradost.cloudstream3.utils.ExtractorLink +import com.lagradost.cloudstream3.newSubtitleFile +import com.lagradost.cloudstream3.utils.* +import com.lagradost.cloudstream3.utils.AppUtils.tryParseJson +import kotlinx.coroutines.delay class Vidmolyme : Vidmoly() { override val mainUrl = "https://vidmoly.me" } -class Vidmolyto : Vidmoly() { - override val mainUrl = "https://vidmoly.to" -} - -class Vidmolybiz : Vidmoly() { - override val mainUrl = "https://vidmoly.biz" -} - open class Vidmoly : ExtractorApi() { override val name = "Vidmoly" - override val mainUrl = "https://vidmoly.net" + override val mainUrl = "https://vidmoly.to" override val requiresReferer = true + private fun String.addMarks(str: String): String { + return this.replace(Regex("\"?$str\"?"), "\"$str\"") + } + override suspend fun getUrl( url: String, referer: String?, subtitleCallback: (SubtitleFile) -> Unit, callback: (ExtractorLink) -> Unit ) { - val headers = mapOf( - "user-agent" to USER_AGENT, + val headers = mapOf( + "user-agent" to USER_AGENT, "Sec-Fetch-Dest" to "iframe" ) - - val newUrl = if (url.contains("/w/")) - url.replaceFirst("/w/", "/embed-") + ".html" + val newUrl = if(url.contains("/w/")) + url.replaceFirst("/w/", "/embed-")+"-920x360.html" else url + var script: String? = null; + var attemps = 0 + while (attemps < 10 && script.isNullOrEmpty()){ + attemps++ + script = app.get( + newUrl, + headers = headers, + referer = referer, + ).document.select("script") + .firstOrNull { it.data().contains("sources:") }?.data() + if(script.isNullOrEmpty()) + delay(500) + } + val videoData = script?.substringAfter("sources: [") + ?.substringBefore("],")?.addMarks("file") + val subData = script?.substringAfter("tracks: [")?.substringBefore("]")?.addMarks("file") + ?.addMarks("label")?.addMarks("kind") - val script = app.get(newUrl, headers = headers, referer = referer) - .document.select("script") - .firstOrNull { it.data().contains("sources:") } - ?.data() + tryParseJson(videoData)?.file?.let { m3uLink -> + M3u8Helper.generateM3u8( + name, + m3uLink, + "$mainUrl/" + ).forEach(callback) + } + + tryParseJson>("[${subData}]") + ?.filter { it.kind == "captions" }?.map { + subtitleCallback.invoke( + newSubtitleFile( + it.label.toString(), + fixUrl(it.file.toString()) + ) + ) + } - // Extracts and parses videoData - JwPlayerHelper.extractStreamLinks(script.orEmpty(), name, mainUrl, callback, subtitleCallback) } + + 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, + ) + } diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Vidsonic.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Vidsonic.kt deleted file mode 100644 index 49560d456..000000000 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Vidsonic.kt +++ /dev/null @@ -1,61 +0,0 @@ -package com.lagradost.cloudstream3.extractors - -import com.lagradost.api.Log -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.ExtractorLinkType -import com.lagradost.cloudstream3.utils.newExtractorLink - -class Vidsonic() : ExtractorApi() { - override val name: String = "Vidsonic" - override val mainUrl: String = "https://vidsonic.net" - override val requiresReferer: Boolean = false - - override suspend fun getUrl( - url: String, - referer: String?, - subtitleCallback: (SubtitleFile) -> Unit, - callback: (ExtractorLink) -> Unit - ) { - // Extracted JavaScript code that decodes the encrypted m3u8 stream URL: - // - // const _0x1 = '3363616238|3638666534|6264323565|3666616366|6636333662|6230626339|30613d3564|6d26743130|6b74693170|336563793d|64695f656c|6966263634|3332363033|3737313d73|6572697078|6526333d64|695f726576|7265733f38|75336d2e72|657473616d|2f7431306b|7469317033|6563792f38|392f657275|6365732f74|656e2e6369|6e6f736469|762e31302d|73752d7473|2f2f3a7370|747468'; - // const _0x2 = function(_0x3) { - // const _0x4 = _0x3.split('|').join(''); - // let _0x5 = ''; - // for (let _0x6 = 0; _0x6 < _0x4.length; _0x6 += 2) { - // _0x5 += String.fromCharCode(parseInt(_0x4.substr(_0x6, 2), 16)); - // } - // return _0x5.split('').reverse().join(''); - // }; - // const _0x7 = _0x2(_0x1); <-- now contains the stream URL - - val response = app.get(url).text - val encodedStreamUrl = response - .substringAfter("const _0x1 = ") - .substringBefore(";") - .replace("'", "") - - // (improved) Kotlin implementation of the JavaScript code from above - val streamUrl = encodedStreamUrl - .replace("|", "") - // always two base16 digits together build one ASCII char - .chunked(2) - .map { - it.toInt(16).toChar() - } - .joinToString("") - .reversed() - - callback.invoke( - newExtractorLink( - source = name, - name = name, - url = streamUrl, - type = ExtractorLinkType.M3U8 - ) - ) - } -} \ No newline at end of file diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Vidstream.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Vidstream.kt new file mode 100644 index 000000000..ab228ee3c --- /dev/null +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Vidstream.kt @@ -0,0 +1,104 @@ +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/VkExtractor.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/VkExtractor.kt index 8e4540874..5009cea3e 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/VkExtractor.kt +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/VkExtractor.kt @@ -1,6 +1,7 @@ // Made by @kraptor123 for cs-kraptor package com.lagradost.cloudstream3.extractors +import com.lagradost.cloudstream3.Prerelease import com.lagradost.cloudstream3.SubtitleFile import com.lagradost.cloudstream3.app import com.lagradost.cloudstream3.utils.ExtractorApi @@ -9,6 +10,7 @@ import com.lagradost.cloudstream3.utils.ExtractorLinkType import com.lagradost.cloudstream3.utils.getQualityFromName import com.lagradost.cloudstream3.utils.newExtractorLink +@Prerelease open class VkExtractor : ExtractorApi() { override val name = "Vk" override val mainUrl = "https://vkvideo.ru" 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 67eb49c9a..860f9b540 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Voe.kt +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Voe.kt @@ -45,10 +45,6 @@ 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 2fdd7082a..37b8ecb23 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Vtbe.kt +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Vtbe.kt @@ -1,11 +1,15 @@ package com.lagradost.cloudstream3.extractors -import com.lagradost.cloudstream3.SubtitleFile +import com.fasterxml.jackson.annotation.JsonProperty import com.lagradost.cloudstream3.app -import com.lagradost.cloudstream3.extractors.helper.JwPlayerHelper +import com.lagradost.cloudstream3.utils.* +import com.lagradost.cloudstream3.utils.AppUtils.tryParseJson +import com.lagradost.cloudstream3.utils.JsUnpacker 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.getQualityFromName +import java.net.URI open class Vtbe : ExtractorApi() { @@ -13,16 +17,23 @@ open class Vtbe : ExtractorApi() { override var mainUrl = "https://vtbe.to" override val requiresReferer = true - override suspend fun getUrl( - url: String, - referer: String?, - subtitleCallback: (SubtitleFile) -> Unit, - callback: (ExtractorLink) -> Unit - ) { + override suspend fun getUrl(url: String, referer: String?): List? { 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 -> - JwPlayerHelper.extractStreamLinks(unPacked, name, mainUrl, callback, subtitleCallback) + 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 + } + ) + } } + return null } } diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/WcoStream.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/WcoStream.kt new file mode 100644 index 000000000..0488c0d38 --- /dev/null +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/WcoStream.kt @@ -0,0 +1,142 @@ +package com.lagradost.cloudstream3.extractors + +import com.fasterxml.jackson.annotation.JsonProperty +import com.lagradost.cloudstream3.ErrorLoadingException +import com.lagradost.cloudstream3.extractors.helper.NineAnimeHelper.cipher +import com.lagradost.cloudstream3.extractors.helper.NineAnimeHelper.encrypt +import com.lagradost.cloudstream3.app +import com.lagradost.cloudstream3.utils.ExtractorApi +import com.lagradost.cloudstream3.utils.ExtractorLink +import com.lagradost.cloudstream3.utils.newExtractorLink +import com.lagradost.cloudstream3.utils.INFER_TYPE +import com.lagradost.cloudstream3.utils.Qualities + +class Vidstreamz : WcoStream() { + override var mainUrl = "https://vidstreamz.online" +} + +class Vizcloud : WcoStream() { + override var mainUrl = "https://vizcloud2.ru" +} + +class Vizcloud2 : WcoStream() { + override var mainUrl = "https://vizcloud2.online" +} + +class VizcloudOnline : WcoStream() { + override var mainUrl = "https://vizcloud.online" +} + +class VizcloudXyz : WcoStream() { + override var mainUrl = "https://vizcloud.xyz" +} + +class VizcloudLive : WcoStream() { + override var mainUrl = "https://vizcloud.live" +} + +class VizcloudInfo : WcoStream() { + override var mainUrl = "https://vizcloud.info" +} + +class MwvnVizcloudInfo : WcoStream() { + override var mainUrl = "https://mwvn.vizcloud.info" +} + +class VizcloudDigital : WcoStream() { + override var mainUrl = "https://vizcloud.digital" +} + +class VizcloudCloud : WcoStream() { + override var mainUrl = "https://vizcloud.cloud" +} + +class VizcloudSite : WcoStream() { + override var mainUrl = "https://vizcloud.site" +} + +class Mcloud : WcoStream() { + override var name = "Mcloud" + override var mainUrl = "https://mcloud.to" + override val requiresReferer = true +} + +open class WcoStream : ExtractorApi() { + override var name = "VidStream" // Cause works for animekisa and wco + override var mainUrl = "https://vidstream.pro" + override val requiresReferer = false + private val regex = Regex("(.+?/)e(?:mbed)?/([a-zA-Z0-9]+)") + + companion object { + // taken from https://github.com/saikou-app/saikou/blob/b35364c8c2a00364178a472fccf1ab72f09815b4/app/src/main/java/ani/saikou/parsers/anime/extractors/VizCloud.kt + // GNU General Public License v3.0 https://github.com/saikou-app/saikou/blob/main/LICENSE.md + private var lastChecked = 0L + private const val jsonLink = + "https://raw.githubusercontent.com/chenkaslowankiya/BruhFlow/main/keys.json" + private var cipherKey: VizCloudKey? = null + suspend fun getKey(): VizCloudKey { + cipherKey = + if (cipherKey != null && (lastChecked - System.currentTimeMillis()) < 1000 * 60 * 30) cipherKey!! + else { + lastChecked = System.currentTimeMillis() + app.get(jsonLink).parsed() + } + return cipherKey!! + } + + data class VizCloudKey( + @JsonProperty("cipherKey") val cipherKey: String, + @JsonProperty("mainKey") val mainKey: String, + @JsonProperty("encryptKey") val encryptKey: String, + @JsonProperty("dashTable") val dashTable: String + ) + + private const val baseTable = + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+=/_" + + private fun dashify(id: String, dashTable: String): String { + val table = dashTable.split(" ") + return id.mapIndexedNotNull { i, c -> + table.getOrNull((baseTable.indexOf(c) * 16) + (i % 16)) + }.joinToString("-") + } + } + + //private val key = "LCbu3iYC7ln24K7P" // key credits @Modder4869 + override suspend fun getUrl(url: String, referer: String?): List { + val group = regex.find(url)?.groupValues!! + + val host = group[1] + val viz = getKey() + val id = encrypt( + cipher( + viz.cipherKey, + encrypt(group[2], viz.encryptKey).also { println(it) } + ).also { println(it) }, + viz.encryptKey + ).also { println(it) } + + val link = + "${host}mediainfo/${dashify(id, viz.dashTable)}?key=${viz.mainKey}" // + val response = app.get(link, referer = referer) + + data class Sources(@JsonProperty("file") val file: String) + data class Media(@JsonProperty("sources") val sources: List) + data class Data(@JsonProperty("media") val media: Media) + data class Response(@JsonProperty("data") val data: Data) + + + if (!response.text.startsWith("{")) throw ErrorLoadingException("Seems like 9Anime kiddies changed stuff again, Go touch some grass for bout an hour Or use a different Server") + return response.parsed().data.media.sources.map { + newExtractorLink( + name, + it.file, + it.file, + type = INFER_TYPE + ) { + this.referer = host + this.quality = Qualities.Unknown.value + } + } + } +} diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/YoutubeExtractor.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/YoutubeExtractor.kt index fb310401a..0ace27a31 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/YoutubeExtractor.kt +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/YoutubeExtractor.kt @@ -1,20 +1,21 @@ +// Made For cs-kraptor By @trup40, @kraptor123, @ByAyzen 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.AppUtils.tryParseJson import com.lagradost.cloudstream3.utils.ExtractorApi import com.lagradost.cloudstream3.utils.ExtractorLink +import com.lagradost.cloudstream3.utils.ExtractorLinkType +import com.lagradost.cloudstream3.utils.HlsPlaylistParser +import com.lagradost.cloudstream3.utils.SubtitleHelper +import com.lagradost.cloudstream3.utils.newExtractorLink +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.RequestBody.Companion.toRequestBody +import java.net.URLDecoder -expect open class YoutubeExtractor() : ExtractorApi { - override val mainUrl: String - override val name: String - override val requiresReferer: Boolean - override suspend fun getUrl( - url: String, - referer: String?, - subtitleCallback: (SubtitleFile) -> Unit, - callback: (ExtractorLink) -> Unit, - ) -} class YoutubeShortLinkExtractor : YoutubeExtractor() { override val mainUrl = "https://youtu.be" @@ -27,3 +28,256 @@ class YoutubeMobileExtractor : YoutubeExtractor() { class YoutubeNoCookieExtractor : YoutubeExtractor() { override val mainUrl = "https://www.youtube-nocookie.com" } + +open class YoutubeExtractor : ExtractorApi() { + override val mainUrl = "https://www.youtube.com" + override val requiresReferer = false + override val name = "YouTube" + private val youtubeUrl = "https://www.youtube.com" + + companion object { + private const val USER_AGENT = + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Safari/605.1.15" + private val HEADERS = mapOf( + "User-Agent" to USER_AGENT, + "Accept-Language" to "en-US,en;q=0.5" + ) + } + + + private fun extractYtCfg(html: String): String? { + val regex = Regex("""ytcfg\.set\(\s*(\{.*?\})\s*\)\s*;""") + val match = regex.find(html) + return match?.groupValues?.getOrNull(1) + } + + data class PageConfig( + @JsonProperty("INNERTUBE_API_KEY") + val apiKey: String, + @JsonProperty("INNERTUBE_CLIENT_VERSION") + val clientVersion: String = "2.20240725.01.00", + @JsonProperty("VISITOR_DATA") + val visitorData: String = "" + ) + + private suspend fun getPageConfig(videoId: String): PageConfig? = + tryParseJson(extractYtCfg(app.get("$mainUrl/watch?v=$videoId", headers = HEADERS).text)) + + fun extractYouTubeId(url: String): String { + return when { + url.contains("oembed") && url.contains("url=") -> { + val encodedUrl = url.substringAfter("url=").substringBefore("&") + val decodedUrl = URLDecoder.decode(encodedUrl, "UTF-8") + extractYouTubeId(decodedUrl) + } + + url.contains("attribution_link") && url.contains("u=") -> { + val encodedUrl = url.substringAfter("u=").substringBefore("&") + val decodedUrl = URLDecoder.decode(encodedUrl, "UTF-8") + extractYouTubeId(decodedUrl) + } + + url.contains("watch?v=") -> url.substringAfter("watch?v=").substringBefore("&") + .substringBefore("#") + + url.contains("&v=") -> url.substringAfter("&v=").substringBefore("&") + .substringBefore("#") + + url.contains("youtu.be/") -> url.substringAfter("youtu.be/").substringBefore("?") + .substringBefore("#").substringBefore("&") + + url.contains("/embed/") -> url.substringAfter("/embed/").substringBefore("?") + .substringBefore("#") + + url.contains("/v/") -> url.substringAfter("/v/").substringBefore("?") + .substringBefore("#") + + url.contains("/e/") -> url.substringAfter("/e/").substringBefore("?") + .substringBefore("#") + + url.contains("/shorts/") -> url.substringAfter("/shorts/").substringBefore("?") + .substringBefore("#") + + url.contains("/live/") -> url.substringAfter("/live/").substringBefore("?") + .substringBefore("#") + + url.contains("/watch/") -> url.substringAfter("/watch/").substringBefore("?") + .substringBefore("#") + + url.contains("watch%3Fv%3D") -> url.substringAfter("watch%3Fv%3D") + .substringBefore("%26").substringBefore("#") + + url.contains("v%3D") -> url.substringAfter("v%3D").substringBefore("%26") + .substringBefore("#") + + else -> error("No Id Found") + } + } + + + override suspend fun getUrl( + url: String, + referer: String?, + subtitleCallback: (SubtitleFile) -> Unit, + callback: (ExtractorLink) -> Unit + ) { + val videoId = extractYouTubeId(url) + val config = getPageConfig(videoId) ?: return + + val jsonBody = """ + { + "context": { + "client": { + "hl": "en", + "gl": "US", + "clientName": "WEB", + "clientVersion": "${config.clientVersion}", + "visitorData": "${config.visitorData}", + "platform": "DESKTOP", + "userAgent": "$USER_AGENT" + } + }, + "videoId": "$videoId", + "playbackContext": { + "contentPlaybackContext": { + "html5Preference": "HTML5_PREF_WANTS" + } + } + } + """.toRequestBody("application/json; charset=utf-8".toMediaType()) + + val response = + app.post( + "$youtubeUrl/youtubei/v1/player?key=${config.apiKey}", + headers = HEADERS, + requestBody = jsonBody + ).parsed() + + val captionTracks = response.captions?.playerCaptionsTracklistRenderer?.captionTracks + + if (captionTracks != null) { + for (caption in captionTracks) { + subtitleCallback.invoke( + newSubtitleFile( + lang =caption.name.simpleText, + url ="${caption.baseUrl}&fmt=ttml" // The default format is not supported + ) { headers = HEADERS }) + } + } + + val hlsUrl = response.streamingData.hlsManifestUrl + val getHls = app.get(hlsUrl, headers = HEADERS).text + val playlist = HlsPlaylistParser.parse(hlsUrl, getHls) ?: return + + var variantIndex = 0 + for (tag in playlist.tags) { + val trimmedTag = tag.trim() + if (!trimmedTag.startsWith("#EXT-X-STREAM-INF")) { + continue + } + val variant = playlist.variants.getOrNull(variantIndex++) ?: continue + + val audioId = trimmedTag.split(",") + .find { it.trim().startsWith("YT-EXT-AUDIO-CONTENT-ID=") } + ?.split("=") + ?.get(1) + ?.trim('"') ?: "" + + val langString = + SubtitleHelper.fromTagToEnglishLanguageName( + audioId.substringBefore(".") + ) ?: SubtitleHelper.fromTagToEnglishLanguageName( + audioId.substringBefore("-") + ) ?: audioId + + val url = variant.url.toString() + + if (url.isBlank()) { + continue + } + + callback.invoke( + newExtractorLink( + source = this.name, + name = "Youtube${if (langString.isNotBlank()) " $langString" else ""}", + url = url, + type = ExtractorLinkType.M3U8 + ) { + this.referer = "${mainUrl}/" + this.quality = variant.format.height + } + ) + } + } + + + private data class Root( + // val responseContext: ResponseContext, + // val playabilityStatus: PlayabilityStatus, + @JsonProperty("streamingData") + val streamingData: StreamingData, + // val playbackTracking: PlaybackTracking, + @JsonProperty("captions") + val captions: Captions?, + // val videoDetails: VideoDetails, + // val annotations: List, + // val playerConfig: PlayerConfig, + // val storyboards: Storyboards, + // val microformat: Microformat, + // val cards: Cards, + // val trackingParams: String, + // val endscreen: Endscreen, + // val paidContentOverlay: PaidContentOverlay, + // val adPlacements: List, + // val adBreakHeartbeatParams: String, + // val frameworkUpdates: FrameworkUpdates, + ) + + private data class StreamingData( + //val expiresInSeconds: String, + //val formats: List, + //val adaptiveFormats: List, + @JsonProperty("hlsManifestUrl") + val hlsManifestUrl: String, + //val serverAbrStreamingUrl: String, + ) + + private data class Captions( + @JsonProperty("playerCaptionsTracklistRenderer") + val playerCaptionsTracklistRenderer: PlayerCaptionsTracklistRenderer?, + ) + + private data class PlayerCaptionsTracklistRenderer( + @JsonProperty("captionTracks") + val captionTracks: List?, + //val audioTracks: List, + //val translationLanguages: List, + //@JsonProperty("defaultAudioTrackIndex") + //val defaultAudioTrackIndex: Long, + ) + + private data class CaptionTrack( + @JsonProperty("baseUrl") + val baseUrl: String, + @JsonProperty("name") + val name: Name, + //val vssId: String, + //val languageCode: String, + //val kind: String?, + //val isTranslatable: Boolean, + //val trackName: String, + ) + + private data class Name( + @JsonProperty("simpleText") + val simpleText: String, + ) + +// data class AudioTrack( +// val captionTrackIndices: List, +// val defaultCaptionTrackIndex: Long, +// val hasDefaultTrack: Boolean, +// val audioTrackId: String, +// val captionsInitialState: String, +// ) +} \ No newline at end of file diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Zorofile.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Zorofile.kt new file mode 100644 index 000000000..43c4eefb2 --- /dev/null +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Zorofile.kt @@ -0,0 +1,76 @@ +package com.lagradost.cloudstream3.extractors + +import com.fasterxml.jackson.annotation.JsonProperty +import com.lagradost.cloudstream3.APIHolder.getCaptchaToken +import com.lagradost.cloudstream3.ErrorLoadingException +import com.lagradost.cloudstream3.SubtitleFile +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 Zorofile : ExtractorApi() { + override val name = "Zorofile" + override val mainUrl = "https://zorofile.com" + override val requiresReferer = true + + override suspend fun getUrl( + url: String, + referer: String?, + subtitleCallback: (SubtitleFile) -> Unit, + callback: (ExtractorLink) -> Unit + ) { + val id = url.split("?").first().split("/").last() + val token = app.get( + url, + referer = referer + ).document.select("button.g-recaptcha").attr("data-sitekey").let { captchaKey -> + getCaptchaToken( + url, + captchaKey, + referer = referer + ) + } ?: throw ErrorLoadingException("can't bypass captcha") + + val data = app.post( + "$mainUrl/dl", + data = mapOf( + "op" to "embed", + "file_code" to id, + "auto" to "1", + "referer" to "$referer/", + "g-recaptcha-response" to token + ), + referer = url, + headers = mapOf( + "Accept" to "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8", + "Content-Type" to "application/x-www-form-urlencoded", + "Origin" to mainUrl, + "Sec-Fetch-Dest" to "iframe", + "Sec-Fetch-Mode" to "navigate", + "Sec-Fetch-Site" to "same-origin", + "Sec-Fetch-User" to "?1", + "Upgrade-Insecure-Requests" to "1", + ) + ).document.select("script").find { it.data().contains("var holaplayer;") }?.data() + ?.substringAfter("sources: [")?.substringBefore("],")?.replace("src", "\"src\"") + ?.replace("type", "\"type\"") + + tryParseJson("$data")?.let { res -> + return M3u8Helper.generateM3u8( + name, + res.src ?: return@let, + "$mainUrl/", + headers = mapOf( + "Origin" to mainUrl, + ) + ).forEach(callback) + } + } + + private data class Sources( + @JsonProperty("src") val src: String? = null, + @JsonProperty("type") val type: String? = null, + ) +} \ No newline at end of file diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/helper/CryptoJSHelper.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/helper/CryptoJSHelper.kt index 6ad0524e8..af59b6f7d 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/helper/CryptoJSHelper.kt +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/helper/CryptoJSHelper.kt @@ -2,11 +2,13 @@ package com.lagradost.cloudstream3.extractors.helper import com.lagradost.cloudstream3.base64DecodeArray import com.lagradost.cloudstream3.base64Encode +import java.util.Arrays import java.security.MessageDigest import java.security.SecureRandom import javax.crypto.Cipher import javax.crypto.spec.SecretKeySpec import javax.crypto.spec.IvParameterSpec +import java.nio.charset.StandardCharsets import kotlin.math.min /** @@ -46,9 +48,9 @@ object CryptoJS { // Create CryptoJS-like encrypted! val sBytes = APPEND.toByteArray() val b = ByteArray(sBytes.size + saltBytes.size + cipherText.size) - sBytes.copyInto(destination = b, destinationOffset = 0) - saltBytes.copyInto(destination = b, destinationOffset = sBytes.size) - cipherText.copyInto(destination = b, destinationOffset = sBytes.size + saltBytes.size) + System.arraycopy(sBytes, 0, b, 0, sBytes.size) + System.arraycopy(saltBytes, 0, b, sBytes.size, saltBytes.size) + System.arraycopy(cipherText, 0, b, sBytes.size + saltBytes.size, cipherText.size) return base64Encode(b) } @@ -61,8 +63,8 @@ object CryptoJS { */ fun decrypt(password: String, cipherText: String): String { val ctBytes = base64DecodeArray(cipherText) - val saltBytes = ctBytes.copyOfRange(8, 16) - val cipherTextBytes = ctBytes.copyOfRange(16, ctBytes.size) + val saltBytes = Arrays.copyOfRange(ctBytes, 8, 16) + val cipherTextBytes = Arrays.copyOfRange(ctBytes, 16, ctBytes.size) val key = ByteArray(KEY_SIZE / 8) val iv = ByteArray(IV_SIZE / 8) @@ -105,18 +107,16 @@ object CryptoJS { hash.reset() } - block!!.copyInto( - destination = derivedBytes, - destinationOffset = numberOfDerivedWords * 4, - startIndex = 0, - endIndex = min(block.size, (targetKeySize - numberOfDerivedWords) * 4) + System.arraycopy( + block!!, 0, derivedBytes, numberOfDerivedWords * 4, + min(block.size, (targetKeySize - numberOfDerivedWords) * 4) ) numberOfDerivedWords += block.size / 4 } - derivedBytes.copyInto(destination = resultKey, destinationOffset = 0, startIndex = 0, endIndex = keySize * 4) - derivedBytes.copyInto(destination = resultIv, destinationOffset = 0, startIndex = keySize * 4, endIndex = (keySize * 4) + (ivSize * 4)) + System.arraycopy(derivedBytes, 0, resultKey, 0, keySize * 4) + System.arraycopy(derivedBytes, keySize * 4, resultIv, 0, ivSize * 4) return derivedBytes // key + iv } @@ -126,4 +126,4 @@ object CryptoJS { SecureRandom().nextBytes(this) } } -} +} \ No newline at end of file diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/helper/GogoHelper.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/helper/GogoHelper.kt index 31618a32b..a16d41943 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/helper/GogoHelper.kt +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/helper/GogoHelper.kt @@ -12,8 +12,8 @@ import com.lagradost.cloudstream3.utils.ExtractorLink import com.lagradost.cloudstream3.utils.M3u8Helper import com.lagradost.cloudstream3.utils.getQualityFromName import com.lagradost.cloudstream3.utils.newExtractorLink -import io.ktor.http.Url import org.jsoup.nodes.Document +import java.net.URI import javax.crypto.Cipher import javax.crypto.spec.IvParameterSpec import javax.crypto.spec.SecretKeySpec @@ -88,8 +88,8 @@ object GogoHelper { val foundKey = secretKey ?: getKey(base64Decode(id) + foundIv) ?: return@safeApiCall val foundDecryptKey = secretDecryptKey ?: foundKey - val url = Url(iframeUrl) - val mainUrl = "https://${url.host}" + val uri = URI(iframeUrl) + val mainUrl = "https://" + uri.host val encryptedId = cryptoHandler(id, foundIv, foundKey) val encryptRequestData = if (isUsingAdaptiveData) { 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 deleted file mode 100644 index 43ceb2314..000000000 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/helper/JWPlayerHelper.kt +++ /dev/null @@ -1,155 +0,0 @@ -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 - * + + diff --git a/server/ui/package-lock.json b/server/ui/package-lock.json new file mode 100644 index 000000000..fee612bb5 --- /dev/null +++ b/server/ui/package-lock.json @@ -0,0 +1,2084 @@ +{ + "name": "ui", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "ui", + "version": "0.0.0", + "dependencies": { + "@tailwindcss/vite": "^4.1.18", + "daisyui": "^5.5.14", + "svelte-spa-router": "^4.0.1", + "tailwindcss": "^4.1.18", + "vidstack": "^0.6.15" + }, + "devDependencies": { + "@sveltejs/vite-plugin-svelte": "^6.2.1", + "@tsconfig/svelte": "^5.0.6", + "@types/node": "^24.10.1", + "svelte": "^5.43.8", + "svelte-check": "^4.3.4", + "typescript": "~5.9.3", + "vite": "^7.2.4" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz", + "integrity": "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.2.tgz", + "integrity": "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.2.tgz", + "integrity": "sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.2.tgz", + "integrity": "sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.2.tgz", + "integrity": "sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.2.tgz", + "integrity": "sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.2.tgz", + "integrity": "sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.2.tgz", + "integrity": "sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.2.tgz", + "integrity": "sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.2.tgz", + "integrity": "sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.2.tgz", + "integrity": "sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.2.tgz", + "integrity": "sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.2.tgz", + "integrity": "sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==", + "cpu": [ + "mips64el" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.2.tgz", + "integrity": "sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.2.tgz", + "integrity": "sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.2.tgz", + "integrity": "sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.2.tgz", + "integrity": "sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.2.tgz", + "integrity": "sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.2.tgz", + "integrity": "sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.2.tgz", + "integrity": "sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.2.tgz", + "integrity": "sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.2.tgz", + "integrity": "sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.2.tgz", + "integrity": "sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.2.tgz", + "integrity": "sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.2.tgz", + "integrity": "sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.2.tgz", + "integrity": "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@maverick-js/signals": { + "version": "5.11.5", + "resolved": "https://registry.npmjs.org/@maverick-js/signals/-/signals-5.11.5.tgz", + "integrity": "sha512-/GO94awrwN9ROYZDMTeByordjvbhcm3CMvB/2aL/sEUy9Va8nM/2GmNgOOe+rrooTGnz8/DzO73xomuBRrnYWw==", + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.56.0.tgz", + "integrity": "sha512-LNKIPA5k8PF1+jAFomGe3qN3bbIgJe/IlpDBwuVjrDKrJhVWywgnJvflMt/zkbVNLFtF1+94SljYQS6e99klnw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.56.0.tgz", + "integrity": "sha512-lfbVUbelYqXlYiU/HApNMJzT1E87UPGvzveGg2h0ktUNlOCxKlWuJ9jtfvs1sKHdwU4fzY7Pl8sAl49/XaEk6Q==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.56.0.tgz", + "integrity": "sha512-EgxD1ocWfhoD6xSOeEEwyE7tDvwTgZc8Bss7wCWe+uc7wO8G34HHCUH+Q6cHqJubxIAnQzAsyUsClt0yFLu06w==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.56.0.tgz", + "integrity": "sha512-1vXe1vcMOssb/hOF8iv52A7feWW2xnu+c8BV4t1F//m9QVLTfNVpEdja5ia762j/UEJe2Z1jAmEqZAK42tVW3g==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.56.0.tgz", + "integrity": "sha512-bof7fbIlvqsyv/DtaXSck4VYQ9lPtoWNFCB/JY4snlFuJREXfZnm+Ej6yaCHfQvofJDXLDMTVxWscVSuQvVWUQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.56.0.tgz", + "integrity": "sha512-KNa6lYHloW+7lTEkYGa37fpvPq+NKG/EHKM8+G/g9WDU7ls4sMqbVRV78J6LdNuVaeeK5WB9/9VAFbKxcbXKYg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.56.0.tgz", + "integrity": "sha512-E8jKK87uOvLrrLN28jnAAAChNq5LeCd2mGgZF+fGF5D507WlG/Noct3lP/QzQ6MrqJ5BCKNwI9ipADB6jyiq2A==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.56.0.tgz", + "integrity": "sha512-jQosa5FMYF5Z6prEpTCCmzCXz6eKr/tCBssSmQGEeozA9tkRUty/5Vx06ibaOP9RCrW1Pvb8yp3gvZhHwTDsJw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.56.0.tgz", + "integrity": "sha512-uQVoKkrC1KGEV6udrdVahASIsaF8h7iLG0U0W+Xn14ucFwi6uS539PsAr24IEF9/FoDtzMeeJXJIBo5RkbNWvQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.56.0.tgz", + "integrity": "sha512-vLZ1yJKLxhQLFKTs42RwTwa6zkGln+bnXc8ueFGMYmBTLfNu58sl5/eXyxRa2RarTkJbXl8TKPgfS6V5ijNqEA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.56.0.tgz", + "integrity": "sha512-FWfHOCub564kSE3xJQLLIC/hbKqHSVxy8vY75/YHHzWvbJL7aYJkdgwD/xGfUlL5UV2SB7otapLrcCj2xnF1dg==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.56.0.tgz", + "integrity": "sha512-z1EkujxIh7nbrKL1lmIpqFTc/sr0u8Uk0zK/qIEFldbt6EDKWFk/pxFq3gYj4Bjn3aa9eEhYRlL3H8ZbPT1xvA==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.56.0.tgz", + "integrity": "sha512-iNFTluqgdoQC7AIE8Q34R3AuPrJGJirj5wMUErxj22deOcY7XwZRaqYmB6ZKFHoVGqRcRd0mqO+845jAibKCkw==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.56.0.tgz", + "integrity": "sha512-MtMeFVlD2LIKjp2sE2xM2slq3Zxf9zwVuw0jemsxvh1QOpHSsSzfNOTH9uYW9i1MXFxUSMmLpeVeUzoNOKBaWg==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.56.0.tgz", + "integrity": "sha512-in+v6wiHdzzVhYKXIk5U74dEZHdKN9KH0Q4ANHOTvyXPG41bajYRsy7a8TPKbYPl34hU7PP7hMVHRvv/5aCSew==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.56.0.tgz", + "integrity": "sha512-yni2raKHB8m9NQpI9fPVwN754mn6dHQSbDTwxdr9SE0ks38DTjLMMBjrwvB5+mXrX+C0npX0CVeCUcvvvD8CNQ==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.56.0.tgz", + "integrity": "sha512-zhLLJx9nQPu7wezbxt2ut+CI4YlXi68ndEve16tPc/iwoylWS9B3FxpLS2PkmfYgDQtosah07Mj9E0khc3Y+vQ==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.56.0.tgz", + "integrity": "sha512-MVC6UDp16ZSH7x4rtuJPAEoE1RwS8N4oK9DLHy3FTEdFoUTCFVzMfJl/BVJ330C+hx8FfprA5Wqx4FhZXkj2Kw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.56.0.tgz", + "integrity": "sha512-ZhGH1eA4Qv0lxaV00azCIS1ChedK0V32952Md3FtnxSqZTBTd6tgil4nZT5cU8B+SIw3PFYkvyR4FKo2oyZIHA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.56.0.tgz", + "integrity": "sha512-O16XcmyDeFI9879pEcmtWvD/2nyxR9mF7Gs44lf1vGGx8Vg2DRNx11aVXBEqOQhWb92WN4z7fW/q4+2NYzCbBA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.56.0.tgz", + "integrity": "sha512-LhN/Reh+7F3RCgQIRbgw8ZMwUwyqJM+8pXNT6IIJAqm2IdKkzpCh/V9EdgOMBKuebIrzswqy4ATlrDgiOwbRcQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.56.0.tgz", + "integrity": "sha512-kbFsOObXp3LBULg1d3JIUQMa9Kv4UitDmpS+k0tinPBz3watcUiV2/LUDMMucA6pZO3WGE27P7DsfaN54l9ing==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.56.0.tgz", + "integrity": "sha512-vSSgny54D6P4vf2izbtFm/TcWYedw7f8eBrOiGGecyHyQB9q4Kqentjaj8hToe+995nob/Wv48pDqL5a62EWtg==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.56.0.tgz", + "integrity": "sha512-FeCnkPCTHQJFbiGG49KjV5YGW/8b9rrXAM2Mz2kiIoktq2qsJxRD5giEMEOD2lPdgs72upzefaUvS+nc8E3UzQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.56.0.tgz", + "integrity": "sha512-H8AE9Ur/t0+1VXujj90w0HrSOuv0Nq9r1vSZF2t5km20NTfosQsGGUXDaKdQZzwuLts7IyL1fYT4hM95TI9c4g==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@sveltejs/acorn-typescript": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.8.tgz", + "integrity": "sha512-esgN+54+q0NjB0Y/4BomT9samII7jGwNy/2a3wNZbT2A2RpmXsXwUt24LvLhx6jUq2gVk4cWEvcRO6MFQbOfNA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^8.9.0" + } + }, + "node_modules/@sveltejs/vite-plugin-svelte": { + "version": "6.2.4", + "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-6.2.4.tgz", + "integrity": "sha512-ou/d51QSdTyN26D7h6dSpusAKaZkAiGM55/AKYi+9AGZw7q85hElbjK3kEyzXHhLSnRISHOYzVge6x0jRZ7DXA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@sveltejs/vite-plugin-svelte-inspector": "^5.0.0", + "deepmerge": "^4.3.1", + "magic-string": "^0.30.21", + "obug": "^2.1.0", + "vitefu": "^1.1.1" + }, + "engines": { + "node": "^20.19 || ^22.12 || >=24" + }, + "peerDependencies": { + "svelte": "^5.0.0", + "vite": "^6.3.0 || ^7.0.0" + } + }, + "node_modules/@sveltejs/vite-plugin-svelte-inspector": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte-inspector/-/vite-plugin-svelte-inspector-5.0.2.tgz", + "integrity": "sha512-TZzRTcEtZffICSAoZGkPSl6Etsj2torOVrx6Uw0KpXxrec9Gg6jFWQ60Q3+LmNGfZSxHRCZL7vXVZIWmuV50Ig==", + "dev": true, + "license": "MIT", + "dependencies": { + "obug": "^2.1.0" + }, + "engines": { + "node": "^20.19 || ^22.12 || >=24" + }, + "peerDependencies": { + "@sveltejs/vite-plugin-svelte": "^6.0.0-next.0", + "svelte": "^5.0.0", + "vite": "^6.3.0 || ^7.0.0" + } + }, + "node_modules/@tailwindcss/node": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.18.tgz", + "integrity": "sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.4", + "enhanced-resolve": "^5.18.3", + "jiti": "^2.6.1", + "lightningcss": "1.30.2", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.1.18" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.18.tgz", + "integrity": "sha512-EgCR5tTS5bUSKQgzeMClT6iCY3ToqE1y+ZB0AKldj809QXk1Y+3jB0upOYZrn9aGIzPtUsP7sX4QQ4XtjBB95A==", + "license": "MIT", + "engines": { + "node": ">= 10" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.1.18", + "@tailwindcss/oxide-darwin-arm64": "4.1.18", + "@tailwindcss/oxide-darwin-x64": "4.1.18", + "@tailwindcss/oxide-freebsd-x64": "4.1.18", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.18", + "@tailwindcss/oxide-linux-arm64-gnu": "4.1.18", + "@tailwindcss/oxide-linux-arm64-musl": "4.1.18", + "@tailwindcss/oxide-linux-x64-gnu": "4.1.18", + "@tailwindcss/oxide-linux-x64-musl": "4.1.18", + "@tailwindcss/oxide-wasm32-wasi": "4.1.18", + "@tailwindcss/oxide-win32-arm64-msvc": "4.1.18", + "@tailwindcss/oxide-win32-x64-msvc": "4.1.18" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.18.tgz", + "integrity": "sha512-dJHz7+Ugr9U/diKJA0W6N/6/cjI+ZTAoxPf9Iz9BFRF2GzEX8IvXxFIi/dZBloVJX/MZGvRuFA9rqwdiIEZQ0Q==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.18.tgz", + "integrity": "sha512-Gc2q4Qhs660bhjyBSKgq6BYvwDz4G+BuyJ5H1xfhmDR3D8HnHCmT/BSkvSL0vQLy/nkMLY20PQ2OoYMO15Jd0A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.18.tgz", + "integrity": "sha512-FL5oxr2xQsFrc3X9o1fjHKBYBMD1QZNyc1Xzw/h5Qu4XnEBi3dZn96HcHm41c/euGV+GRiXFfh2hUCyKi/e+yw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.18.tgz", + "integrity": "sha512-Fj+RHgu5bDodmV1dM9yAxlfJwkkWvLiRjbhuO2LEtwtlYlBgiAT4x/j5wQr1tC3SANAgD+0YcmWVrj8R9trVMA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.18.tgz", + "integrity": "sha512-Fp+Wzk/Ws4dZn+LV2Nqx3IilnhH51YZoRaYHQsVq3RQvEl+71VGKFpkfHrLM/Li+kt5c0DJe/bHXK1eHgDmdiA==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.18.tgz", + "integrity": "sha512-S0n3jboLysNbh55Vrt7pk9wgpyTTPD0fdQeh7wQfMqLPM/Hrxi+dVsLsPrycQjGKEQk85Kgbx+6+QnYNiHalnw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.18.tgz", + "integrity": "sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.18.tgz", + "integrity": "sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.18.tgz", + "integrity": "sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.18.tgz", + "integrity": "sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1", + "@emnapi/wasi-threads": "^1.1.0", + "@napi-rs/wasm-runtime": "^1.1.0", + "@tybys/wasm-util": "^0.10.1", + "tslib": "^2.4.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.18.tgz", + "integrity": "sha512-HjSA7mr9HmC8fu6bdsZvZ+dhjyGCLdotjVOgLA2vEqxEBZaQo9YTX4kwgEvPCpRh8o4uWc4J/wEoFzhEmjvPbA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.18.tgz", + "integrity": "sha512-bJWbyYpUlqamC8dpR7pfjA0I7vdF6t5VpUGMWRkXVE3AXgIZjYUYAK7II1GNaxR8J1SSrSrppRar8G++JekE3Q==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.1.18.tgz", + "integrity": "sha512-jVA+/UpKL1vRLg6Hkao5jldawNmRo7mQYrZtNHMIVpLfLhDml5nMRUo/8MwoX2vNXvnaXNNMedrMfMugAVX1nA==", + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.1.18", + "@tailwindcss/oxide": "4.1.18", + "tailwindcss": "4.1.18" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7" + } + }, + "node_modules/@tsconfig/svelte": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/@tsconfig/svelte/-/svelte-5.0.6.tgz", + "integrity": "sha512-yGxYL0I9eETH1/DR9qVJey4DAsCdeau4a9wYPKuXfEhm8lFO8wg+LLYJjIpAm6Fw7HSlhepPhYPDop75485yWQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "24.10.9", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.9.tgz", + "integrity": "sha512-ne4A0IpG3+2ETuREInjPNhUGis1SFjv1d5asp8MzEAGtOZeTeHVDOYqOgqfhvseqg/iXty2hjBf1zAOb7RNiNw==", + "devOptional": true, + "license": "MIT", + "peer": true, + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "node_modules/acorn": { + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/aria-query": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", + "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/axobject-query": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", + "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/daisyui": { + "version": "5.5.14", + "resolved": "https://registry.npmjs.org/daisyui/-/daisyui-5.5.14.tgz", + "integrity": "sha512-L47rvw7I7hK68TA97VB8Ee0woHew+/ohR6Lx6Ah/krfISOqcG4My7poNpX5Mo5/ytMxiR40fEaz6njzDi7cuSg==", + "license": "MIT", + "funding": { + "url": "https://github.com/saadeghi/daisyui?sponsor=1" + } + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/devalue": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.6.2.tgz", + "integrity": "sha512-nPRkjWzzDQlsejL1WVifk5rvcFi/y1onBRxjaFMjZeR9mFpqu2gmAZ9xUB9/IEanEP/vBtGeGganC/GO1fmufg==", + "dev": true, + "license": "MIT" + }, + "node_modules/enhanced-resolve": { + "version": "5.18.4", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.4.tgz", + "integrity": "sha512-LgQMM4WXU3QI+SYgEc2liRgznaD5ojbmY3sb8LxyguVkIg5FxdpTkvk72te2R38/TGKxH634oLxXRGY6d7AP+Q==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.2.0" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/esbuild": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz", + "integrity": "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.2", + "@esbuild/android-arm": "0.27.2", + "@esbuild/android-arm64": "0.27.2", + "@esbuild/android-x64": "0.27.2", + "@esbuild/darwin-arm64": "0.27.2", + "@esbuild/darwin-x64": "0.27.2", + "@esbuild/freebsd-arm64": "0.27.2", + "@esbuild/freebsd-x64": "0.27.2", + "@esbuild/linux-arm": "0.27.2", + "@esbuild/linux-arm64": "0.27.2", + "@esbuild/linux-ia32": "0.27.2", + "@esbuild/linux-loong64": "0.27.2", + "@esbuild/linux-mips64el": "0.27.2", + "@esbuild/linux-ppc64": "0.27.2", + "@esbuild/linux-riscv64": "0.27.2", + "@esbuild/linux-s390x": "0.27.2", + "@esbuild/linux-x64": "0.27.2", + "@esbuild/netbsd-arm64": "0.27.2", + "@esbuild/netbsd-x64": "0.27.2", + "@esbuild/openbsd-arm64": "0.27.2", + "@esbuild/openbsd-x64": "0.27.2", + "@esbuild/openharmony-arm64": "0.27.2", + "@esbuild/sunos-x64": "0.27.2", + "@esbuild/win32-arm64": "0.27.2", + "@esbuild/win32-ia32": "0.27.2", + "@esbuild/win32-x64": "0.27.2" + } + }, + "node_modules/esm-env": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz", + "integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esrap": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.2.2.tgz", + "integrity": "sha512-zA6497ha+qKvoWIK+WM9NAh5ni17sKZKhbS5B3PoYbBvaYHZWoS33zmFybmyqpn07RLUxSmn+RCls2/XF+d0oQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.4.15" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, + "node_modules/is-reference": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz", + "integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.6" + } + }, + "node_modules/jiti": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", + "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/lightningcss": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz", + "integrity": "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==", + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.30.2", + "lightningcss-darwin-arm64": "1.30.2", + "lightningcss-darwin-x64": "1.30.2", + "lightningcss-freebsd-x64": "1.30.2", + "lightningcss-linux-arm-gnueabihf": "1.30.2", + "lightningcss-linux-arm64-gnu": "1.30.2", + "lightningcss-linux-arm64-musl": "1.30.2", + "lightningcss-linux-x64-gnu": "1.30.2", + "lightningcss-linux-x64-musl": "1.30.2", + "lightningcss-win32-arm64-msvc": "1.30.2", + "lightningcss-win32-x64-msvc": "1.30.2" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.30.2.tgz", + "integrity": "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.2.tgz", + "integrity": "sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.2.tgz", + "integrity": "sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.2.tgz", + "integrity": "sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.30.2.tgz", + "integrity": "sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA==", + "cpu": [ + "arm" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.30.2.tgz", + "integrity": "sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.30.2.tgz", + "integrity": "sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.30.2.tgz", + "integrity": "sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.30.2.tgz", + "integrity": "sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.2.tgz", + "integrity": "sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.2.tgz", + "integrity": "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/locate-character": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz", + "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==", + "dev": true, + "license": "MIT" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/maverick.js": { + "version": "0.37.0", + "resolved": "https://registry.npmjs.org/maverick.js/-/maverick.js-0.37.0.tgz", + "integrity": "sha512-1Dk/9rienLiihlktVvH04ADC2UJTMflC1fOMVQCCaQAaz7hgzDI5i0p/arFbDM52hFFiIcq4RdXtYz47SgsLgw==", + "license": "MIT", + "dependencies": { + "@maverick-js/signals": "^5.10.3", + "type-fest": "^3.8.0" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/media-captions": { + "version": "0.0.18", + "resolved": "https://registry.npmjs.org/media-captions/-/media-captions-0.0.18.tgz", + "integrity": "sha512-JW18P6FuHdyLSGwC4TQ0kF3WdNj/+wMw2cKOb8BnmY6vSJGtnwJ+vkYj+IjHOV34j3XMc70HDeB/QYKR7E7fuQ==", + "license": "MIT", + "engines": { + "node": ">=16" + } + }, + "node_modules/mri": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", + "integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/regexparam": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/regexparam/-/regexparam-2.0.2.tgz", + "integrity": "sha512-A1PeDEYMrkLrfyOwv2jwihXbo9qxdGD3atBYQA9JJgreAx8/7rC6IUkWOw2NQlOxLp2wL0ifQbh1HuidDfYA6w==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/rollup": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.56.0.tgz", + "integrity": "sha512-9FwVqlgUHzbXtDg9RCMgodF3Ua4Na6Gau+Sdt9vyCN4RhHfVKX2DCHy3BjMLTDd47ITDhYAnTwGulWTblJSDLg==", + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.56.0", + "@rollup/rollup-android-arm64": "4.56.0", + "@rollup/rollup-darwin-arm64": "4.56.0", + "@rollup/rollup-darwin-x64": "4.56.0", + "@rollup/rollup-freebsd-arm64": "4.56.0", + "@rollup/rollup-freebsd-x64": "4.56.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.56.0", + "@rollup/rollup-linux-arm-musleabihf": "4.56.0", + "@rollup/rollup-linux-arm64-gnu": "4.56.0", + "@rollup/rollup-linux-arm64-musl": "4.56.0", + "@rollup/rollup-linux-loong64-gnu": "4.56.0", + "@rollup/rollup-linux-loong64-musl": "4.56.0", + "@rollup/rollup-linux-ppc64-gnu": "4.56.0", + "@rollup/rollup-linux-ppc64-musl": "4.56.0", + "@rollup/rollup-linux-riscv64-gnu": "4.56.0", + "@rollup/rollup-linux-riscv64-musl": "4.56.0", + "@rollup/rollup-linux-s390x-gnu": "4.56.0", + "@rollup/rollup-linux-x64-gnu": "4.56.0", + "@rollup/rollup-linux-x64-musl": "4.56.0", + "@rollup/rollup-openbsd-x64": "4.56.0", + "@rollup/rollup-openharmony-arm64": "4.56.0", + "@rollup/rollup-win32-arm64-msvc": "4.56.0", + "@rollup/rollup-win32-ia32-msvc": "4.56.0", + "@rollup/rollup-win32-x64-gnu": "4.56.0", + "@rollup/rollup-win32-x64-msvc": "4.56.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/sade": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz", + "integrity": "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "mri": "^1.1.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/svelte": { + "version": "5.48.0", + "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.48.0.tgz", + "integrity": "sha512-+NUe82VoFP1RQViZI/esojx70eazGF4u0O/9ucqZ4rPcOZD+n5EVp17uYsqwdzjUjZyTpGKunHbDziW6AIAVkQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@jridgewell/remapping": "^2.3.4", + "@jridgewell/sourcemap-codec": "^1.5.0", + "@sveltejs/acorn-typescript": "^1.0.5", + "@types/estree": "^1.0.5", + "acorn": "^8.12.1", + "aria-query": "^5.3.1", + "axobject-query": "^4.1.0", + "clsx": "^2.1.1", + "devalue": "^5.6.2", + "esm-env": "^1.2.1", + "esrap": "^2.2.1", + "is-reference": "^3.0.3", + "locate-character": "^3.0.0", + "magic-string": "^0.30.11", + "zimmerframe": "^1.1.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/svelte-check": { + "version": "4.3.5", + "resolved": "https://registry.npmjs.org/svelte-check/-/svelte-check-4.3.5.tgz", + "integrity": "sha512-e4VWZETyXaKGhpkxOXP+B/d0Fp/zKViZoJmneZWe/05Y2aqSKj3YN2nLfYPJBQ87WEiY4BQCQ9hWGu9mPT1a1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.25", + "chokidar": "^4.0.1", + "fdir": "^6.2.0", + "picocolors": "^1.0.0", + "sade": "^1.7.4" + }, + "bin": { + "svelte-check": "bin/svelte-check" + }, + "engines": { + "node": ">= 18.0.0" + }, + "peerDependencies": { + "svelte": "^4.0.0 || ^5.0.0-next.0", + "typescript": ">=5.0.0" + } + }, + "node_modules/svelte-spa-router": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/svelte-spa-router/-/svelte-spa-router-4.0.1.tgz", + "integrity": "sha512-2JkmUQ2f9jRluijL58LtdQBIpynSbem2eBGp4zXdi7aDY1znbR6yjw0KsonD0aq2QLwf4Yx4tBJQjxIjgjXHKg==", + "license": "MIT", + "dependencies": { + "regexparam": "2.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/ItalyPaleAle" + } + }, + "node_modules/tailwindcss": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz", + "integrity": "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==", + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz", + "integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==", + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/type-fest": { + "version": "3.13.1", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-3.13.1.tgz", + "integrity": "sha512-tLq3bSNx+xSpwvAJnzrK0Ep5CLNWjvFTOp71URMaAEWBfRb9nnJiBoUe0tF8bI4ZFO3omgBR6NvnbzVUT3Ly4g==", + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/vidstack": { + "version": "0.6.15", + "resolved": "https://registry.npmjs.org/vidstack/-/vidstack-0.6.15.tgz", + "integrity": "sha512-pI2aixBuOpu/LSnRgNJ40tU/KFW+x1X+O2bW1hz946ZZShDM5oqRXF9pavDOuckHAHPgUN9HYUr9vUNTBUPF1Q==", + "license": "MIT", + "dependencies": { + "maverick.js": "0.37.0", + "media-captions": "0.0.18", + "type-fest": "^3.8.0" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/vite": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", + "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", + "license": "MIT", + "peer": true, + "dependencies": { + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitefu": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.1.tgz", + "integrity": "sha512-B/Fegf3i8zh0yFbpzZ21amWzHmuNlLlmJT6n7bu5e+pCHUKQIfXSYokrqOBGEMMe9UG2sostKQF9mml/vYaWJQ==", + "dev": true, + "license": "MIT", + "workspaces": [ + "tests/deps/*", + "tests/projects/*", + "tests/projects/workspace/packages/*" + ], + "peerDependencies": { + "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0-beta.0" + }, + "peerDependenciesMeta": { + "vite": { + "optional": true + } + } + }, + "node_modules/zimmerframe": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz", + "integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/server/ui/package.json b/server/ui/package.json new file mode 100644 index 000000000..d951ff8a4 --- /dev/null +++ b/server/ui/package.json @@ -0,0 +1,28 @@ +{ + "name": "ui", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview", + "check": "svelte-check --tsconfig ./tsconfig.app.json && tsc -p tsconfig.node.json" + }, + "devDependencies": { + "@sveltejs/vite-plugin-svelte": "^6.2.1", + "@tsconfig/svelte": "^5.0.6", + "@types/node": "^24.10.1", + "svelte": "^5.43.8", + "svelte-check": "^4.3.4", + "typescript": "~5.9.3", + "vite": "^7.2.4" + }, + "dependencies": { + "@tailwindcss/vite": "^4.1.18", + "daisyui": "^5.5.14", + "svelte-spa-router": "^4.0.1", + "tailwindcss": "^4.1.18", + "vidstack": "^0.6.15" + } +} diff --git a/server/ui/src/App.svelte b/server/ui/src/App.svelte new file mode 100644 index 000000000..3645ed03c --- /dev/null +++ b/server/ui/src/App.svelte @@ -0,0 +1,47 @@ + + +
+ + + + + +
+ +
+
+ + diff --git a/server/ui/src/api/index.ts b/server/ui/src/api/index.ts new file mode 100644 index 000000000..7e7bf5806 --- /dev/null +++ b/server/ui/src/api/index.ts @@ -0,0 +1,156 @@ +const RAW_API_BASE = import.meta.env.VITE_API_BASE || 'http://127.0.0.1:8080'; +export const API_BASE_URL = RAW_API_BASE.replace(/\/+$/, ''); + +export class CloudstreamAPI { + private baseUrl: string; + + constructor(baseUrl: string = API_BASE_URL) { + this.baseUrl = baseUrl.replace(/\/+$/, ''); + } + + private async request(endpoint: string, options: RequestInit = {}): Promise { + const response = await fetch(`${this.baseUrl}${endpoint}`, options); + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`API Error: ${response.status} ${response.statusText} - ${errorText}`); + } + return response.json(); + } + + // --- Config --- + async getConfig(): Promise { + return this.request('/config'); + } + + async updateConfig(config: any): Promise { + return this.request('/config', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(config), + }); + } + + // --- Repositories --- + async getRepositories(): Promise { + return this.request('/repositories'); + } + + async addRepository(urlOrShortcode: string, name?: string): Promise { + const body: any = {}; + if (urlOrShortcode.startsWith('http')) { + body.url = urlOrShortcode; + } else { + body.shortcode = urlOrShortcode; + } + if (name) body.name = name; + + return this.request('/repositories', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + } + + async removeRepository(id: string): Promise { + return this.request(`/repositories/${id}`, { method: 'DELETE' }); + } + + async getRepositoryPlugins(id: string): Promise { + return this.request(`/repositories/${id}/plugins`); + } + + async installRepositoryPlugin(repoId: string, internalName: string): Promise { + return this.request(`/repositories/${repoId}/plugins/${internalName}/install`, { + method: 'POST' + }); + } + + // --- Plugins --- + async getPlugins(): Promise { + return this.request('/plugins'); + } + + async installPlugin(repositoryUrl: string, internalName: string): Promise { + return this.request('/plugins/install', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ repositoryUrl, internalName }), + }); + } + + async removePlugin(request: { filePath?: string; repositoryUrl?: string; internalName?: string }): Promise { + return this.request('/plugins', { + method: 'DELETE', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(request), + }); + } + + async uploadPlugin(file: File): Promise { + const formData = new FormData(); + formData.append('file', file); + return this.request('/plugins/local', { + method: 'POST', + body: formData, + }); + } + + // --- Providers --- + async getProviders(): Promise { + return this.request('/providers'); + } + + async getProviderOverrides(): Promise { + return this.request('/providers/overrides'); + } + + async addProviderOverride(payload: { + parentClassName: string; + name: string; + url: string; + lang?: string; + }): Promise { + return this.request('/providers/overrides', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + } + + async removeProviderOverride(name: string): Promise { + return this.request(`/providers/overrides/${encodeURIComponent(name)}`, { + method: 'DELETE', + }); + } + + async getProviderMainPages(providerName: string): Promise { + return this.request(`/providers/${providerName}/main-pages`); + } + + async getProviderMainPage(providerName: string, options: { data?: string; page?: number } = {}): Promise { + const params = new URLSearchParams(); + if (options.data) params.set('data', options.data); + if (options.page) params.set('page', String(options.page)); + const query = params.toString(); + return this.request(`/providers/${providerName}/main-page${query ? `?${query}` : ''}`); + } + + async searchProvider(providerName: string, query: string): Promise { + // Basic search; robust implementation would handle page pagination + return this.request(`/providers/${providerName}/search?query=${encodeURIComponent(query)}`); + } + + async loadMedia(providerName: string, url: string): Promise { + return this.request(`/providers/${providerName}/load?url=${encodeURIComponent(url)}`); + } + + async getProviderLinks(providerName: string, data: string, isCasting: boolean = false): Promise { + return this.request(`/providers/${providerName}/links`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ data, isCasting }), + }); + } +} + +export const api = new CloudstreamAPI(); diff --git a/server/ui/src/app.css b/server/ui/src/app.css new file mode 100644 index 000000000..299e26afb --- /dev/null +++ b/server/ui/src/app.css @@ -0,0 +1,52 @@ +@import "tailwindcss"; +@import "vidstack/styles/defaults.css"; +@import "vidstack/styles/community-skin/video.css"; + +@plugin "daisyui" { + themes: forest, dracula, black, sunset, autumn, synthwave, retro, nord, coffee, night, lemonade, aqua; +} + +@theme { + --font-display: "Work Sans", "Inter", sans-serif; + --font-body: "Inter", sans-serif; +} + +@layer base { + + body { + @apply bg-base-300 text-base-content font-sans antialiased; + font-feature-settings: "cv11", "ss01"; + } +} + +:global(media-player.app-player) { + --video-bg: hsl(var(--b1)); + --video-border: 1px solid hsl(var(--bc) / 0.12); + --video-border-radius: 16px; + --video-brand: hsl(var(--p)); + --video-controls-color: hsl(var(--bc)); + --video-scrim-bg: hsl(var(--b1) / 0.2); + --video-font-family: var(--font-body); + --media-focus-ring: 0 0 0 3px hsl(var(--p) / 0.35); + --media-tooltip-bg-color: hsl(var(--b1)); + --media-tooltip-color: hsl(var(--bc)); + --media-time-color: hsl(var(--bc)); + --media-slider-track-bg: hsl(var(--bc) / 0.2); + --media-menu-bg: hsl(var(--b1)); + --media-menu-border: 1px solid hsl(var(--bc) / 0.1); + --media-menu-color: hsl(var(--bc)); +} + +/* Scrollbar Styling */ +::-webkit-scrollbar { + width: 8px; + height: 8px; +} + +::-webkit-scrollbar-track { + @apply bg-transparent; +} + +::-webkit-scrollbar-thumb { + @apply bg-base-content/20 rounded-full hover:bg-base-content/40 transition-colors; +} diff --git a/server/ui/src/assets/poster-fallback.svg b/server/ui/src/assets/poster-fallback.svg new file mode 100644 index 000000000..4d98f7cef --- /dev/null +++ b/server/ui/src/assets/poster-fallback.svg @@ -0,0 +1,8 @@ + + + + + + + No Image + diff --git a/server/ui/src/components/layout/Sidebar.svelte b/server/ui/src/components/layout/Sidebar.svelte new file mode 100644 index 000000000..54388c26d --- /dev/null +++ b/server/ui/src/components/layout/Sidebar.svelte @@ -0,0 +1,85 @@ + + + diff --git a/server/ui/src/components/layout/ToastContainer.svelte b/server/ui/src/components/layout/ToastContainer.svelte new file mode 100644 index 000000000..1e1db8b5b --- /dev/null +++ b/server/ui/src/components/layout/ToastContainer.svelte @@ -0,0 +1,23 @@ + + +
+ {#each $toast as t (t.id)} +
toast.remove(t.id)} + role="alert" + > + {t.message} +
+ {/each} +
diff --git a/server/ui/src/components/shared/ConfirmModal.svelte b/server/ui/src/components/shared/ConfirmModal.svelte new file mode 100644 index 000000000..7b32ab625 --- /dev/null +++ b/server/ui/src/components/shared/ConfirmModal.svelte @@ -0,0 +1,44 @@ + + + + + + diff --git a/server/ui/src/components/shared/PosterCard.svelte b/server/ui/src/components/shared/PosterCard.svelte new file mode 100644 index 000000000..c6483485d --- /dev/null +++ b/server/ui/src/components/shared/PosterCard.svelte @@ -0,0 +1,50 @@ + + +
onSelect?.()} + onkeydown={(e) => (e.key === 'Enter' || e.key === ' ') && onSelect?.()} +> +
+ {title} + +
+ +
+
+
+

{title}

+ {#if subtitle} +

{subtitle}

+ {/if} +
+
diff --git a/server/ui/src/components/shared/ProviderPicker.svelte b/server/ui/src/components/shared/ProviderPicker.svelte new file mode 100644 index 000000000..26d3afc74 --- /dev/null +++ b/server/ui/src/components/shared/ProviderPicker.svelte @@ -0,0 +1,128 @@ + + + + + + + + diff --git a/server/ui/src/main.ts b/server/ui/src/main.ts new file mode 100644 index 000000000..25b9e6e7b --- /dev/null +++ b/server/ui/src/main.ts @@ -0,0 +1,13 @@ +import { mount } from 'svelte' +import './app.css' +import App from './App.svelte' +import 'vidstack/define/media-player' +import 'vidstack/define/media-outlet' +import 'vidstack/define/media-poster' +import 'vidstack/define/media-community-skin' + +const app = mount(App, { + target: document.getElementById('app')!, +}) + +export default app diff --git a/server/ui/src/pages/Details.svelte b/server/ui/src/pages/Details.svelte new file mode 100644 index 000000000..1186b2349 --- /dev/null +++ b/server/ui/src/pages/Details.svelte @@ -0,0 +1,374 @@ + + +
+ {#if loading} +
+ +
+ {:else if error} +
+
!
+

Failed to load details

+

{error}

+ +
+ {:else if details} + {@const heroImage = details?.backgroundPosterUrl || details?.posterUrl || queryPoster} + {@const posterImage = details?.posterUrl || queryPoster} + {@const title = details?.name || queryName || 'Details'} + {@const typeLabel = details?.type || queryType} +
+ {#if heroImage} + + {/if} +
+
+ +
+
+
+
+ {#if posterImage} + {title} + {:else} +
+ {/if} +
+
+
+ {#if typeLabel} + {typeLabel} + {/if} + {#if details?.year} + {details.year} + {/if} + {#if details?.duration} + {formatRuntime(details.duration)} + {/if} + {#if details?.contentRating} + {details.contentRating} + {/if} + {#if details?.tags} + {#each details.tags as tag} + {tag} + {/each} + {/if} + {#if details?.score?.data} + Score {Math.round(details.score.data / 10000000) / 10} + {/if} +
+

{title}

+ {#if details?.plot} +

+ {details.plot} +

+ {/if} +
+ {#if !isSeries} + + {/if} +
+
+
+
+
+ + + + {#if isSeries} +
+
+

Seasons

+
+ {#each seasons as season} + + {/each} + {#if seasons.length === 0} +
No seasons available.
+ {/if} +
+
+
+
+

Episodes

+ {#if selectedSeason !== null} + Season {selectedSeason} + {/if} +
+
+ {#each episodes.filter((ep) => ep.season === selectedSeason) as ep} +
+
+
+ {#if ep.posterUrl} + + {:else} +
+ {/if} +
+
Episode {ep.episode}
+
{ep.name}
+ {#if ep.description} +
{ep.description}
+ {/if} +
+ {#if ep.runTime} +
{formatRuntime(ep.runTime)}
+ {/if} + {#if ep.rating} +
Rating {ep.rating}%
+ {/if} + {#if ep.score?.data} +
Score {formatScore(ep.score.data)}
+ {/if} + {#if ep.date} +
Aired {formatDate(ep.date)}
+ {/if} +
+ {#if ep.dub} +
{ep.dub}
+ {/if} +
+
+ +
+
+ {/each} + {#if selectedSeason !== null && episodes.filter((ep) => ep.season === selectedSeason).length === 0} +
No episodes found for this season.
+ {/if} +
+
+
+ {/if} + + {#if details?.recommendations && details.recommendations.length > 0} +
+

Recommendations

+
+
+ {#each details.recommendations as rec} +
+ openRec(rec)} + /> +
+ {/each} +
+
+
+ {/if} + {:else} +
+ Select a title to view details. +
+ {/if} +
+ + diff --git a/server/ui/src/pages/Home.svelte b/server/ui/src/pages/Home.svelte new file mode 100644 index 000000000..083eba6d5 --- /dev/null +++ b/server/ui/src/pages/Home.svelte @@ -0,0 +1,195 @@ + + +
+ + +
+

Browse

+ +
+ + {#if loading} +
+ +
+ {:else if error} +
+
⚠️
+

Failed to load content

+

{error}

+ +
+ {:else if mainPageData} + {#if mainPageData.length === 0} +
+ No main page content available for this provider. +
+ {:else} + + {#if mainPageData.length > 0 && mainPageData[0]?.list?.length > 0} + {@const heroItem = mainPageData[0].list[0]} +
+
+ Hero +
+
+ +
+
Featured
+

+ {heroItem.name} +

+

+ {heroItem.type} • Click to watch now +

+
+ + +
+
+
+ {/if} + + +
+ {#each mainPageData as row} + {#if row.list && row.list.length > 0} +
+

+
+ {row.name} +

+ + +
+
+ {#each row.list as item} +
+ openDetails(item)} + /> +
+ {/each} +
+
+
+ {/if} + {/each} +
+ {/if} + {:else} +
+ Select a provider to start browsing. +
+ {/if} +
+ + diff --git a/server/ui/src/pages/Play.svelte b/server/ui/src/pages/Play.svelte new file mode 100644 index 000000000..ad4d6c3b0 --- /dev/null +++ b/server/ui/src/pages/Play.svelte @@ -0,0 +1,219 @@ + + +
+
+ + {#if links.length > 1} + + {/if} +
+ +
+

+ {name || episode || show || 'Playback'} +

+ {#if show && episode} +

{show} • {episode}

+ {:else if show} +

{show}

+ {/if} +
+ + {#if loading} +
+ +
+ {:else if error} +
+ {error} +
+ {:else if playerSrc} + + + {#each subtitles as sub} + + {/each} + + + + + {:else} +
Select a title to play.
+ {/if} +
diff --git a/server/ui/src/pages/PluginManager.svelte b/server/ui/src/pages/PluginManager.svelte new file mode 100644 index 000000000..db06af0af --- /dev/null +++ b/server/ui/src/pages/PluginManager.svelte @@ -0,0 +1,704 @@ + + +
+
+ {#if browsingRepo} +
+ +
+

{browsingRepo.name}

+

Browsing Repository

+
+
+ {:else} +

Plugins

+ {/if} + + {#if !browsingRepo} +
+ + + +
+ {/if} +
+ + {#if browsingRepo} + +
+ + + + + +
+ + +
+ {#each filteredRepoPlugins as plugin} + {@const status = getPluginStatus(plugin, $plugins)} +
+
+
+
+
+ + + + {#if plugin.iconUrl} + + {/if} +
+
+

{plugin.name}

+

by {plugin.authors?.join(', ') || 'Unknown'}

+
+ {#if plugin.language} + {plugin.language} + {/if} + {#if plugin.version} + v{plugin.version} + {/if} +
+
+
+
+ + {#if plugin.tvTypes && plugin.tvTypes.length > 0} +
+ {#each plugin.tvTypes.slice(0, 3) as type} + {type} + {/each} + {#if plugin.tvTypes.length > 3} + +{plugin.tvTypes.length - 3} + {/if} +
+ {/if} + +

+ {plugin.description || 'No description provided.'} +

+ +
+ {#if status === 'update'} + + {:else if status === 'installed'} + + {:else} + + {/if} +
+
+
+ {/each} + {#if filteredRepoPlugins.length === 0} +
+ {#if repoPluginsList.length === 0} + No plugins found in this repository. + {:else} + No plugins match your filters. + {/if} +
+ {/if} +
+ + {:else if activeTab === 'installed'} +
+ +
+
+

Install Local Plugin

+

Drag & drop .cs3 file or click to upload

+ +
+
+ + +
+ {#each $plugins as plugin} +
+
+
+
+
+ + + + {#if plugin.iconUrl} + + {/if} +
+
+

{plugin.name || plugin.internalName}

+

v{plugin.version} • {plugin.authors?.join(', ')}

+
+
+
+ + {plugin.status === 1 ? 'Working' : 'Issues'} +
+
+ +

+ {plugin.description || 'No description provided.'} +

+ +
+ +
+
+
+ {/each} + {#if $plugins.length === 0} +
+ No plugins installed. Check Repositories to add some. +
+ {/if} +
+
+ {:else if activeTab === 'repositories'} +
+ +
+
+ + +
+
+ + +
+ +
+ + +
+ {#each $repositories as repo} +
+
+
+ + + + {#if repo.iconUrl} + + {/if} +
+
+

{repo.name}

+
{repo.url}
+ {#if repo.description} +
{repo.description}
+ {/if} +
+
+
+ + +
+
+ {/each} +
+
+ {:else if activeTab === 'overrides'} +
+
+
+
+

Add Provider Override

+

+ Create a custom provider by overriding the base URL, name, or language. +

+
+
+
+ + (overrideBaseClass = event.detail.value)} + /> +
+
+ + +
+
+ + +
+
+ + + +
+
+
+ +
+
+
+ +
+

Current Overrides

+ {#if providerOverrides.length === 0} +
+ No overrides added yet. +
+ {:else} +
+ {#each providerOverrides as overrideEntry} +
+
+

{overrideEntry.name}

+
+ Base: {resolveProviderLabel(overrideEntry.parentClassName)} +
+
{overrideEntry.url}
+
Lang: {overrideEntry.lang}
+
+ +
+ {/each} +
+ {/if} +
+
+ {/if} + + + + + + +
diff --git a/server/ui/src/pages/Search.svelte b/server/ui/src/pages/Search.svelte new file mode 100644 index 000000000..40caff038 --- /dev/null +++ b/server/ui/src/pages/Search.svelte @@ -0,0 +1,150 @@ + + +
+ +
+

Search

+ + +
+
+ +
+ e.key === 'Enter' && handleSearch()} + /> + {#if loading} + + {/if} +
+ +
+
+ + +
+ {#each tvTypes as type} + + {/each} +
+
+ + +
+ {#if error} +
+ + {error} +
+ {:else if searchResults.length === 0 && !loading && query} +
+ No results found for "{query}". +
+ {:else if searchResults.length > 0} +
+ {#each searchResults as item} + openDetails(item)} + /> + {/each} +
+ {/if} +
+ +
diff --git a/server/ui/src/pages/Settings.svelte b/server/ui/src/pages/Settings.svelte new file mode 100644 index 000000000..2629c19aa --- /dev/null +++ b/server/ui/src/pages/Settings.svelte @@ -0,0 +1,152 @@ + + +
+

Settings

+ + + + +
+ + {#if activeTab === 'general'} +
+

Server Configuration

+ +
+
+ Host +
+ +
+ +
+
+ Port +
+ +
+ +
+ +
+ +
+ + {#if saveStatus === 'success'} + Saved! + {/if} + {#if saveStatus === 'error'} + Failed to save. + {/if} +
+
+ + {:else if activeTab === 'theme'} +
+

Appearance

+
+ {#each themes as t} + + {/each} +
+
+ + {:else if activeTab === 'accounts'} +
+

Accounts

+
+ + Account management coming soon. Configuration is stored in `config.json`. +
+
+ + + + + + + + + + {#if $configStore && $configStore.accounts} + {#each $configStore.accounts as account} + + + + + + {/each} + {/if} + +
TypeNameID
{account.type}{account.name || '-'}{account.id}
+ {#if !$configStore?.accounts?.length} +

No accounts configured.

+ {/if} +
+
+ {/if} + +
+
diff --git a/server/ui/src/stores/index.ts b/server/ui/src/stores/index.ts new file mode 100644 index 000000000..7c691771a --- /dev/null +++ b/server/ui/src/stores/index.ts @@ -0,0 +1,48 @@ +import { writable } from 'svelte/store'; +import { api } from '../api'; + +export const config = writable(null); +export const providers = writable([]); +export const plugins = writable([]); +export const repositories = writable([]); + +export const activeProvider = writable(null); +const ACTIVE_PROVIDER_KEY = 'cloudstream_active_provider'; + +if (typeof localStorage !== 'undefined') { + activeProvider.subscribe((value) => { + if (value) { + localStorage.setItem(ACTIVE_PROVIDER_KEY, value); + } + }); +} + +export async function loadInitialData() { + try { + const [cfg, provs, plugs, repos] = await Promise.all([ + api.getConfig(), + api.getProviders(), + api.getPlugins(), + api.getRepositories() + ]); + + config.set(cfg); + providers.set(provs); + plugins.set(plugs); + repositories.set(repos); + + if (provs.length > 0) { + const stored = typeof localStorage !== 'undefined' + ? localStorage.getItem(ACTIVE_PROVIDER_KEY) + : null; + const storedValid = stored && provs.some(p => p.name === stored) ? stored : null; + activeProvider.update(current => { + if (current && provs.some(p => p.name === current)) return current; + if (storedValid) return storedValid; + return provs[0].name; + }); + } + } catch (err) { + console.error("Failed to load initial data", err); + } +} diff --git a/server/ui/src/stores/theme.ts b/server/ui/src/stores/theme.ts new file mode 100644 index 000000000..33b69ad36 --- /dev/null +++ b/server/ui/src/stores/theme.ts @@ -0,0 +1,43 @@ +import { writable } from 'svelte/store'; + +const THEME_KEY = 'cloudstream_theme'; +const DEFAULT_THEME = 'forest'; + +export const themes = [ + 'forest', + 'dracula', + 'black', + 'sunset', + 'autumn', + 'synthwave', + 'retro', + 'nord', + 'coffee', + 'night', + 'lemonade', + 'aqua' +]; + +function createThemeStore() { + const stored = localStorage.getItem(THEME_KEY); + const initial = stored && themes.includes(stored) ? stored : DEFAULT_THEME; + + const { subscribe, set } = writable(initial); + + return { + subscribe, + set: (theme: string) => { + if (!themes.includes(theme)) return; + localStorage.setItem(THEME_KEY, theme); + document.documentElement.setAttribute('data-theme', theme); + set(theme); + }, + init: () => { + const current = localStorage.getItem(THEME_KEY) || DEFAULT_THEME; + document.documentElement.setAttribute('data-theme', current); + set(current); + } + }; +} + +export const theme = createThemeStore(); diff --git a/server/ui/src/stores/toast.ts b/server/ui/src/stores/toast.ts new file mode 100644 index 000000000..c03d1a012 --- /dev/null +++ b/server/ui/src/stores/toast.ts @@ -0,0 +1,38 @@ +import { writable } from 'svelte/store'; + +export type ToastType = 'info' | 'success' | 'warning' | 'error'; + +export interface Toast { + id: number; + message: string; + type: ToastType; +} + +function createToastStore() { + const { subscribe, update } = writable([]); + + let nextId = 0; + + return { + subscribe, + push: (message: string, type: ToastType = 'info', duration = 3000) => { + const id = nextId++; + update(toasts => [...toasts, { id, message, type }]); + + if (duration > 0) { + setTimeout(() => { + update(toasts => toasts.filter(t => t.id !== id)); + }, duration); + } + }, + remove: (id: number) => { + update(toasts => toasts.filter(t => t.id !== id)); + }, + success: (msg: string, duration?: number) => toast.push(msg, 'success', duration), + error: (msg: string, duration?: number) => toast.push(msg, 'error', duration), + info: (msg: string, duration?: number) => toast.push(msg, 'info', duration), + warning: (msg: string, duration?: number) => toast.push(msg, 'warning', duration) + }; +} + +export const toast = createToastStore(); diff --git a/server/ui/svelte.config.js b/server/ui/svelte.config.js new file mode 100644 index 000000000..96b345548 --- /dev/null +++ b/server/ui/svelte.config.js @@ -0,0 +1,8 @@ +import { vitePreprocess } from '@sveltejs/vite-plugin-svelte' + +/** @type {import("@sveltejs/vite-plugin-svelte").SvelteConfig} */ +export default { + // Consult https://svelte.dev/docs#compile-time-svelte-preprocess + // for more information about preprocessors + preprocess: vitePreprocess(), +} diff --git a/server/ui/tsconfig.app.json b/server/ui/tsconfig.app.json new file mode 100644 index 000000000..31c18cfd8 --- /dev/null +++ b/server/ui/tsconfig.app.json @@ -0,0 +1,21 @@ +{ + "extends": "@tsconfig/svelte/tsconfig.json", + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "target": "ES2022", + "useDefineForClassFields": true, + "module": "ESNext", + "types": ["svelte", "vite/client"], + "noEmit": true, + /** + * Typecheck JS in `.svelte` and `.js` files by default. + * Disable checkJs if you'd like to use dynamic types in JS. + * Note that setting allowJs false does not prevent the use + * of JS in `.svelte` files. + */ + "allowJs": true, + "checkJs": true, + "moduleDetection": "force" + }, + "include": ["src/**/*.ts", "src/**/*.js", "src/**/*.svelte"] +} diff --git a/server/ui/tsconfig.json b/server/ui/tsconfig.json new file mode 100644 index 000000000..1ffef600d --- /dev/null +++ b/server/ui/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ] +} diff --git a/server/ui/tsconfig.node.json b/server/ui/tsconfig.node.json new file mode 100644 index 000000000..8a67f62f4 --- /dev/null +++ b/server/ui/tsconfig.node.json @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "target": "ES2023", + "lib": ["ES2023"], + "module": "ESNext", + "types": ["node"], + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["vite.config.ts"] +} diff --git a/server/ui/vite.config.ts b/server/ui/vite.config.ts new file mode 100644 index 000000000..36b7af5ae --- /dev/null +++ b/server/ui/vite.config.ts @@ -0,0 +1,23 @@ +import { defineConfig } from 'vite' +import { svelte } from '@sveltejs/vite-plugin-svelte' +import tailwindcss from '@tailwindcss/vite'; + +// https://vite.dev/config/ +export default defineConfig({ + plugins: [ + svelte(), + tailwindcss() + ], + optimizeDeps: { + exclude: ['vidstack'], + }, + server: { + proxy: { + '/api': { + target: 'http://127.0.0.1:8080', + changeOrigin: true, + rewrite: (path) => path.replace(/^\/api/, ''), + }, + }, + }, +}) diff --git a/settings.gradle.kts b/settings.gradle.kts index 73bf5a195..947bd10ff 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -18,4 +18,4 @@ dependencyResolutionManagement { } rootProject.name = "CloudStream" -include(":app", ":library", ":docs") +include(":app", ":library", ":docs", ":server")