diff --git a/.github/locales.py b/.github/locales.py index 6127d9d80..a74d72588 100644 --- a/.github/locales.py +++ b/.github/locales.py @@ -1,13 +1,14 @@ import re import glob import requests +import os import lxml.etree as ET # builtin library doesn't preserve comments SETTINGS_PATH = "app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsGeneral.kt" START_MARKER = "/* begin language list */" END_MARKER = "/* end language list */" -XML_NAME = "app/src/main/res/values-b+" +XML_NAME = "app/src/main/res/values-" ISO_MAP_URL = "https://raw.githubusercontent.com/haliaeetus/iso-639/master/data/iso_639-1.min.json" INDENT = " "*4 @@ -20,29 +21,29 @@ rest, after_src = rest.split(END_MARKER) # Load already added langs languages = {} -for lang in re.finditer(r'Pair\("(.*)", "(.*)"\)', rest): - name, iso = lang.groups() - languages[iso] = name +for lang in re.finditer(r'Triple\("(.*)", "(.*)", "(.*)"\)', rest): + flag, name, iso = lang.groups() + languages[iso] = (flag, name) # Add not yet added langs for folder in glob.glob(f"{XML_NAME}*"): - iso = folder[len(XML_NAME):].replace("+", "-") + iso = folder[len(XML_NAME):] if iso not in languages.keys(): - entry = iso_map.get(iso.lower(), {'nativeName':iso}) # fallback to iso code if not found - languages[iso] = entry['nativeName'].split(',')[0] # first name if there are multiple + entry = iso_map.get(iso.lower(),{'nativeName':iso}) + languages[iso] = ("", entry['nativeName'].split(',')[0]) -# Create pairs -pairs = [] -for iso in sorted(languages, key=lambda iso: languages[iso].lower()): # sort by language name - name = languages[iso] - pairs.append(f'{INDENT}Pair("{name}", "{iso}"),') +# Create triples +triples = [] +for iso in sorted(languages.keys()): + flag, name = languages[iso] + triples.append(f'{INDENT}Triple("{flag}", "{name}", "{iso}"),') # Update settings file open(SETTINGS_PATH, "w+",encoding='utf-8').write( before_src + START_MARKER + "\n" + - "\n".join(pairs) + + "\n".join(triples) + "\n" + END_MARKER + after_src @@ -61,5 +62,8 @@ for file in glob.glob(f"{XML_NAME}*/strings.xml"): with open(file, 'wb') as fp: fp.write(b'\n') tree.write(fp, encoding="utf-8", method="xml", pretty_print=True, xml_declaration=False) + # Remove trailing new line to be consistent with weblate + fp.seek(-1, os.SEEK_END) + fp.truncate() except ET.ParseError as ex: print(f"[{file}] {ex}") diff --git a/.github/workflows/build_to_archive.yml b/.github/workflows/build_to_archive.yml index 056022d22..f62f1ba05 100644 --- a/.github/workflows/build_to_archive.yml +++ b/.github/workflows/build_to_archive.yml @@ -9,10 +9,7 @@ on: - '**/wcokey.txt' workflow_dispatch: -permissions: - contents: read - -concurrency: +concurrency: group: "Archive-build" cancel-in-progress: true @@ -27,7 +24,6 @@ jobs: app_id: ${{ secrets.GH_APP_ID }} private_key: ${{ secrets.GH_APP_KEY }} repository: "recloudstream/secrets" - - name: Generate access token (archive) id: generate_archive_token uses: tibdex/github-app-token@v2 @@ -35,18 +31,14 @@ jobs: app_id: ${{ secrets.GH_APP_ID }} private_key: ${{ secrets.GH_APP_KEY }} repository: "recloudstream/cloudstream-archive" - - - uses: actions/checkout@v6 - + - uses: actions/checkout@v4 - name: Set up JDK 17 - uses: actions/setup-java@v5 + uses: actions/setup-java@v4 with: - distribution: temurin - java-version: 17 - + java-version: '17' + distribution: 'adopt' - name: Grant execute permission for gradlew run: chmod +x gradlew - - name: Fetch keystore id: fetch_keystore run: | @@ -57,34 +49,25 @@ jobs: KEY_PWD="$(cat keystore_password.txt)" echo "::add-mask::${KEY_PWD}" echo "key_pwd=$KEY_PWD" >> $GITHUB_OUTPUT - - - name: Setup Gradle - uses: gradle/actions/setup-gradle@v5 - with: - 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 }} - MAL_KEY: ${{ secrets.MAL_KEY }} - ANILIST_KEY: ${{ secrets.ANILIST_KEY }} - - - uses: actions/checkout@v6 + - uses: actions/checkout@v4 with: repository: "recloudstream/cloudstream-archive" token: ${{ steps.generate_archive_token.outputs.token }} path: "archive" - name: Move build - run: cp app/build/outputs/apk/prerelease/release/*.apk "archive/$(git rev-parse --short HEAD).apk" - + run: | + cp app/build/outputs/apk/prerelease/release/*.apk "archive/$(git rev-parse --short HEAD).apk" + - name: Push archive run: | cd $GITHUB_WORKSPACE/archive @@ -92,4 +75,4 @@ jobs: git config --local user.name "GitHub Actions" git add . git commit --amend -m "Build $GITHUB_SHA" || exit 0 # do not error if nothing to commit - git push --force + git push --force \ No newline at end of file diff --git a/.github/workflows/generate_dokka.yml b/.github/workflows/generate_dokka.yml index d67b8a519..666e2ba10 100644 --- a/.github/workflows/generate_dokka.yml +++ b/.github/workflows/generate_dokka.yml @@ -1,18 +1,19 @@ name: Dokka +# https://docs.github.com/en/actions/learn-github-actions/workflow-syntax-for-github-actions#concurrency +concurrency: + group: "dokka" + cancel-in-progress: true + on: push: - branches: [ master ] + branches: + # choose your default branch + - master + - main paths-ignore: - '*.md' -permissions: - contents: read - -concurrency: - group: "dokka" - cancel-in-progress: true - jobs: build: runs-on: ubuntu-latest @@ -24,35 +25,32 @@ jobs: app_id: ${{ secrets.GH_APP_ID }} private_key: ${{ secrets.GH_APP_KEY }} repository: "recloudstream/dokka" - - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@master with: path: "src" - name: Checkout dokka - uses: actions/checkout@v6 + uses: actions/checkout@master with: repository: "recloudstream/dokka" path: "dokka" token: ${{ steps.generate_token.outputs.token }} - + - name: Clean old builds run: | cd $GITHUB_WORKSPACE/dokka/ rm -rf "./app" rm -rf "./library" - - name: Set up JDK 17 - uses: actions/setup-java@v5 + - name: Setup JDK 17 + uses: actions/setup-java@v4 with: - distribution: temurin java-version: 17 + distribution: 'adopt' - - name: Setup Gradle - uses: gradle/actions/setup-gradle@v5 - with: - cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} + - name: Setup Android SDK + uses: android-actions/setup-android@v3 - name: Generate Dokka run: | @@ -61,7 +59,8 @@ jobs: ./gradlew docs:dokkaGeneratePublicationHtml - name: Copy Dokka - run: cp -r $GITHUB_WORKSPACE/src/docs/build/dokka/html/* $GITHUB_WORKSPACE/dokka/ + run: | + cp -r $GITHUB_WORKSPACE/src/docs/build/dokka/html/* $GITHUB_WORKSPACE/dokka/ - name: Push builds run: | diff --git a/.github/workflows/issue_action.yml b/.github/workflows/issue_action.yml new file mode 100644 index 000000000..88ab3656c --- /dev/null +++ b/.github/workflows/issue_action.yml @@ -0,0 +1,88 @@ +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@v7 + 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@v4 + - 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@v7 + 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 f089afa8f..dd608b321 100644 --- a/.github/workflows/prerelease.yml +++ b/.github/workflows/prerelease.yml @@ -8,13 +8,10 @@ on: - '*.json' - '**/wcokey.txt' -concurrency: +concurrency: group: "pre-release" cancel-in-progress: true -permissions: - contents: write - jobs: build: runs-on: ubuntu-latest @@ -26,18 +23,14 @@ jobs: app_id: ${{ secrets.GH_APP_ID }} private_key: ${{ secrets.GH_APP_KEY }} repository: "recloudstream/secrets" - - - uses: actions/checkout@v6 - + - uses: actions/checkout@v4 - name: Set up JDK 17 - uses: actions/setup-java@v5 + uses: actions/setup-java@v4 with: - distribution: temurin - java-version: 17 - + java-version: '17' + distribution: 'adopt' - name: Grant execute permission for gradlew run: chmod +x gradlew - - name: Fetch keystore id: fetch_keystore run: | @@ -48,27 +41,19 @@ jobs: KEY_PWD="$(cat keystore_password.txt)" echo "::add-mask::${KEY_PWD}" echo "key_pwd=$KEY_PWD" >> $GITHUB_OUTPUT - - - name: Setup Gradle - uses: gradle/actions/setup-gradle@v5 - with: - cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} - - name: Run Gradle - run: ./gradlew assemblePrereleaseRelease androidSourcesJar makeJar + run: | + ./gradlew assemblePrerelease build androidSourcesJar + ./gradlew makeJar # for classes.jar, has to be done after 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 }} - MAL_KEY: ${{ secrets.MAL_KEY }} - ANILIST_KEY: ${{ secrets.ANILIST_KEY }} - - name: Create pre-release - uses: marvinpinto/action-automatic-releases@latest + uses: "marvinpinto/action-automatic-releases@latest" with: repo_token: "${{ secrets.GITHUB_TOKEN }}" automatic_release_tag: "pre-release" diff --git a/.github/workflows/pull_request.yml b/.github/workflows/pull_request.yml index 8f5c62866..7f6dd4123 100644 --- a/.github/workflows/pull_request.yml +++ b/.github/workflows/pull_request.yml @@ -2,35 +2,22 @@ name: Artifact Build on: [pull_request] -permissions: - contents: read - jobs: build: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 - + - uses: actions/checkout@v4 - name: Set up JDK 17 - uses: actions/setup-java@v5 + uses: actions/setup-java@v4 with: - distribution: temurin - java-version: 17 - + java-version: '17' + distribution: 'adopt' - name: Grant execute permission for gradlew run: chmod +x gradlew - - - name: Setup Gradle - uses: gradle/actions/setup-gradle@v5 - with: - cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} - 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@v4 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..ce140e559 100644 --- a/.github/workflows/update_locales.yml +++ b/.github/workflows/update_locales.yml @@ -1,19 +1,17 @@ name: Fix locale issues on: + workflow_dispatch: push: - branches: [ master ] paths: - '**.xml' - workflow_dispatch: + branches: + - master -concurrency: +concurrency: group: "locale" cancel-in-progress: true -permissions: - contents: read - jobs: create: runs-on: ubuntu-latest @@ -25,17 +23,15 @@ jobs: app_id: ${{ secrets.GH_APP_ID }} private_key: ${{ secrets.GH_APP_KEY }} repository: "recloudstream/cloudstream" - - - uses: actions/checkout@v6 + - uses: actions/checkout@v4 with: token: ${{ steps.generate_token.outputs.token }} - - name: Install dependencies - run: pip3 install lxml requests - + run: | + pip3 install lxml - name: Edit files - run: python3 .github/locales.py - + run: | + python3 .github/locales.py - name: Commit to the repo run: | git config --local user.email "111277985+recloudstream[bot]@users.noreply.github.com" diff --git a/AI-POLICY.md b/AI-POLICY.md deleted file mode 100644 index 5409393fb..000000000 --- a/AI-POLICY.md +++ /dev/null @@ -1,11 +0,0 @@ -# AI Policy - -AI is a great tool. However, we want you to follow these rules regarding usage of AI in order to ensure the quality of both code and discussions. - -1. Always state any AI usage in pull requests and issues. - -2. Always test code before making a pull request. We do not want to test your AI generated code. - -3. Listen to humans over computers. Contributors to CloudStream know this codebase better than an AI. - -4. You should be able to explain and fix any code you submit. We do in-depth reviews and will reject low effort contributions. diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 66a55ae88..5203a28cb 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,96 +1,53 @@ import com.android.build.gradle.internal.cxx.configure.gradleLocalProperties import org.jetbrains.dokka.gradle.engine.parameters.KotlinPlatform import org.jetbrains.dokka.gradle.engine.parameters.VisibilityModifier -import org.jetbrains.kotlin.gradle.dsl.JvmDefaultMode import org.jetbrains.kotlin.gradle.dsl.JvmTarget import org.jetbrains.kotlin.gradle.tasks.KotlinJvmCompile plugins { - alias(libs.plugins.android.application) - alias(libs.plugins.dokka) - alias(libs.plugins.kotlin.serialization) + id("com.android.application") + id("kotlin-android") + id("org.jetbrains.dokka") } 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,10 +61,12 @@ 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 = 66 + versionName = "4.5.4" - manifestPlaceholders["target_sdk_version"] = libs.versions.targetSdk.get() + resValue("string", "app_version", "${defaultConfig.versionName}${versionNameSuffix ?: ""}") + resValue("string", "commit_hash", getGitCommitHash()) + resValue("bool", "is_prerelease", "false") // Reads local.properties val localProperties = gradleLocalProperties(rootDir, project.providers) @@ -127,16 +86,6 @@ android { "SIMKL_CLIENT_SECRET", "\"" + (System.getenv("SIMKL_CLIENT_SECRET") ?: localProperties["simkl.secret"]) + "\"" ) - buildConfigField( - "String", - "MAL_KEY", - "\"" + (System.getenv("MAL_KEY") ?: localProperties["mal.key"]) + "\"" - ) - buildConfigField( - "String", - "ANILIST_KEY", - "\"" + (System.getenv("ANILIST_KEY") ?: localProperties["anilist.key"]) + "\"" - ) testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } @@ -164,9 +113,12 @@ android { productFlavors { create("stable") { dimension = "state" + resValue("bool", "is_prerelease", "false") } create("prerelease") { dimension = "state" + resValue("bool", "is_prerelease", "true") + buildConfigField("boolean", "BETA", "true") applicationIdSuffix = ".prerelease" if (signingConfigs.names.contains("prerelease")) { signingConfig = signingConfigs.getByName("prerelease") @@ -184,29 +136,13 @@ android { targetCompatibility = JavaVersion.toVersion(javaTarget.target) } - java { - // 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())) - } - } - 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 - } } namespace = "com.lagradost.cloudstream3" @@ -217,46 +153,43 @@ 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 + implementation(libs.bundles.navigationKtx) + implementation(libs.lifecycle.livedata.ktx) + implementation(libs.lifecycle.viewmodel.ktx) // Design & UI implementation(libs.preference.ktx) implementation(libs.material) implementation(libs.constraintlayout) + implementation(libs.swiperefreshlayout) // Coil Image Loading - implementation(libs.bundles.coil) + implementation(libs.coil) + implementation(libs.coil.network.okhttp) // Media 3 (ExoPlayer) implementation(libs.bundles.media3) implementation(libs.video) - // 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 implementation(libs.juniversalchardet) // Subtitle Decoding + // FFmpeg Decoding + implementation(libs.bundles.nextlibMedia3) + + // Crash Reports (AcraApplication.kt) + implementation(libs.acra.core) + implementation(libs.acra.toast) + // UI Stuff implementation(libs.shimmer) // Shimmering Effect (Loading Skeleton) implementation(libs.palette.ktx) // Palette for Images -> Colors @@ -267,34 +200,50 @@ dependencies { implementation(libs.qrcode.kotlin) // QR Code for PIN Auth on TV // Extensions & Other Libs - implementation(libs.jsoup) // HTML Parser implementation(libs.rhino) // Run JavaScript + implementation(libs.quickjs) + 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") + implementation(libs.conscrypt.android) { + version { + strictly("2.5.2") + } + because("2.5.3 crashes everything for everyone.") + } // To Fix SSL Fu*kery on Android 9 + implementation(libs.jackson.module.kotlin) { + version { + strictly("2.13.1") + } + because("Don't Bump Jackson above 2.13.1, Crashes on Android TV's and FireSticks that have Min API Level 25 or Less.") + } // JSON Parser // Torrent Support implementation(libs.torrentserver) // Downloading & Networking + implementation(libs.work.runtime) 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) + }) } tasks.register("androidSourcesJar") { archiveClassifier.set("sources") - from(android.sourceSets.getByName("main").java.directories) // Full Sources + from(android.sourceSets.getByName("main").java.srcDirs) // Full Sources } tasks.register("copyJar") { - dependsOn("build", ":library:jvmJar") from( "build/intermediates/compile_app_classes_jar/prereleaseDebug/bundlePrereleaseDebugClassesToCompileJar", "../library/build/libs" @@ -321,23 +270,15 @@ tasks.register("makeJar") { tasks.withType { compilerOptions { jvmTarget.set(javaTarget) - jvmDefault.set(JvmDefaultMode.ENABLE) - freeCompilerArgs.add("-Xannotation-default-target=param-property") - optIn.addAll( - "com.lagradost.cloudstream3.InternalAPI", - "com.lagradost.cloudstream3.Prerelease", - "kotlin.uuid.ExperimentalUuidApi", - ) + freeCompilerArgs.add("-Xjvm-default=all-compatibility") } } 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 deleted file mode 100644 index b2f5e8f2b..000000000 --- a/app/lint.xml +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - - - - - - - diff --git a/app/src/androidTest/java/com/lagradost/cloudstream3/ExampleInstrumentedTest.kt b/app/src/androidTest/java/com/lagradost/cloudstream3/ExampleInstrumentedTest.kt index 4c5cdea5b..0adfc1fae 100644 --- a/app/src/androidTest/java/com/lagradost/cloudstream3/ExampleInstrumentedTest.kt +++ b/app/src/androidTest/java/com/lagradost/cloudstream3/ExampleInstrumentedTest.kt @@ -136,14 +136,14 @@ class ExampleInstrumentedTest { @Test @Throws(AssertionError::class) fun providerCorrectData() { - val langTagsIETF = SubtitleHelper.languages.map { it.IETF_tag } - Assert.assertFalse("IETFTagNames does not contain any languages", langTagsIETF.isNullOrEmpty()) + val isoNames = SubtitleHelper.languages.map { it.ISO_639_1 } + Assert.assertFalse("ISO does not contain any languages", isoNames.isNullOrEmpty()) for (api in getAllProviders()) { Assert.assertTrue("Api does not contain a mainUrl", api.mainUrl != "NONE") Assert.assertTrue("Api does not contain a name", api.name != "NONE") Assert.assertTrue( "Api ${api.name} does not contain a valid language code", - langTagsIETF.contains(api.lang) + isoNames.contains(api.lang) ) Assert.assertTrue( "Api ${api.name} does not contain any supported types", 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..d960d910c 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -2,6 +2,8 @@ + + @@ -16,53 +18,12 @@ - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + tools:targetApi="35"> - - - - - - - - - - - - + android:supportsPictureInPicture="true"> @@ -200,14 +144,7 @@ - - - - - - - @@ -231,7 +168,7 @@ - + @@ -244,6 +181,21 @@ + + + + + + + + + + + + - - +#include +#include + +#define TAG "CloudStream Crash Handler" +volatile sig_atomic_t gSignalStatus = 0; +void handleNativeCrash(int signal) { + gSignalStatus = signal; +} + +extern "C" JNIEXPORT void JNICALL +Java_com_lagradost_cloudstream3_NativeCrashHandler_initNativeCrashHandler(JNIEnv *env, jobject) { + #define REGISTER_SIGNAL(X) signal(X, handleNativeCrash); + REGISTER_SIGNAL(SIGSEGV) + #undef REGISTER_SIGNAL +} + +//extern "C" JNIEXPORT void JNICALL +//Java_com_lagradost_cloudstream3_NativeCrashHandler_triggerNativeCrash(JNIEnv *env, jobject thiz) { +// int *p = nullptr; +// *p = 0; +//} + +extern "C" JNIEXPORT int JNICALL +Java_com_lagradost_cloudstream3_NativeCrashHandler_getSignalStatus(JNIEnv *env, jobject) { + //__android_log_print(ANDROID_LOG_INFO, TAG, "Got signal status %d", gSignalStatus); + return gSignalStatus; +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/AcraApplication.kt b/app/src/main/java/com/lagradost/cloudstream3/AcraApplication.kt index bbe7d97de..9f493fbbc 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/AcraApplication.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/AcraApplication.kt @@ -1,78 +1,233 @@ package com.lagradost.cloudstream3 -/** - * Deprecated alias for CloudStreamApp for backwards compatibility with plugins. - * Use CloudStreamApp instead. - */ -@Deprecated( - message = "AcraApplication is deprecated, use CloudStreamApp instead", - replaceWith = ReplaceWith("com.lagradost.cloudstream3.CloudStreamApp"), - level = DeprecationLevel.WARNING -) -class AcraApplication { - companion object { +import android.app.Activity +import android.app.Application +import android.content.Context +import android.content.ContextWrapper +import android.content.Intent +import android.widget.Toast +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentActivity +import coil3.PlatformContext +import coil3.SingletonImageLoader +import com.lagradost.api.setContext +import com.lagradost.cloudstream3.mvvm.safe +import com.lagradost.cloudstream3.mvvm.safeAsync +import com.lagradost.cloudstream3.plugins.PluginManager +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.Coroutines.runOnMainThread +import com.lagradost.cloudstream3.utils.DataStore.getKey +import com.lagradost.cloudstream3.utils.DataStore.getKeys +import com.lagradost.cloudstream3.utils.DataStore.removeKey +import com.lagradost.cloudstream3.utils.DataStore.removeKeys +import com.lagradost.cloudstream3.utils.DataStore.setKey +import com.lagradost.cloudstream3.utils.ImageLoader +import kotlinx.coroutines.runBlocking +import org.acra.ACRA +import org.acra.ReportField +import org.acra.config.CoreConfiguration +import org.acra.data.CrashReportData +import org.acra.data.StringFormat +import org.acra.ktx.initAcra +import org.acra.sender.ReportSender +import org.acra.sender.ReportSenderFactory +import java.io.File +import java.io.FileNotFoundException +import java.io.PrintStream +import java.lang.ref.WeakReference +import java.util.Locale +import kotlin.concurrent.thread +import kotlin.system.exitProcess - @Deprecated( - message = "AcraApplication is deprecated, use CloudStreamApp instead", - replaceWith = ReplaceWith("com.lagradost.cloudstream3.CloudStreamApp.context"), - level = DeprecationLevel.WARNING - ) - val context get() = CloudStreamApp.context +class CustomReportSender : ReportSender { + // Sends all your crashes to google forms + override fun send(context: Context, errorContent: CrashReportData) { + /*println("Sending report") + val url = + "https://docs.google.com/forms/d/e/$id/formResponse" + val data = mapOf( + "entry.$entry" to errorContent.toJSON() + ) - @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) + thread { // to not run it on main thread + runBlocking { + safeAsync { + app.post(url, data = data) + //println("Report response: $post") + } + } + } - @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) - - @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) - - @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) - - @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) - - @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) - - @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) - } + runOnMainThread { // to run it on main looper + safe { + Toast.makeText(context, R.string.acra_report_toast, Toast.LENGTH_SHORT).show() + } + }*/ + } } + +class CustomSenderFactory : ReportSenderFactory { + override fun create(context: Context, config: CoreConfiguration): ReportSender { + return CustomReportSender() + } + + override fun enabled(config: CoreConfiguration): Boolean { + return true + } +} + +class ExceptionHandler(val errorFile: File, val onError: (() -> Unit)) : + Thread.UncaughtExceptionHandler { + override fun uncaughtException(thread: Thread, error: Throwable) { + ACRA.errorReporter.handleException(error) + try { + PrintStream(errorFile).use { ps -> + ps.println("Currently loading extension: ${PluginManager.currentlyLoading ?: "none"}") + ps.println("Fatal exception on thread ${thread.name} (${thread.id})") + error.printStackTrace(ps) + } + } catch (ignored: FileNotFoundException) { + } + try { + onError.invoke() + } catch (ignored: Exception) { + } + exitProcess(1) + } + +} + +class AcraApplication : Application(), SingletonImageLoader.Factory { + + override fun onCreate() { + super.onCreate() + // if we want to initialise coil at earliest + // (maybe when loading an image or gif using in splash screen activity) + //ImageLoader.buildImageLoader(applicationContext) + + ExceptionHandler(filesDir.resolve("last_error")) { + val intent = context!!.packageManager.getLaunchIntentForPackage(context!!.packageName) + startActivity(Intent.makeRestartActivityTask(intent!!.component)) + }.also { + exceptionHandler = it + Thread.setDefaultUncaughtExceptionHandler(it) + } + } + + override fun attachBaseContext(base: Context?) { + super.attachBaseContext(base) + context = base + + initAcra { + //core configuration: + buildConfigClass = BuildConfig::class.java + reportFormat = StringFormat.JSON + + reportContent = listOf( + ReportField.BUILD_CONFIG, ReportField.USER_CRASH_DATE, + ReportField.ANDROID_VERSION, ReportField.PHONE_MODEL, + ReportField.STACK_TRACE, + ) + + // removed this due to bug when starting the app, moved it to when it actually crashes + //each plugin you chose above can be configured in a block like this: + /*toast { + text = getString(R.string.acra_report_toast) + //opening this block automatically enables the plugin. + }*/ + } + } + + override fun newImageLoader(context: PlatformContext): coil3.ImageLoader { + // Coil Module will be initialized & setSafe globally when first loadImage() is invoked + return ImageLoader.buildImageLoader(applicationContext) + } + + companion object { + var exceptionHandler: ExceptionHandler? = null + + /** Use to get activity from Context */ + tailrec fun Context.getActivity(): Activity? { + return when (this) { + is Activity -> this + is ContextWrapper -> baseContext.getActivity() + else -> null + } + } + + private var _context: WeakReference? = null + var context + get() = _context?.get() + private set(value) { + _context = WeakReference(value) + setContext(WeakReference(value)) + } + + fun getKeyClass(path: String, valueType: Class): T? { + return context?.getKey(path, valueType) + } + + fun setKeyClass(path: String, value: T) { + context?.setKey(path, value) + } + + fun removeKeys(folder: String): Int? { + return context?.removeKeys(folder) + } + + fun setKey(path: String, value: T) { + context?.setKey(path, value) + } + + fun setKey(folder: String, path: String, value: T) { + context?.setKey(folder, path, value) + } + + inline fun getKey(path: String, defVal: T?): T? { + return context?.getKey(path, defVal) + } + + inline fun getKey(path: String): T? { + return context?.getKey(path) + } + + inline fun getKey(folder: String, path: String): T? { + return context?.getKey(folder, path) + } + + inline fun getKey(folder: String, path: String, defVal: T?): T? { + return context?.getKey(folder, path, defVal) + } + + fun getKeys(folder: String): List? { + return context?.getKeys(folder) + } + + fun removeKey(folder: String, path: String) { + context?.removeKey(folder, path) + } + + fun removeKey(path: String) { + context?.removeKey(path) + } + + /** + * If fallbackWebview is true and a fragment is supplied then it will open a webview with the url if the browser fails. + * */ + fun openBrowser(url: String, fallbackWebview: Boolean = false, fragment: Fragment? = null) { + context?.openBrowser(url, fallbackWebview, fragment) + } + + /** Will fallback to webview if in TV layout */ + fun openBrowser(url: String, activity: FragmentActivity?) { + openBrowser( + url, + isLayout(TV or EMULATOR), + activity?.supportFragmentManager?.fragments?.lastOrNull() + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/CloudStreamApp.kt b/app/src/main/java/com/lagradost/cloudstream3/CloudStreamApp.kt deleted file mode 100644 index a9cd9c01e..000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/CloudStreamApp.kt +++ /dev/null @@ -1,181 +0,0 @@ -package com.lagradost.cloudstream3 - -import android.app.Activity -import android.app.Application -import android.content.Context -import android.content.ContextWrapper -import android.content.Intent -import android.os.Build -import android.widget.Toast -import androidx.fragment.app.Fragment -import androidx.fragment.app.FragmentActivity -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 -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 -import com.lagradost.cloudstream3.utils.DataStore.removeKey -import com.lagradost.cloudstream3.utils.DataStore.removeKeys -import com.lagradost.cloudstream3.utils.DataStore.setKey -import com.lagradost.cloudstream3.utils.ImageLoader.buildImageLoader -import kotlinx.coroutines.runBlocking -import java.io.File -import java.io.FileNotFoundException -import java.io.PrintStream -import java.lang.ref.WeakReference -import java.util.Locale -import kotlin.concurrent.thread -import kotlin.system.exitProcess - -class ExceptionHandler( - val errorFile: File, - val onError: (() -> Unit) -) : Thread.UncaughtExceptionHandler { - - override fun uncaughtException(thread: Thread, error: Throwable) { - try { - val threadId = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.BAKLAVA) { - thread.threadId() - } else { - @Suppress("DEPRECATION") - thread.id - } - - PrintStream(errorFile).use { ps -> - ps.println("Currently loading extension: ${PluginManager.currentlyLoading ?: "none"}") - ps.println("Fatal exception on thread ${thread.name} ($threadId)") - error.printStackTrace(ps) - } - } catch (_: FileNotFoundException) { - } - try { - onError() - } catch (_: Exception) { - } - exitProcess(1) - } -} - -class CloudStreamApp : Application(), SingletonImageLoader.Factory { - - override fun onCreate() { - super.onCreate() - // If we want to initialize Coil as early as possible, maybe when - // loading an image or GIF in a splash screen activity. - // buildImageLoader(applicationContext) - - ExceptionHandler(filesDir.resolve("last_error")) { - val intent = context!!.packageManager.getLaunchIntentForPackage(context!!.packageName) - startActivity(Intent.makeRestartActivityTask(intent!!.component)) - }.also { - exceptionHandler = it - Thread.setDefaultUncaughtExceptionHandler(it) - } - - AppDebug.isDebug = BuildConfig.DEBUG - } - - override fun attachBaseContext(base: Context?) { - super.attachBaseContext(base) - context = base - } - - override fun newImageLoader(context: PlatformContext): ImageLoader { - // Coil module will be initialized globally when first loadImage() is invoked. - return buildImageLoader(applicationContext) - } - - companion object { - var exceptionHandler: ExceptionHandler? = null - - /** Use to get Activity from Context. */ - tailrec fun Context.getActivity(): Activity? { - return when (this) { - is Activity -> this - is ContextWrapper -> baseContext.getActivity() - else -> null - } - } - - private var _context: WeakReference? = null - var context - get() = _context?.get() - private set(value) { - _context = WeakReference(value) - setContext(WeakReference(value)) - } - - fun getKeyClass(path: String, valueType: Class): T? { - return context?.getKey(path, valueType) - } - - fun setKeyClass(path: String, value: T) { - context?.setKey(path, value) - } - - fun removeKeys(folder: String): Int? { - return context?.removeKeys(folder) - } - - fun setKey(path: String, value: T) { - context?.setKey(path, value) - } - - fun setKey(folder: String, path: String, value: T) { - context?.setKey(folder, path, value) - } - - inline fun getKey(path: String, defVal: T?): T? { - return context?.getKey(path, defVal) - } - - inline fun getKey(path: String): T? { - return context?.getKey(path) - } - - inline fun getKey(folder: String, path: String): T? { - return context?.getKey(folder, path) - } - - inline fun getKey(folder: String, path: String, defVal: T?): T? { - return context?.getKey(folder, path, defVal) - } - - fun getKeys(folder: String): List? { - return context?.getKeys(folder) - } - - fun removeKey(folder: String, path: String) { - context?.removeKey(folder, path) - } - - fun removeKey(path: String) { - context?.removeKey(path) - } - - /** If fallbackWebView is true and a fragment is supplied then it will open a WebView with the URL if the browser fails. */ - fun openBrowser(url: String, fallbackWebView: Boolean = false, fragment: Fragment? = null) { - context?.openBrowser(url, fallbackWebView, fragment) - } - - /** Will fall back to WebView if in TV or emulator layout. */ - fun openBrowser(url: String, activity: FragmentActivity?) { - openBrowser( - url, - isLayout(TV or EMULATOR), - activity?.supportFragmentManager?.fragments?.lastOrNull() - ) - } - } -} diff --git a/app/src/main/java/com/lagradost/cloudstream3/CommonActivity.kt b/app/src/main/java/com/lagradost/cloudstream3/CommonActivity.kt index 4ce09bd44..34698fee0 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/CommonActivity.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/CommonActivity.kt @@ -1,16 +1,13 @@ package com.lagradost.cloudstream3 -import android.annotation.SuppressLint +import android.Manifest import android.app.Activity import android.app.PictureInPictureParams import android.content.Context import android.content.pm.PackageManager 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 @@ -27,41 +24,35 @@ import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.widget.SearchView import androidx.core.content.ContextCompat import androidx.core.view.children -import androidx.core.view.isNotEmpty import androidx.preference.PreferenceManager import com.google.android.gms.cast.framework.CastSession import com.google.android.material.chip.ChipGroup import com.google.android.material.navigationrail.NavigationRailView -import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey -import com.lagradost.cloudstream3.CloudStreamApp.Companion.removeKey +import com.lagradost.cloudstream3.AcraApplication.Companion.getKey +import com.lagradost.cloudstream3.AcraApplication.Companion.removeKey import com.lagradost.cloudstream3.actions.OpenInAppAction import com.lagradost.cloudstream3.actions.VideoClickActionHolder import com.lagradost.cloudstream3.databinding.ToastBinding 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.PlayerPipHelper.isPIPPossible +import com.lagradost.cloudstream3.ui.player.PlayerEventType import com.lagradost.cloudstream3.ui.player.Torrent -import com.lagradost.cloudstream3.ui.result.ActorAdaptor -import com.lagradost.cloudstream3.ui.result.EpisodeAdapter -import com.lagradost.cloudstream3.ui.result.ImageAdapter -import com.lagradost.cloudstream3.ui.search.SearchAdapter -import com.lagradost.cloudstream3.ui.settings.Globals.isLayout import com.lagradost.cloudstream3.ui.settings.Globals.TV +import com.lagradost.cloudstream3.ui.settings.Globals.isLayout +import com.lagradost.cloudstream3.utils.UiText import com.lagradost.cloudstream3.ui.settings.Globals.updateTv -import com.lagradost.cloudstream3.ui.settings.extensions.PluginAdapter import com.lagradost.cloudstream3.utils.AppContextUtils.isRtl import com.lagradost.cloudstream3.utils.Coroutines.ioSafe import com.lagradost.cloudstream3.utils.Event -import com.lagradost.cloudstream3.utils.UIHelper.showInputMethod +import com.lagradost.cloudstream3.utils.UIHelper +import com.lagradost.cloudstream3.utils.UIHelper.hasPIPPermission +import com.lagradost.cloudstream3.utils.UIHelper.shouldShowPIPMode import com.lagradost.cloudstream3.utils.UIHelper.toPx -import com.lagradost.cloudstream3.utils.UiText +import org.schabi.newpipe.extractor.NewPipe import java.lang.ref.WeakReference import java.util.Locale import kotlin.math.max import kotlin.math.min -import org.schabi.newpipe.extractor.NewPipe enum class FocusDirection { Start, @@ -110,15 +101,15 @@ object CommonActivity { return displayMetrics.heightPixels } - var isPipDesired: Boolean = false + var canEnterPipMode: Boolean = false + var canShowPipMode: Boolean = false var isInPIPMode: Boolean = false val onColorSelectedEvent = Event>() val onDialogDismissedEvent = Event() + var playerEventListener: ((PlayerEventType) -> Unit)? = null var keyEventListener: ((Pair) -> Boolean)? = null - var appliedTheme: Int = 0 - var appliedColor: Int = 0 private var currentToast: Toast? = null @@ -191,35 +182,23 @@ 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) } } /** - * Set locale - * @param languageTag shall a IETF BCP 47 conformant tag. - * Check [com.lagradost.cloudstream3.utils.SubtitleHelper]. - * - * See locales on: - * https://github.com/unicode-org/cldr-json/blob/main/cldr-json/cldr-core/availableLocales.json - * https://www.iana.org/assignments/language-subtag-registry/language-subtag-registry - * https://android.googlesource.com/platform/frameworks/base/+/android-16.0.0_r2/core/res/res/values/locale_config.xml - * https://iso639-3.sil.org/code_tables/639/data/all - */ - fun setLocale(context: Context?, languageTag: String?) { - if (context == null || languageTag == null) return - val locale = Locale.forLanguageTag(languageTag) + * Not all languages can be fetched from locale with a code. + * This map allows sidestepping the default Locale(languageCode) + * when setting the app language. + **/ + val appLanguageExceptions = hashMapOf( + "zh-rTW" to Locale.TRADITIONAL_CHINESE + ) + + fun setLocale(context: Context?, languageCode: String?) { + if (context == null || languageCode == null) return + val locale = appLanguageExceptions[languageCode] ?: Locale(languageCode) val resources: Resources = context.resources val config = resources.configuration Locale.setDefault(locale) @@ -227,7 +206,6 @@ object CommonActivity { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) context.createConfigurationContext(config) - @Suppress("DEPRECATION") resources.updateConfiguration( config, @@ -244,8 +222,16 @@ object CommonActivity { fun init(act: Activity) { setActivityInstance(act) ioSafe { Torrent.deleteAllFiles() } + val componentActivity = activity as? ComponentActivity ?: return + //https://stackoverflow.com/questions/52594181/how-to-know-if-user-has-disabled-picture-in-picture-feature-permission + //https://developer.android.com/guide/topics/ui/picture-in-picture + canShowPipMode = + Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && // OS SUPPORT + componentActivity.packageManager.hasSystemFeature(PackageManager.FEATURE_PICTURE_IN_PICTURE) && // HAS FEATURE, MIGHT BE BLOCKED DUE TO POWER DRAIN + componentActivity.hasPIPPermission() // CHECK IF FEATURE IS ENABLED IN SETTINGS + componentActivity.updateLocale() componentActivity.updateTv() AccountManager.initMainAPI() @@ -261,7 +247,7 @@ object CommonActivity { ?: return@registerForActivityResult action.onResultSafe(act, result.data) removeKey("last_click_action") - removeKey("last_opened") + removeKey("last_opened_id") } } @@ -283,15 +269,13 @@ object CommonActivity { } } - /** Enters pip mode if it is both possible and desired to do so*/ private fun Activity.enterPIPMode() { - if (!isPipDesired || !this.isPIPPossible()) return - + if (!shouldShowPIPMode(canEnterPipMode) || !canShowPipMode) return try { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { try { enterPictureInPictureMode(PictureInPictureParams.Builder().build()) - } catch (_: Exception) { + } catch (e: Exception) { // Use fallback just in case @Suppress("DEPRECATION") enterPictureInPictureMode() @@ -307,10 +291,10 @@ object CommonActivity { } } - fun onUserLeaveHint(act: Activity) { - // On Android 12 and later we use setAutoEnterEnabled() instead. - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) return - act.enterPIPMode() + fun onUserLeaveHint(act: Activity?) { + if (canEnterPipMode && canShowPipMode) { + act?.enterPIPMode() + } } fun updateTheme(act: Activity) { @@ -350,10 +334,6 @@ object CommonActivity { "Monet" -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) R.style.MonetMode else R.style.AppTheme - "Dracula" -> R.style.DraculaMode - "Lavender" -> R.style.LavenderMode - "SilentBlue" -> R.style.SilentBlueMode - else -> R.style.AppTheme } @@ -389,8 +369,6 @@ object CommonActivity { act.theme.applyStyle(currentTheme, true) act.theme.applyStyle(currentOverlayTheme, true) - appliedTheme = currentTheme - appliedColor = currentOverlayTheme act.updateTv() if (isLayout(TV)) act.theme.applyStyle(R.style.AppThemeTvOverlay, true) act.theme.applyStyle( @@ -423,7 +401,8 @@ object CommonActivity { private fun View.hasContent(): Boolean { return isShown && when (this) { - is ViewGroup -> this.isNotEmpty() + //is RecyclerView -> this.childCount > 0 + is ViewGroup -> this.childCount > 0 else -> true } } @@ -453,7 +432,7 @@ object CommonActivity { // if cant focus but visible then break and let android decide // the exception if is the view is a parent and has children that wants focus val hasChildrenThatWantsFocus = (next as? ViewGroup)?.let { parent -> - parent.descendantFocusability == ViewGroup.FOCUS_AFTER_DESCENDANTS && parent.isNotEmpty() + parent.descendantFocusability == ViewGroup.FOCUS_AFTER_DESCENDANTS && parent.childCount > 0 } ?: false if (!next.isFocusable && shown && !hasChildrenThatWantsFocus) return null @@ -532,7 +511,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 */ @@ -569,7 +628,6 @@ object CommonActivity { else -> null } - // println("NEXT FOCUS : $nextView") if (nextView != null) { nextView.requestFocus() @@ -577,15 +635,10 @@ object CommonActivity { return true } - // 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()) + UIHelper.showInputMethod(act.currentFocus?.findFocus()) } //println("Keycode: $keyCode") @@ -594,6 +647,7 @@ object CommonActivity { // "Got Keycode $keyCode | ${KeyEvent.keyCodeToString(keyCode)} \n ${event?.action}", // Toast.LENGTH_LONG //) + } // if someone else want to override the focus then don't handle the event as it is already @@ -603,4 +657,4 @@ object CommonActivity { } return null } -} +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/HeaderDecorationBindingAdapter.kt b/app/src/main/java/com/lagradost/cloudstream3/HeaderDecorationBindingAdapter.kt new file mode 100644 index 000000000..045a7963a --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/HeaderDecorationBindingAdapter.kt @@ -0,0 +1,11 @@ +package com.lagradost.cloudstream3 + +import android.view.LayoutInflater +import androidx.annotation.LayoutRes +import androidx.recyclerview.widget.RecyclerView +import com.lagradost.cloudstream3.ui.HeaderViewDecoration + +fun setHeaderDecoration(view: RecyclerView, @LayoutRes headerViewRes: Int) { + val headerView = LayoutInflater.from(view.context).inflate(headerViewRes, null) + view.addItemDecoration(HeaderViewDecoration(headerView)) +} \ 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..4bf26ceed 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt @@ -9,6 +9,7 @@ import android.content.SharedPreferences import android.content.res.ColorStateList import android.content.res.Configuration import android.graphics.Rect +import android.net.Uri import android.os.Bundle import android.util.AttributeSet import android.util.Log @@ -23,14 +24,14 @@ import android.widget.CheckBox import android.widget.ImageView import android.widget.LinearLayout import android.widget.Toast +import androidx.activity.OnBackPressedCallback import androidx.activity.result.ActivityResultLauncher import androidx.annotation.IdRes import androidx.annotation.MainThread import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AppCompatActivity import androidx.cardview.widget.CardView -import androidx.core.content.edit -import androidx.core.net.toUri +import androidx.constraintlayout.widget.ConstraintLayout import androidx.core.view.children import androidx.core.view.get import androidx.core.view.isGone @@ -64,9 +65,9 @@ import com.jaredrummler.android.colorpicker.ColorPickerDialogListener import com.lagradost.cloudstream3.APIHolder.allProviders import com.lagradost.cloudstream3.APIHolder.apis import com.lagradost.cloudstream3.APIHolder.initAll -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.AcraApplication.Companion.getKey +import com.lagradost.cloudstream3.AcraApplication.Companion.removeKey +import com.lagradost.cloudstream3.AcraApplication.Companion.setKey import com.lagradost.cloudstream3.CommonActivity.loadThemes import com.lagradost.cloudstream3.CommonActivity.onColorSelectedEvent import com.lagradost.cloudstream3.CommonActivity.onDialogDismissedEvent @@ -97,7 +98,6 @@ import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.APP_STR import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.APP_STRING_REPO import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.APP_STRING_RESUME_WATCHING import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.APP_STRING_SEARCH -import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.APP_STRING_SHARE import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.localListApi import com.lagradost.cloudstream3.syncproviders.SyncAPI import com.lagradost.cloudstream3.ui.APIRepository @@ -119,7 +119,6 @@ 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.isLandscape import com.lagradost.cloudstream3.ui.settings.Globals.isLayout import com.lagradost.cloudstream3.ui.settings.Globals.updateTv import com.lagradost.cloudstream3.ui.settings.SettingsGeneral @@ -157,20 +156,17 @@ import com.lagradost.cloudstream3.utils.DataStoreHelper.accounts import com.lagradost.cloudstream3.utils.DataStoreHelper.migrateResumeWatching import com.lagradost.cloudstream3.utils.Event import com.lagradost.cloudstream3.utils.ImageLoader.loadImage -import com.lagradost.cloudstream3.utils.InAppUpdater.runAutoUpdate +import com.lagradost.cloudstream3.utils.InAppUpdater.Companion.runAutoUpdate import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showBottomDialog import com.lagradost.cloudstream3.utils.SnackbarHelper.showSnackbar -import com.lagradost.cloudstream3.utils.TvChannelUtils import com.lagradost.cloudstream3.utils.UIHelper.changeStatusBarState import com.lagradost.cloudstream3.utils.UIHelper.checkWrite +import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute import com.lagradost.cloudstream3.utils.UIHelper.dismissSafe -import com.lagradost.cloudstream3.utils.UIHelper.enableEdgeToEdgeCompat -import com.lagradost.cloudstream3.utils.UIHelper.fixSystemBarsPadding import com.lagradost.cloudstream3.utils.UIHelper.getResourceColor import com.lagradost.cloudstream3.utils.UIHelper.hideKeyboard import com.lagradost.cloudstream3.utils.UIHelper.navigate import com.lagradost.cloudstream3.utils.UIHelper.requestRW -import com.lagradost.cloudstream3.utils.UIHelper.setNavigationBarColorCompat import com.lagradost.cloudstream3.utils.UIHelper.toPx import com.lagradost.cloudstream3.utils.USER_PROVIDER_API import com.lagradost.cloudstream3.utils.USER_SELECTED_HOMEPAGE_API @@ -188,9 +184,7 @@ 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 + class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCallback { companion object { @@ -200,21 +194,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 +255,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?, @@ -352,7 +332,7 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa activity?.findViewById(R.id.nav_rail_view)?.selectedItemId = R.id.navigation_search } else if (safeURI(str)?.scheme == APP_STRING_PLAYER) { - val uri = str.toUri() + val uri = Uri.parse(str) val name = uri.getQueryParameter("name") val url = URLDecoder.decode(uri.authority, "UTF-8") @@ -362,8 +342,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) { @@ -379,20 +358,6 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa START_ACTION_RESUME_LATEST ) } - } else if (str.startsWith(APP_STRING_SHARE)) { - try { - val data = str.substringAfter("$APP_STRING_SHARE:") - val parts = data.split("?", limit = 2) - loadResult( - String(base64DecodeArray(parts[1]), Charsets.UTF_8), - String(base64DecodeArray(parts[0]), Charsets.UTF_8), - "" - ) - return true - } catch (e: Exception) { - showToast("Invalid Uri", Toast.LENGTH_SHORT) - return false - } } else if (!isWebview) { if (str.startsWith(DOWNLOAD_NAVIGATE_TO)) { this.navigate(R.id.navigation_downloads) @@ -408,39 +373,22 @@ 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 + } + } } } } } return false } - - - fun centerView(view: View?) { - if (view == null) return - try { - Log.v(TAG, "centerView: $view") - val r = Rect(0, 0, 0, 0) - view.getDrawingRect(r) - val x = r.centerX() - val y = r.centerY() - val dx = r.width() / 2 //screenWidth / 2 - val dy = screenHeight / 2 - val r2 = Rect(x - dx, y - dy, x + dx, y + dy) - view.requestRectangleOnScreen(r2, false) - // TvFocus.current =TvFocus.current.copy(y=y.toFloat()) - } catch (_: Throwable) { - } - } } - 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 +404,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 +451,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, @@ -519,7 +465,7 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa ).contains(destination.id) - /*val dontPush = listOf( + val dontPush = listOf( R.id.navigation_home, R.id.navigation_search, R.id.navigation_results_phone, @@ -550,19 +496,25 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa } layoutParams = params - }*/ + } + + val landscape = when (resources.configuration.orientation) { + Configuration.ORIENTATION_LANDSCAPE -> { + true + } + + Configuration.ORIENTATION_PORTRAIT -> { + isLayout(TV or EMULATOR) + } + + else -> { + false + } + } binding?.apply { - navRailView.isVisible = isNavVisible && isLandscape() - 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 - } - } + navRailView.isVisible = isNavVisible && landscape + navView.isVisible = isNavVisible && !landscape /** * We need to make sure if we return to a sub-fragment, @@ -570,11 +522,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 } @@ -696,9 +644,7 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa .setNegativeButton(R.string.no) { _, _ -> /*NO-OP*/ } .setPositiveButton(R.string.yes) { _, _ -> if (dontShowAgainCheck.isChecked) { - settingsManager.edit(commit = true) { - putInt(getString(R.string.confirm_exit_key), 1) - } + settingsManager.edit().putInt(getString(R.string.confirm_exit_key), 1).commit() } // finish() causes a bug on some TVs where player // may keep playing after closing the app. @@ -723,11 +669,10 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa broadcastIntent.setClass(this, VideoDownloadRestartReceiver::class.java) this.sendBroadcast(broadcastIntent) afterPluginsLoadedEvent -= ::onAllPluginsLoaded - detachBackPressedCallback("MainActivityDefault") super.onDestroy() } - override fun onNewIntent(intent: Intent) { + override fun onNewIntent(intent: Intent?) { handleAppIntent(intent) super.onNewIntent(intent) } @@ -736,7 +681,6 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa if (intent == null) return val str = intent.dataString loadCache() - handleAppIntentUrl(this, str, false, intent.extras) } @@ -806,11 +750,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 +801,6 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa private fun hidePreviewPopupDialog() { bottomPreviewPopup.dismissSafe(this) - lastPopupJob?.cancel() - lastPopupJob = null bottomPreviewPopup = null bottomPreviewBinding = null } @@ -1177,14 +1120,35 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa } } - override fun onCreate(savedInstanceState: Bundle?) { - app.initClient(this, ignoreSSL = false) - @OptIn(UnsafeSSL::class) - insecureApp.initClient(this, ignoreSSL = true) + private fun centerView(view: View?) { + if (view == null) return + try { + Log.v(TAG, "centerView: $view") + val r = Rect(0, 0, 0, 0) + view.getDrawingRect(r) + val x = r.centerX() + val y = r.centerY() + val dx = r.width() / 2 //screenWidth / 2 + val dy = screenHeight / 2 + val r2 = Rect(x - dx, y - dy, x + dx, y + dy) + view.requestRectangleOnScreen(r2, false) + // TvFocus.current =TvFocus.current.copy(y=y.toFloat()) + } catch (_: Throwable) { + } + } + @Suppress("DEPRECATION_ERROR") + override fun onCreate(savedInstanceState: Bundle?) { + 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 = @@ -1193,8 +1157,6 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa MainAPI.settingsForProvider = settingsForProvider loadThemes(this) - enableEdgeToEdgeCompat() - setNavigationBarColorCompat(R.attr.primaryGrayBackground) updateLocale() super.onCreate(savedInstanceState) try { @@ -1215,8 +1177,6 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa val lastAppAutoBackup: String = getKey("VERSION_NAME") ?: "" if (appVer != lastAppAutoBackup) { setKey("VERSION_NAME", BuildConfig.VERSION_NAME) - if (lastAppAutoBackup.isEmpty()) return@safe - safe { backup(this) } @@ -1248,7 +1208,7 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa if (isLayout(TV)) { // Put here any button you don't want focusing it to center the view val exceptionButtons = listOf( - //R.id.home_preview_play_btt, + R.id.home_preview_play_btt, R.id.home_preview_info_btt, R.id.home_preview_hidden_next_focus, R.id.home_preview_hidden_prev_focus, @@ -1280,22 +1240,6 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa null } - binding?.apply { - fixSystemBarsPadding( - navView, - heightResId = R.dimen.nav_view_height, - padTop = false, - overlayCutout = false - ) - - fixSystemBarsPadding( - navRailView, - widthResId = R.dimen.nav_rail_view_width, - padRight = false, - padTop = false - ) - } - // overscan val padding = settingsManager.getInt(getString(R.string.overscan_key), 0).toPx binding?.homeRoot?.setPadding(padding, padding, padding, padding) @@ -1386,9 +1330,6 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa false ) } - -// Add your channel creation here - } } else { val builder: AlertDialog.Builder = AlertDialog.Builder(this) @@ -1653,7 +1594,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) @@ -1676,6 +1619,10 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa if (navDestination.matchDestination(R.id.navigation_home)) { attachBackPressedCallback("MainActivity") { showConfirmExitDialog(settingsManager) + @Suppress("DEPRECATION") + window?.navigationBarColor = + colorFromAttribute(R.attr.primaryGrayBackground) + updateLocale() } } else detachBackPressedCallback("MainActivity") } @@ -1707,23 +1654,14 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa } binding?.navRailView?.apply { - if (isLayout(PHONE)) { - itemRippleColor = rippleColor - itemActiveIndicatorColor = rippleColor - } else { - val rippleColor = ColorStateList.valueOf(getResourceColor(R.attr.textColor, 1.0f)) - val rippleColorTransparent = - ColorStateList.valueOf(getResourceColor(R.attr.textColor, 0.2f)) - itemSpacing = 12.toPx // expandedItemSpacing does not have an attr - itemRippleColor = rippleColorTransparent - itemActiveIndicatorColor = rippleColor - } + itemRippleColor = rippleColor + itemActiveIndicatorColor = rippleColor setupWithNavController(navController) - /*if (isLayout(TV or EMULATOR)) { + if (isLayout(TV or EMULATOR)) { background?.alpha = 200 } else { background?.alpha = 255 - }*/ + } setOnItemSelectedListener { item -> onNavDestinationSelected( @@ -1772,54 +1710,31 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa } val rail = binding?.navRailView - if (rail != null) { - binding?.navRailView?.labelVisibilityMode = - NavigationRailView.LABEL_VISIBILITY_UNLABELED - //val focus = mutableSetOf() - - var prevId: Int? = null - var prevView: View? = null - - // The genius engineers at google did not actually - // write a nextFocus for the navrail - rail.findViewById(R.id.navigation_settings)?.nextFocusDownId = - R.id.nav_footer_profile_card + if (rail != null && isLayout(TV)) { + val focus = mutableSetOf() for (id in arrayOf( R.id.navigation_home, - R.id.navigation_search, R.id.navigation_library, + R.id.navigation_search, R.id.navigation_downloads, R.id.navigation_settings )) { - val view = rail.findViewById(id) ?: continue - prevId?.let { view.nextFocusUpId = it } - prevView?.nextFocusDownId = id - - prevView = view - prevId = id - // Uncomment for focus expand - /*if (!isLayout(TV)) { - view.onFocusChangeListener = null - } else { - view.onFocusChangeListener = - View.OnFocusChangeListener { v, hasFocus -> - if (hasFocus) { - focus += id - binding?.navRailView?.labelVisibilityMode = - NavigationRailView.LABEL_VISIBILITY_LABELED - binding?.navRailView?.expand() - } else { - focus -= id - v.post { - if (focus.isEmpty()) { - binding?.navRailView?.labelVisibilityMode = - NavigationRailView.LABEL_VISIBILITY_UNLABELED - binding?.navRailView?.collapse() - } + rail.findViewById(id)?.onFocusChangeListener = + View.OnFocusChangeListener { v, hasFocus -> + if (hasFocus) { + focus += id + binding?.navRailView?.labelVisibilityMode = NavigationRailView.LABEL_VISIBILITY_LABELED + binding?.navRailView?.expand() + } else { + focus -= id + v.post { + if(focus.isEmpty()) { + binding?.navRailView?.labelVisibilityMode = NavigationRailView.LABEL_VISIBILITY_UNLABELED + binding?.navRailView?.collapse() } } } - }*/ + } } } @@ -1935,7 +1850,7 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa fun buildMediaQueueItem(video: String): MediaQueueItem { // val movieMetadata = MediaMetadata(MediaMetadata.MEDIA_TYPE_PHOTO) //movieMetadata.putString(MediaMetadata.KEY_TITLE, "CloudStream") - val mediaInfo = MediaInfo.Builder(video.toUri().toString()) + val mediaInfo = MediaInfo.Builder(Uri.parse(video).toString()) .setStreamType(MediaInfo.STREAM_TYPE_NONE) .setContentType(MimeTypes.IMAGE_JPEG) // .setMetadata(movieMetadata).build() @@ -1961,7 +1876,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 += "(USER_SELECTED_HOMEPAGE_API)?.let { homepage -> DataStoreHelper.currentHomePage = homepage removeKey(USER_SELECTED_HOMEPAGE_API) @@ -2039,14 +1943,23 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa // } // } - attachBackPressedCallback("MainActivityDefault") { - setNavigationBarColorCompat(R.attr.primaryGrayBackground) - updateLocale() - runDefault() - } + onBackPressedDispatcher.addCallback( + this, + object : OnBackPressedCallback(true) { + override fun handleOnBackPressed() { + @Suppress("DEPRECATION") + window?.navigationBarColor = colorFromAttribute(R.attr.primaryGrayBackground) + updateLocale() - // Start the download queue - DownloadQueueManager.init(this) + // If we don't disable we end up in a loop with default behavior calling + // this callback as well, so we disable it, run default behavior, + // then re-enable this callback so it can be used for next back press. + isEnabled = false + onBackPressedDispatcher.onBackPressed() + isEnabled = true + } + } + ) } /** Biometric stuff **/ @@ -2069,4 +1982,4 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa false } } -} +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/actions/OpenInAppAction.kt b/app/src/main/java/com/lagradost/cloudstream3/actions/OpenInAppAction.kt index ac912cbeb..cc64a6d39 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/actions/OpenInAppAction.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/actions/OpenInAppAction.kt @@ -6,8 +6,8 @@ import android.content.Context import android.content.Intent import androidx.core.content.FileProvider import androidx.core.net.toUri -import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey -import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey +import com.lagradost.cloudstream3.AcraApplication.Companion.getKey +import com.lagradost.cloudstream3.AcraApplication.Companion.setKey import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.ui.result.LinkLoadingResult @@ -21,8 +21,7 @@ import java.io.File fun updateDurationAndPosition(position: Long, duration: Long) { if (position <= 0 || duration <= 0) return - val episode = getKey("last_opened") ?: return - DataStoreHelper.setViewPosAndResume(episode.id, position, duration, episode, null) + DataStoreHelper.setViewPos(getKey("last_opened_id"), position, duration) ResultFragment.updateUI() } @@ -99,7 +98,7 @@ abstract class OpenInAppAction( intent.component = ComponentName(packageName, intentClass) } putExtra(context, intent, video, result, index) - setKey("last_opened", video) + setKey("last_opened_id", video.id) launchResult(intent) } diff --git a/app/src/main/java/com/lagradost/cloudstream3/actions/VideoClickAction.kt b/app/src/main/java/com/lagradost/cloudstream3/actions/VideoClickAction.kt index a864b5fb7..8407fa7a4 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/actions/VideoClickAction.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/actions/VideoClickAction.kt @@ -16,16 +16,12 @@ import com.lagradost.cloudstream3.actions.temp.BiglyBTPackage import com.lagradost.cloudstream3.actions.temp.CopyClipboardAction import com.lagradost.cloudstream3.actions.temp.JustPlayerPackage import com.lagradost.cloudstream3.actions.temp.LibreTorrentPackage -import com.lagradost.cloudstream3.actions.temp.MpvExPackage import com.lagradost.cloudstream3.actions.temp.MpvKtPackage import com.lagradost.cloudstream3.actions.temp.MpvKtPreviewPackage import com.lagradost.cloudstream3.actions.temp.MpvPackage -import com.lagradost.cloudstream3.actions.temp.MpvRxPackage import com.lagradost.cloudstream3.actions.temp.MpvYTDLPackage import com.lagradost.cloudstream3.actions.temp.NextPlayerPackage -import com.lagradost.cloudstream3.actions.temp.OnlyPlayer import com.lagradost.cloudstream3.actions.temp.PlayInBrowserAction -import com.lagradost.cloudstream3.actions.temp.PlayMirrorAction import com.lagradost.cloudstream3.actions.temp.ViewM3U8Action import com.lagradost.cloudstream3.actions.temp.VlcNightlyPackage import com.lagradost.cloudstream3.actions.temp.VlcPackage @@ -34,8 +30,8 @@ import com.lagradost.cloudstream3.actions.temp.fcast.FcastAction import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.ui.result.LinkLoadingResult import com.lagradost.cloudstream3.ui.result.ResultEpisode -import com.lagradost.cloudstream3.utils.Coroutines.atomicListOf import com.lagradost.cloudstream3.utils.Coroutines.ioSafe +import com.lagradost.cloudstream3.utils.Coroutines.threadSafeListOf import com.lagradost.cloudstream3.utils.ExtractorLinkType import com.lagradost.cloudstream3.utils.UiText import kotlinx.coroutines.Dispatchers @@ -45,16 +41,14 @@ import java.util.concurrent.FutureTask import kotlin.reflect.jvm.jvmName object VideoClickActionHolder { - val allVideoClickActions = atomicListOf( + val allVideoClickActions = threadSafeListOf( // Default PlayInBrowserAction(), CopyClipboardAction(), ViewM3U8Action(), - PlayMirrorAction(), // main support external apps VlcPackage(), MpvPackage(), - MpvExPackage(), NextPlayerPackage(), JustPlayerPackage(), FcastAction(), @@ -66,8 +60,6 @@ object VideoClickActionHolder { MpvYTDLPackage(), MpvKtPackage(), MpvKtPreviewPackage(), - OnlyPlayer(), - MpvRxPackage(), // Always Ask option AlwaysAskAction(), // added by plugins diff --git a/app/src/main/java/com/lagradost/cloudstream3/actions/temp/CloudStreamPackage.kt b/app/src/main/java/com/lagradost/cloudstream3/actions/temp/CloudStreamPackage.kt index d414b6117..d7f69db2c 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/actions/temp/CloudStreamPackage.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/actions/temp/CloudStreamPackage.kt @@ -5,8 +5,8 @@ import android.content.Context import android.content.Intent import android.net.Uri import com.fasterxml.jackson.annotation.JsonProperty -import com.lagradost.cloudstream3.actions.OpenInAppAction import com.lagradost.cloudstream3.BuildConfig +import com.lagradost.cloudstream3.actions.OpenInAppAction import com.lagradost.cloudstream3.ui.player.ExtractorUri import com.lagradost.cloudstream3.ui.player.SubtitleData import com.lagradost.cloudstream3.ui.player.SubtitleOrigin @@ -18,10 +18,8 @@ import com.lagradost.cloudstream3.utils.DrmExtractorLink import com.lagradost.cloudstream3.utils.ExtractorLink import com.lagradost.cloudstream3.utils.ExtractorLinkPlayList import com.lagradost.cloudstream3.utils.ExtractorLinkType -import com.lagradost.cloudstream3.utils.newExtractorLink import com.lagradost.cloudstream3.utils.Qualities -import com.lagradost.cloudstream3.utils.SubtitleHelper.fromCodeToLangTagIETF -import com.lagradost.cloudstream3.utils.SubtitleHelper.fromLanguageToTagIETF +import com.lagradost.cloudstream3.utils.newExtractorLink import com.lagradost.cloudstream3.utils.txt /** @@ -124,9 +122,7 @@ class CloudStreamPackage : OpenInAppAction( originalName = name ?: "Unknown", headers = headers, origin = SubtitleOrigin.URL, - languageCode = fromCodeToLangTagIETF(name) ?: - fromLanguageToTagIETF(name, true) ?: - name, + languageCode = null, ) } diff --git a/app/src/main/java/com/lagradost/cloudstream3/actions/temp/MpvKtPackage.kt b/app/src/main/java/com/lagradost/cloudstream3/actions/temp/MpvKtPackage.kt index faae39212..102f0ac8b 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/actions/temp/MpvKtPackage.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/actions/temp/MpvKtPackage.kt @@ -3,6 +3,7 @@ package com.lagradost.cloudstream3.actions.temp import android.app.Activity import android.content.Context import android.content.Intent +import android.net.Uri import androidx.core.net.toUri import com.lagradost.cloudstream3.actions.OpenInAppAction import com.lagradost.cloudstream3.actions.updateDurationAndPosition @@ -44,7 +45,7 @@ open class MpvKtPackage( intent.apply { putExtra("subs", result.subs.map { it.url.toUri() }.toTypedArray()) - setDataAndType(link.url.toUri(), "video/*") + setDataAndType(Uri.parse(link.url), "video/*") // m3u8 plays, but changing sources feature is not available // makeTempM3U8Intent(activity, this, result) diff --git a/app/src/main/java/com/lagradost/cloudstream3/actions/temp/MpvPackage.kt b/app/src/main/java/com/lagradost/cloudstream3/actions/temp/MpvPackage.kt index cd49eb994..68e619c92 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/actions/temp/MpvPackage.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/actions/temp/MpvPackage.kt @@ -3,6 +3,7 @@ package com.lagradost.cloudstream3.actions.temp import android.app.Activity import android.content.Context import android.content.Intent +import android.net.Uri import androidx.core.net.toUri import com.lagradost.api.Log import com.lagradost.cloudstream3.actions.OpenInAppAction @@ -17,9 +18,6 @@ import com.lagradost.cloudstream3.utils.ExtractorLinkType // https://github.com/mpv-android/mpv-android/blob/0eb3cdc6f1632636b9c30d52ec50e4b017661980/app/src/main/java/is/xyz/mpv/MPVActivity.kt#L904 // https://mpv-android.github.io/mpv-android/intent.html -//https://github.com/marlboro-advance/mpvEx -class MpvExPackage: MpvPackage("mpvEx","app.marlboroadvance.mpvex","app.marlboroadvance.mpvex.ui.player.PlayerActivity") - class MpvYTDLPackage : MpvPackage("MPV YTDL", "is.xyz.mpv.ytdl") { override val sourceTypes = setOf( ExtractorLinkType.VIDEO, @@ -28,10 +26,10 @@ class MpvYTDLPackage : MpvPackage("MPV YTDL", "is.xyz.mpv.ytdl") { ) } -open class MpvPackage(appName: String = "MPV", packageName: String = "is.xyz.mpv",intentClass:String = "is.xyz.mpv.MPVActivity"): OpenInAppAction( +open class MpvPackage(appName: String = "MPV", packageName: String = "is.xyz.mpv"): OpenInAppAction( txt(appName), packageName, - intentClass + "is.xyz.mpv.MPVActivity" ) { override val oneSource = true // mpv has poor playlist support on TV override suspend fun putExtra( @@ -46,7 +44,7 @@ open class MpvPackage(appName: String = "MPV", packageName: String = "is.xyz.mpv putExtra("title", video.name) if (index != null) { - setDataAndType((result.links.getOrNull(index)?.url ?: return).toUri(), "video/*") + setDataAndType(Uri.parse(result.links.getOrNull(index)?.url ?: return), "video/*") } else { makeTempM3U8Intent(context, this, result) } diff --git a/app/src/main/java/com/lagradost/cloudstream3/actions/temp/MpvRxPackage.kt b/app/src/main/java/com/lagradost/cloudstream3/actions/temp/MpvRxPackage.kt deleted file mode 100644 index e8bb93a99..000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/actions/temp/MpvRxPackage.kt +++ /dev/null @@ -1,75 +0,0 @@ -package com.lagradost.cloudstream3.actions.temp - -import android.app.Activity -import android.content.Context -import android.content.Intent -import androidx.core.net.toUri -import com.lagradost.api.Log -import com.lagradost.cloudstream3.actions.OpenInAppAction -import com.lagradost.cloudstream3.actions.updateDurationAndPosition -import com.lagradost.cloudstream3.isEpisodeBased -import com.lagradost.cloudstream3.ui.result.LinkLoadingResult -import com.lagradost.cloudstream3.ui.result.ResultEpisode -import com.lagradost.cloudstream3.utils.DataStoreHelper.getViewPos -import com.lagradost.cloudstream3.utils.txt - -/** https://github.com/Riteshp2001/mpvRx - * - * https://github.com/Riteshp2001/mpvRx/blob/00e0c5e803ab53e5757426cbf2248448ba1f49bf/app/src/main/java/app/gyrolet/mpvrx/utils/media/MediaUtils.kt#L132 - * https://github.com/Riteshp2001/mpvRx/blob/00e0c5e803ab53e5757426cbf2248448ba1f49bf/app/src/main/java/app/gyrolet/mpvrx/utils/media/MediaUtils.kt#L56 - * */ -class MpvRxPackage : OpenInAppAction( - appName = txt("mpvRx"), - packageName = "app.gyrolet.mpvrx", - intentClass = "app.gyrolet.mpvrx.ui.player.PlayerActivity" -) { - override val oneSource = true - override suspend fun putExtra( - context: Context, - intent: Intent, - video: ResultEpisode, - result: LinkLoadingResult, - index: Int? - ) { - intent.apply { - putExtra("title", video.name) - val link = result.links[index!!] - val headers = link.headers - - setData(link.url.toUri()) - if (headers.isNotEmpty()) { - // PlayerActivity expects a flat array: [key1, value1, key2, value2, ...] - val flat = headers.entries.flatMap { listOf(it.key, it.value) }.toTypedArray() - intent.putExtra("headers", flat) - } - /*val subs = result.subs // disabled due to https://github.com/Riteshp2001/mpvRx/issues/146 - intent.putExtra("subs", subs.map { it.url.toUri() }.toTypedArray()) - intent.putExtra( - "subs.titles", - subs.map { it.name }.toTypedArray(), - ) - intent.putExtra( - "subs.langs", - subs.map { it.languageCode }.toTypedArray(), - ) - val selected = subs.firstOrNull { it.matchesLanguageCode("en") }?.url?.toUri() - intent.putExtra("subs.enable", selected?.let { arrayOf(it) } ?: arrayOf() )*/ - - 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/PlayInBrowserAction.kt b/app/src/main/java/com/lagradost/cloudstream3/actions/temp/PlayInBrowserAction.kt index bfd2926bf..7c1b68c05 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/actions/temp/PlayInBrowserAction.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/actions/temp/PlayInBrowserAction.kt @@ -2,7 +2,7 @@ package com.lagradost.cloudstream3.actions.temp import android.content.Context import android.content.Intent -import androidx.core.net.toUri +import android.net.Uri import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.actions.VideoClickAction import com.lagradost.cloudstream3.ui.result.LinkLoadingResult @@ -33,7 +33,7 @@ class PlayInBrowserAction: VideoClickAction() { ) { val link = result.links.getOrNull(index ?: 0) ?: return val i = Intent(Intent.ACTION_VIEW) - i.data = link.url.toUri() + i.data = Uri.parse(link.url) launch(i) } } \ 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 deleted file mode 100644 index 56512377b..000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/actions/temp/PlayMirrorAction.kt +++ /dev/null @@ -1,65 +0,0 @@ -package com.lagradost.cloudstream3.actions.temp - -import android.app.Activity -import android.content.Context -import com.lagradost.cloudstream3.R -import com.lagradost.cloudstream3.actions.VideoClickAction -import com.lagradost.cloudstream3.ui.player.ExtractorUri -import com.lagradost.cloudstream3.ui.player.GeneratorPlayer -import com.lagradost.cloudstream3.ui.player.LOADTYPE_INAPP -import com.lagradost.cloudstream3.ui.player.SubtitleData -import com.lagradost.cloudstream3.ui.player.VideoGenerator -import com.lagradost.cloudstream3.ui.result.LinkLoadingResult -import com.lagradost.cloudstream3.ui.result.ResultEpisode -import com.lagradost.cloudstream3.utils.ExtractorLink -import com.lagradost.cloudstream3.utils.ExtractorLinkType -import com.lagradost.cloudstream3.utils.UIHelper.navigate -import com.lagradost.cloudstream3.utils.txt - -class PlayMirrorAction : VideoClickAction() { - override val name = txt(R.string.episode_action_play_mirror) - - override val oneSource = true - - override val isPlayer = true - - override val sourceTypes: Set = LOADTYPE_INAPP - - override fun shouldShow(context: Context?, video: ResultEpisode?) = true - - override suspend fun runAction( - context: Context?, - video: ResultEpisode, - result: LinkLoadingResult, - index: Int? - ) { - //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, - sourceTypes: Set, - callback: (Pair) -> Unit, - subtitleCallback: (SubtitleData) -> Unit, - offset: Int, - isCasting: Boolean - ): Boolean { - index?.let { callback(link to null) } - result.subs.forEach { subtitle -> subtitleCallback(subtitle) } - return true - } - } - - activity.navigate( - R.id.global_to_navigation_player, - GeneratorPlayer.newInstance( - generatorMirror, 0, result.syncData - ) - ) - } -} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/actions/temp/VlcPackage.kt b/app/src/main/java/com/lagradost/cloudstream3/actions/temp/VlcPackage.kt index 46b46a2c2..e1fc22d3c 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/actions/temp/VlcPackage.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/actions/temp/VlcPackage.kt @@ -6,7 +6,7 @@ import android.content.Intent import android.os.Build import androidx.core.net.toUri import com.lagradost.api.Log -import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey +import com.lagradost.cloudstream3.AcraApplication.Companion.getKey import com.lagradost.cloudstream3.actions.OpenInAppAction import com.lagradost.cloudstream3.actions.makeTempM3U8Intent import com.lagradost.cloudstream3.actions.updateDurationAndPosition diff --git a/app/src/main/java/com/lagradost/cloudstream3/actions/temp/WebVideoCastPackage.kt b/app/src/main/java/com/lagradost/cloudstream3/actions/temp/WebVideoCastPackage.kt index 963221bb3..9f7eee7b8 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/actions/temp/WebVideoCastPackage.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/actions/temp/WebVideoCastPackage.kt @@ -3,6 +3,7 @@ package com.lagradost.cloudstream3.actions.temp import android.app.Activity import android.content.Context import android.content.Intent +import android.net.Uri import android.os.Bundle import androidx.core.net.toUri import com.lagradost.cloudstream3.USER_AGENT @@ -37,7 +38,7 @@ class WebVideoCastPackage: OpenInAppAction( val link = result.links[index ?: 0] intent.apply { - setDataAndType(link.url.toUri(), "video/*") + setDataAndType(Uri.parse(link.url), "video/*") val title = video.name ?: video.headerName diff --git a/app/src/main/java/com/lagradost/cloudstream3/actions/temp/fcast/FcastAction.kt b/app/src/main/java/com/lagradost/cloudstream3/actions/temp/fcast/FcastAction.kt index 1036a7055..e3916df01 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/actions/temp/fcast/FcastAction.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/actions/temp/fcast/FcastAction.kt @@ -1,7 +1,7 @@ package com.lagradost.cloudstream3.actions.temp.fcast import android.content.Context -import com.lagradost.cloudstream3.CloudStreamApp.Companion.getActivity +import com.lagradost.cloudstream3.AcraApplication.Companion.getActivity import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.USER_AGENT import com.lagradost.cloudstream3.actions.VideoClickAction diff --git a/app/src/main/java/com/lagradost/cloudstream3/actions/temp/fcast/FcastManager.kt b/app/src/main/java/com/lagradost/cloudstream3/actions/temp/fcast/FcastManager.kt index e2cf4f002..282ef834e 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/actions/temp/fcast/FcastManager.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/actions/temp/fcast/FcastManager.kt @@ -7,7 +7,6 @@ import android.net.nsd.NsdServiceInfo import android.os.Build import android.os.ext.SdkExtensions import android.util.Log -import com.lagradost.cloudstream3.mvvm.safe import com.lagradost.cloudstream3.utils.Coroutines.ioSafe class FcastManager { @@ -73,66 +72,52 @@ class FcastManager { } override fun onServiceFound(serviceInfo: NsdServiceInfo?) { - // Safe here as, java.lang.NoClassDefFoundError: Failed resolution of: Landroid/net/nsd/NsdManager$ServiceInfoCallback - safe { - if (serviceInfo == null) return@safe + if (serviceInfo == null) return - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && SdkExtensions.getExtensionVersion( - Build.VERSION_CODES.TIRAMISU - ) >= 7 - ) { - nsdManager?.registerServiceInfoCallback( - serviceInfo, - Runnable::run, - object : NsdManager.ServiceInfoCallback { - override fun onServiceInfoCallbackRegistrationFailed(errorCode: Int) { - Log.e(tag, "Service registration failed: $errorCode") - } - - override fun onServiceUpdated(serviceInfo: NsdServiceInfo) { - Log.d( - tag, - "Service updated: ${serviceInfo.serviceName}," + - "Net: ${serviceInfo.hostAddresses.firstOrNull()?.hostAddress}" - ) - synchronized(_currentDevices) { - _currentDevices.removeIf { it.rawName == serviceInfo.serviceName } - _currentDevices.add(PublicDeviceInfo(serviceInfo)) - } - } - - override fun onServiceLost() { - Log.d(tag, "Service lost: ${serviceInfo.serviceName},") - synchronized(_currentDevices) { - _currentDevices.removeIf { it.rawName == serviceInfo.serviceName } - } - } - - override fun onServiceInfoCallbackUnregistered() {} - }) - } else { - @Suppress("DEPRECATION") - nsdManager?.resolveService(serviceInfo, object : ResolveListener { - override fun onResolveFailed( - serviceInfo: NsdServiceInfo?, - errorCode: Int - ) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && SdkExtensions.getExtensionVersion( + Build.VERSION_CODES.TIRAMISU) >= 7) { + nsdManager?.registerServiceInfoCallback(serviceInfo, + Runnable::run, + object : NsdManager.ServiceInfoCallback { + override fun onServiceInfoCallbackRegistrationFailed(errorCode: Int) { + Log.e(tag, "Service registration failed: $errorCode") } - - override fun onServiceResolved(serviceInfo: NsdServiceInfo?) { - if (serviceInfo == null) return - + override fun onServiceUpdated(serviceInfo: NsdServiceInfo) { + Log.d(tag, + "Service updated: ${serviceInfo.serviceName}," + + "Net: ${serviceInfo.hostAddresses.firstOrNull()?.hostAddress}" + ) synchronized(_currentDevices) { + _currentDevices.removeIf { it.rawName == serviceInfo.serviceName } _currentDevices.add(PublicDeviceInfo(serviceInfo)) } - - Log.d( - tag, - "Service found: ${serviceInfo.serviceName}, Net: ${serviceInfo.host.hostAddress}" - ) } + override fun onServiceLost() { + Log.d(tag, "Service lost: ${serviceInfo.serviceName},") + synchronized(_currentDevices) { + _currentDevices.removeIf { it.rawName == serviceInfo.serviceName } + } + } + override fun onServiceInfoCallbackUnregistered() {} }) - } + } else { + @Suppress("DEPRECATION") + nsdManager?.resolveService(serviceInfo, object : ResolveListener { + override fun onResolveFailed(serviceInfo: NsdServiceInfo?, errorCode: Int) {} + + override fun onServiceResolved(serviceInfo: NsdServiceInfo?) { + if (serviceInfo == null) return + + synchronized(_currentDevices) { + _currentDevices.add(PublicDeviceInfo(serviceInfo)) + } + + Log.d( + tag, + "Service found: ${serviceInfo.serviceName}, Net: ${serviceInfo.host.hostAddress}" + ) + } + }) } } @@ -183,9 +168,8 @@ class PublicDeviceInfo(serviceInfo: NsdServiceInfo) { val host: String? = if ( Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && SdkExtensions.getExtensionVersion( - Build.VERSION_CODES.TIRAMISU - ) >= 7 - ) { + Build.VERSION_CODES.TIRAMISU) >= 7 + ) { serviceInfo.hostAddresses.firstOrNull()?.hostAddress } else { @Suppress("DEPRECATION") 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..1cffa7c1b 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 @@ -21,17 +20,15 @@ import androidx.fragment.app.FragmentActivity import com.fasterxml.jackson.annotation.JsonProperty import com.lagradost.cloudstream3.APIHolder import com.lagradost.cloudstream3.APIHolder.removePluginMapping +import com.lagradost.cloudstream3.AcraApplication.Companion.getKey +import com.lagradost.cloudstream3.AcraApplication.Companion.removeKey +import com.lagradost.cloudstream3.AcraApplication.Companion.setKey import com.lagradost.cloudstream3.AllLanguagesName import com.lagradost.cloudstream3.AutoDownloadMode -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..82537ccbc 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/plugins/RepositoryManager.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/plugins/RepositoryManager.kt @@ -1,11 +1,10 @@ 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 -import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey +import com.lagradost.cloudstream3.AcraApplication.Companion.context +import com.lagradost.cloudstream3.AcraApplication.Companion.getKey +import com.lagradost.cloudstream3.AcraApplication.Companion.setKey import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.amap import com.lagradost.cloudstream3.app @@ -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. @@ -65,12 +62,10 @@ data class SitePlugin( @JsonProperty("repositoryUrl") val repositoryUrl: String?, // These types are yet to be mapped and used, ignore for now @JsonProperty("tvTypes") val tvTypes: List?, - // Most often a language tag like "en" or "zh-TW" @JsonProperty("language") val language: String?, @JsonProperty("iconUrl") val iconUrl: String?, // Automatically generated by the gradle plugin @JsonProperty("fileSize") val fileSize: Long?, - @JsonProperty("fileHash") val fileHash: String?, ) @@ -79,26 +74,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 +139,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 +201,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..d1b702f4c 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/plugins/VotingApi.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/plugins/VotingApi.kt @@ -2,9 +2,9 @@ package com.lagradost.cloudstream3.plugins import android.util.Log import android.widget.Toast -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.AcraApplication.Companion.context +import com.lagradost.cloudstream3.AcraApplication.Companion.getKey +import com.lagradost.cloudstream3.AcraApplication.Companion.setKey import com.lagradost.cloudstream3.R import java.security.MessageDigest import com.lagradost.cloudstream3.app @@ -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..20a0b6446 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/AccountManager.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/AccountManager.kt @@ -1,11 +1,10 @@ package com.lagradost.cloudstream3.syncproviders -import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey -import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey +import com.lagradost.cloudstream3.AcraApplication.Companion.getKey +import com.lagradost.cloudstream3.AcraApplication.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,14 @@ 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(subSourceApi) ) fun updateAccountIds() { @@ -112,7 +108,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 } @@ -120,11 +115,11 @@ abstract class AccountManager { val subtitleProviders = arrayOf( SubtitleRepo(openSubtitlesApi), SubtitleRepo(addic7ed), - SubtitleRepo(subDlApi) + SubtitleRepo(subDlApi), + SubtitleRepo(subSourceApi) ) val syncApis = arrayOf( SyncRepo(malApi), - SyncRepo(kitsuApi), SyncRepo(aniListApi), SyncRepo(simklApi), SyncRepo(localListApi) @@ -140,8 +135,6 @@ abstract class AccountManager { // Instantly resume watching a show const val APP_STRING_RESUME_WATCHING = "cloudstreamcontinuewatching" - const val APP_STRING_SHARE = "csshare" - fun secondsToReadable(seconds: Int, completedValue: String): String { var secondsLong = seconds.toLong() val days = TimeUnit.SECONDS 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 b6997d494..457efce99 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/AuthAPI.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/AuthAPI.kt @@ -1,14 +1,52 @@ package com.lagradost.cloudstream3.syncproviders +import android.util.Base64 +import androidx.annotation.WorkerThread import com.fasterxml.jackson.annotation.JsonProperty -import com.lagradost.cloudstream3.APIHolder import com.lagradost.cloudstream3.APIHolder.unixTime -import com.lagradost.cloudstream3.APIHolder.unixTimeMS -import com.lagradost.cloudstream3.base64Encode +import com.lagradost.cloudstream3.AcraApplication.Companion.getKey +import com.lagradost.cloudstream3.AcraApplication.Companion.openBrowser +import com.lagradost.cloudstream3.AcraApplication.Companion.setKey +import com.lagradost.cloudstream3.ActorData +import com.lagradost.cloudstream3.CommonActivity.showToast +import com.lagradost.cloudstream3.ErrorLoadingException +import com.lagradost.cloudstream3.LoadResponse +import com.lagradost.cloudstream3.NextAiring +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.Score +import com.lagradost.cloudstream3.SearchQuality +import com.lagradost.cloudstream3.SearchResponse +import com.lagradost.cloudstream3.ShowStatus +import com.lagradost.cloudstream3.TvType +import com.lagradost.cloudstream3.mvvm.Resource +import com.lagradost.cloudstream3.mvvm.logError +import com.lagradost.cloudstream3.mvvm.safe +import com.lagradost.cloudstream3.mvvm.safeApiCall +import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities.SubtitleEntity +import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities.SubtitleSearch +import com.lagradost.cloudstream3.subtitles.SubtitleResource import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.APP_STRING +import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.NONE_ID +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.OpenSubtitlesApi +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.ui.SyncWatchType +import com.lagradost.cloudstream3.ui.library.ListSorting import com.lagradost.cloudstream3.utils.AppContextUtils.splitQuery -import java.net.URI +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 +import java.util.concurrent.TimeUnit data class AuthLoginPage( /** The website to open to authenticate */ @@ -45,10 +83,10 @@ data class AuthToken( val payload: String? = null, ) { fun isAccessTokenExpired(marginSec: Long = 10L) = - accessTokenLifetime != null && unixTime + marginSec >= accessTokenLifetime + accessTokenLifetime != null && (System.currentTimeMillis() / 1000) + marginSec >= accessTokenLifetime fun isRefreshTokenExpired(marginSec: Long = 10L) = - refreshTokenLifetime != null && unixTime + marginSec >= refreshTokenLifetime + refreshTokenLifetime != null && (System.currentTimeMillis() / 1000) + marginSec >= refreshTokenLifetime } data class AuthUser( @@ -143,33 +181,16 @@ abstract class AuthAPI { open val inAppLoginRequirement: AuthLoginRequirement? = null companion object { - @Deprecated( - message = "Use APIHolder.unixTime instead", - replaceWith = ReplaceWith( - expression = "APIHolder.unixTime", - imports = ["com.lagradost.cloudstream3.APIHolder"] - ), - level = DeprecationLevel.WARNING, - ) val unixTime: Long - get() = APIHolder.unixTime - - @Deprecated( - message = "Use APIHolder.unixTimeMS instead", - replaceWith = ReplaceWith( - expression = "unixTimeMS", - imports = ["com.lagradost.cloudstream3.APIHolder.unixTimeMS"] - ), - level = DeprecationLevel.WARNING, - ) + get() = System.currentTimeMillis() / 1000L val unixTimeMs: Long - get() = unixTimeMS + get() = System.currentTimeMillis() fun splitRedirectUrl(redirectUrl: String): Map { return splitQuery( - URI( + URL( redirectUrl.replace(APP_STRING, "https").replace("/#", "?") - ).toURL() + ) ) } @@ -179,8 +200,9 @@ abstract class AuthAPI { val secureRandom = SecureRandom() val codeVerifierBytes = ByteArray(96) // base64 has 6bit per char; (8/6)*96 = 128 secureRandom.nextBytes(codeVerifierBytes) - return base64Encode(codeVerifierBytes).trimEnd('=') - .replace("+", "-").replace("/", "_").replace("\n", "") + return Base64.encodeToString(codeVerifierBytes, Base64.DEFAULT).trimEnd('=') + .replace("+", "-") + .replace("/", "_").replace("\n", "") } } @@ -228,15 +250,14 @@ abstract class AuthAPI { open suspend fun invalidateToken(token: AuthToken): Nothing = throw NotImplementedError() @Throws - @Deprecated("Please use the new API for AuthAPI", level = DeprecationLevel.ERROR) + @Deprecated("Please the the new api for AuthAPI", level = DeprecationLevel.WARNING) fun toRepo(): AuthRepo = when (this) { is SubtitleAPI -> SubtitleRepo(this) is SyncAPI -> SyncRepo(this) else -> throw NotImplementedError("Unknown inheritance from AuthAPI") } - @Suppress("DEPRECATION_ERROR") - @Deprecated("Please use the new API for AuthAPI", level = DeprecationLevel.ERROR) + @Deprecated("Please the the new api for AuthAPI", level = DeprecationLevel.WARNING) fun loginInfo(): LoginInfo? { return this.toRepo().authUser()?.let { user -> LoginInfo( @@ -247,16 +268,19 @@ abstract class AuthAPI { } } - @Deprecated("Please use the new API for AuthAPI", level = DeprecationLevel.ERROR) + @Deprecated("Please the the new api for AuthAPI", level = DeprecationLevel.WARNING) suspend fun getPersonalLibrary(): SyncAPI.LibraryMetadata? { - @Suppress("DEPRECATION_ERROR") return (this.toRepo() as? SyncRepo)?.library()?.getOrThrow() } - @Deprecated("Please use the new API for AuthAPI", level = DeprecationLevel.ERROR) + @Deprecated("Please the the new api for AuthAPI", level = DeprecationLevel.WARNING) class LoginInfo( val profilePicture: String? = null, val name: String?, val accountIndex: Int, ) } + + + + 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..9444c6367 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/AuthRepo.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/AuthRepo.kt @@ -1,6 +1,6 @@ package com.lagradost.cloudstream3.syncproviders -import com.lagradost.cloudstream3.CloudStreamApp.Companion.openBrowser +import com.lagradost.cloudstream3.AcraApplication.Companion.openBrowser import com.lagradost.cloudstream3.CommonActivity.showToast import com.lagradost.cloudstream3.ErrorLoadingException import com.lagradost.cloudstream3.R @@ -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..e831fb3e8 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) ?: throw ErrorLoadingException("Null subtitles") // 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/Addic7ed.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/Addic7ed.kt index 144efff99..5f71ac9a1 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/Addic7ed.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/Addic7ed.kt @@ -1,17 +1,16 @@ package com.lagradost.cloudstream3.syncproviders.providers -import com.lagradost.cloudstream3.AllLanguagesName +import com.lagradost.cloudstream3.TvType import com.lagradost.cloudstream3.app -import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities.SubtitleEntity -import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities.SubtitleSearch +import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities import com.lagradost.cloudstream3.syncproviders.AuthData import com.lagradost.cloudstream3.syncproviders.SubtitleAPI -import com.lagradost.cloudstream3.TvType -import com.lagradost.cloudstream3.utils.SubtitleHelper.fromTagToEnglishLanguageName +import com.lagradost.cloudstream3.utils.SubtitleHelper class Addic7ed : SubtitleAPI() { override val name = "Addic7ed" override val idPrefix = "addic7ed" + override val requiresLogin = false companion object { @@ -19,8 +18,7 @@ class Addic7ed : SubtitleAPI() { const val TAG = "ADDIC7ED" } - private fun String.fixUrl(): String { - val url = this + private fun fixUrl(url: String): String { return if (url.startsWith("/")) HOST + url else if (!url.startsWith("http")) "$HOST/$url" else url @@ -28,178 +26,84 @@ class Addic7ed : SubtitleAPI() { override suspend fun search( auth: AuthData?, - query: SubtitleSearch - ): List? { - val langTagIETF = query.lang ?: AllLanguagesName - val langNumAddic7ed = - langTagIETF2Addic7ed[langTagIETF]?.first ?: 0 // all languages = 0 - val langName = - langTagIETF2Addic7ed[langTagIETF]?.second ?: - fromTagToEnglishLanguageName(langTagIETF) ?: - "Completed" // this bypasses language filtering - val title = query.query.trim() + query: AbstractSubtitleEntities.SubtitleSearch + ): List? { + val lang = query.lang + val queryLang = SubtitleHelper.fromTwoLettersToLanguage(lang.toString()) + val queryText = query.query.trim() val epNum = query.epNumber ?: 0 val seasonNum = query.seasonNumber ?: 0 val yearNum = query.year ?: 0 - val searchQuery = if (seasonNum > 0) "$title $seasonNum $epNum" else title - var downloadPage = "" - fun newSubtitleEntity ( - displayName: String?, - link: String?, + fun cleanResources( + results: MutableList, + name: String, + link: String, + headers: Map, isHearingImpaired: Boolean - ): SubtitleEntity? { - if (displayName.isNullOrBlank() || link.isNullOrBlank()) return null - return SubtitleEntity( - idPrefix = this.idPrefix, - name = displayName, - lang = langTagIETF, - data = link, - source = this.name, - type = if (seasonNum > 0) TvType.TvSeries else TvType.Movie, - epNumber = epNum, - seasonNumber = seasonNum, - year = yearNum, - headers = mapOf("referer" to "$HOST/"), - isHearingImpaired = isHearingImpaired + ) { + results.add( + AbstractSubtitleEntities.SubtitleEntity( + idPrefix = idPrefix, + name = name, + lang = queryLang.toString(), + data = link, + source = this.name, + type = if (seasonNum > 0) TvType.TvSeries else TvType.Movie, + epNumber = epNum, + seasonNumber = seasonNum, + year = yearNum, + headers = headers, + isHearingImpaired = isHearingImpaired + ) ) } - val response = app.get(url = "$HOST/search.php?search=$searchQuery&Submit=Search") - val hostDocument = response.document - - // 1st case: found one movie or episode. Redirected to $HOST/movie/1234 or $HOST/serie/show-name/$seasonNum/$epNum/ep-name - if (response.url.contains("/movie/") || response.url.contains("/serie/")) - downloadPage = response.url - - // 2nd case: found tv series ep list. Redirected to $HOST/show/1234 - else if (response.url.contains("/show/")) { - val showId = response.url.substringAfterLast("/") + val title = queryText.substringBefore("(").trim() + val url = "$HOST/search.php?search=${title}&Submit=Search" + val hostDocument = app.get(url).document + var searchResult = "" + if (hostDocument.select("span:contains($title)").isNotEmpty()) searchResult = url + else if (hostDocument.select("table.tabel") + .isNotEmpty() + ) searchResult = hostDocument.select("a:contains($title)").attr("href").toString() + else { + val show = + hostDocument.selectFirst("#sl button")?.attr("onmouseup")?.substringAfter("(") + ?.substringBefore(",") val doc = app.get( - "$HOST/ajax_loadShow.php?show=$showId&season=$seasonNum&langs=|$langNumAddic7ed|&hd=0&hi=0", + "$HOST/ajax_loadShow.php?show=$show&season=$seasonNum&langs=&hd=undefined&hi=undefined", referer = "$HOST/" ).document - - // get direct subtitles links from list - return doc.select("#season tbody tr").mapNotNull { node -> - if (node.select("td:eq(1)").text().toIntOrNull() == epNum) - newSubtitleEntity( - displayName = node.select("td:eq(2)").text() + "\n" + node.select("td:eq(4)").text(), - link = node.selectFirst("a[href~=updated\\/|original\\/]")?.attr("href")?.fixUrl(), - isHearingImpaired = node.select("td:eq(6)").text().isNotEmpty() - ) - else null + doc.select("#season tr:contains($queryLang)").mapNotNull { node -> + if (node.selectFirst("td")?.text() + ?.toIntOrNull() == seasonNum && node.select("td:eq(1)") + .text() + .toIntOrNull() == epNum + ) searchResult = fixUrl(node.select("a").attr("href")) } - // 3rd case: found several or no results. Still in $HOST/search.php?search=title - } else {// (response.url.contains("/search.php")) - downloadPage = hostDocument.select("table.tabel a").selectFirst({ - // tv series - if (seasonNum > 0) "a[href~=serie\\/.+\\/$seasonNum\\/$epNum\\/\\w]" - // movie + year - else if( yearNum > 0) "a[href~=movie\\/]:contains($yearNum)" - // movie - else "a[href~=movie\\/]" - }())?.attr("href")?.fixUrl() ?: return null } + val results = mutableListOf() + val document = app.get( + url = fixUrl(searchResult), + ).document - // filter download page by language. Do not work for movies :/ - if (downloadPage.contains("/serie/")) - downloadPage = downloadPage.substringBeforeLast("/") + "/$langNumAddic7ed" - val doc = app.get(url = downloadPage).document - - // get subtitles links from download page - return doc.select(".tabel95 .tabel95 tr:has(.language):contains($langName)").mapNotNull { node -> - val displayName = - doc.selectFirst("span.titulo")?.text()?.substringBefore(" Subtitle") + "\n" + - node.parent()!!.select(".NewsTitle").text().substringAfter("Version ").substringBefore(", Duration") - val link = - node.selectFirst("a[href~=updated\\/|original\\/]")?.attr("href")?.fixUrl() + document.select(".tabel95 .tabel95 tr:contains($queryLang)").mapNotNull { node -> + val name = if (seasonNum > 0) "${document.select(".titulo").text().replace("Subtitle","").trim()}${ + node.parent()!!.select(".NewsTitle").text().substringAfter("Version").substringBefore(", Duration") + }" else "${document.select(".titulo").text().replace("Subtitle","").trim()}${node.parent()!!.select(".NewsTitle").text().substringAfter("Version").substringBefore(", Duration")}" + val link = fixUrl(node.select("a.buttonDownload").attr("href")) val isHearingImpaired = node.parent()!!.select("tr:last-child [title=\"Hearing Impaired\"]").isNotEmpty() - - newSubtitleEntity(displayName, link, isHearingImpaired) + cleanResources(results, name, link, mapOf("referer" to "$HOST/"), isHearingImpaired) } + return results } override suspend fun load( auth: AuthData?, - subtitle: SubtitleEntity + subtitle: AbstractSubtitleEntities.SubtitleEntity ): String? { return subtitle.data } - - // Missing (?_?) - // Pair("2", ""), - // Pair("3", ""), - // Pair("33", ""), - // Pair("34", ""), - // Do not modify unless Addic7ed changes them! - // as they are the exact values from their website - private val langTagIETF2Addic7ed = mapOf( - "ar" to Pair("38", "Arabic"), - "az" to Pair("48", "Azerbaijani"), - "bg" to Pair("35", "Bulgarian"), - "bn" to Pair("47", "Bengali"), - "bs" to Pair("44", "Bosnian"), - "ca" to Pair("12", "Català"), - "cs" to Pair("14", "Czech"), - "cy" to Pair("65", "Welsh"), - "da" to Pair("30", "Danish"), - "de" to Pair("11", "German"), - "el" to Pair("27", "Greek"), - "en" to Pair("1", "English"), - "es-419" to Pair("6", "Spanish (Latin America)"), - "es-ar" to Pair("69", "Spanish (Argentina)"), - "es-es" to Pair("5", "Spanish (Spain)"), - "es" to Pair("4", "Spanish"), - "et" to Pair("54", "Estonian"), - "eu" to Pair("13", "Euskera"), - "fa" to Pair("43", "Persian"), - "fi" to Pair("28", "Finnish"), - "fr-ca" to Pair("53", "French (Canadian)"), - "fr" to Pair("8", "French"), - "gl" to Pair("15", "Galego"), - "he" to Pair("23", "Hebrew"), - "hi" to Pair("55", "Hindi"), - "hr" to Pair("31", "Croatian"), - "hu" to Pair("20", "Hungarian"), - "hy" to Pair("50", "Armenian"), - "id" to Pair("37", "Indonesian"), - "is" to Pair("56", "Icelandic"), - "it" to Pair("7", "Italian"), - "ja" to Pair("32", "Japanese"), - "kn" to Pair("66", "Kannada"), - "ko" to Pair("42", "Korean"), - "lt" to Pair("58", "Lithuanian"), - "lv" to Pair("57", "Latvian"), - "mk" to Pair("49", "Macedonian"), - "ml" to Pair("67", "Malayalam"), - "mr" to Pair("62", "Marathi"), - "ms" to Pair("40", "Malay"), - "nl" to Pair("17", "Dutch"), - "no" to Pair("29", "Norwegian"), - "pl" to Pair("21", "Polish"), - "pt-br" to Pair("10", "Portuguese (Brazilian)"), - "pt" to Pair("9", "Portuguese"), - "ro" to Pair("26", "Romanian"), - "ru" to Pair("19", "Russian"), - "si" to Pair("60", "Sinhala"), - "sk" to Pair("25", "Slovak"), - "sl" to Pair("22", "Slovenian"), - "sq" to Pair("52", "Albanian"), - "sr-latn" to Pair("36", "Serbian (Latin)"), - "sr" to Pair("39", "Serbian (Cyrillic)"), - "sv" to Pair("18", "Swedish"), - "ta" to Pair("59", "Tamil"), - "te" to Pair("63", "Telugu"), - "th" to Pair("46", "Thai"), - "tl" to Pair("68", "Tagalog"), - "tlh" to Pair("61", "Klingon"), - "tr" to Pair("16", "Turkish"), - "uk" to Pair("51", "Ukrainian"), - "vi" to Pair("45", "Vietnamese"), - "yue" to Pair("64", "Cantonese"), - "zh-hans" to Pair("41", "Chinese (Simplified)"), - "zh-hant" to Pair("24", "Chinese (Traditional)"), - ) } \ 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 441eb1bf2..a4cd42848 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 @@ -2,13 +2,11 @@ package com.lagradost.cloudstream3.syncproviders.providers import androidx.annotation.StringRes import com.fasterxml.jackson.annotation.JsonProperty +import com.lagradost.cloudstream3.AcraApplication.Companion.getKey +import com.lagradost.cloudstream3.AcraApplication.Companion.setKey import com.lagradost.cloudstream3.Actor import com.lagradost.cloudstream3.ActorData import com.lagradost.cloudstream3.ActorRole -import com.lagradost.cloudstream3.APIHolder -import com.lagradost.cloudstream3.BuildConfig -import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey -import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey import com.lagradost.cloudstream3.ErrorLoadingException import com.lagradost.cloudstream3.NextAiring import com.lagradost.cloudstream3.R @@ -37,7 +35,7 @@ class AniListApi : SyncAPI() { override var name = "AniList" override val idPrefix = "anilist" - private val key = BuildConfig.ANILIST_KEY + val key = "6871" override val redirectUrlIdentifier = "anilistlogin" override var requireLibraryRefresh = true override val hasOAuth2 = true @@ -52,10 +50,9 @@ 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 = APIHolder.unixTime + sanitizer["expires_in"]!!.toLong(), + accessTokenLifetime = unixTime + sanitizer["expires_in"]!!.toLong(), ) return token } @@ -86,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, @@ -99,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 @@ -109,7 +106,7 @@ class AniListApi : SyncAPI() { nextAiring = season.nextAiringEpisode?.let { NextAiring( it.episode ?: return@let null, - (it.timeUntilAiring ?: return@let null) + APIHolder.unixTime + (it.timeUntilAiring ?: return@let null) + unixTime ) }, title = season.title?.userPreferred, @@ -161,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 @@ -462,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) @@ -509,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( @@ -641,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 -> @@ -669,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" @@ -717,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 { @@ -740,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?, @@ -789,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 9eb49b4bd..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,677 +1,8 @@ package com.lagradost.cloudstream3.syncproviders.providers - -import androidx.annotation.StringRes import com.fasterxml.jackson.annotation.JsonProperty -import com.lagradost.cloudstream3.APIHolder -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 = APIHolder.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 = APIHolder.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 @@ -811,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 0809ccc43..e8c343519 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 @@ -2,10 +2,8 @@ package com.lagradost.cloudstream3.syncproviders.providers import androidx.annotation.StringRes import com.fasterxml.jackson.annotation.JsonProperty -import com.lagradost.cloudstream3.APIHolder -import com.lagradost.cloudstream3.BuildConfig -import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey -import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey +import com.lagradost.cloudstream3.AcraApplication.Companion.getKey +import com.lagradost.cloudstream3.AcraApplication.Companion.setKey import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.Score import com.lagradost.cloudstream3.ShowStatus @@ -36,7 +34,7 @@ class MALApi : SyncAPI() { override var name = "MAL" override val idPrefix = "mal" - private val key = BuildConfig.MAL_KEY + val key = "1714d6f2f4f7cc19644384f8c4629910" private val apiUrl = "https://api.myanimelist.net" override val hasOAuth2 = true override val redirectUrlIdentifier: String? = "mallogin" @@ -80,7 +78,7 @@ class MALApi : SyncAPI() { ) ).parsed() return AuthToken( - accessTokenLifetime = APIHolder.unixTime + token.expiresIn.toLong(), + accessTokenLifetime = unixTime + token.expiresIn.toLong(), refreshToken = token.refreshToken, accessToken = token.accessToken ) @@ -100,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", @@ -124,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 { @@ -227,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 = @@ -273,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 @@ -368,7 +366,7 @@ class MALApi : SyncAPI() { return AuthToken( accessToken = res.accessToken, refreshToken = res.refreshToken, - accessTokenLifetime = APIHolder.unixTime + res.expiresIn.toLong() + accessTokenLifetime = unixTime + res.expiresIn.toLong() ) } @@ -479,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 -> @@ -507,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/OpenSubtitlesApi.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/OpenSubtitlesApi.kt index 15ef6bfab..02f828a22 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/OpenSubtitlesApi.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/OpenSubtitlesApi.kt @@ -2,10 +2,9 @@ package com.lagradost.cloudstream3.syncproviders.providers import android.util.Log import com.fasterxml.jackson.annotation.JsonProperty -import com.lagradost.cloudstream3.APIHolder -import com.lagradost.cloudstream3.APIHolder.unixTimeMS import com.lagradost.cloudstream3.ErrorLoadingException import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.TvType import com.lagradost.cloudstream3.app import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities import com.lagradost.cloudstream3.syncproviders.AuthData @@ -14,12 +13,9 @@ import com.lagradost.cloudstream3.syncproviders.AuthLoginResponse import com.lagradost.cloudstream3.syncproviders.AuthToken import com.lagradost.cloudstream3.syncproviders.AuthUser import com.lagradost.cloudstream3.syncproviders.SubtitleAPI -import com.lagradost.cloudstream3.TvType import com.lagradost.cloudstream3.utils.AppUtils import com.lagradost.cloudstream3.utils.AppUtils.parseJson import com.lagradost.cloudstream3.utils.AppUtils.toJson -import com.lagradost.cloudstream3.utils.SubtitleHelper.fromCodeToLangTagIETF -import com.lagradost.cloudstream3.utils.SubtitleHelper.fromCodeToOpenSubtitlesTag class OpenSubtitlesApi : SubtitleAPI() { override val name = "OpenSubtitles" @@ -45,17 +41,17 @@ class OpenSubtitlesApi : SubtitleAPI() { } private fun canDoRequest(): Boolean { - return unixTimeMS > currentCoolDown + return unixTimeMs > currentCoolDown } private fun throwIfCantDoRequest() { if (!canDoRequest()) { - throw ErrorLoadingException("Too many requests wait for ${(currentCoolDown - unixTimeMS) / 1000L}s") + throw ErrorLoadingException("Too many requests wait for ${(currentCoolDown - unixTimeMs) / 1000L}s") } } private fun throwGotTooManyRequests() { - currentCoolDown = unixTimeMS + COOLDOWN_DURATION + currentCoolDown = unixTimeMs + COOLDOWN_DURATION throw ErrorLoadingException("Too many requests") } @@ -91,11 +87,29 @@ class OpenSubtitlesApi : SubtitleAPI() { accessToken = response.token ?: throw ErrorLoadingException("Invalid password or username"), /// JWT token is valid 24 hours after successfully authentication of user - accessTokenLifetime = APIHolder.unixTime + 60 * 60 * 24, + accessTokenLifetime = unixTime + 60 * 60 * 24, payload = form.toJson() ) } + /** + * Some languages do not use the normal country codes on OpenSubtitles + * */ + private val languageExceptions = mapOf( +// "pt" to "pt-PT", +// "pt" to "pt-BR" + ) + + private fun fixLanguage(language: String?): String? { + return languageExceptions[language] ?: language + } + + // O(n) but good enough, BiMap did not want to work properly + private fun fixLanguageReverse(language: String?): String? { + return languageExceptions.entries.firstOrNull { it.value == language }?.key ?: language + } + + /** * Fetch subtitles using token authenticated on previous method (see authorize). * Returns list of Subtitles which user can select to download (see load). @@ -105,7 +119,7 @@ class OpenSubtitlesApi : SubtitleAPI() { query: AbstractSubtitleEntities.SubtitleSearch ): List? { throwIfCantDoRequest() - val langOpenSubTag = fromCodeToOpenSubtitlesTag(query.lang) ?: query.lang ?: "" + val fixedLang = fixLanguage(query.lang) val imdbId = query.imdbId?.replace("tt", "")?.toInt() ?: 0 val queryText = query.query @@ -118,8 +132,8 @@ class OpenSubtitlesApi : SubtitleAPI() { val searchQueryUrl = when (imdbId > 0) { //Use imdb_id to search if its valid - true -> "$HOST/subtitles?imdb_id=$imdbId&languages=${langOpenSubTag}$yearQuery$epQuery$seasonQuery" - false -> "$HOST/subtitles?query=${queryText}&languages=${langOpenSubTag}$yearQuery$epQuery$seasonQuery" + true -> "$HOST/subtitles?imdb_id=$imdbId&languages=${fixedLang}$yearQuery$epQuery$seasonQuery" + false -> "$HOST/subtitles?query=${queryText}&languages=${fixedLang}$yearQuery$epQuery$seasonQuery" } val req = app.get( @@ -128,7 +142,6 @@ class OpenSubtitlesApi : SubtitleAPI() { Pair("Content-Type", "application/json") ) + headers, ) - Log.i(TAG, "searchQueryUrl => ${searchQueryUrl}") Log.i(TAG, "Search Req => ${req.text}") if (!req.isSuccessful) { if (req.code == 429) @@ -149,7 +162,7 @@ class OpenSubtitlesApi : SubtitleAPI() { //Use any valid name/title in hierarchy val name = filename ?: featureDetails?.movieName ?: featureDetails?.title ?: featureDetails?.parentTitle ?: attr.release ?: query.query - val langTagIETF = fromCodeToLangTagIETF(attr.language) ?: "" + val lang = fixLanguageReverse(attr.language) ?: "" val resEpNum = featureDetails?.episodeNumber ?: query.epNumber val resSeasonNum = featureDetails?.seasonNumber ?: query.seasonNumber val year = featureDetails?.year ?: query.year @@ -163,7 +176,7 @@ class OpenSubtitlesApi : SubtitleAPI() { AbstractSubtitleEntities.SubtitleEntity( idPrefix = this.idPrefix, name = name, - lang = langTagIETF, + lang = lang, data = resultData, type = type, source = this.name, 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 075c08bb8..9518f5a20 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 @@ -4,19 +4,19 @@ import androidx.annotation.StringRes import androidx.core.net.toUri import com.fasterxml.jackson.annotation.JsonInclude import com.fasterxml.jackson.annotation.JsonProperty -import com.lagradost.cloudstream3.APIHolder +import com.lagradost.cloudstream3.AcraApplication +import com.lagradost.cloudstream3.AcraApplication.Companion.getKey +import com.lagradost.cloudstream3.AcraApplication.Companion.getKeys +import com.lagradost.cloudstream3.AcraApplication.Companion.removeKey +import com.lagradost.cloudstream3.AcraApplication.Companion.setKey import com.lagradost.cloudstream3.BuildConfig -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.removeKey -import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey import com.lagradost.cloudstream3.LoadResponse.Companion.readIdFromString import com.lagradost.cloudstream3.R 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 @@ -30,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 @@ -78,15 +77,15 @@ class SimklApi : SyncAPI() { private class SimklCacheWrapper( @JsonProperty("obj") val obj: T?, @JsonProperty("validUntil") val validUntil: Long, - @JsonProperty("cacheTime") val cacheTime: Long = APIHolder.unixTime, + @JsonProperty("cacheTime") val cacheTime: Long = unixTime, ) { /** Returns true if cache is newer than cacheDays */ fun isFresh(): Boolean { - return validUntil > APIHolder.unixTime + return validUntil > unixTime } fun remainingTime(): Duration { - val unixTime = APIHolder.unixTime + val unixTime = unixTime return if (validUntil > unixTime) { (validUntil - unixTime).toDuration(DurationUnit.SECONDS) } else { @@ -97,7 +96,7 @@ class SimklApi : SyncAPI() { fun cleanOldCache() { getKeys(SIMKL_CACHE_KEY)?.forEach { - val isOld = CloudStreamApp.getKey>(it)?.isFresh() == false + val isOld = AcraApplication.getKey>(it)?.isFresh() == false if (isOld) { removeKey(it) } @@ -110,7 +109,7 @@ class SimklApi : SyncAPI() { SIMKL_CACHE_KEY, path, // Storing as plain sting is required to make generics work. - SimklCacheWrapper(value, APIHolder.unixTime + cacheTime.inWholeSeconds).toJson() + SimklCacheWrapper(value, unixTime + cacheTime.inWholeSeconds).toJson() ) } @@ -118,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) { @@ -419,7 +423,7 @@ class SimklApi : SyncAPI() { } suspend fun execute(): Boolean { - val time = getDateTime(APIHolder.unixTime) + val time = getDateTime(unixTime) val headers = this.headers ?: emptyMap() return if (this.status == SimklListStatusType.None.value) { app.post( @@ -569,7 +573,7 @@ class SimklApi : SyncAPI() { @JsonProperty("year") year: Int?, @JsonProperty("ids") ids: Ids?, @JsonProperty("rating") val rating: Int, - @JsonProperty("rated_at") val ratedAt: String? = getDateTime(APIHolder.unixTime) + @JsonProperty("rated_at") val ratedAt: String? = getDateTime(unixTime) ) : MediaObject(title, year, ids) @JsonInclude(JsonInclude.Include.NON_EMPTY) @@ -578,7 +582,7 @@ class SimklApi : SyncAPI() { @JsonProperty("year") year: Int?, @JsonProperty("ids") ids: Ids?, @JsonProperty("to") val to: String, - @JsonProperty("watched_at") val watchedAt: String? = getDateTime(APIHolder.unixTime) + @JsonProperty("watched_at") val watchedAt: String? = getDateTime(unixTime) ) : MediaObject(title, year, ids) @JsonInclude(JsonInclude.Include.NON_EMPTY) @@ -863,7 +867,7 @@ class SimklApi : SyncAPI() { newStatus: AbstractSyncStatus ): Boolean { val parsedId = readIdFromString(id) - lastScoreTime = APIHolder.unixTime + lastScoreTime = unixTime val simklStatus = newStatus as? SimklSyncStatus val builder = SimklScoreBuilder.Builder() @@ -912,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/syncproviders/providers/SubSource.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/SubSource.kt index 19122768e..df635c13c 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/SubSource.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/SubSource.kt @@ -29,7 +29,7 @@ class SubSourceApi : SubtitleAPI() { //Only supports Imdb Id search for now if (query.imdbId == null) return null - val queryLang = SubtitleHelper.fromTagToEnglishLanguageName(query.lang) + val queryLang = SubtitleHelper.fromTwoLettersToLanguage(query.lang!!) val type = if ((query.seasonNumber ?: 0) > 0) TvType.TvSeries else TvType.Movie val searchRes = app.post( diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/Subdl.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/Subdl.kt index 19bd3b1a7..efe96371f 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/Subdl.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/Subdl.kt @@ -1,8 +1,9 @@ package com.lagradost.cloudstream3.syncproviders.providers import com.fasterxml.jackson.annotation.JsonProperty -import com.lagradost.cloudstream3.app import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.TvType +import com.lagradost.cloudstream3.app import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities import com.lagradost.cloudstream3.subtitles.SubtitleResource import com.lagradost.cloudstream3.syncproviders.AuthData @@ -11,9 +12,6 @@ import com.lagradost.cloudstream3.syncproviders.AuthLoginResponse import com.lagradost.cloudstream3.syncproviders.AuthToken import com.lagradost.cloudstream3.syncproviders.AuthUser import com.lagradost.cloudstream3.syncproviders.SubtitleAPI -import com.lagradost.cloudstream3.TvType -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable class SubDlApi : SubtitleAPI() { override val name = "SubDL" @@ -26,7 +24,7 @@ class SubDlApi : SubtitleAPI() { override val createAccountUrl = "https://subdl.com/panel/register" companion object { - const val APIURL = "https://api.subdl.com" + const val APIURL = "https://apiold.subdl.com" const val APIENDPOINT = "$APIURL/api/v1/subtitles" const val DOWNLOADENDPOINT = "https://dl.subdl.com" } @@ -67,7 +65,6 @@ class SubDlApi : SubtitleAPI() { val epNum = query.epNumber ?: 0 val seasonNum = query.seasonNumber ?: 0 val yearNum = query.year ?: 0 - val langSubdlCode = langTagIETF2subdl[query.lang.toString()] ?: query.lang val idQuery = when { query.imdbId != null -> "&imdb_id=${query.imdbId}" @@ -81,8 +78,8 @@ class SubDlApi : SubtitleAPI() { val searchQueryUrl = when (idQuery) { //Use imdb/tmdb id to search if its valid - null -> "$APIENDPOINT?api_key=${apiKey}&film_name=$queryText&languages=$langSubdlCode$epQuery$seasonQuery$yearQuery" - else -> "$APIENDPOINT?api_key=${apiKey}$idQuery&languages=$langSubdlCode$epQuery$seasonQuery$yearQuery" + null -> "$APIENDPOINT?api_key=${apiKey}&film_name=$queryText&languages=${query.lang}$epQuery$seasonQuery$yearQuery" + else -> "$APIENDPOINT?api_key=${apiKey}$idQuery&languages=${query.lang}$epQuery$seasonQuery$yearQuery" } val req = app.get( @@ -94,9 +91,7 @@ class SubDlApi : SubtitleAPI() { return req.parsedSafe()?.subtitles?.map { subtitle -> - val langTagIETF = - langTagIETF2subdl.entries.find { it.value == subtitle.lang }?.key ?: - subtitle.lang + val lang = subtitle.lang.replaceFirstChar { it.uppercase() } val resEpNum = subtitle.episode ?: query.epNumber val resSeasonNum = subtitle.season ?: query.seasonNumber val type = if ((resSeasonNum ?: 0) > 0) TvType.TvSeries else TvType.Movie @@ -104,7 +99,7 @@ class SubDlApi : SubtitleAPI() { AbstractSubtitleEntities.SubtitleEntity( idPrefix = this.idPrefix, name = subtitle.releaseName, - lang = langTagIETF, + lang = lang, data = "${DOWNLOADENDPOINT}${subtitle.url}", type = type, source = this.name, @@ -124,146 +119,68 @@ class SubDlApi : SubtitleAPI() { } } - @Serializable data class SubtitleOAuthEntity( - @JsonProperty("userEmail") @SerialName("userEmail") var userEmail: String, - @JsonProperty("pass") @SerialName("pass") var pass: String, - @JsonProperty("name") @SerialName("name") var name: String? = null, - @JsonProperty("accessToken") @SerialName("accessToken") var accessToken: String? = null, - @JsonProperty("apiKey") @SerialName("apiKey") var apiKey: String? = null, + @JsonProperty("userEmail") var userEmail: String, + @JsonProperty("pass") var pass: String, + @JsonProperty("name") var name: String? = null, + @JsonProperty("accessToken") var accessToken: String? = null, + @JsonProperty("apiKey") var apiKey: String? = null, ) - @Serializable data class OAuthTokenResponse( - @JsonProperty("token") @SerialName("token") val token: String, - @JsonProperty("userData") @SerialName("userData") val userData: UserData? = null, - @JsonProperty("status") @SerialName("status") val status: Boolean? = null, - @JsonProperty("message") @SerialName("message") val message: String? = null, + @JsonProperty("token") val token: String, + @JsonProperty("userData") val userData: UserData? = null, + @JsonProperty("status") val status: Boolean? = null, + @JsonProperty("message") val message: String? = null, ) - @Serializable data class UserData( - @JsonProperty("email") @SerialName("email") val email: String, - @JsonProperty("name") @SerialName("name") val name: String, - @JsonProperty("country") @SerialName("country") val country: String, - @JsonProperty("scStepCode") @SerialName("scStepCode") val scStepCode: String, - @JsonProperty("scVerified") @SerialName("scVerified") val scVerified: Boolean, - @JsonProperty("username") @SerialName("username") val username: String? = null, - @JsonProperty("scUsername") @SerialName("scUsername") val scUsername: String, + @JsonProperty("email") val email: String, + @JsonProperty("name") val name: String, + @JsonProperty("country") val country: String, + @JsonProperty("scStepCode") val scStepCode: String, + @JsonProperty("scVerified") val scVerified: Boolean, + @JsonProperty("username") val username: String? = null, + @JsonProperty("scUsername") val scUsername: String, ) - @Serializable data class ApiKeyResponse( - @JsonProperty("ok") @SerialName("ok") val ok: Boolean? = false, - @JsonProperty("api_key") @SerialName("api_key") val apiKey: String, - @JsonProperty("usage") @SerialName("usage") val usage: Usage? = null, + @JsonProperty("ok") val ok: Boolean? = false, + @JsonProperty("api_key") val apiKey: String, + @JsonProperty("usage") val usage: Usage? = null, ) - @Serializable data class Usage( - @JsonProperty("total") @SerialName("total") val total: Long? = 0, - @JsonProperty("today") @SerialName("today") val today: Long? = 0, + @JsonProperty("total") val total: Long? = 0, + @JsonProperty("today") val today: Long? = 0, ) - @Serializable data class ApiResponse( - @JsonProperty("status") @SerialName("status") val status: Boolean? = null, - @JsonProperty("results") @SerialName("results") val results: List? = null, - @JsonProperty("subtitles") @SerialName("subtitles") val subtitles: List? = null, + @JsonProperty("status") val status: Boolean? = null, + @JsonProperty("results") val results: List? = null, + @JsonProperty("subtitles") val subtitles: List? = null, ) - @Serializable data class Result( - @JsonProperty("sd_id") @SerialName("sd_id") val sdId: Int? = null, - @JsonProperty("type") @SerialName("type") val type: String? = null, - @JsonProperty("name") @SerialName("name") val name: String? = null, - @JsonProperty("imdb_id") @SerialName("imdb_id") val imdbId: String? = null, - @JsonProperty("tmdb_id") @SerialName("tmdb_id") val tmdbId: Long? = null, - @JsonProperty("first_air_date") @SerialName("first_air_date") val firstAirDate: String? = null, - @JsonProperty("year") @SerialName("year") val year: Int? = null, + @JsonProperty("sd_id") val sdId: Int? = null, + @JsonProperty("type") val type: String? = null, + @JsonProperty("name") val name: String? = null, + @JsonProperty("imdb_id") val imdbId: String? = null, + @JsonProperty("tmdb_id") val tmdbId: Long? = null, + @JsonProperty("first_air_date") val firstAirDate: String? = null, + @JsonProperty("year") val year: Int? = null, ) - @Serializable data class Subtitle( - @JsonProperty("release_name") @SerialName("release_name") val releaseName: String, - @JsonProperty("name") @SerialName("name") val name: String, - @JsonProperty("lang") @SerialName("lang") val lang: String, // subdl language code - @JsonProperty("author") @SerialName("author") val author: String? = null, - @JsonProperty("url") @SerialName("url") val url: String? = null, - @JsonProperty("subtitlePage") @SerialName("subtitlePage") val subtitlePage: String? = null, - @JsonProperty("season") @SerialName("season") val season: Int? = null, - @JsonProperty("episode") @SerialName("episode") val episode: Int? = null, - @JsonProperty("language") @SerialName("language") val language: String? = null, // full language name - @JsonProperty("hi") @SerialName("hi") val hearingImpaired: Boolean? = null, - ) - - // https://subdl.com/api-files/language_list.json - // most of it is IETF BPC 47 conformant tag - // but there are some exceptions - private val langTagIETF2subdl = mapOf( - "en-bg" to "BG_EN", // "Bulgarian_English" - "en-de" to "EN_DE", // "English_German" - "en-hu" to "HU_EN", // "Hungarian_English" - "en-nl" to "NL_EN", // "Dutch_English" - "pt-br" to "BR_PT", // "Brazillian Portuguese" - "zh-hant" to "ZH_BG", // "Big 5 code" -> traditional Chinese (?_?) - // "ar" to "AR", // "Arabic" - // "az" to "AZ", // "Azerbaijani" - // "be" to "BE", // "Belarusian" - // "bg" to "BG", // "Bulgarian" - // "bn" to "BN", // "Bengali" - // "bs" to "BS", // "Bosnian" - // "ca" to "CA", // "Catalan" - // "cs" to "CS", // "Czech" - // "da" to "DA", // "Danish" - // "de" to "DE", // "German" - // "el" to "EL", // "Greek" - // "en" to "EN", // "English" - // "eo" to "EO", // "Esperanto" - // "es" to "ES", // "Spanish" - // "et" to "ET", // "Estonian" - // "fa" to "FA", // "Farsi_Persian" - // "fi" to "FI", // "Finnish" - // "fr" to "FR", // "French" - // "he" to "HE", // "Hebrew" - // "hi" to "HI", // "Hindi" - // "hr" to "HR", // "Croatian" - // "hu" to "HU", // "Hungarian" - // "id" to "ID", // "Indonesian" - // "is" to "IS", // "Icelandic" - // "it" to "IT", // "Italian" - // "ja" to "JA", // "Japanese" - // "ka" to "KA", // "Georgian" - // "kl" to "KL", // "Greenlandic" - // "ko" to "KO", // "Korean" - // "ku" to "KU", // "Kurdish" - // "lt" to "LT", // "Lithuanian" - // "lv" to "LV", // "Latvian" - // "mk" to "MK", // "Macedonian" - // "ml" to "ML", // "Malayalam" - // "mni" to "MNI", // "Manipuri" - // "ms" to "MS", // "Malay" - // "my" to "MY", // "Burmese" - // "nl" to "NL", // "Dutch" - // "no" to "NO", // "Norwegian" - // "pl" to "PL", // "Polish" - // "pt" to "PT", // "Portuguese" - // "ro" to "RO", // "Romanian" - // "ru" to "RU", // "Russian" - // "si" to "SI", // "Sinhala" - // "sk" to "SK", // "Slovak" - // "sl" to "SL", // "Slovenian" - // "sq" to "SQ", // "Albanian" - // "sr" to "SR", // "Serbian" - // "sv" to "SV", // "Swedish" - // "ta" to "TA", // "Tamil" - // "te" to "TE", // "Telugu" - // "th" to "TH", // "Thai" - // "tl" to "TL", // "Tagalog" - // "tr" to "TR", // "Turkish" - // "uk" to "UK", // "Ukranian" - // "ur" to "UR", // "Urdu" - // "vi" to "VI", // "Vietnamese" - // "zh" to "ZH", // "Chinese BG code" + @JsonProperty("release_name") val releaseName: String, + @JsonProperty("name") val name: String, + @JsonProperty("lang") val lang: String, + @JsonProperty("author") val author: String? = null, + @JsonProperty("url") val url: String? = null, + @JsonProperty("subtitlePage") val subtitlePage: String? = null, + @JsonProperty("season") val season: Int? = null, + @JsonProperty("episode") val episode: Int? = null, + @JsonProperty("language") val language: String? = null, + @JsonProperty("hi") val hearingImpaired: Boolean? = null, ) } 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..492efacec 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/APIRepository.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/APIRepository.kt @@ -9,15 +9,14 @@ import com.lagradost.cloudstream3.LoadResponse import com.lagradost.cloudstream3.MainAPI import com.lagradost.cloudstream3.MainActivity.Companion.afterPluginsLoadedEvent import com.lagradost.cloudstream3.MainPageRequest -import com.lagradost.cloudstream3.SearchResponseList +import com.lagradost.cloudstream3.SearchResponse import com.lagradost.cloudstream3.SubtitleFile import com.lagradost.cloudstream3.TvType import com.lagradost.cloudstream3.fixUrl 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 @@ -29,7 +28,7 @@ class APIRepository(val api: MainAPI) { // 2 minute timeout to prevent bad extensions/extractors from hogging the resources // No real provider should take longer, so we hard kill them. private const val DEFAULT_TIMEOUT = 120_000L - private const val MAX_TIMEOUT = 4 * DEFAULT_TIMEOUT + private const val MAX_TIMEOUT = 4*DEFAULT_TIMEOUT private const val MIN_TIMEOUT = 5_000L var dubStatusActive = HashSet() @@ -55,18 +54,20 @@ 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 - fun getTimeout(desired: Long?): Long { - return (desired ?: DEFAULT_TIMEOUT).coerceIn(MIN_TIMEOUT, MAX_TIMEOUT) + fun getTimeout(desired : Long?) : Long { + return (desired ?: DEFAULT_TIMEOUT).coerceIn(MIN_TIMEOUT, MAX_TIMEOUT) } } private fun afterPluginsLoaded(forceReload: Boolean) { if (forceReload) { - cache.clear() + synchronized(cache) { + cache.clear() + } } } @@ -89,25 +90,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 @@ -120,29 +117,27 @@ class APIRepository(val api: MainAPI) { } } - suspend fun search(query: String, page: Int): Resource { + suspend fun search(query: String): Resource> { if (query.isEmpty()) - return Resource.Success(newSearchResponseList(emptyList())) + return Resource.Success(emptyList()) return safeApiCall { withTimeout(getTimeout(api.searchTimeoutMs)) { - (api.search(query, page) + (api.search(query) ?: throw ErrorLoadingException()) - // .filter { typesActive.contains(it.type) } + // .filter { typesActive.contains(it.type) } + .toList() } } } - suspend fun quickSearch(query: String): Resource { + suspend fun quickSearch(query: String): Resource> { if (query.isEmpty()) - return Resource.Success(newSearchResponseList(emptyList())) + return Resource.Success(emptyList()) return safeApiCall { withTimeout(getTimeout(api.quickSearchTimeoutMs)) { - newSearchResponseList( - api.quickSearch(query) ?: throw ErrorLoadingException(), - false - ) + api.quickSearch(query) ?: throw ErrorLoadingException() } } } @@ -217,4 +212,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..e930961c5 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/BaseAdapter.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/BaseAdapter.kt @@ -1,55 +1,34 @@ package com.lagradost.cloudstream3.ui -import android.content.Context import android.view.View import android.view.ViewGroup -import android.widget.ImageView import androidx.core.view.children +import androidx.fragment.app.Fragment +import androidx.fragment.app.viewModels +import androidx.lifecycle.ViewModel import androidx.recyclerview.widget.AsyncDifferConfig import androidx.recyclerview.widget.AsyncListDiffer import androidx.recyclerview.widget.DiffUtil 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) { open fun save(): T? = null open fun restore(state: T) = Unit + open fun onViewAttachedToWindow() = Unit + open fun onViewDetachedFromWindow() = Unit + open fun onViewRecycled() = Unit } -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) - }) - } +// Based of the concept https://github.com/brahmkshatriya/echo/blob/main/app%2Fsrc%2Fmain%2Fjava%2Fdev%2Fbrahmkshatriya%2Fecho%2Fui%2Fadapters%2FMediaItemsContainerAdapter.kt#L108-L154 +class StateViewModel : ViewModel() { + val layoutManagerStates = hashMapOf>() } -/** Clears the shared pool of views */ -fun Pair, RecyclerView.RecycledViewPool.() -> Unit>.clear() { - synchronized(this.first) { - for (pool in this.first.values) { - pool?.clear() - } - } -} +abstract class NoStateAdapter(fragment: Fragment) : BaseAdapter(fragment, 0) /** * BaseAdapter is a persistent state stored adapter that supports headers and footers. @@ -70,14 +49,13 @@ fun Pair, RecyclerView.Recyc abstract class BaseAdapter< T : Any, S : Any>( + fragment: Fragment, val id: Int = 0, diffCallback: DiffUtil.ItemCallback = BaseDiffCallback() ) : RecyclerView.Adapter>() { open val footers: Int = 0 open val headers: Int = 0 - val immutableCurrentList: List get() = mDiffer.currentList - fun getItem(position: Int): T { return mDiffer.currentList[position] } @@ -107,33 +85,9 @@ abstract class BaseAdapter< AsyncDifferConfig.Builder(diffCallback).build() ) - /** - * Instantly submits a **new and fresh** list. This means that no changes like moves are done as - * we assume the new list is not the same thing as the old list, nothing is shared. - * - * The views are rendered instantly as a result, so no fade/pop-ins or similar. - * - * Use `submitList` for general use, as that can reuse old views. - * */ - open fun submitIncomparableList(list: List?, commitCallback : Runnable? = null) { - // This leverages a quirk in the submitList function that has a fast case for null arrays - // What this implies is that as long as we do a double submit we can ensure no pop-ins, - // as the changes are the entire list instead of calculating deltas - submitList(null) - submitList(list, commitCallback) - } - - /** - * @param commitCallback Optional runnable that is executed when the List is committed, if it is committed. - * This is needed for some tasks as submitList will use a background thread for diff - * */ - open fun submitList(list: Collection?, commitCallback : Runnable? = null) { + open fun submitList(list: List?) { // deep copy at least the top list, because otherwise adapter can go crazy - if (list.isNullOrEmpty()) { - mDiffer.submitList(null, commitCallback) // It is "faster" to submit null than emptyList() - } else { - mDiffer.submitList(CopyOnWriteArrayList(list), commitCallback) - } + mDiffer.submitList(list?.let { CopyOnWriteArrayList(it) }) } override fun getItemCount(): Int { @@ -147,25 +101,16 @@ abstract class BaseAdapter< open fun onBindFooter(holder: ViewHolderState) = Unit open fun onBindHeader(holder: ViewHolderState) = Unit open fun onCreateContent(parent: ViewGroup): ViewHolderState = throw NotImplementedError() - open fun onCreateCustomContent( - parent: ViewGroup, - viewType: Int - ) = onCreateContent(parent) - open fun onCreateFooter(parent: ViewGroup): ViewHolderState = throw NotImplementedError() - open fun onCreateCustomFooter( - parent: ViewGroup, - viewType: Int - ) = onCreateFooter(parent) - open fun onCreateHeader(parent: ViewGroup): ViewHolderState = throw NotImplementedError() - open fun onCreateCustomHeader( - parent: ViewGroup, - viewType: Int - ) = onCreateHeader(parent) - override fun onViewAttachedToWindow(holder: ViewHolderState) {} - override fun onViewDetachedFromWindow(holder: ViewHolderState) {} + override fun onViewAttachedToWindow(holder: ViewHolderState) { + holder.onViewAttachedToWindow() + } + + override fun onViewDetachedFromWindow(holder: ViewHolderState) { + holder.onViewDetachedFromWindow() + } @Suppress("UNCHECKED_CAST") fun save(recyclerView: RecyclerView) { @@ -176,20 +121,21 @@ abstract class BaseAdapter< } } - fun clearState() { - layoutManagerStates[id]?.clear() + fun clear() { + stateViewModel.layoutManagerStates[id]?.clear() } @Suppress("UNCHECKED_CAST") private fun getState(holder: ViewHolderState): S? = - layoutManagerStates[id]?.get(holder.absoluteAdapterPosition) as? S + stateViewModel.layoutManagerStates[id]?.get(holder.absoluteAdapterPosition) as? S private fun setState(holder: ViewHolderState) { - if (id == 0) return - if (!layoutManagerStates.contains(id)) { - layoutManagerStates[id] = HashMap() + if(id == 0) return + + if (!stateViewModel.layoutManagerStates.contains(id)) { + stateViewModel.layoutManagerStates[id] = HashMap() } - layoutManagerStates[id]?.let { map -> + stateViewModel.layoutManagerStates[id]?.let { map -> map[holder.absoluteAdapterPosition] = holder.save() } } @@ -212,40 +158,30 @@ abstract class BaseAdapter< super.onDetachedFromRecyclerView(recyclerView) } - open fun customContentViewType(item: T): Int = 0 - open fun customFooterViewType(): Int = 0 - open fun customHeaderViewType(): Int = 0 - final override fun getItemViewType(position: Int): Int { if (position < headers) { - return HEADER or customHeaderViewType() + return HEADER } - val realPosition = position - headers - if (realPosition >= mDiffer.currentList.size) { - return FOOTER or customFooterViewType() + if (position - headers >= mDiffer.currentList.size) { + return FOOTER } - return CONTENT or customContentViewType(getItem(realPosition)) + + return CONTENT } + private val stateViewModel: StateViewModel by fragment.viewModels() + final override fun onViewRecycled(holder: ViewHolderState) { setState(holder) - onClearView(holder) + holder.onViewRecycled() super.onViewRecycled(holder) } - /** Same as onViewRecycled, but for the purpose of cleaning the view of any relevant data. - * - * If an item view has large or expensive data bound to it such as large bitmaps, this may be a good place to release those resources. - * - * Use this with `clearImage` - * */ - open fun onClearView(holder: ViewHolderState) {} - final override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolderState { - return when (viewType and TYPE_MASK) { - CONTENT -> onCreateCustomContent(parent, viewType and CUSTOM_MASK) - HEADER -> onCreateCustomHeader(parent, viewType and CUSTOM_MASK) - FOOTER -> onCreateCustomFooter(parent, viewType and CUSTOM_MASK) + return when (viewType) { + CONTENT -> onCreateContent(parent) + HEADER -> onCreateHeader(parent) + FOOTER -> onCreateFooter(parent) else -> throw NotImplementedError() } } @@ -260,7 +196,7 @@ abstract class BaseAdapter< super.onBindViewHolder(holder, position, payloads) return } - when (getItemViewType(position) and TYPE_MASK) { + when (getItemViewType(position)) { CONTENT -> { val realPosition = position - headers val item = getItem(realPosition) @@ -278,7 +214,7 @@ abstract class BaseAdapter< } final override fun onBindViewHolder(holder: ViewHolderState, position: Int) { - when (getItemViewType(position) and TYPE_MASK) { + when (getItemViewType(position)) { CONTENT -> { val realPosition = position - headers val item = getItem(realPosition) @@ -300,20 +236,9 @@ abstract class BaseAdapter< } companion object { - val layoutManagerStates = hashMapOf>() - fun clearImage(image: ImageView?) { - image?.dispose() - } - - // Use the lowermost MASK_SIZE bits for the custom content, - // use the uppermost 32 - MASK_SIZE to the type - private const val MASK_SIZE = 28 - private const val CUSTOM_MASK = (1 shl MASK_SIZE) - 1 - private const val TYPE_MASK = CUSTOM_MASK.inv() - const val HEADER: Int = 3 shl MASK_SIZE - const val FOOTER: Int = 2 shl MASK_SIZE - /** For custom content, write `CONTENT or X` when calling setMaxRecycledViews */ - const val CONTENT: Int = 1 shl MASK_SIZE + private const val HEADER: Int = 1 + private const val FOOTER: Int = 2 + private const val CONTENT: Int = 0 } } @@ -323,5 +248,5 @@ class BaseDiffCallback( ) : DiffUtil.ItemCallback() { override fun areItemsTheSame(oldItem: T, newItem: T): Boolean = itemSame(oldItem, newItem) override fun areContentsTheSame(oldItem: T, newItem: T): Boolean = contentSame(oldItem, newItem) - override fun getChangePayload(oldItem: T, newItem: T): Any? = Any() + override fun getChangePayload(oldItem: T, newItem: T): Any = Any() } \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/BaseFragment.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/BaseFragment.kt deleted file mode 100644 index 72955e7cf..000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/BaseFragment.kt +++ /dev/null @@ -1,278 +0,0 @@ -package com.lagradost.cloudstream3.ui - -import android.content.res.Configuration -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.widget.Toast -import androidx.annotation.LayoutRes -import androidx.fragment.app.DialogFragment -import androidx.fragment.app.Fragment -import androidx.preference.PreferenceFragmentCompat -import androidx.viewbinding.ViewBinding -import com.google.android.material.bottomsheet.BottomSheetDialogFragment -import com.lagradost.cloudstream3.CommonActivity.showToast -import com.lagradost.cloudstream3.R -import com.lagradost.cloudstream3.mvvm.logError -import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setSystemBarsPadding -import com.lagradost.cloudstream3.utils.txt - -/** - * A base Fragment class that simplifies ViewBinding usage and handles view inflation safely. - * - * This class allows two modes of creating ViewBinding: - * 1. Inflate: Using the standard `inflate()` method provided by generated ViewBinding classes. - * 2. Bind: Using `bind()` on an existing root view. - * - * It also provides hooks for: - * - Safe initialization of the binding (`onBindingCreated`) - * - Automatic padding adjustment for system bars (`fixPadding`) - * - Optional layout resource selection via `pickLayout()` - * - * @param T The type of ViewBinding for this Fragment. - * @param bindingCreator The strategy used to create the binding instance. - */ -private interface BaseFragmentHelper { - val bindingCreator: BaseFragment.BindingCreator - - var _binding: T? - val binding: T? get() = _binding - - fun createBinding( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View? { - val layoutId = pickLayout() - val root: View? = layoutId?.let { inflater.inflate(it, container, false) } - _binding = try { - when (val creator = bindingCreator) { - is BaseFragment.BindingCreator.Inflate -> creator.fn(inflater, container, false) - is BaseFragment.BindingCreator.Bind -> { - if (root != null) creator.fn(root) - else throw IllegalStateException("Root view is null for bind()") - } - } - } catch (t: Throwable) { - showToast( - txt(R.string.unable_to_inflate, t.message ?: ""), - Toast.LENGTH_LONG - ) - logError(t) - null - } - - return _binding?.root ?: root - } - - /** - * Called after the fragment's view has been created. - * - * This method is `final` to ensure that the binding is properly initialized and - * system bar padding adjustments are applied before any subclass logic runs. - * Subclasses should use [onBindingCreated] instead of overriding this method directly. - */ - fun onViewReady(view: View, savedInstanceState: Bundle?) { - fixLayout(view) - binding?.let { onBindingCreated(it, savedInstanceState) } - } - - /** - * Called when the binding is safely created and view is ready. - * Can be overridden to provide fragment-specific initialization. - * - * @param binding The safely created ViewBinding. - * @param savedInstanceState Saved state bundle or null. - */ - fun onBindingCreated(binding: T, savedInstanceState: Bundle?) { - onBindingCreated(binding) - } - - /** - * Called when the binding is safely created and view is ready. - * Overload without savedInstanceState for convenience. - * - * @param binding The safely created ViewBinding. - */ - fun onBindingCreated(binding: T) {} - - /** - * Pick a layout resource ID for the fragment. - * - * Return `null` by default. Override to provide a layout resource when using - * `BindingCreator.Bind`. Not needed if using `BindingCreator.Inflate`. - * - * @return Layout resource ID or null. - */ - @LayoutRes - fun pickLayout(): Int? = null - - /** - * Ensures the layout of the root view is correctly adjusted for the current configuration. - * - * This may include applying padding for system bars, adjusting insets, or performing other - * layout updates. `fixLayout` should remain idempotent, as it can be called multiple - * times on the same view, such as during configuration changes (e.g. device rotation) or when - * the view is recreated. - * - * @param view The root view to adjust. - */ - fun fixLayout(view: View) -} - -abstract class BaseFragment( - override val bindingCreator: BindingCreator -) : Fragment(), BaseFragmentHelper { - override var _binding: T? = null - - /** Safer activity?.onBackPressedDispatcher?.onBackPressed() with fallback behavior instead of app crash */ - fun dispatchBackPressed() { - try { - activity?.onBackPressedDispatcher?.onBackPressed() - } catch (_: IllegalStateException) { - // FragmentManager is already executing transactions, so try again - delayedDispatchBackPressed(5) - } catch (t: Throwable) { - logError(t) - } - } - - /** Recursive back press when available */ - private fun delayedDispatchBackPressed(remaining: Int) { - if (remaining <= 0) return - binding?.root?.postDelayed({ - try { - activity?.onBackPressedDispatcher?.onBackPressed() - } catch (_: IllegalStateException) { - // FragmentManager is already executing transactions, so try again - delayedDispatchBackPressed(remaining - 1) - } catch (t: Throwable) { - logError(t) - } - }, 200) - } - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View? = createBinding(inflater, container, savedInstanceState) - - final override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - onViewReady(view, savedInstanceState) - } - - /** - * Called when the device configuration changes (e.g., orientation). - * Re-applies system bar padding fixes to the root view to ensure it - * readjusts for orientation changes. - */ - override fun onConfigurationChanged(newConfig: Configuration) { - super.onConfigurationChanged(newConfig) - view?.let { fixLayout(it) } - } - - /** Cleans up the binding reference when the view is destroyed to avoid memory leaks. */ - override fun onDestroyView() { - super.onDestroyView() - _binding = null - } - - /** - * Sealed class representing the two strategies for creating a ViewBinding instance. - */ - sealed class BindingCreator { - - /** - * Use the standard inflate() method for creating the binding. - * - * @param fn Lambda that inflates the binding. - */ - class Inflate( - val fn: (LayoutInflater, ViewGroup?, Boolean) -> T - ) : BindingCreator() - - /** - * Use bind() on an existing root view to create the binding. This should - * be used if you are differing per device layouts, such as different - * layouts for TV and Phone. - * - * @param fn Lambda that binds the root view. - */ - class Bind( - val fn: (View) -> T - ) : BindingCreator() - } -} - -abstract class BaseDialogFragment( - override val bindingCreator: BaseFragment.BindingCreator -) : DialogFragment(), BaseFragmentHelper { - override var _binding: T? = null - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View? = createBinding(inflater, container, savedInstanceState) - - final override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - onViewReady(view, savedInstanceState) - } - - /** @see [BaseFragment.onConfigurationChanged] for documentation. */ - override fun onConfigurationChanged(newConfig: Configuration) { - super.onConfigurationChanged(newConfig) - view?.let { fixLayout(it) } - } - - /** Cleans up the binding reference when the view is destroyed to avoid memory leaks. */ - override fun onDestroyView() { - super.onDestroyView() - _binding = null - } -} - -abstract class BaseBottomSheetDialogFragment( - override val bindingCreator: BaseFragment.BindingCreator -) : BottomSheetDialogFragment(), BaseFragmentHelper { - override var _binding: T? = null - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View? = createBinding(inflater, container, savedInstanceState) - - final override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - onViewReady(view, savedInstanceState) - } - - /** @see [BaseFragment.onConfigurationChanged] for documentation. */ - override fun onConfigurationChanged(newConfig: Configuration) { - super.onConfigurationChanged(newConfig) - view?.let { fixLayout(it) } - } - - /** Cleans up the binding reference when the view is destroyed to avoid memory leaks. */ - override fun onDestroyView() { - super.onDestroyView() - _binding = null - } -} - -abstract class BasePreferenceFragmentCompat() : PreferenceFragmentCompat() { - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - setSystemBarsPadding() - } - - override fun onConfigurationChanged(newConfig: Configuration) { - super.onConfigurationChanged(newConfig) - setSystemBarsPadding() - } -} 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..e66b57ab1 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 { @@ -239,12 +245,7 @@ class SelectSourceController(val view: ImageView, val activity: ControllerActivi .setPlayPosition(startAt) .setAutoplay(true) .build() - awaitLinks( - remoteMediaClient?.load( - mediaItem, - mediaLoadOptions - ) - ) { + awaitLinks(remoteMediaClient?.load(mediaItem, mediaLoadOptions)) { loadMirror(index + 1) } } @@ -298,13 +299,7 @@ class SelectSourceController(val view: ImageView, val activity: ControllerActivi val currentDuration = remoteMediaClient?.streamDuration val currentPosition = remoteMediaClient?.approximateStreamPosition if (currentDuration != null && currentPosition != null) - DataStoreHelper.setViewPosAndResume( - epData.id, - currentPosition, - currentDuration, - epData, - meta.episodes.getOrNull(index + 1) - ) + DataStoreHelper.setViewPos(epData.id, currentPosition, currentDuration) } catch (t: Throwable) { logError(t) } @@ -320,7 +315,7 @@ class SelectSourceController(val view: ImageView, val activity: ControllerActivi val isSuccessful = safeApiCall { generator.generateLinks( clearCache = false, - sourceTypes = LOADTYPE_CHROMECAST, + allowedTypes = LOADTYPE_CHROMECAST, callback = { it.first?.let { link -> currentLinks.add(link) @@ -328,9 +323,7 @@ class SelectSourceController(val view: ImageView, val activity: ControllerActivi }, subtitleCallback = { currentSubs.add(it) }, - offset = 0, - isCasting = true - ) + isCasting = true) } val sortedLinks = sortUrls(currentLinks) @@ -443,4 +436,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/EasterEggMonkeFragment.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/EasterEggMonkeFragment.kt index 9be862077..bf7f6b8fc 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/EasterEggMonkeFragment.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/EasterEggMonkeFragment.kt @@ -4,13 +4,17 @@ import android.animation.Animator import android.animation.AnimatorListenerAdapter import android.animation.ObjectAnimator import android.annotation.SuppressLint +import android.os.Bundle +import android.view.LayoutInflater import android.view.MotionEvent import android.view.View +import android.view.ViewGroup import android.view.animation.AccelerateInterpolator import android.view.animation.LinearInterpolator import android.widget.FrameLayout import android.widget.ImageView import androidx.core.view.isVisible +import androidx.fragment.app.Fragment import androidx.lifecycle.lifecycleScope import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.databinding.FragmentEasterEggMonkeBinding @@ -22,9 +26,10 @@ import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlin.random.Random -class EasterEggMonkeFragment : BaseFragment( - BaseFragment.BindingCreator.Inflate(FragmentEasterEggMonkeBinding::inflate) -) { +class EasterEggMonkeFragment : Fragment() { + + private var _binding: FragmentEasterEggMonkeBinding? = null + private val binding get() = _binding!! // planet of monks private val monkeys: List = listOf( @@ -46,20 +51,27 @@ class EasterEggMonkeFragment : BaseFragment( private val activeMonkeys = mutableListOf() private var spawningJob: Job? = null - override fun fixLayout(view: View) = Unit + override fun onCreateView( + inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? + ): View { + _binding = FragmentEasterEggMonkeBinding.inflate(layoutInflater) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) - override fun onBindingCreated(binding: FragmentEasterEggMonkeBinding) { activity?.hideSystemUI() spawningJob = lifecycleScope.launch { delay(1000) while (isActive) { - spawnMonkey(binding) + spawnMonkey() delay(500) } } } - private fun spawnMonkey(binding: FragmentEasterEggMonkeBinding) { + private fun spawnMonkey() { val newMonkey = ImageView(context ?: return).apply { setImageResource(monkeys.random()) isVisible = true @@ -90,12 +102,12 @@ class EasterEggMonkeFragment : BaseFragment( } @SuppressLint("ClickableViewAccessibility") - newMonkey.setOnTouchListener { view, event -> handleTouch(view, event, binding) } + newMonkey.setOnTouchListener { view, event -> handleTouch(view, event) } - startFloatingAnimation(newMonkey, binding) + startFloatingAnimation(newMonkey) } - private fun startFloatingAnimation(monkey: ImageView, binding: FragmentEasterEggMonkeBinding) { + private fun startFloatingAnimation(monkey: ImageView) { val floatUpAnimator = ObjectAnimator.ofFloat( monkey, View.TRANSLATION_Y, monkey.y, -monkey.height.toFloat() ).apply { @@ -105,8 +117,11 @@ class EasterEggMonkeFragment : BaseFragment( floatUpAnimator.addListener(object : AnimatorListenerAdapter() { override fun onAnimationEnd(animation: Animator) { - binding.frame.removeView(monkey) - activeMonkeys.remove(monkey) + // necessary check because binding becomes null but monkes are still moving until onDestroy() + if (_binding != null) { + binding.frame.removeView(monkey) + activeMonkeys.remove(monkey) + } } }) @@ -114,11 +129,7 @@ class EasterEggMonkeFragment : BaseFragment( monkey.tag = floatUpAnimator } - private fun handleTouch( - view: View, - event: MotionEvent, - binding: FragmentEasterEggMonkeBinding - ): Boolean { + private fun handleTouch(view: View, event: MotionEvent): Boolean { val monkey = view as ImageView when (event.action) { MotionEvent.ACTION_DOWN -> { @@ -132,17 +143,17 @@ class EasterEggMonkeFragment : BaseFragment( monkey.y = event.rawY - monkey.height / 2 // Check if monkey touches the screen edge - if (isTouchingEdge(monkey, binding)) { - removeMonkey(monkey, binding) + if (isTouchingEdge(monkey)) { + removeMonkey(monkey) } return true } MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> { - if (isTouchingEdge(monkey, binding)) { - removeMonkey(monkey, binding) + if (isTouchingEdge(monkey)) { + removeMonkey(monkey) } else { - startFloatingAnimation(monkey, binding) + startFloatingAnimation(monkey) } return true } @@ -150,12 +161,12 @@ class EasterEggMonkeFragment : BaseFragment( return false } - private fun isTouchingEdge(monkey: ImageView, binding: FragmentEasterEggMonkeBinding): Boolean { + private fun isTouchingEdge(monkey: ImageView): Boolean { return monkey.x <= 0 || monkey.x + monkey.width >= binding.frame.width || monkey.y <= 0 || monkey.y + monkey.height >= binding.frame.height } - private fun removeMonkey(monkey: ImageView, binding: FragmentEasterEggMonkeBinding) { + private fun removeMonkey(monkey: ImageView) { // Fade out and remove the monkey ObjectAnimator.ofFloat(monkey, View.ALPHA, 1f, 0f).apply { duration = 300 @@ -173,5 +184,6 @@ class EasterEggMonkeFragment : BaseFragment( super.onDestroyView() activity?.showSystemUI() spawningJob?.cancel() + _binding = null } -} +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/HeaderViewDecoration.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/HeaderViewDecoration.kt new file mode 100644 index 000000000..40c03012a --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/HeaderViewDecoration.kt @@ -0,0 +1,42 @@ +package com.lagradost.cloudstream3.ui + +import android.graphics.Canvas +import android.graphics.Rect +import android.view.View +import androidx.recyclerview.widget.RecyclerView + +class HeaderViewDecoration(private val customView: View) : RecyclerView.ItemDecoration() { + override fun onDraw(c: Canvas, parent: RecyclerView, state: RecyclerView.State) { + super.onDraw(c, parent, state) + customView.layout(parent.left, 0, parent.right, customView.measuredHeight) + for (i in 0 until parent.childCount) { + val view = parent.getChildAt(i) + if (parent.getChildAdapterPosition(view) == 0) { + c.save() + val height = customView.measuredHeight + val top = view.top - height + c.translate(0f, top.toFloat()) + customView.draw(c) + c.restore() + break + } + } + } + + override fun getItemOffsets( + outRect: Rect, + view: View, + parent: RecyclerView, + state: RecyclerView.State + ) { + if (parent.getChildAdapterPosition(view) == 0) { + customView.measure( + View.MeasureSpec.makeMeasureSpec(parent.measuredWidth, View.MeasureSpec.AT_MOST), + View.MeasureSpec.makeMeasureSpec(parent.measuredHeight, View.MeasureSpec.AT_MOST) + ) + outRect.set(0, customView.measuredHeight, 0, 0) + } else { + outRect.setEmpty() + } + } +} \ No newline at end of file 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/WebviewFragment.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/WebviewFragment.kt index 0d951bf6a..5e2b97e57 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/WebviewFragment.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/WebviewFragment.kt @@ -1,12 +1,17 @@ package com.lagradost.cloudstream3.ui import android.os.Bundle +import android.view.LayoutInflater import android.view.View +import android.view.ViewGroup import android.webkit.JavascriptInterface import android.webkit.WebResourceRequest import android.webkit.WebView import android.webkit.WebViewClient +import androidx.annotation.OptIn +import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentActivity +import androidx.media3.common.util.UnstableApi import androidx.navigation.fragment.findNavController import com.lagradost.cloudstream3.MainActivity import com.lagradost.cloudstream3.USER_AGENT @@ -14,18 +19,19 @@ import com.lagradost.cloudstream3.databinding.FragmentWebviewBinding import com.lagradost.cloudstream3.network.WebViewResolver import com.lagradost.cloudstream3.utils.AppContextUtils.loadRepository -class WebviewFragment : BaseFragment( - BaseFragment.BindingCreator.Inflate(FragmentWebviewBinding::inflate) -) { - override fun fixLayout(view: View) = Unit +class WebviewFragment : Fragment() { - override fun onBindingCreated(binding: FragmentWebviewBinding) { + var binding: FragmentWebviewBinding? = null + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) val url = arguments?.getString(WEBVIEW_URL) ?: "".also { findNavController().popBackStack() } - binding.webView.webViewClient = object : WebViewClient() { + binding?.webView?.webViewClient = object : WebViewClient() { + @OptIn(UnstableApi::class) override fun shouldOverrideUrlLoading( view: WebView?, request: WebResourceRequest? @@ -40,17 +46,28 @@ class WebviewFragment : BaseFragment( return super.shouldOverrideUrlLoading(view, request) } } - - binding.webView.apply { + binding?.webView?.apply { WebViewResolver.webViewUserAgent = settings.userAgentString addJavascriptInterface(RepoApi(activity), "RepoApi") settings.javaScriptEnabled = true settings.userAgentString = USER_AGENT settings.domStorageEnabled = true +// WebView.setWebContentsDebuggingEnabled(true) loadUrl(url) } + + } + + override fun onCreateView( + inflater: LayoutInflater, container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + val localBinding = FragmentWebviewBinding.inflate(inflater, container, false) + binding = localBinding + // Inflate the layout for this fragment + return localBinding.root//inflater.inflate(R.layout.fragment_webview, container, false) } companion object { @@ -67,4 +84,4 @@ class WebviewFragment : BaseFragment( activity?.loadRepository(repoUrl) } } -} +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/account/AccountAdapter.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/account/AccountAdapter.kt index 92d33d0f3..1e7e0f112 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/account/AccountAdapter.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/account/AccountAdapter.kt @@ -1,17 +1,16 @@ package com.lagradost.cloudstream3.ui.account -import android.os.Build import android.view.LayoutInflater import android.view.ViewGroup import androidx.core.content.ContextCompat import androidx.core.view.isVisible +import androidx.recyclerview.widget.RecyclerView +import androidx.viewbinding.ViewBinding import coil3.transform.RoundedCornersTransformation import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.databinding.AccountListItemAddBinding import com.lagradost.cloudstream3.databinding.AccountListItemBinding import com.lagradost.cloudstream3.databinding.AccountListItemEditBinding -import com.lagradost.cloudstream3.ui.NoStateAdapter -import com.lagradost.cloudstream3.ui.ViewHolderState import com.lagradost.cloudstream3.ui.account.AccountHelper.showAccountEditDialog import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR import com.lagradost.cloudstream3.ui.settings.Globals.TV @@ -20,174 +19,137 @@ import com.lagradost.cloudstream3.utils.DataStoreHelper import com.lagradost.cloudstream3.utils.ImageLoader.loadImage class AccountAdapter( + private val accounts: List, private val accountSelectCallback: (DataStoreHelper.Account) -> Unit, private val accountCreateCallback: (DataStoreHelper.Account) -> Unit, private val accountEditCallback: (DataStoreHelper.Account) -> Unit, private val accountDeleteCallback: (DataStoreHelper.Account) -> Unit -) : NoStateAdapter() { +) : RecyclerView.Adapter() { companion object { const val VIEW_TYPE_SELECT_ACCOUNT = 0 + const val VIEW_TYPE_ADD_ACCOUNT = 1 const val VIEW_TYPE_EDIT_ACCOUNT = 2 } + inner class AccountViewHolder(private val binding: ViewBinding) : + RecyclerView.ViewHolder(binding.root) { - override val footers: Int = 1 - var viewType = VIEW_TYPE_SELECT_ACCOUNT + fun bind(account: DataStoreHelper.Account?) { + when (binding) { + is AccountListItemBinding -> binding.apply { + if (account == null) return@apply - override fun customContentViewType(item: DataStoreHelper.Account): Int { - return viewType - } + val isTv = isLayout(TV or EMULATOR) || !root.isInTouchMode - override fun onBindContent( - holder: ViewHolderState, - item: DataStoreHelper.Account, - position: Int - ) { - when (val binding = holder.view) { - is AccountListItemBinding -> binding.apply { - val isTv = isLayout(TV or EMULATOR) || !root.isInTouchMode + val isLastUsedAccount = account.keyIndex == DataStoreHelper.selectedKeyIndex - val isLastUsedAccount = item.keyIndex == DataStoreHelper.selectedKeyIndex + accountName.text = account.name + accountImage.loadImage(account.image) + lockIcon.isVisible = account.lockPin != null + outline.isVisible = !isTv && isLastUsedAccount - accountName.text = item.name - accountImage.loadImage(item.image) - lockIcon.isVisible = item.lockPin != null - outline.isVisible = !isTv && isLastUsedAccount + if (isTv) { + // For emulator but this is fine on TV also + root.isFocusableInTouchMode = true + if (isLastUsedAccount) { + root.requestFocus() + } - if (isTv) { - // For emulator but this is fine on TV also - root.isFocusableInTouchMode = true - if (isLastUsedAccount) { - root.requestFocus() + root.foreground = ContextCompat.getDrawable( + root.context, + R.drawable.outline_drawable + ) + } else { + root.setOnLongClickListener { + showAccountEditDialog( + context = root.context, + account = account, + isNewAccount = false, + accountEditCallback = { account -> accountEditCallback.invoke(account) }, + accountDeleteCallback = { account -> accountDeleteCallback.invoke(account) } + ) + + true + } } - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + root.setOnClickListener { + accountSelectCallback.invoke(account) + } + } + + is AccountListItemEditBinding -> binding.apply { + if (account == null) return@apply + + val isTv = isLayout(TV or EMULATOR) || !root.isInTouchMode + + val isLastUsedAccount = account.keyIndex == DataStoreHelper.selectedKeyIndex + + accountName.text = account.name + accountImage.loadImage(account.image) { + RoundedCornersTransformation(10f) + } + lockIcon.isVisible = account.lockPin != null + outline.isVisible = !isTv && isLastUsedAccount + + if (isTv) { + // For emulator but this is fine on TV also + root.isFocusableInTouchMode = true + if (isLastUsedAccount) { + root.requestFocus() + } + root.foreground = ContextCompat.getDrawable( root.context, R.drawable.outline_drawable ) } - } else { - root.setOnLongClickListener { + + root.setOnClickListener { showAccountEditDialog( context = root.context, - account = item, + account = account, isNewAccount = false, - accountEditCallback = { account -> - accountEditCallback.invoke( - account - ) - }, - accountDeleteCallback = { account -> - accountDeleteCallback.invoke( - account - ) - } + accountEditCallback = { account -> accountEditCallback.invoke(account) }, + accountDeleteCallback = { account -> accountDeleteCallback.invoke(account) } ) - - true } } - root.setOnClickListener { - accountSelectCallback.invoke(item) - } - } + is AccountListItemAddBinding -> binding.apply { + root.setOnClickListener { + val remainingImages = + DataStoreHelper.profileImages.toSet() - accounts.filter { it.customImage == null } + .mapNotNull { DataStoreHelper.profileImages.getOrNull(it.defaultImageIndex) }.toSet() - is AccountListItemEditBinding -> binding.apply { - val isTv = isLayout(TV or EMULATOR) || !root.isInTouchMode + val image = + DataStoreHelper.profileImages.indexOf(remainingImages.randomOrNull() ?: DataStoreHelper.profileImages.random()) + val keyIndex = (accounts.maxOfOrNull { it.keyIndex } ?: 0) + 1 - val isLastUsedAccount = item.keyIndex == DataStoreHelper.selectedKeyIndex + val accountName = root.context.getString(R.string.account) - accountName.text = item.name - accountImage.loadImage(item.image) { - RoundedCornersTransformation(10f) - } - lockIcon.isVisible = item.lockPin != null - outline.isVisible = !isTv && isLastUsedAccount - - if (isTv) { - // For emulator but this is fine on TV also - root.isFocusableInTouchMode = true - if (isLastUsedAccount) { - root.requestFocus() - } - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - root.foreground = ContextCompat.getDrawable( + showAccountEditDialog( root.context, - R.drawable.outline_drawable + DataStoreHelper.Account( + keyIndex = keyIndex, + name = "$accountName $keyIndex", + customImage = null, + defaultImageIndex = image + ), + isNewAccount = true, + accountEditCallback = { account -> accountCreateCallback.invoke(account) }, + accountDeleteCallback = {} ) } } - - root.setOnClickListener { - showAccountEditDialog( - context = root.context, - account = item, - isNewAccount = false, - accountEditCallback = { account -> accountEditCallback.invoke(account) }, - accountDeleteCallback = { account -> - accountDeleteCallback.invoke( - account - ) - } - ) - } } } } - override fun onBindFooter(holder: ViewHolderState) { - val binding = holder.view as? AccountListItemAddBinding ?: return - binding.apply { - root.setOnClickListener { - val accounts = this@AccountAdapter.immutableCurrentList - - val remainingImages = - DataStoreHelper.profileImages.toSet() - accounts.filter { it.customImage == null } - .mapNotNull { DataStoreHelper.profileImages.getOrNull(it.defaultImageIndex) } - .toSet() - - val image = - DataStoreHelper.profileImages.indexOf( - remainingImages.randomOrNull() - ?: DataStoreHelper.profileImages.random() - ) - val keyIndex = (accounts.maxOfOrNull { it.keyIndex } ?: 0) + 1 - - val accountName = root.context.getString(R.string.account) - - showAccountEditDialog( - root.context, - DataStoreHelper.Account( - keyIndex = keyIndex, - name = "$accountName $keyIndex", - customImage = null, - defaultImageIndex = image - ), - isNewAccount = true, - accountEditCallback = { account -> accountCreateCallback.invoke(account) }, - accountDeleteCallback = {} - ) - } - } - } - - override fun onCreateFooter(parent: ViewGroup): ViewHolderState { - return ViewHolderState( - AccountListItemAddBinding.inflate( - LayoutInflater.from(parent.context), - parent, - false - ) - ) - } - - override fun onCreateContent(parent: ViewGroup): ViewHolderState { - return ViewHolderState( - when (viewType) { + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): AccountViewHolder = + AccountViewHolder( + binding = when (viewType) { VIEW_TYPE_SELECT_ACCOUNT -> { AccountListItemBinding.inflate( LayoutInflater.from(parent.context), @@ -195,7 +157,13 @@ class AccountAdapter( false ) } - + VIEW_TYPE_ADD_ACCOUNT -> { + AccountListItemAddBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false + ) + } VIEW_TYPE_EDIT_ACCOUNT -> { AccountListItemEditBinding.inflate( LayoutInflater.from(parent.context), @@ -203,9 +171,28 @@ class AccountAdapter( false ) } - else -> throw IllegalArgumentException("Invalid view type") } ) + + override fun onBindViewHolder(holder: AccountViewHolder, position: Int) { + holder.bind(accounts.getOrNull(position)) + } + + var viewType = 0 + + override fun getItemViewType(position: Int): Int { + if (viewType != 0 && position != accounts.count()) { + return viewType + } + + return when (position) { + accounts.count() -> VIEW_TYPE_ADD_ACCOUNT + else -> VIEW_TYPE_SELECT_ACCOUNT + } + } + + override fun getItemCount(): Int { + return accounts.count() + 1 } } \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/account/AccountHelper.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/account/AccountHelper.kt index 1d6b41e5b..0fd37e245 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/account/AccountHelper.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/account/AccountHelper.kt @@ -21,7 +21,7 @@ import coil3.ImageLoader import coil3.request.ImageRequest import coil3.request.allowHardware import com.google.android.material.bottomsheet.BottomSheetDialog -import com.lagradost.cloudstream3.CloudStreamApp.Companion.getActivity +import com.lagradost.cloudstream3.AcraApplication.Companion.getActivity import com.lagradost.cloudstream3.CommonActivity.showToast import com.lagradost.cloudstream3.MainActivity import com.lagradost.cloudstream3.R @@ -392,6 +392,7 @@ object AccountHelper { activity.observe(viewModel.accounts) { liveAccounts -> recyclerView.adapter = AccountAdapter( + liveAccounts, accountSelectCallback = { account -> viewModel.handleAccountSelect(account, activity) builder.dismissSafe() @@ -399,9 +400,7 @@ object AccountHelper { accountCreateCallback = { viewModel.handleAccountUpdate(it, activity) }, accountEditCallback = { viewModel.handleAccountUpdate(it, activity) }, accountDeleteCallback = { viewModel.handleAccountDelete(it, activity) } - ).apply { - submitList(liveAccounts) - } + ) activity.observe(viewModel.selectedKeyIndex) { selectedKeyIndex -> // Scroll to current account (which is focused by default) 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..a0647219e 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 @@ -31,22 +31,20 @@ import com.lagradost.cloudstream3.utils.BiometricAuthenticator.startBiometricAut import com.lagradost.cloudstream3.utils.DataStoreHelper.accounts import com.lagradost.cloudstream3.utils.DataStoreHelper.selectedKeyIndex import com.lagradost.cloudstream3.utils.DataStoreHelper.setAccount -import com.lagradost.cloudstream3.utils.UIHelper.enableEdgeToEdgeCompat -import com.lagradost.cloudstream3.utils.UIHelper.fixSystemBarsPadding +import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute import com.lagradost.cloudstream3.utils.UIHelper.openActivity -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) + + @Suppress("DEPRECATION") + window.navigationBarColor = colorFromAttribute(R.attr.primaryBlackBackground) // Are we editing and coming from MainActivity? val isEditingFromMainActivity = intent.getBooleanExtra( @@ -54,22 +52,8 @@ 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 + val skipStartup = settingsManager.getBoolean(getString(R.string.skip_startup_account_select_key), false ) || accounts.count() <= 1 fun askBiometricAuth() { @@ -105,12 +89,10 @@ class AccountSelectActivity : FragmentActivity(), BiometricCallback { accountViewModel.handleAccountSelect(currentAccount, this, true) } else { if (accounts.count() > 1) { - showToast( - this, getString( - R.string.logged_account, - currentAccount?.name - ) - ) + showToast(this, getString( + R.string.logged_account, + currentAccount?.name + )) } navigateToMainActivity() @@ -123,12 +105,12 @@ class AccountSelectActivity : FragmentActivity(), BiometricCallback { val binding = ActivityAccountSelectBinding.inflate(layoutInflater) setContentView(binding.root) - fixSystemBarsPadding(binding.root, padTop = false) val recyclerView: AutofitRecyclerView = binding.accountRecyclerView observe(accountViewModel.accounts) { liveAccounts -> val adapter = AccountAdapter( + liveAccounts, // Handle the selected account accountSelectCallback = { accountViewModel.handleAccountSelect(it, this) @@ -136,6 +118,7 @@ class AccountSelectActivity : FragmentActivity(), BiometricCallback { accountCreateCallback = { accountViewModel.handleAccountUpdate(it, this) }, accountEditCallback = { accountViewModel.handleAccountUpdate(it, this) + // We came from MainActivity, return there // and switch to the edited account if (isEditingFromMainActivity) { @@ -143,10 +126,8 @@ class AccountSelectActivity : FragmentActivity(), BiometricCallback { navigateToMainActivity() } }, - accountDeleteCallback = { accountViewModel.handleAccountDelete(it, this) } - ).apply { - submitList(liveAccounts) - } + accountDeleteCallback = { accountViewModel.handleAccountDelete(it,this) } + ) recyclerView.adapter = adapter @@ -201,19 +182,16 @@ 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 } override fun onAuthenticationSuccess() { - Log.i(BiometricAuthenticator.TAG, "Authentication successful in AccountSelectActivity") + Log.i(BiometricAuthenticator.TAG,"Authentication successful in AccountSelectActivity") } override fun onAuthenticationError() { finish() } -} +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/account/AccountViewModel.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/account/AccountViewModel.kt index 96eaf52a7..af62a2b08 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/account/AccountViewModel.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/account/AccountViewModel.kt @@ -4,8 +4,8 @@ import android.content.Context import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel -import com.lagradost.cloudstream3.CloudStreamApp.Companion.context -import com.lagradost.cloudstream3.CloudStreamApp.Companion.removeKeys +import com.lagradost.cloudstream3.AcraApplication.Companion.context +import com.lagradost.cloudstream3.AcraApplication.Companion.removeKeys import com.lagradost.cloudstream3.MainActivity import com.lagradost.cloudstream3.ui.account.AccountHelper.showPinInputDialog import com.lagradost.cloudstream3.utils.DataStoreHelper 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..a0e5cabc4 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 @@ -1,6 +1,5 @@ package com.lagradost.cloudstream3.ui.download -import android.annotation.SuppressLint import android.text.format.Formatter.formatShortFileSize import android.view.LayoutInflater import android.view.ViewGroup @@ -8,18 +7,19 @@ import android.widget.CheckBox import androidx.core.content.ContextCompat import androidx.core.view.isVisible import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import androidx.recyclerview.widget.RecyclerView import androidx.viewbinding.ViewBinding import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.databinding.DownloadChildEpisodeBinding import com.lagradost.cloudstream3.databinding.DownloadHeaderEpisodeBinding import com.lagradost.cloudstream3.mvvm.logError -import com.lagradost.cloudstream3.ui.NoStateAdapter -import com.lagradost.cloudstream3.ui.ViewHolderState import com.lagradost.cloudstream3.ui.download.button.DownloadStatusTell import com.lagradost.cloudstream3.utils.AppContextUtils.getNameFull +import com.lagradost.cloudstream3.utils.DataStoreHelper.fixVisual 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,19 +57,19 @@ 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( private val onHeaderClickEvent: (DownloadHeaderClickEvent) -> Unit, private val onItemClickEvent: (DownloadClickEvent) -> Unit, private val onItemSelectionChanged: (Int, Boolean) -> Unit, -) : NoStateAdapter(DiffCallback()) { +) : ListAdapter(DiffCallback()) { private var isMultiDeleteState: Boolean = false @@ -79,224 +78,112 @@ class DownloadAdapter( private const val VIEW_TYPE_CHILD = 1 } + inner class DownloadViewHolder( + private val binding: ViewBinding + ) : RecyclerView.ViewHolder(binding.root) { - private fun bindHeader(binding: ViewBinding, card: VisualDownloadCached.Header?) { - if (binding !is DownloadHeaderEpisodeBinding || card == null) return + fun bind(card: VisualDownloadCached?) { + when (binding) { + is DownloadHeaderEpisodeBinding -> bindHeader(card as? VisualDownloadCached.Header) + is DownloadChildEpisodeBinding -> bindChild(card as? VisualDownloadCached.Child) + } + } - val data = card.data - binding.apply { - episodeHolder.apply { - if (isMultiDeleteState) { - setOnClickListener { - toggleIsChecked(deleteCheckbox, data.id) + private fun bindHeader(card: VisualDownloadCached.Header?) { + if (binding !is DownloadHeaderEpisodeBinding || card == null) return + + val data = card.data + binding.apply { + episodeHolder.apply { + if (isMultiDeleteState) { + setOnClickListener { + toggleIsChecked(deleteCheckbox, data.id) + } } + setOnLongClickListener { toggleIsChecked(deleteCheckbox, data.id) true } - } else { - setOnLongClickListener { - onItemSelectionChanged.invoke(data.id, true) - true - } } - } - downloadHeaderPoster.apply { - loadImage(data.poster) - if (isMultiDeleteState) { - setOnClickListener { - toggleIsChecked(deleteCheckbox, data.id) - } - } else { - setOnClickListener { - onHeaderClickEvent.invoke( - DownloadHeaderClickEvent( - DOWNLOAD_ACTION_LOAD_RESULT, - data + downloadHeaderPoster.apply { + loadImage(data.poster) + if (isMultiDeleteState) { + setOnClickListener { + toggleIsChecked(deleteCheckbox, data.id) + } + } else { + setOnClickListener { + onHeaderClickEvent.invoke( + DownloadHeaderClickEvent( + DOWNLOAD_ACTION_LOAD_RESULT, + data + ) ) - ) + } + } + + setOnLongClickListener { + toggleIsChecked(deleteCheckbox, data.id) + true } } + downloadHeaderTitle.text = data.name + val formattedSize = formatShortFileSize(itemView.context, card.totalBytes) - setOnLongClickListener { - toggleIsChecked(deleteCheckbox, data.id) - true - } - } - downloadHeaderTitle.text = data.name - val formattedSize = formatShortFileSize(binding.root.context, card.totalBytes) + if (card.child != null) { + handleChildDownload(card, formattedSize) + } else handleParentDownload(card, formattedSize) - if (card.child != null) { - handleChildDownload(card, formattedSize) - } else handleParentDownload(card, formattedSize) + if (isMultiDeleteState) { + deleteCheckbox.setOnCheckedChangeListener { _, isChecked -> + onItemSelectionChanged.invoke(data.id, isChecked) + } + } else deleteCheckbox.setOnCheckedChangeListener(null) - if (isMultiDeleteState) { - deleteCheckbox.setOnCheckedChangeListener { _, isChecked -> - onItemSelectionChanged.invoke(data.id, isChecked) - } - } else deleteCheckbox.setOnCheckedChangeListener(null) - - deleteCheckbox.apply { - isVisible = isMultiDeleteState - isChecked = card.isSelected - } - } - } - - private fun DownloadHeaderEpisodeBinding.handleChildDownload( - card: VisualDownloadCached.Header, - formattedSize: String - ) { - card.child ?: return - downloadHeaderGotoChild.isVisible = false - - val posDur = getViewPos(card.data.id) - watchProgressContainer.isVisible = true - downloadHeaderEpisodeProgress.apply { - isVisible = posDur != null - posDur?.let { - val max = (it.duration / 1000).toInt() - val progress = (it.position / 1000).toInt() - - if (max > 0 && progress >= (0.95 * max).toInt()) { - playIcon.setImageResource(R.drawable.ic_baseline_check_24) - isVisible = false - } else { - playIcon.setImageResource(R.drawable.netflix_play) - this.max = max - this.progress = progress - isVisible = true + deleteCheckbox.apply { + isVisible = isMultiDeleteState + isChecked = card.isSelected } } } - 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 - // so that we can use the value from the view model - // rather than extra unneeded disk operations and to prevent a - // delay in updating download icon state. - downloadButton.setProgress(card.currentBytes, card.totalBytes) - downloadButton.applyMetaData(card.child.id, card.currentBytes, card.totalBytes) - // We will let the view model handle this - downloadButton.doSetProgress = false - downloadButton.progressBar.progressDrawable = - downloadButton.getDrawableFromStatus(status) - ?.let { ContextCompat.getDrawable(downloadButton.context, it) } - downloadHeaderInfo.text = formattedSize - } else { - // We need to make sure we restore the correct progress - // when we refresh data in the adapter. - val drawable = downloadButton.getDrawableFromStatus(status)?.let { - ContextCompat.getDrawable(downloadButton.context, it) - } - downloadButton.statusView.setImageDrawable(drawable) - downloadButton.progressBar.progressDrawable = - ContextCompat.getDrawable( - downloadButton.context, - downloadButton.progressDrawable - ) - } + private fun DownloadHeaderEpisodeBinding.handleChildDownload( + card: VisualDownloadCached.Header, + formattedSize: String + ) { + card.child ?: return + downloadHeaderGotoChild.isVisible = false - downloadHeaderInfo.isVisible = true - downloadButton.setDefaultClickListener(card.child, downloadHeaderInfo, onItemClickEvent) - downloadButton.isVisible = !isMultiDeleteState - - if (!isMultiDeleteState) { - episodeHolder.setOnClickListener { - onItemClickEvent.invoke( - DownloadClickEvent( - DOWNLOAD_ACTION_PLAY_FILE, - card.child - ) - ) - } - } - } - - private fun DownloadHeaderEpisodeBinding.handleParentDownload( - card: VisualDownloadCached.Header, - formattedSize: String - ) { - downloadButton.resetViewData() - watchProgressContainer.isVisible = false - downloadButton.isVisible = false - downloadHeaderEpisodeProgress.isVisible = false - downloadHeaderGotoChild.isVisible = !isMultiDeleteState - - try { - downloadHeaderInfo.isVisible = true - downloadHeaderInfo.text = - downloadHeaderInfo.context.getString(R.string.extra_info_format).format( - card.totalDownloads, - downloadHeaderInfo.context.resources.getQuantityString( - R.plurals.episodes, - card.totalDownloads - ), - formattedSize - ) - } catch (e: Exception) { - downloadHeaderInfo.text = null - logError(e) - } - - if (!isMultiDeleteState) { - episodeHolder.setOnClickListener { - onHeaderClickEvent.invoke( - DownloadHeaderClickEvent( - DOWNLOAD_ACTION_GO_TO_CHILD, - card.data - ) - ) - } - } - } - - private fun bindChild(binding: ViewBinding, card: VisualDownloadCached.Child?) { - if (binding !is DownloadChildEpisodeBinding || card == null) return - - val data = card.data - binding.apply { - val posDur = getViewPos(data.id) - downloadChildEpisodeProgress.apply { + val posDur = getViewPos(card.data.id) + downloadHeaderEpisodeProgress.apply { isVisible = posDur != null posDur?.let { - val max = (it.duration / 1000).toInt() - val progress = (it.position / 1000).toInt() - - if (max > 0 && progress >= (0.95 * max).toInt()) { - downloadChildEpisodePlay.setImageResource(R.drawable.ic_baseline_check_24) - isVisible = false - } else { - downloadChildEpisodePlay.setImageResource(R.drawable.play_button_transparent) - this.max = max - this.progress = progress - isVisible = true - } + val visualPos = it.fixVisual() + max = (visualPos.duration / 1000).toInt() + progress = (visualPos.position / 1000).toInt() } } - downloadButton.resetView() - val status = downloadButton.getStatus(data.id, card.currentBytes, card.totalBytes) + 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 // so that we can use the value from the view model // rather than extra unneeded disk operations and to prevent a // delay in updating download icon state. downloadButton.setProgress(card.currentBytes, card.totalBytes) - downloadButton.applyMetaData(data.id, card.currentBytes, card.totalBytes) + downloadButton.applyMetaData(card.child.id, card.currentBytes, card.totalBytes) // We will let the view model handle this downloadButton.doSetProgress = false downloadButton.progressBar.progressDrawable = downloadButton.getDrawableFromStatus(status) ?.let { ContextCompat.getDrawable(downloadButton.context, it) } - downloadChildEpisodeTextExtra.text = - formatShortFileSize(downloadChildEpisodeTextExtra.context, card.totalBytes) + downloadHeaderInfo.text = formattedSize } 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) } @@ -308,105 +195,199 @@ class DownloadAdapter( ) } - downloadButton.setDefaultClickListener( - data, - downloadChildEpisodeTextExtra, - onItemClickEvent - ) + downloadButton.setDefaultClickListener(card.child, downloadHeaderInfo, onItemClickEvent) downloadButton.isVisible = !isMultiDeleteState - downloadChildEpisodeText.apply { - text = context.getNameFull(data.name, data.episode, data.season) - isSelected = true // Needed for text repeating + if (!isMultiDeleteState) { + episodeHolder.setOnClickListener { + onItemClickEvent.invoke( + DownloadClickEvent( + DOWNLOAD_ACTION_PLAY_FILE, + card.child + ) + ) + } + } + } + + private fun DownloadHeaderEpisodeBinding.handleParentDownload( + card: VisualDownloadCached.Header, + formattedSize: String + ) { + downloadButton.isVisible = false + downloadHeaderEpisodeProgress.isVisible = false + downloadHeaderGotoChild.isVisible = !isMultiDeleteState + + try { + downloadHeaderInfo.text = + downloadHeaderInfo.context.getString(R.string.extra_info_format).format( + card.totalDownloads, + downloadHeaderInfo.context.resources.getQuantityString( + R.plurals.episodes, + card.totalDownloads + ), + formattedSize + ) + } catch (e: Exception) { + downloadHeaderInfo.text = null + logError(e) } - downloadChildEpisodeHolder.setOnClickListener { - onItemClickEvent.invoke(DownloadClickEvent(DOWNLOAD_ACTION_PLAY_FILE, data)) + if (!isMultiDeleteState) { + episodeHolder.setOnClickListener { + onHeaderClickEvent.invoke( + DownloadHeaderClickEvent( + DOWNLOAD_ACTION_GO_TO_CHILD, + card.data + ) + ) + } } + } - downloadChildEpisodeHolder.apply { - when { - isMultiDeleteState -> { - setOnClickListener { - toggleIsChecked(deleteCheckbox, data.id) - } - setOnLongClickListener { - toggleIsChecked(deleteCheckbox, data.id) - true - } + private fun bindChild(card: VisualDownloadCached.Child?) { + if (binding !is DownloadChildEpisodeBinding || card == null) return + + val data = card.data + binding.apply { + val posDur = getViewPos(data.id) + downloadChildEpisodeProgress.apply { + isVisible = posDur != null + posDur?.let { + val visualPos = it.fixVisual() + max = (visualPos.duration / 1000).toInt() + progress = (visualPos.position / 1000).toInt() } + } - else -> { - setOnClickListener { - onItemClickEvent.invoke( - DownloadClickEvent( - DOWNLOAD_ACTION_PLAY_FILE, - data + val status = downloadButton.getStatus(data.id, card.currentBytes, card.totalBytes) + if (status == DownloadStatusTell.IsDone) { + // We do this here instead if we are finished downloading + // so that we can use the value from the view model + // rather than extra unneeded disk operations and to prevent a + // delay in updating download icon state. + downloadButton.setProgress(card.currentBytes, card.totalBytes) + downloadButton.applyMetaData(data.id, card.currentBytes, card.totalBytes) + // We will let the view model handle this + downloadButton.doSetProgress = false + downloadButton.progressBar.progressDrawable = + downloadButton.getDrawableFromStatus(status) + ?.let { ContextCompat.getDrawable(downloadButton.context, it) } + downloadChildEpisodeTextExtra.text = + formatShortFileSize(downloadChildEpisodeTextExtra.context, card.totalBytes) + } 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) + } + downloadButton.statusView.setImageDrawable(drawable) + downloadButton.progressBar.progressDrawable = + ContextCompat.getDrawable( + downloadButton.context, + downloadButton.progressDrawable + ) + } + + downloadButton.setDefaultClickListener( + data, + downloadChildEpisodeTextExtra, + onItemClickEvent + ) + downloadButton.isVisible = !isMultiDeleteState + + downloadChildEpisodeText.apply { + text = context.getNameFull(data.name, data.episode, data.season) + isSelected = true // Needed for text repeating + } + + downloadChildEpisodeHolder.setOnClickListener { + onItemClickEvent.invoke(DownloadClickEvent(DOWNLOAD_ACTION_PLAY_FILE, data)) + } + + downloadChildEpisodeHolder.apply { + when { + isMultiDeleteState -> { + setOnClickListener { + toggleIsChecked(deleteCheckbox, data.id) + } + } + + else -> { + setOnClickListener { + onItemClickEvent.invoke( + DownloadClickEvent( + DOWNLOAD_ACTION_PLAY_FILE, + data + ) ) - ) - } - - setOnLongClickListener { - onItemSelectionChanged.invoke(data.id, true) - true + } } } - } - } - if (isMultiDeleteState) { - deleteCheckbox.setOnCheckedChangeListener { _, isChecked -> - onItemSelectionChanged.invoke(data.id, isChecked) + setOnLongClickListener { + toggleIsChecked(deleteCheckbox, data.id) + true + } } - } else deleteCheckbox.setOnCheckedChangeListener(null) - deleteCheckbox.apply { - isVisible = isMultiDeleteState - isChecked = card.isSelected + if (isMultiDeleteState) { + deleteCheckbox.setOnCheckedChangeListener { _, isChecked -> + onItemSelectionChanged.invoke(data.id, isChecked) + } + } else deleteCheckbox.setOnCheckedChangeListener(null) + + deleteCheckbox.apply { + isVisible = isMultiDeleteState + isChecked = card.isSelected + } } } } - override fun onCreateCustomContent(parent: ViewGroup, viewType: Int): ViewHolderState { + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): DownloadViewHolder { val inflater = LayoutInflater.from(parent.context) val binding = when (viewType) { VIEW_TYPE_HEADER -> DownloadHeaderEpisodeBinding.inflate(inflater, parent, false) VIEW_TYPE_CHILD -> DownloadChildEpisodeBinding.inflate(inflater, parent, false) else -> throw IllegalArgumentException("Invalid view type") } - return ViewHolderState(binding) + return DownloadViewHolder(binding) } - override fun onBindContent( - holder: ViewHolderState, - item: VisualDownloadCached, - position: Int - ) { - when (val binding = holder.view) { - is DownloadHeaderEpisodeBinding -> bindHeader( - binding, - item as? VisualDownloadCached.Header - ) - - is DownloadChildEpisodeBinding -> bindChild( - binding, - item as? VisualDownloadCached.Child - ) - } + override fun onBindViewHolder(holder: DownloadViewHolder, position: Int) { + holder.bind(getItem(position)) } - override fun customContentViewType(item: VisualDownloadCached): Int { - return when (item) { + override fun getItemViewType(position: Int): Int { + return when (getItem(position)) { is VisualDownloadCached.Child -> VIEW_TYPE_CHILD is VisualDownloadCached.Header -> VIEW_TYPE_HEADER + else -> throw IllegalArgumentException("Invalid data type at position $position") } } - @SuppressLint("NotifyDataSetChanged") fun setIsMultiDeleteState(value: Boolean) { if (isMultiDeleteState == value) return isMultiDeleteState = value - notifyDataSetChanged() // This is shit, but what can you do? + notifyItemRangeChanged(0, itemCount) + } + + fun notifyAllSelected() { + currentList.indices.forEach { index -> + if (!currentList[index].isSelected) { + notifyItemChanged(index) + } + } + } + + fun notifySelectionStates() { + currentList.indices.forEach { index -> + if (currentList[index].isSelected) { + notifyItemChanged(index) + } + } } private fun toggleIsChecked(checkbox: CheckBox, itemId: Int) { 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..83e0d0167 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 @@ -4,8 +4,8 @@ import android.content.DialogInterface import android.net.Uri import androidx.appcompat.app.AlertDialog import com.google.android.material.snackbar.Snackbar -import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey -import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKeys +import com.lagradost.cloudstream3.AcraApplication.Companion.getKey +import com.lagradost.cloudstream3.AcraApplication.Companion.getKeys import com.lagradost.cloudstream3.CommonActivity.activity import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.mvvm.logError @@ -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 @@ -149,7 +141,7 @@ object DownloadButtonSetup { uri = Uri.EMPTY, id = it.id, parentId = it.parentId, - name = it.name ?: act.getString(R.string.downloaded_file), + name = act.getString(R.string.downloaded_file), season = it.season, episode = it.episode, headerName = parent.name, @@ -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/DownloadChildFragment.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadChildFragment.kt index d44ea0020..1f5b9e337 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadChildFragment.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadChildFragment.kt @@ -1,35 +1,32 @@ package com.lagradost.cloudstream3.ui.download import android.os.Bundle +import android.os.Handler +import android.os.Looper import android.text.format.Formatter.formatShortFileSize +import android.view.LayoutInflater import android.view.View -import androidx.core.view.isGone +import android.view.ViewGroup import androidx.core.view.isVisible -import androidx.fragment.app.activityViewModels +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModelProvider import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.databinding.FragmentChildDownloadsBinding -import com.lagradost.cloudstream3.mvvm.Resource 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.result.FOCUS_SELF import com.lagradost.cloudstream3.ui.result.setLinearListLayout 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.BackPressedCallbackHelper.attachBackPressedCallback import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.detachBackPressedCallback -import com.lagradost.cloudstream3.utils.UIHelper.fixSystemBarsPadding +import com.lagradost.cloudstream3.utils.UIHelper.fixPaddingStatusbar import com.lagradost.cloudstream3.utils.UIHelper.setAppBarNoScrollFlagsOnTV -class DownloadChildFragment : BaseFragment( - BaseFragment.BindingCreator.Inflate(FragmentChildDownloadsBinding::inflate) -) { - - private val downloadViewModel: DownloadViewModel by activityViewModels() +class DownloadChildFragment : Fragment() { + private lateinit var downloadsViewModel: DownloadViewModel + private var binding: FragmentChildDownloadsBinding? = null companion object { fun newInstance(headerName: String, folder: String): Bundle { @@ -42,104 +39,99 @@ class DownloadChildFragment : BaseFragment( override fun onDestroyView() { activity?.detachBackPressedCallback("Downloads") - downloadViewModel.clearChildren() + binding = null super.onDestroyView() } - override fun fixLayout(view: View) { - fixSystemBarsPadding( - view, - padBottom = isLandscape(), - padLeft = isLayout(TV or EMULATOR) - ) + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + downloadsViewModel = ViewModelProvider(this)[DownloadViewModel::class.java] + val localBinding = FragmentChildDownloadsBinding.inflate(inflater, container, false) + binding = localBinding + return localBinding.root } - override fun onBindingCreated(binding: FragmentChildDownloadsBinding) { + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + /** + * We never want to retain multi-delete state + * when navigating to downloads. Setting this state + * immediately can sometimes result in the observer + * not being notified in time to update the UI. + * + * By posting to the main looper, we ensure that this + * operation is executed after the view has been fully created + * and all initializations are completed, allowing the + * observer to properly receive and handle the state change. + */ + Handler(Looper.getMainLooper()).post { + downloadsViewModel.setIsMultiDeleteState(false) + } + + /** + * We have to make sure selected items are + * cleared here as well so we don't run in an + * inconsistent state where selected items do + * not match the multi delete state we are in. + */ + downloadsViewModel.clearSelectedItems() + val folder = arguments?.getString("folder") val name = arguments?.getString("name") if (folder == null) { - dispatchBackPressed() + activity?.onBackPressedDispatcher?.onBackPressed() return } - context?.let { downloadViewModel.updateChildList(it, folder) } - - binding.downloadChildToolbar.apply { + binding?.downloadChildToolbar?.apply { title = name if (isLayout(PHONE or EMULATOR)) { setNavigationIcon(R.drawable.ic_baseline_arrow_back_24) setNavigationOnClickListener { - dispatchBackPressed() + activity?.onBackPressedDispatcher?.onBackPressed() } } setAppBarNoScrollFlagsOnTV() } - binding.downloadDeleteAppbar.setAppBarNoScrollFlagsOnTV() + binding?.downloadDeleteAppbar?.setAppBarNoScrollFlagsOnTV() - observe(downloadViewModel.childCards) { cards -> - when (cards) { - is Resource.Success -> { - if (cards.value.isEmpty()) { - dispatchBackPressed() - } - (binding.downloadChildList.adapter as? DownloadAdapter)?.submitList(cards.value) - } - - else -> { - (binding.downloadChildList.adapter as? DownloadAdapter)?.submitList(null) - } + observe(downloadsViewModel.childCards) { + if (it.isEmpty()) { + activity?.onBackPressedDispatcher?.onBackPressed() + return@observe } + + (binding?.downloadChildList?.adapter as? DownloadAdapter)?.submitList(it) } - - observe(downloadViewModel.selectedBytes) { - updateDeleteButton(downloadViewModel.selectedItemIds.value?.count() ?: 0, it) - } - - - binding.apply { - btnDelete.setOnClickListener { view -> - downloadViewModel.handleMultiDelete(view.context ?: return@setOnClickListener) - } - - btnCancel.setOnClickListener { - downloadViewModel.cancelSelection() - } - - btnToggleAll.setOnClickListener { - val allSelected = downloadViewModel.isAllChildrenSelected() - if (allSelected) { - downloadViewModel.clearSelectedItems() - } else { - downloadViewModel.selectAllChildren() - } - } - } - - observeNullable(downloadViewModel.selectedItemIds) { selection -> - val isMultiDeleteState = selection != null - val adapter = binding.downloadChildList.adapter as? DownloadAdapter + observe(downloadsViewModel.isMultiDeleteState) { isMultiDeleteState -> + val adapter = binding?.downloadChildList?.adapter as? DownloadAdapter adapter?.setIsMultiDeleteState(isMultiDeleteState) - binding.downloadDeleteAppbar.isVisible = isMultiDeleteState - binding.downloadChildToolbar.isGone = isMultiDeleteState - - if (selection == null) { + binding?.downloadDeleteAppbar?.isVisible = isMultiDeleteState + if (!isMultiDeleteState) { activity?.detachBackPressedCallback("Downloads") - return@observeNullable - } - activity?.attachBackPressedCallback("Downloads") { - downloadViewModel.cancelSelection() + downloadsViewModel.clearSelectedItems() + binding?.downloadChildToolbar?.isVisible = true } + } + observe(downloadsViewModel.selectedBytes) { + updateDeleteButton(downloadsViewModel.selectedItemIds.value?.count() ?: 0, it) + } + observe(downloadsViewModel.selectedItemIds) { + handleSelectedChange(it) + updateDeleteButton(it.count(), downloadsViewModel.selectedBytes.value ?: 0L) - updateDeleteButton(selection.count(), downloadViewModel.selectedBytes.value ?: 0L) + binding?.btnDelete?.isVisible = it.isNotEmpty() + binding?.selectItemsText?.isVisible = it.isEmpty() - binding.btnDelete.isVisible = selection.isNotEmpty() - binding.selectItemsText.isVisible = selection.isEmpty() - - val allSelected = downloadViewModel.isAllChildrenSelected() + val allSelected = downloadsViewModel.isAllSelected() if (allSelected) { - binding.btnToggleAll.setText(R.string.deselect_all) - } else binding.btnToggleAll.setText(R.string.select_all) + binding?.btnToggleAll?.setText(R.string.deselect_all) + } else binding?.btnToggleAll?.setText(R.string.select_all) } val adapter = DownloadAdapter( @@ -147,18 +139,18 @@ class DownloadChildFragment : BaseFragment( { click -> if (click.action == DOWNLOAD_ACTION_DELETE_FILE) { context?.let { ctx -> - downloadViewModel.handleSingleDelete(ctx, click.data.id) + downloadsViewModel.handleSingleDelete(ctx, click.data.id) } } else handleDownloadClick(click) }, { itemId, isChecked -> if (isChecked) { - downloadViewModel.addSelected(itemId) - } else downloadViewModel.removeSelected(itemId) + downloadsViewModel.addSelected(itemId) + } else downloadsViewModel.removeSelected(itemId) } ) - binding.downloadChildList.apply { + binding?.downloadChildList?.apply { setHasFixedSize(true) setItemViewCacheSize(20) this.adapter = adapter @@ -168,6 +160,43 @@ class DownloadChildFragment : BaseFragment( nextDown = FOCUS_SELF, ) } + + context?.let { downloadsViewModel.updateChildList(it, folder) } + fixPaddingStatusbar(binding?.downloadChildRoot) + } + + private fun handleSelectedChange(selected: MutableSet) { + if (selected.isNotEmpty()) { + binding?.downloadDeleteAppbar?.isVisible = true + binding?.downloadChildToolbar?.isVisible = false + activity?.attachBackPressedCallback("Downloads") { + downloadsViewModel.setIsMultiDeleteState(false) + } + + binding?.btnDelete?.setOnClickListener { + context?.let { ctx -> + downloadsViewModel.handleMultiDelete(ctx) + } + } + + binding?.btnCancel?.setOnClickListener { + downloadsViewModel.setIsMultiDeleteState(false) + } + + binding?.btnToggleAll?.setOnClickListener { + val allSelected = downloadsViewModel.isAllSelected() + val adapter = binding?.downloadChildList?.adapter as? DownloadAdapter + if (allSelected) { + adapter?.notifySelectionStates() + downloadsViewModel.clearSelectedItems() + } else { + adapter?.notifyAllSelected() + downloadsViewModel.selectAllItems() + } + } + + downloadsViewModel.setIsMultiDeleteState(true) + } } private fun updateDeleteButton(count: Int, selectedBytes: Long) { 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..2010fe7e3 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 @@ -7,8 +7,13 @@ import android.content.Context import android.content.Intent import android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION import android.os.Build +import android.os.Bundle +import android.os.Handler +import android.os.Looper import android.text.format.Formatter.formatShortFileSize +import android.view.LayoutInflater import android.view.View +import android.view.ViewGroup import android.widget.LinearLayout import android.widget.TextView import android.widget.Toast @@ -17,28 +22,23 @@ import androidx.annotation.StringRes import androidx.core.view.isGone import androidx.core.view.isVisible import androidx.core.widget.doOnTextChanged -import androidx.fragment.app.activityViewModels +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModelProvider import com.lagradost.cloudstream3.CommonActivity.showToast import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.databinding.FragmentDownloadsBinding import com.lagradost.cloudstream3.databinding.StreamInputBinding import com.lagradost.cloudstream3.isEpisodeBased -import com.lagradost.cloudstream3.mvvm.Resource import com.lagradost.cloudstream3.mvvm.safe 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 import com.lagradost.cloudstream3.ui.player.OfflinePlaybackHelper.playUri import com.lagradost.cloudstream3.ui.result.FOCUS_SELF import com.lagradost.cloudstream3.ui.result.setLinearListLayout -import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR 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.AppContextUtils.loadResult import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.attachBackPressedCallback @@ -46,7 +46,7 @@ import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.detachBackPres import com.lagradost.cloudstream3.utils.DOWNLOAD_EPISODE_CACHE import com.lagradost.cloudstream3.utils.DataStore.getFolderName import com.lagradost.cloudstream3.utils.UIHelper.dismissSafe -import com.lagradost.cloudstream3.utils.UIHelper.fixSystemBarsPadding +import com.lagradost.cloudstream3.utils.UIHelper.fixPaddingStatusbar import com.lagradost.cloudstream3.utils.UIHelper.hideKeyboard import com.lagradost.cloudstream3.utils.UIHelper.navigate import com.lagradost.cloudstream3.utils.UIHelper.setAppBarNoScrollFlagsOnTV @@ -54,12 +54,9 @@ import java.net.URI const val DOWNLOAD_NAVIGATE_TO = "downloadpage" -class DownloadFragment : BaseFragment( - BaseFragment.BindingCreator.Inflate(FragmentDownloadsBinding::inflate) -) { - - private val downloadViewModel: DownloadViewModel by activityViewModels() - private val downloadQueueViewModel: DownloadQueueViewModel by activityViewModels() +class DownloadFragment : Fragment() { + private lateinit var downloadsViewModel: DownloadViewModel + private var binding: FragmentDownloadsBinding? = null private fun View.setLayoutWidth(weight: Long) { val param = LinearLayout.LayoutParams( @@ -72,135 +69,120 @@ class DownloadFragment : BaseFragment( override fun onDestroyView() { activity?.detachBackPressedCallback("Downloads") + binding = null super.onDestroyView() } - override fun fixLayout(view: View) { - fixSystemBarsPadding( - view, - padBottom = isLandscape(), - padLeft = isLayout(TV or EMULATOR) - ) + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + downloadsViewModel = ViewModelProvider(this)[DownloadViewModel::class.java] + val localBinding = FragmentDownloadsBinding.inflate(inflater, container, false) + binding = localBinding + return localBinding.root } - override fun onBindingCreated(binding: FragmentDownloadsBinding) { + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) hideKeyboard() - binding.downloadAppbar.setAppBarNoScrollFlagsOnTV() - binding.downloadDeleteAppbar.setAppBarNoScrollFlagsOnTV() + binding?.downloadAppbar?.setAppBarNoScrollFlagsOnTV() + binding?.downloadDeleteAppbar?.setAppBarNoScrollFlagsOnTV() - observe(downloadViewModel.headerCards) { cards -> - when (cards) { - is Resource.Success -> { - (binding.downloadList.adapter as? DownloadAdapter)?.submitList(cards.value) - binding.textNoDownloads.isVisible = cards.value.isEmpty() - binding.downloadLoading.isVisible = false - binding.downloadList.isVisible = true - } - - is Resource.Loading -> { - binding.downloadList.isVisible = false - binding.downloadLoading.isVisible = true - } - - is Resource.Failure -> { - binding.downloadList.isVisible = true - binding.downloadLoading.isVisible = false - } - } + /** + * We never want to retain multi-delete state + * when navigating to downloads. Setting this state + * immediately can sometimes result in the observer + * not being notified in time to update the UI. + * + * By posting to the main looper, we ensure that this + * operation is executed after the view has been fully created + * and all initializations are completed, allowing the + * observer to properly receive and handle the state change. + */ + Handler(Looper.getMainLooper()).post { + downloadsViewModel.setIsMultiDeleteState(false) } - observe(downloadViewModel.availableBytes) { + /** + * We have to make sure selected items are + * cleared here as well so we don't run in an + * inconsistent state where selected items do + * not match the multi delete state we are in. + */ + downloadsViewModel.clearSelectedItems() + + observe(downloadsViewModel.headerCards) { + (binding?.downloadList?.adapter as? DownloadAdapter)?.submitList(it) + binding?.downloadLoading?.isVisible = false + binding?.textNoDownloads?.isVisible = it.isEmpty() + } + observe(downloadsViewModel.availableBytes) { updateStorageInfo( - binding.root.context, + view.context, it, R.string.free_storage, - binding.downloadFreeTxt, - binding.downloadFree + binding?.downloadFreeTxt, + binding?.downloadFree ) } - observe(downloadViewModel.usedBytes) { + observe(downloadsViewModel.usedBytes) { updateStorageInfo( - binding.root.context, + view.context, it, R.string.used_storage, - binding.downloadUsedTxt, - binding.downloadUsed + binding?.downloadUsedTxt, + binding?.downloadUsed ) val hasBytes = it > 0 - if (hasBytes) { - binding.downloadLoadingBytes.stopShimmer() - } else binding.downloadLoadingBytes.startShimmer() + if(hasBytes) { + binding?.downloadLoadingBytes?.stopShimmer() + } else { + binding?.downloadLoadingBytes?.startShimmer() + } - binding.downloadBytesBar.isVisible = hasBytes - binding.downloadLoadingBytes.isGone = hasBytes + binding?.downloadBytesBar?.isVisible = hasBytes + binding?.downloadLoadingBytes?.isGone = hasBytes } - observe(downloadViewModel.downloadBytes) { + observe(downloadsViewModel.downloadBytes) { updateStorageInfo( - binding.root.context, + view.context, it, R.string.app_storage, - binding.downloadAppTxt, - binding.downloadApp + binding?.downloadAppTxt, + 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(downloadsViewModel.selectedBytes) { + updateDeleteButton(downloadsViewModel.selectedItemIds.value?.count() ?: 0, it) } - - observe(downloadViewModel.selectedBytes) { - updateDeleteButton(downloadViewModel.selectedItemIds.value?.count() ?: 0, it) - } - - binding.apply { - btnDelete.setOnClickListener { view -> - downloadViewModel.handleMultiDelete(view.context ?: return@setOnClickListener) - } - - btnCancel.setOnClickListener { - downloadViewModel.cancelSelection() - } - - btnToggleAll.setOnClickListener { - val allSelected = downloadViewModel.isAllHeadersSelected() - if (allSelected) { - downloadViewModel.clearSelectedItems() - } else { - downloadViewModel.selectAllHeaders() + observe(downloadsViewModel.isMultiDeleteState) { isMultiDeleteState -> + val adapter = binding?.downloadList?.adapter as? DownloadAdapter + adapter?.setIsMultiDeleteState(isMultiDeleteState) + binding?.downloadDeleteAppbar?.isVisible = isMultiDeleteState + if (!isMultiDeleteState) { + activity?.detachBackPressedCallback("Downloads") + downloadsViewModel.clearSelectedItems() + // Prevent race condition and make sure + // we don't display it early + if (downloadsViewModel.usedBytes.value?.let { it > 0 } == true) { + binding?.downloadAppbar?.isVisible = true } } } + observe(downloadsViewModel.selectedItemIds) { + handleSelectedChange(it) + updateDeleteButton(it.count(), downloadsViewModel.selectedBytes.value ?: 0L) - observeNullable(downloadViewModel.selectedItemIds) { selection -> - val isMultiDeleteState = selection != null - val adapter = binding.downloadList.adapter as? DownloadAdapter - adapter?.setIsMultiDeleteState(isMultiDeleteState) - binding.downloadDeleteAppbar.isVisible = isMultiDeleteState - binding.downloadAppbar.isGone = isMultiDeleteState + binding?.btnDelete?.isVisible = it.isNotEmpty() + binding?.selectItemsText?.isVisible = it.isEmpty() - if (selection == null) { - activity?.detachBackPressedCallback("Downloads") - return@observeNullable - } - activity?.attachBackPressedCallback("Downloads") { - downloadViewModel.cancelSelection() - } - updateDeleteButton(selection.count(), downloadViewModel.selectedBytes.value ?: 0L) - - binding.btnDelete.isVisible = selection.isNotEmpty() - binding.selectItemsText.isVisible = selection.isEmpty() - - val allSelected = downloadViewModel.isAllHeadersSelected() + val allSelected = downloadsViewModel.isAllSelected() if (allSelected) { - binding.btnToggleAll.setText(R.string.deselect_all) - } else binding.btnToggleAll.setText(R.string.select_all) + binding?.btnToggleAll?.setText(R.string.deselect_all) + } else binding?.btnToggleAll?.setText(R.string.select_all) } val adapter = DownloadAdapter( @@ -208,29 +190,29 @@ class DownloadFragment : BaseFragment( { click -> if (click.action == DOWNLOAD_ACTION_DELETE_FILE) { context?.let { ctx -> - downloadViewModel.handleSingleDelete(ctx, click.data.id) + downloadsViewModel.handleSingleDelete(ctx, click.data.id) } } else handleDownloadClick(click) }, { itemId, isChecked -> if (isChecked) { - downloadViewModel.addSelected(itemId) - } else downloadViewModel.removeSelected(itemId) + downloadsViewModel.addSelected(itemId) + } else downloadsViewModel.removeSelected(itemId) } ) - binding.downloadList.apply { + binding?.downloadList?.apply { setHasFixedSize(true) setItemViewCacheSize(20) this.adapter = adapter setLinearListLayout( isHorizontal = false, nextRight = FOCUS_SELF, - nextDown = R.id.download_queue_button, + nextDown = FOCUS_SELF, ) } - binding.apply { + binding?.apply { openLocalVideoButton.apply { isGone = isLayout(TV) setOnClickListener { openLocalVideo() } @@ -240,10 +222,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) @@ -252,12 +230,13 @@ class DownloadFragment : BaseFragment( } if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - binding.downloadList.setOnScrollChangeListener { _, _, scrollY, _, oldScrollY -> + binding?.downloadList?.setOnScrollChangeListener { _, _, scrollY, _, oldScrollY -> handleScroll(scrollY - oldScrollY) } } - context?.let { downloadViewModel.updateHeaderList(it) } + context?.let { downloadsViewModel.updateHeaderList(it) } + fixPaddingStatusbar(binding?.downloadRoot) } private fun handleItemClick(click: DownloadHeaderClickEvent) { @@ -279,6 +258,40 @@ class DownloadFragment : BaseFragment( } } + private fun handleSelectedChange(selected: MutableSet) { + if (selected.isNotEmpty()) { + binding?.downloadDeleteAppbar?.isVisible = true + binding?.downloadAppbar?.isVisible = false + activity?.attachBackPressedCallback("Downloads") { + downloadsViewModel.setIsMultiDeleteState(false) + } + + binding?.btnDelete?.setOnClickListener { + context?.let { ctx -> + downloadsViewModel.handleMultiDelete(ctx) + } + } + + binding?.btnCancel?.setOnClickListener { + downloadsViewModel.setIsMultiDeleteState(false) + } + + binding?.btnToggleAll?.setOnClickListener { + val allSelected = downloadsViewModel.isAllSelected() + val adapter = binding?.downloadList?.adapter as? DownloadAdapter + if (allSelected) { + adapter?.notifySelectionStates() + downloadsViewModel.clearSelectedItems() + } else { + adapter?.notifyAllSelected() + downloadsViewModel.selectAllItems() + } + } + + downloadsViewModel.setIsMultiDeleteState(true) + } + } + private fun updateDeleteButton(count: Int, selectedBytes: Long) { val formattedSize = formatShortFileSize(context, selectedBytes) binding?.btnDelete?.text = @@ -349,8 +362,7 @@ class DownloadFragment : BaseFragment( listOf(BasicLink(url)), extract = true, refererUrl = referer, - id = url.hashCode() - ), 0 + ) ) ) dialog.dismissSafe(activity) @@ -381,7 +393,7 @@ class DownloadFragment : BaseFragment( ActivityResultContracts.StartActivityForResult() ) { result -> if (result.resultCode != Activity.RESULT_OK) return@registerForActivityResult - val selectedVideoUri = result.data?.data ?: return@registerForActivityResult + val selectedVideoUri = result?.data?.data ?: return@registerForActivityResult playUri(activity ?: return@registerForActivityResult, selectedVideoUri) } } \ No newline at end of file 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..137f1355e 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,119 +5,91 @@ 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.MutableLiveData 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 + private val _headerCards = MutableLiveData>() + val headerCards: LiveData> = _headerCards - private val _childCards = ResourceLiveData>(Resource.Loading()) - val childCards: LiveData>> = _childCards + private val _childCards = MutableLiveData>() + val childCards: LiveData> = _childCards - private val _usedBytes = ConsistentLiveData() + private val _usedBytes = MutableLiveData() val usedBytes: LiveData = _usedBytes - private val _availableBytes = ConsistentLiveData() + private val _availableBytes = MutableLiveData() val availableBytes: LiveData = _availableBytes - private val _downloadBytes = ConsistentLiveData() + private val _downloadBytes = MutableLiveData() val downloadBytes: LiveData = _downloadBytes - private val _selectedBytes = ConsistentLiveData(0) + private val _selectedBytes = MutableLiveData(0) val selectedBytes: LiveData = _selectedBytes - private val _selectedItemIds = ConsistentLiveData?>(null) - val selectedItemIds: LiveData?> = _selectedItemIds + private val _isMultiDeleteState = MutableLiveData(false) + val isMultiDeleteState: LiveData = _isMultiDeleteState + private val _selectedItemIds = MutableLiveData>(mutableSetOf()) + val selectedItemIds: LiveData> = _selectedItemIds - fun cancelSelection() { - updateSelectedItems { null } + private var previousVisual: List? = null + + fun setIsMultiDeleteState(value: Boolean) { + _isMultiDeleteState.postValue(value) } fun addSelected(itemId: Int) { - updateSelectedItems { it?.plus(itemId) ?: setOf(itemId) } + updateSelectedItems { it.add(itemId) } } fun removeSelected(itemId: Int) { - updateSelectedItems { it?.minus(itemId) ?: emptySet() } + updateSelectedItems { it.remove(itemId) } } - fun selectAllHeaders() { - updateSelectedItems { - _headerCards.success.orEmpty() - .map { item -> item.data.id }.toSet() - } - } - - fun selectAllChildren() { - updateSelectedItems { - _childCards.success.orEmpty() - .map { item -> item.data.id }.toSet() - } + fun selectAllItems() { + val items = headerCards.value.orEmpty() + childCards.value.orEmpty() + updateSelectedItems { it.addAll(items.map { item -> item.data.id }) } } fun clearSelectedItems() { // We need this to be done immediately // so we can't use postValue - updateSelectedItems { emptySet() } + _selectedItemIds.value = mutableSetOf() + updateSelectedItems { it.clear() } } - fun isAllChildrenSelected(): Boolean { + fun isAllSelected(): Boolean { val currentSelected = selectedItemIds.value ?: return false - val children = _childCards.success.orEmpty() - return currentSelected.size == children.size && children.all { it.data.id in currentSelected } + val items = headerCards.value.orEmpty() + childCards.value.orEmpty() + return items.count() == currentSelected.count() && items.all { it.data.id in currentSelected } } - fun isAllHeadersSelected(): Boolean { - val currentSelected = selectedItemIds.value ?: return false - val headers = _headerCards.success.orEmpty() - return currentSelected.size == headers.size && headers.all { it.data.id in currentSelected } - } - - private fun updateSelectedItems(action: (Set?) -> Set?) { - val currentSelected = action(selectedItemIds.value) + private fun updateSelectedItems(action: (MutableSet) -> Unit) { + val currentSelected = selectedItemIds.value ?: mutableSetOf() + action(currentSelected) _selectedItemIds.postValue(currentSelected) - postHeaders() - postChildren() updateSelectedBytes() + updateSelectedCards() } private fun updateSelectedBytes() = viewModelScope.launchSafe { @@ -126,173 +98,61 @@ class DownloadViewModel : ViewModel() { _selectedBytes.postValue(totalSelectedBytes) } + private fun updateSelectedCards() = viewModelScope.launchSafe { + val currentSelected = selectedItemIds.value ?: return@launchSafe - 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) - } + headerCards.value?.let { headers -> + headers.forEach { header -> + header.isSelected = header.data.id in currentSelected } + _headerCards.postValue(headers) } - } - 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) - } - } + childCards.value?.let { children -> + children.forEach { child -> + child.isSelected = child.data.id in currentSelected } + _childCards.postValue(children) } } 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 ) } - updateStorageStats(visual) - postHeaders(visual) + if (visual != previousVisual) { + previousVisual = visual + updateStorageStats(visual) + _headerCards.postValue(visual) + } } - fun postHeaders(newValue: List? = null) { - val newValue = newValue ?: _headerCards.success ?: return - val selection = selectedItemIds.value ?: emptySet() - _headerCards.postValue(Resource.Success(newValue.map { - it.copy( - isSelected = selection.contains( - it.data.id - ) - ) - })) - } - - fun postChildren(newValue: List? = null) { - val newValue = newValue ?: _childCards.success ?: return - val selection = selectedItemIds.value ?: emptySet() - _childCards.postValue(Resource.Success(newValue.map { - it.copy( - isSelected = selection.contains( - it.data.id - ) - ) - })) - } - - 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 +162,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,17 +176,13 @@ 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( - DOWNLOAD_EPISODE_CACHE, - getFolderName(it.id.toString(), it.id.toString()) - ) + val movieEpisode = if (it.type.isEpisodeBased()) null else context.getKey( + DOWNLOAD_EPISODE_CACHE, + getFolderName(it.id.toString(), it.id.toString()) + ) VisualDownloadCached.Header( currentBytes = currentBytes, @@ -357,14 +208,12 @@ class DownloadViewModel : ViewModel() { } fun updateChildList(context: Context, folder: String) = viewModelScope.launchSafe { - _childCards.postValue(Resource.Loading()) // always push loading - 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, @@ -372,21 +221,24 @@ class DownloadViewModel : ViewModel() { data = it, ) } - }.sortedWith( - compareBy( - // Sort by season first, and then by episode number, - // to ensure sorting is consistent. - { it.data.season ?: 0 }, - { it.data.episode } - )) + }.sortedWith(compareBy( + // Sort by season first, and then by episode number, + // to ensure sorting is consistent. + { it.data.season ?: 0 }, + { it.data.episode } + )) - postChildren(visual) + if (previousVisual != visual) { + previousVisual = visual + _childCards.postValue(visual) + } } private fun removeItems(idsToRemove: Set) = viewModelScope.launchSafe { - _selectedItemIds.postValue(null) - postHeaders(_headerCards.success?.filter { it.data.id !in idsToRemove }) - postChildren(_childCards.success?.filter { it.data.id !in idsToRemove }) + val updatedHeaders = headerCards.value.orEmpty().filter { it.data.id !in idsToRemove } + val updatedChildren = childCards.value.orEmpty().filter { it.data.id !in idsToRemove } + _headerCards.postValue(updatedHeaders) + _childCards.postValue(updatedChildren) } private fun updateStorageStats(visual: List) { @@ -440,7 +292,7 @@ class DownloadViewModel : ViewModel() { if (item.data.type.isEpisodeBased()) { val episodes = context.getKeys(DOWNLOAD_EPISODE_CACHE) .mapNotNull { - context.getKey( + context.getKey( it ) } @@ -464,7 +316,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() ) @@ -493,16 +345,16 @@ class DownloadViewModel : ViewModel() { .joinToString(separator = "\n") { "• $it" } return when { - data.seriesNames.isNotEmpty() && data.names.isEmpty() -> { - context.getString(R.string.delete_message_series_only).format(formattedSeriesNames) - } - data.ids.count() == 1 -> { context.getString(R.string.delete_message).format( data.names.firstOrNull() ) } + data.seriesNames.isNotEmpty() && data.names.isEmpty() -> { + context.getString(R.string.delete_message_series_only).format(formattedSeriesNames) + } + data.parentName != null && data.names.isNotEmpty() -> { context.getString(R.string.delete_message_series_episodes) .format(data.parentName, formattedNames) @@ -531,6 +383,7 @@ class DownloadViewModel : ViewModel() { when (which) { DialogInterface.BUTTON_POSITIVE -> { viewModelScope.launchSafe { + setIsMultiDeleteState(false) deleteFilesAndUpdateSettings(context, ids, this) { successfulIds -> // We always remove parent because if we are deleting from here // and we have it as non-empty, it was triggered on @@ -561,8 +414,8 @@ class DownloadViewModel : ViewModel() { } private fun getSelectedItemsData(): List? { - val headers = _headerCards.success.orEmpty() - val children = _childCards.success.orEmpty() + val headers = headerCards.value.orEmpty() + val children = childCards.value.orEmpty() return selectedItemIds.value?.mapNotNull { id -> headers.find { it.data.id == id } ?: children.find { it.data.id == id } @@ -570,11 +423,10 @@ class DownloadViewModel : ViewModel() { } private fun getItemDataFromId(itemId: Int): List { - return (_headerCards.success.orEmpty() + _childCards.success.orEmpty()).filter { it.data.id == itemId } - } + val headers = headerCards.value.orEmpty() + val children = childCards.value.orEmpty() - fun clearChildren() { - _childCards.postValue(Resource.Loading()) + return (headers + children).filter { it.data.id == itemId } } private data class DeleteData( 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..908e3a80a 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 @@ -62,7 +62,6 @@ abstract class BaseFetchButton(context: Context, attributeSet: AttributeSet) : open fun resetViewData() { // lastRequest = null - progressText = null isZeroBytes = true doSetProgress = true persistentId = null @@ -76,10 +75,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 +86,7 @@ abstract class BaseFetchButton(context: Context, attributeSet: AttributeSet) : setProgress(downloadedBytes, totalBytes) applyMetaData(id, downloadedBytes, totalBytes) - } + } else run { resetView() } } } } @@ -216,4 +215,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..29c2daa2c 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.AcraApplication.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..ae22afdb2 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 @@ -1,10 +1,9 @@ package com.lagradost.cloudstream3.ui.home -import android.content.Context import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import android.widget.FrameLayout +import androidx.fragment.app.Fragment import androidx.preference.PreferenceManager import androidx.viewbinding.ViewBinding import com.lagradost.cloudstream3.R @@ -14,9 +13,7 @@ import com.lagradost.cloudstream3.databinding.HomeRemoveGridExpandedBinding import com.lagradost.cloudstream3.databinding.HomeResultGridBinding 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 @@ -44,11 +41,13 @@ class HomeScrollViewHolderState(view: ViewBinding) : ViewHolderState(vi } class ResumeItemAdapter( + fragment: Fragment, nextFocusUp: Int? = null, nextFocusDown: Int? = null, clickCallback: (SearchClickCallback) -> Unit, private val removeCallback: (View) -> Unit, ) : HomeChildItemAdapter( + fragment = fragment, id = "resumeAdapter".hashCode(), nextFocusUp = nextFocusUp, nextFocusDown = nextFocusDown, @@ -68,32 +67,20 @@ class ResumeItemAdapter( return HomeScrollViewHolderState(binding) } - override fun onClearView(holder: ViewHolderState) { - // Clear the image, idk if this saves ram or not, but I guess? - clearImage(holder.view.root.findViewById(R.id.imageView)) - } - override fun onBindFooter(holder: ViewHolderState) { this.applyBinding(holder, false) - when (val binding = holder.view) { - is HomeRemoveGridBinding -> { - updateLayoutParms(binding.backgroundCard, setWidth, setHeight) - } - - is HomeRemoveGridExpandedBinding -> { - updateLayoutParms(binding.backgroundCard, setWidth, setHeight) - } - } holder.itemView.apply { if (isLayout(TV)) { isFocusableInTouchMode = true isFocusable = true } - nextFocusUp?.let { - nextFocusUpId = it + + if (nextFocusUp != null) { + nextFocusUpId = nextFocusUp } - nextFocusDown?.let { - nextFocusDownId = it + + if (nextFocusDown != null) { + nextFocusDownId = nextFocusDown } setOnClickListener { v -> @@ -103,49 +90,16 @@ class ResumeItemAdapter( } } -/** Remember to set `updatePosterSize` to cache the poster size, - * otherwise the width and height is unset */ open class HomeChildItemAdapter( + fragment: Fragment, id: Int, - var nextFocusUp: Int? = null, - var nextFocusDown: Int? = null, - var clickCallback: (SearchClickCallback) -> Unit, + protected val nextFocusUp: Int? = null, + protected val nextFocusDown: Int? = null, + private val clickCallback: (SearchClickCallback) -> Unit, ) : - BaseAdapter( - id, diffCallback = BaseDiffCallback( - itemSame = { a, b -> - a.url == b.url && a.name == b.name - }, - contentSame = { a, b -> - a == b - }) - ) { - var hasNext: Boolean = false + BaseAdapter(fragment, id) { var isHorizontal: Boolean = false - set(value) { - field = value - updateCachedPosterSize() - } - - private fun updateCachedPosterSize() { - setWidth = if (!isHorizontal) { - minPosterSize - } else { - maxPosterSize - } - setHeight = if (!isHorizontal) { - maxPosterSize - } else { - minPosterSize - } - } - - init { - updateCachedPosterSize() - } - - protected var setWidth = 0 - protected var setHeight = 0 + var hasNext: Boolean = false override fun onCreateContent(parent: ViewGroup): ViewHolderState { val expanded = parent.context.isBottomLayout() @@ -158,43 +112,52 @@ open class HomeChildItemAdapter( return HomeScrollViewHolderState(binding) } - companion object { - // 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) } - - var minPosterSize: Int = 0 - var maxPosterSize: Int = 0 - - fun updatePosterSize(context: Context, value: Int? = null) { - val scale = value ?: PreferenceManager.getDefaultSharedPreferences(context) - ?.getInt(context.getString(R.string.poster_size_key), 0) ?: 0 - // Scale by +10% per step - val mul = 1.0f + scale * 0.1f - minPosterSize = (114.toPx.toFloat() * mul).toInt() - maxPosterSize = (180.toPx.toFloat() * mul).toInt() - } - - fun updateLayoutParms(layout: FrameLayout, width: Int, height: Int) { - val params = layout.layoutParams - if (params.height == height && params.width == width) return - - params.width = width - params.height = height - - layout.layoutParams = params - } - } - protected fun applyBinding(holder: ViewHolderState, isFirstItem: Boolean) { + val context = holder.view.root.context + val scale = PreferenceManager.getDefaultSharedPreferences(context) + ?.getInt(context.getString(R.string.poster_size_key), 0) ?: 0 + // Scale by +10% per step + val mul = 1.0f + scale * 0.1f + val min = (114.toPx.toFloat() * mul).toInt() + val max = (180.toPx.toFloat() * mul).toInt() + when (val binding = holder.view) { is HomeResultGridBinding -> { - updateLayoutParms(binding.backgroundCard, setWidth, setHeight) + binding.backgroundCard.apply { + + layoutParams = + layoutParams.apply { + width = if (!isHorizontal) { + min + } else { + max + } + height = if (!isHorizontal) { + max + } else { + min + } + } + } } is HomeResultGridExpandedBinding -> { - updateLayoutParms(binding.backgroundCard, setWidth, setHeight) + binding.backgroundCard.apply { + + layoutParams = + layoutParams.apply { + width = if (!isHorizontal) { + min + } else { + max + } + height = if (!isHorizontal) { + max + } else { + min + } + } + } if (isFirstItem) { // to fix tv binding.backgroundCard.nextFocusLeftId = R.id.nav_rail_view 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..35c7e1271 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 @@ -5,36 +5,26 @@ import android.app.Activity import android.content.Context import android.content.DialogInterface import android.content.Intent +import android.content.res.Configuration +import android.net.Uri import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import android.widget.AbsListView -import android.widget.ArrayAdapter -import android.widget.ImageView -import android.widget.ListView -import android.widget.TextView -import android.widget.Toast -import androidx.activity.ComponentActivity +import android.widget.* import androidx.appcompat.app.AlertDialog -import androidx.core.net.toUri import androidx.core.view.isGone -import androidx.core.view.isInvisible import androidx.core.view.isVisible +import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels import androidx.preference.PreferenceManager import androidx.recyclerview.widget.RecyclerView import com.google.android.material.bottomsheet.BottomSheetBehavior import com.google.android.material.bottomsheet.BottomSheetDialog import com.google.android.material.chip.Chip -import com.lagradost.api.Log +import com.lagradost.cloudstream3.* import com.lagradost.cloudstream3.APIHolder.apis -import com.lagradost.cloudstream3.AllLanguagesName import com.lagradost.cloudstream3.CommonActivity.showToast -import com.lagradost.cloudstream3.MainAPI -import com.lagradost.cloudstream3.R -import com.lagradost.cloudstream3.SearchResponse -import com.lagradost.cloudstream3.TvType import com.lagradost.cloudstream3.databinding.FragmentHomeBinding import com.lagradost.cloudstream3.databinding.HomeEpisodesExpandedBinding import com.lagradost.cloudstream3.databinding.HomeSelectMainpageBinding @@ -45,18 +35,13 @@ import com.lagradost.cloudstream3.mvvm.observe import com.lagradost.cloudstream3.mvvm.observeNullable import com.lagradost.cloudstream3.ui.APIRepository.Companion.noneApi import com.lagradost.cloudstream3.ui.APIRepository.Companion.randomApi -import com.lagradost.cloudstream3.ui.BaseFragment import com.lagradost.cloudstream3.ui.account.AccountHelper.showAccountSelectLinear -import com.lagradost.cloudstream3.ui.account.AccountViewModel -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.utils.txt +import com.lagradost.cloudstream3.ui.search.* 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 -import com.lagradost.cloudstream3.ui.settings.Globals.isLandscape import com.lagradost.cloudstream3.ui.settings.Globals.isLayout import com.lagradost.cloudstream3.utils.AppContextUtils.filterProviderByPreferredMedia import com.lagradost.cloudstream3.utils.AppContextUtils.getApiProviderLangSettings @@ -66,30 +51,22 @@ 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 +import com.lagradost.cloudstream3.utils.Event import com.lagradost.cloudstream3.utils.SubtitleHelper.getFlagFromIso -import com.lagradost.cloudstream3.utils.TvChannelUtils import com.lagradost.cloudstream3.utils.UIHelper.dismissSafe -import com.lagradost.cloudstream3.utils.UIHelper.fixSystemBarsPadding import com.lagradost.cloudstream3.utils.UIHelper.getSpanCount import com.lagradost.cloudstream3.utils.UIHelper.navigate import com.lagradost.cloudstream3.utils.UIHelper.popupMenuNoIconsAndNoStringRes -import com.lagradost.cloudstream3.utils.UIHelper.toPx +import java.util.* -private const val TAG = "HomeFragment" -class HomeFragment : BaseFragment( - BindingCreator.Bind(FragmentHomeBinding::bind) -) { +class HomeFragment : Fragment() { companion object { - // Used for configuration changed events to fix any popups that are not attached to a fragment - val configEvent = EmptyEvent() + val configEvent = Event() var currentSpan = 1 + val listHomepageItems = mutableListOf() private val errorProfilePics = listOf( R.drawable.monke_benene, @@ -118,7 +95,6 @@ class HomeFragment : BaseFragment( //} // returns a BottomSheetDialog that will be hidden with OwnHidden upon hide, and must be saved to be able call ownShow in onCreateView - fun Activity.loadHomepageList( expand: HomeViewModel.ExpandableHomepageList, deleteCallback: (() -> Unit)? = null, @@ -200,17 +176,16 @@ class HomeFragment : BaseFragment( // Span settings - binding.homeExpandedRecycler.spanCount = context.getSpanCount(item.isHorizontalImages) - binding.homeExpandedRecycler.setRecycledViewPool(SearchAdapter.sharedPool) + binding.homeExpandedRecycler.spanCount = currentSpan + binding.homeExpandedRecycler.adapter = - SearchAdapter(binding.homeExpandedRecycler,item.isHorizontalImages) { callback -> + SearchAdapter(item.list.toMutableList(), binding.homeExpandedRecycler) { callback -> handleSearchClickCallback(callback) if (callback.action == SEARCH_ACTION_LOAD || callback.action == SEARCH_ACTION_PLAY_FILE) { bottomSheetDialogBuilder.ownHide() // we hide here because we want to resume it later //bottomSheetDialogBuilder.dismissSafe(this) } }.apply { - submitList(item.list) hasNext = expand.hasNext } @@ -234,7 +209,7 @@ class HomeFragment : BaseFragment( expandCallback?.invoke(name)?.let { newExpand -> (recyclerView.adapter as? SearchAdapter?)?.apply { hasNext = newExpand.hasNext - submitList(newExpand.list.list) + updateList(newExpand.list.list) } } } @@ -242,12 +217,9 @@ class HomeFragment : BaseFragment( } }) - val spanListener = Runnable { - binding.homeExpandedRecycler.spanCount = context.getSpanCount(item.isHorizontalImages) - // We want to rebind everything to update the UI, however we also want to avoid - // any animations ect, this is the easiest way to do this, and the most correct - @SuppressLint("NotifyDataSetChanged") - binding.homeExpandedRecycler.adapter?.notifyDataSetChanged() + val spanListener = { span: Int -> + binding.homeExpandedRecycler.spanCount = span + //(recycle.adapter as SearchAdapter).notifyDataSetChanged() } configEvent += spanListener @@ -317,7 +289,7 @@ class HomeFragment : BaseFragment( val pairList = getPairList(header) for ((button, types) in pairList) { button?.isChecked = - button.isVisible && selectedTypes.any { types.contains(it) } + button?.isVisible == true && selectedTypes.any { types.contains(it) } } } @@ -411,23 +383,16 @@ class HomeFragment : BaseFragment( val listView = dialog.findViewById(R.id.listview1) - val arrayAdapter = object : ArrayAdapter( - this, R.layout.sort_bottom_single_provider_choice, + val arrayAdapter = object : ArrayAdapter(this, R.layout.sort_bottom_single_provider_choice, mutableListOf() ) { - override fun getView( - position: Int, - convertView: View?, - parent: ViewGroup - ): View { - val view = convertView ?: LayoutInflater.from(context) - .inflate(R.layout.sort_bottom_single_provider_choice, parent, false) + override fun getView(position: Int, convertView: View?, parent: ViewGroup): View { + val view = convertView ?: LayoutInflater.from(context).inflate(R.layout.sort_bottom_single_provider_choice, parent, false) val titleText = view.findViewById(R.id.text1) val pinIcon = view.findViewById(R.id.pinicon) val name = getItem(position) titleText?.text = name - val isPinned = - pinnedphashset.contains(currentValidApis[position].name) + val isPinned = pinnedphashset.contains(currentValidApis[position].name ?: "") pinIcon.visibility = if (isPinned) View.VISIBLE else View.GONE return view } @@ -439,7 +404,7 @@ class HomeFragment : BaseFragment( if (currentValidApis.isNotEmpty()) { currentApiName = currentValidApis[i].name //to switch to apply simply remove this - currentApiName.let(callback) + currentApiName?.let(callback) dialog.dismissSafe() } } @@ -450,11 +415,7 @@ class HomeFragment : BaseFragment( pinnedphashset = pinnedp.toHashSet() arrayAdapter.clear() val sortedApis = validAPIs - .filter { - it.hasMainPage && (pinnedphashset.contains(it.name) || it.supportedTypes.any( - preSelectedTypes::contains - )) - } + .filter {it.hasMainPage && (pinnedphashset.contains(it.name) || it.supportedTypes.any(preSelectedTypes::contains)) } .sortedBy { it.name.lowercase() } val sortedApiMap = LinkedHashMap().apply { @@ -482,12 +443,12 @@ class HomeFragment : BaseFragment( } // pin provider on hold listView?.setOnItemLongClickListener { _, _, i, _ -> - if (currentValidApis.isNotEmpty() && i > 1) { + if (currentValidApis.isNotEmpty() && i>1) { val pinnedp = DataStoreHelper.pinnedProviders.toMutableList() val thisapi = currentValidApis[i].name - if (pinnedp.contains(thisapi)) { + if(pinnedp.contains(thisapi)){ pinnedp.remove(thisapi) - } else { + }else{ pinnedp.add(thisapi) } DataStoreHelper.pinnedProviders = pinnedp.toTypedArray() @@ -511,71 +472,47 @@ class HomeFragment : BaseFragment( } private val homeViewModel: HomeViewModel by activityViewModels() - private val accountViewModel: AccountViewModel by activityViewModels() - fun addMovies(cards: List) { - val ctx = context ?: run { - Log.e(TAG, "Context is null, aborting addMovies") - return - } + var binding: FragmentHomeBinding? = null - try { - val existingId = TvChannelUtils.getChannelId(ctx, getString(R.string.app_name)) - if (existingId != null) { - Log.d(TAG, "Channel ID: $existingId") - - val programCards = cards - - TvChannelUtils.addPrograms( - context = ctx, - channelId = existingId, - items = programCards - ) - } else { - Log.d(TAG, "Channel does not exist") - } - } catch (e: Exception) { - Log.e(TAG, "Error adding movies: $e") - } - } - - private fun deleteAll() { - val ctx = context ?: run { - Log.e(TAG, "Context is null, aborting deleteAll") - return - } - - try { - val existingId = TvChannelUtils.getChannelId(ctx, getString(R.string.app_name)) - if (existingId != null) { - Log.d(TAG, "Channel ID: $existingId") - TvChannelUtils.deleteStoredPrograms(ctx) - } else { - Log.d(TAG, "Channel does not exist") - } - } catch (e: Exception) { - Log.e(TAG, "Error deleting programs: ${e.message}") - } - } - - override fun pickLayout(): Int? = - if (isLayout(PHONE)) R.layout.fragment_home else R.layout.fragment_home_tv override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View? { + //homeViewModel = + // ViewModelProvider(this).get(HomeViewModel::class.java) + bottomSheetDialog?.ownShow() - return super.onCreateView(inflater, container, savedInstanceState) + val layout = + if (isLayout(TV or EMULATOR)) R.layout.fragment_home_tv else R.layout.fragment_home + val root = inflater.inflate(layout, container, false) + binding = try { + FragmentHomeBinding.bind(root) + } catch (t: Throwable) { + showToast(txt(R.string.unable_to_inflate, t.message ?: ""), Toast.LENGTH_LONG) + logError(t) + null + } + + return root } override fun onDestroyView() { - (activity as? ComponentActivity)?.detachBackPressedCallback("HomeFragment_BackPress") + bottomSheetDialog?.ownHide() + binding = null super.onDestroyView() } + private fun fixGrid() { + activity?.getSpanCount()?.let { + currentSpan = it + } + configEvent.invoke(currentSpan) + } + private val apiChangeClickListener = View.OnClickListener { view -> view.context.selectHomepage(currentApiName) { api -> homeViewModel.loadAndCancel(api, forceReload = true, fromUI = true) @@ -589,129 +526,55 @@ class HomeFragment : BaseFragment( }*/ } + override fun onConfigurationChanged(newConfig: Configuration) { + super.onConfigurationChanged(newConfig) + //(home_preview_viewpager?.adapter as? HomeScrollAdapter)?.notifyDataSetChanged() + fixGrid() + } + private var currentApiName: String? = null private var toggleRandomButton = false private var bottomSheetDialog: BottomSheetDialog? = null private var homeMasterAdapter: HomeParentItemAdapterPreview? = null - var lastSavedHomepage: String? = null - - fun saveHomepageToTV(page: Map) { - // No need to update for phone - if (isLayout(PHONE)) { - return - } - val (name, data) = page.entries.firstOrNull() ?: return - // Modifying homepage is an expensive operation, and therefore we avoid it at all cost - if (name == lastSavedHomepage) { - return - } - Log.i(TAG, "Adding programs $name to TV") - lastSavedHomepage = name - ioSafe { - // empty the channel - deleteAll() - // insert the program from first array - addMovies(data.list.list) - } - } - - override fun fixLayout(view: View) { - fixSystemBarsPadding( - view, - padTop = false, - padBottom = isLandscape(), - padLeft = isLayout(TV or EMULATOR) - ) - - // Fix grid - configEvent.invoke() - } - @SuppressLint("SetTextI18n") - override fun onBindingCreated(binding: FragmentHomeBinding) { - context?.let { HomeChildItemAdapter.updatePosterSize(it) } - (activity as? ComponentActivity)?.attachBackPressedCallback("HomeFragment_BackPress") { - handleTvBackPress(this) - } - binding.apply { + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + fixGrid() + + binding?.apply { //homeChangeApiLoading.setOnClickListener(apiChangeClickListener) //homeChangeApiLoading.setOnClickListener(apiChangeClickListener) homeApiFab.setOnClickListener(apiChangeClickListener) - homeApiFab.setOnLongClickListener { - if (currentApiName == noneApi.name) return@setOnLongClickListener false - homeViewModel.loadAndCancel(currentApiName, forceReload = true, fromUI = true) - showToast(R.string.action_reload, Toast.LENGTH_SHORT) - true - } homeChangeApi.setOnClickListener(apiChangeClickListener) homeSwitchAccount.setOnClickListener { activity?.showAccountSelectLinear() } + homeRandom.setOnClickListener { + if (listHomepageItems.isNotEmpty()) { + activity.loadSearchResult(listHomepageItems.random()) + } + } homeMasterAdapter = HomeParentItemAdapterPreview( - homeViewModel, accountViewModel + fragment = this@HomeFragment, + homeViewModel, ) - homeMasterRecycler.setRecycledViewPool(ParentItemAdapter.sharedPool) homeMasterRecycler.adapter = homeMasterAdapter + //fixPaddingStatusbar(homeLoadingStatusbar) homeApiFab.isVisible = isLayout(PHONE) - homePreviewReloadProvider.setOnClickListener { - homeViewModel.loadAndCancel( - homeViewModel.apiName.value ?: noneApi.name, - forceReload = true, - fromUI = true - ) - showToast(R.string.action_reload, Toast.LENGTH_SHORT) - true - } - - homePreviewSearchButton.setOnClickListener { _ -> - // Open blank screen. - homeViewModel.queryTextSubmit("") - } - homeMasterRecycler.addOnScrollListener(object : RecyclerView.OnScrollListener() { override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { - if (isLayout(PHONE)) { - // Fab is only relevant to Phone - if (dy > 0) { //check for scroll down - homeApiFab.shrink() // hide - homeRandom.shrink() - } else if (dy < -5) { - if (isLayout(PHONE)) { - homeApiFab.extend() // show - homeRandom.extend() - } - } - } else { - // Header scrolling is only relevant to TV/Emulator - - val view = recyclerView.findViewHolderForAdapterPosition(0)?.itemView - val scrollParent = binding.homeApiHolder - - if (view == null) { - // The first view is not visible, so we can assume we have scrolled past it - scrollParent.isVisible = false - } else { - // A bit weird, but this is a major limitation we are working around here - // 1. We cant have a real parent to the recyclerview as android cant layout that without lagging - // 2. We cant put the view in the recyclerview, as it should always be shown - // 3. We cant mirror the view in the recyclerview as then it causes focus issues when swaping out the mirror view - // - // This means that if we want to have a parent view to the recyclerview we are out of luck - // Instead this uses getLocationInWindow to calculate how much the view should be scrolled - // as recyclerView has no scrollY (always 0) - // - // Then it manually "scrolls" it to the correct position - // - // Hopefully getLocationInWindow acts correctly on all devices - val rect = IntArray(2) - view.getLocationInWindow(rect) - scrollParent.isVisible = true - scrollParent.translationY = rect[1].toFloat() - 60.toPx + if (dy > 0) { //check for scroll down + homeApiFab.shrink() // hide + homeRandom.shrink() + } else if (dy < -5) { + if (isLayout(PHONE)) { + homeApiFab.extend() // show + homeRandom.extend() } } super.onScrolled(recyclerView, dx, dy) @@ -720,6 +583,7 @@ class HomeFragment : BaseFragment( } + //Load value for toggling Random button. Hide at startup context?.let { val settingsManager = PreferenceManager.getDefaultSharedPreferences(it) @@ -727,56 +591,46 @@ class HomeFragment : BaseFragment( settingsManager.getBoolean( getString(R.string.random_button_key), false - ) - binding.homeRandom.visibility = View.GONE - binding.homeRandomButtonTv.visibility = View.GONE + ) && isLayout(PHONE) + binding?.homeRandom?.visibility = View.GONE } observe(homeViewModel.apiName) { apiName -> currentApiName = apiName - binding.apply { - homeApiFab.text = apiName - homeChangeApi.text = apiName - homePreviewReloadProvider.isGone = (apiName == noneApi.name) - homePreviewSearchButton.isGone = (apiName == noneApi.name) - } + binding?.homeApiFab?.text = apiName + binding?.homeChangeApi?.text = apiName } observe(homeViewModel.page) { data -> - binding.apply { + binding?.apply { when (data) { is Resource.Success -> { + homeLoadingShimmer.stopShimmer() + val d = data.value + val mutableListOfResponse = mutableListOf() + listHomepageItems.clear() + (homeMasterRecycler.adapter as? ParentItemAdapter)?.submitList(d.values.map { it.copy( list = it.list.copy(list = it.list.list.toMutableList()) ) - }) - - saveHomepageToTV(d) + }.toMutableList()) 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 + 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 } } @@ -794,7 +648,7 @@ class HomeFragment : BaseFragment( }) { try { val i = Intent(Intent.ACTION_VIEW) - i.data = validAPIs[itemId].mainUrl.toUri() + i.data = Uri.parse(validAPIs[itemId].mainUrl) startActivity(i) } catch (e: Exception) { logError(e) @@ -804,7 +658,7 @@ class HomeFragment : BaseFragment( homeLoading.isVisible = false homeLoadingError.isVisible = true - homeMasterRecycler.isInvisible = true + homeMasterRecycler.isVisible = false // Based on https://github.com/recloudstream/cloudstream/pull/1438 val hasNoNetworkConnection = context?.isNetworkAvailable() == false @@ -826,28 +680,24 @@ class HomeFragment : BaseFragment( homeReloadConnectionGoToDownloads.setOnClickListener { activity.navigate(R.id.navigation_downloads) } - - (homeMasterRecycler.adapter as? ParentItemAdapter)?.apply { - submitList(null) - clearState() - } } is Resource.Loading -> { + (homeMasterRecycler.adapter as? ParentItemAdapter)?.submitList(listOf()) homeLoadingShimmer.startShimmer() homeLoading.isVisible = true homeLoadingError.isVisible = false - homeMasterRecycler.isInvisible = true - (homeMasterRecycler.adapter as? ParentItemAdapter)?.apply { - submitList(null) - clearState() - } + homeMasterRecycler.isVisible = false //home_loaded?.isVisible = false } } } } + + //context?.fixPaddingStatusbarView(home_statusbar) + //context?.fixPaddingStatusbar(home_padding) + observeNullable(homeViewModel.popup) { item -> if (item == null) { bottomSheetDialog?.dismissSafe() @@ -892,44 +742,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..8bc0aa287 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 @@ -34,11 +34,13 @@ class LoadClickCallback( ) open class ParentItemAdapter( + open val fragment: Fragment, id: Int, private val clickCallback: (SearchClickCallback) -> Unit, private val moreInfoClickCallback: (HomeViewModel.ExpandableHomepageList) -> Unit, private val expandCallback: ((String) -> Unit)? = null, ) : BaseAdapter( + fragment, id, diffCallback = BaseDiffCallback( itemSame = { a, b -> a.list.name == b.list.name }, @@ -46,11 +48,6 @@ open class ParentItemAdapter( a.list.list == b.list.list }) ) { - companion object { - val sharedPool = - newSharedPool { setMaxRecycledViews(CONTENT, 4) } - } - data class ParentItemHolder(val binding: ViewBinding) : ViewHolderState(binding) { override fun save(): Bundle = Bundle().apply { val recyclerView = (binding as? HomepageParentBinding)?.homeChildRecyclerview @@ -63,16 +60,13 @@ open class ParentItemAdapter( override fun restore(state: Bundle) { (binding as? HomepageParentBinding)?.homeChildRecyclerview?.layoutManager?.onRestoreInstanceState( - state.getSafeParcelable("value") + state.getSafeParcelable("value") ) } } - override fun submitList( - list: Collection?, - commitCallback: Runnable? - ) { - super.submitList(list?.sortedBy { it.list.list.isEmpty() }, commitCallback) + override fun submitList(list: List?) { + super.submitList(list?.sortedBy { it.list.list.isEmpty() }) } override fun onUpdateContent( @@ -96,30 +90,17 @@ open class ParentItemAdapter( if (binding !is HomepageParentBinding) return val info = item.list binding.apply { - val currentAdapter = homeChildRecyclerview.adapter as? HomeChildItemAdapter - if (currentAdapter == null) { - homeChildRecyclerview.setRecycledViewPool(HomeChildItemAdapter.sharedPool) - homeChildRecyclerview.adapter = HomeChildItemAdapter( - id = id + position + 100, - clickCallback = clickCallback, - nextFocusUp = homeChildRecyclerview.nextFocusUpId, - nextFocusDown = homeChildRecyclerview.nextFocusDownId, - ).apply { - isHorizontal = info.isHorizontalImages - hasNext = item.hasNext - submitList(item.list.list) - } - } else { - currentAdapter.apply { - isHorizontal = info.isHorizontalImages - hasNext = item.hasNext - this.clickCallback = this@ParentItemAdapter.clickCallback - nextFocusUp = homeChildRecyclerview.nextFocusUpId - nextFocusDown = homeChildRecyclerview.nextFocusDownId - submitIncomparableList(item.list.list) - } + homeChildRecyclerview.adapter = HomeChildItemAdapter( + fragment = fragment, + id = id + position + 100, + clickCallback = clickCallback, + nextFocusUp = homeChildRecyclerview.nextFocusUpId, + nextFocusDown = homeChildRecyclerview.nextFocusDownId, + ).apply { + isHorizontal = info.isHorizontalImages + hasNext = item.hasNext + submitList(item.list.list) } - homeChildRecyclerview.setLinearListLayout( isHorizontal = true, nextLeft = startFocus, @@ -185,6 +166,11 @@ open class ParentItemAdapter( return ParentItemHolder(binding) } + + fun updateList(newList: List) { + submitList(newList.map { HomeViewModel.ExpandableHomepageList(it, 1, false) } + .toMutableList()) + } } @Suppress("DEPRECATION") 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..0ce7ca8f2 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 @@ -1,18 +1,16 @@ package com.lagradost.cloudstream3.ui.home -import android.content.Context import android.os.Bundle import android.os.Parcelable import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import android.widget.ImageView import androidx.appcompat.app.AlertDialog import androidx.appcompat.widget.SearchView import androidx.core.content.ContextCompat import androidx.core.view.isGone import androidx.core.view.isVisible -import androidx.lifecycle.LifecycleOwner +import androidx.fragment.app.Fragment import androidx.lifecycle.findViewTreeLifecycleOwner import androidx.recyclerview.widget.RecyclerView import androidx.viewbinding.ViewBinding @@ -20,8 +18,9 @@ import androidx.viewpager2.widget.ViewPager2 import com.google.android.material.chip.Chip import com.google.android.material.chip.ChipGroup import com.google.android.material.navigation.NavigationBarItemView -import com.lagradost.cloudstream3.CloudStreamApp.Companion.getActivity +import com.lagradost.cloudstream3.AcraApplication.Companion.getActivity import com.lagradost.cloudstream3.CommonActivity.activity +import com.lagradost.cloudstream3.CommonActivity.showToast import com.lagradost.cloudstream3.HomePageList import com.lagradost.cloudstream3.LoadResponse import com.lagradost.cloudstream3.MainActivity @@ -35,11 +34,9 @@ import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.mvvm.observe import com.lagradost.cloudstream3.ui.ViewHolderState import com.lagradost.cloudstream3.ui.WatchType -import com.lagradost.cloudstream3.ui.account.AccountHelper.showAccountEditDialog import com.lagradost.cloudstream3.ui.account.AccountHelper.showAccountSelectLinear -import com.lagradost.cloudstream3.ui.account.AccountViewModel +import com.lagradost.cloudstream3.ui.home.HomeFragment.Companion.selectHomepage import com.lagradost.cloudstream3.ui.result.FOCUS_SELF -import com.lagradost.cloudstream3.ui.result.ResultFragment.bindLogo import com.lagradost.cloudstream3.ui.result.ResultViewModel2 import com.lagradost.cloudstream3.ui.result.START_ACTION_RESUME_LATEST import com.lagradost.cloudstream3.ui.result.getId @@ -50,23 +47,19 @@ import com.lagradost.cloudstream3.ui.search.SearchClickCallback 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.html import com.lagradost.cloudstream3.utils.AppContextUtils.setDefaultFocus import com.lagradost.cloudstream3.utils.DataStoreHelper -import com.lagradost.cloudstream3.utils.ImageLoader.loadImage import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showBottomDialog import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showOptionSelectStringRes 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( + override val fragment: Fragment, private val viewModel: HomeViewModel, - private val accountViewModel: AccountViewModel ) : ParentItemAdapter( - id = "HomeParentItemAdapterPreview".hashCode(), + fragment, id = "HomeParentItemAdapterPreview".hashCode(), clickCallback = { viewModel.click(it) }, moreInfoClickCallback = { @@ -104,33 +97,15 @@ class HomeParentItemAdapterPreview( ) } - return HeaderViewHolder(binding, viewModel, accountViewModel) + return HeaderViewHolder(binding, viewModel, fragment = fragment) } override fun onBindHeader(holder: ViewHolderState) { (holder as? HeaderViewHolder)?.bind() } - override fun onViewDetachedFromWindow(holder: ViewHolderState) { - when (holder) { - is HeaderViewHolder -> { - holder.onViewDetachedFromWindow() - } - } - } - - override fun onViewAttachedToWindow(holder: ViewHolderState) { - when (holder) { - is HeaderViewHolder -> { - holder.onViewAttachedToWindow() - } - } - } - private class HeaderViewHolder( - val binding: ViewBinding, - val viewModel: HomeViewModel, - accountViewModel: AccountViewModel, + val binding: ViewBinding, val viewModel: HomeViewModel, fragment: Fragment, ) : ViewHolderState(binding) { @@ -156,13 +131,9 @@ class HomeParentItemAdapterPreview( } } - val previewAdapter = HomeScrollAdapter { view, position, item -> - viewModel.click( - LoadClickCallback(0, view, position, item) - ) - } - + val previewAdapter = HomeScrollAdapter(fragment = fragment) private val resumeAdapter = ResumeItemAdapter( + fragment, nextFocusUp = itemView.nextFocusUpId, nextFocusDown = itemView.nextFocusDownId, removeCallback = { v -> @@ -245,6 +216,7 @@ class HomeParentItemAdapterPreview( } }) private val bookmarkAdapter = HomeChildItemAdapter( + fragment, id = "bookmarkAdapter".hashCode(), nextFocusUp = itemView.nextFocusUpId, nextFocusDown = itemView.nextFocusDownId @@ -321,14 +293,9 @@ class HomeParentItemAdapterPreview( private val bookmarkRecyclerView: RecyclerView = itemView.findViewById(R.id.home_bookmarked_child_recyclerview) - private val headProfilePic: ImageView? = itemView.findViewById(R.id.home_head_profile_pic) - private val headProfilePicCard: View? = - itemView.findViewById(R.id.home_head_profile_padding) - - private val alternateHeadProfilePic: ImageView? = - itemView.findViewById(R.id.alternate_home_head_profile_pic) - private val alternateHeadProfilePicCard: View? = - itemView.findViewById(R.id.alternate_home_head_profile_padding) + private val homeAccount: View? = itemView.findViewById(R.id.home_preview_switch_account) + private val alternativeHomeAccount: View? = + itemView.findViewById(R.id.alternative_switch_account) private val topPadding: View? = itemView.findViewById(R.id.home_padding) @@ -339,73 +306,38 @@ class HomeParentItemAdapterPreview( fun onSelect(item: LoadResponse, position: Int) { (binding as? FragmentHomeHeadTvBinding)?.apply { - homePreviewDescription.isGone = item.plot.isNullOrBlank() - homePreviewDescription.text = item.plot?.html() ?: "" + homePreviewDescription.isGone = + item.plot.isNullOrBlank() + homePreviewDescription.text = + item.plot ?: "" - val scoreText = item.score?.toStringNull(0.1, 10, 1, false) - - scoreText?.let { score -> - homePreviewScore.text = - homePreviewScore.context.getString(R.string.extension_rating, score) - - // while it should never fail, we do this just in case - val rating = score.toDoubleOrNull() ?: item.score?.toDouble() ?: 0.0 - - val color = when { - rating < 5.0 -> "#eb2f2f".toColorInt() // Red - rating < 8.0 -> "#eda009".toColorInt() // Yellow - else -> "#3bb33b".toColorInt() // Green - } - homePreviewScore.backgroundTintList = - android.content.res.ColorStateList.valueOf(color) - } - homePreviewScore.isGone = scoreText == null - - item.year?.let { year -> - homePreviewYear.text = year.toString() - } - homePreviewYear.isGone = item.year == null - - val duration = item.duration - duration?.let { min -> - homePreviewDuration.text = - homePreviewDuration.context.getString(R.string.duration_format, min) - } - homePreviewDuration.isGone = duration == null || duration <= 0 - - val castText = item.actors?.take(3)?.joinToString(", ") { it.actor.name } - if (!castText.isNullOrBlank()) { - homePreviewCast.text = - homePreviewCast.context.getString(R.string.cast_format, castText) - homePreviewCast.isVisible = true - } else { - homePreviewCast.isVisible = false - } - - homePreviewText.text = item.name.html() + homePreviewText.text = item.name populateChips( homePreviewTags, item.tags?.take(6) ?: emptyList(), - R.style.ChipFilledSemiTransparent, - null - ) - - - bindLogo( - url = item.logoUrl, - headers = item.posterHeaders, - titleView = homePreviewText, - logoView = homeBackgroundPosterWatermarkBadgeHolder + R.style.ChipFilledSemiTransparent ) homePreviewTags.isGone = item.tags.isNullOrEmpty() + homePreviewPlayBtt.setOnClickListener { view -> + viewModel.click( + LoadClickCallback( + START_ACTION_RESUME_LATEST, + view, + position, + item + ) + ) + } + homePreviewInfoBtt.setOnClickListener { view -> viewModel.click( LoadClickCallback(0, view, position, item) ) } + } (binding as? FragmentHomeHeadBinding)?.apply { //homePreviewImage.setImage(item.posterUrl, item.posterHeaders) @@ -490,7 +422,7 @@ class HomeParentItemAdapterPreview( } } - fun onViewDetachedFromWindow() { + override fun onViewDetachedFromWindow() { previewViewpager.unregisterOnPageChangeCallback(previewCallback) } @@ -511,14 +443,12 @@ class HomeParentItemAdapterPreview( previewViewpager.adapter = previewAdapter resumeRecyclerView.adapter = resumeAdapter - bookmarkRecyclerView.setRecycledViewPool(HomeChildItemAdapter.sharedPool) bookmarkRecyclerView.adapter = bookmarkAdapter resumeRecyclerView.setLinearListLayout( nextLeft = R.id.nav_rail_view, nextRight = FOCUS_SELF ) - bookmarkRecyclerView.setLinearListLayout( nextLeft = R.id.nav_rail_view, nextRight = FOCUS_SELF @@ -539,80 +469,36 @@ class HomeParentItemAdapterPreview( } } - headProfilePicCard?.isGone = isLayout(TV or EMULATOR) - alternateHeadProfilePicCard?.isGone = isLayout(TV or EMULATOR) + homeAccount?.isGone = isLayout(TV or EMULATOR) - (headProfilePic ?: alternateHeadProfilePic)?.observe(viewModel.currentAccount) { currentAccount -> - headProfilePic?.loadImage(currentAccount?.image) - alternateHeadProfilePic?.loadImage(currentAccount?.image) - } - - headProfilePicCard?.setOnClickListener { + homeAccount?.setOnClickListener { activity?.showAccountSelectLinear() } - fun showAccountEditBox(context: Context): Boolean { - val currentAccount = DataStoreHelper.getCurrentAccount() - return if (currentAccount != null) { - showAccountEditDialog( - context = context, - account = currentAccount, - isNewAccount = false, - accountEditCallback = { accountViewModel.handleAccountUpdate(it, context) }, - accountDeleteCallback = { - accountViewModel.handleAccountDelete( - it, - context - ) - } - ) - true - } else false - } - - alternateHeadProfilePicCard?.setOnLongClickListener { - showAccountEditBox(it.context) - } - headProfilePicCard?.setOnLongClickListener { - showAccountEditBox(it.context) - } - - alternateHeadProfilePicCard?.setOnClickListener { + alternativeHomeAccount?.setOnClickListener { activity?.showAccountSelectLinear() } (binding as? FragmentHomeHeadTvBinding)?.apply { - /*homePreviewChangeApi.setOnClickListener { view -> + homePreviewChangeApi.setOnClickListener { view -> view.context.selectHomepage(viewModel.repo?.name) { api -> viewModel.loadAndCancel(api, forceReload = true, fromUI = true) } } - homePreviewReloadProvider.setOnClickListener { - viewModel.loadAndCancel( - viewModel.apiName.value ?: noneApi.name, - forceReload = true, - fromUI = true - ) - showToast(R.string.action_reload, Toast.LENGTH_SHORT) - true - } + homePreviewSearchButton.setOnClickListener { _ -> // Open blank screen. viewModel.queryTextSubmit("") - }*/ + } - // A workaround to the focus problem of always centering the view on focus - // as that causes higher android versions to stretch the ui when switching between shows - var lastFocusTimeoutMs = 0L - homePreviewInfoBtt.setOnFocusChangeListener { view, hasFocus -> - val lastFocusMs = lastFocusTimeoutMs - // Always reset timer, as we only want to update - // it if we have not interacted in half a second - lastFocusTimeoutMs = System.currentTimeMillis() - if (!hasFocus) return@setOnFocusChangeListener - if (lastFocusMs + 500L < System.currentTimeMillis()) { - MainActivity.centerView(view) - } + // This makes the hidden next buttons only available when on the info button + // Otherwise you might be able to go to the next item without being at the info button + homePreviewInfoBtt.setOnFocusChangeListener { _, hasFocus -> + homePreviewHiddenNextFocus.isFocusable = hasFocus + } + + homePreviewPlayBtt.setOnFocusChangeListener { _, hasFocus -> + homePreviewHiddenPrevFocus.isFocusable = hasFocus } homePreviewHiddenNextFocus.setOnFocusChangeListener { _, hasFocus -> @@ -630,8 +516,7 @@ class HomeParentItemAdapterPreview( )?.requestFocus() } else { previewViewpager.setCurrentItem(previewViewpager.currentItem - 1, true) - binding.homePreviewInfoBtt.requestFocus() - //binding.homePreviewPlayBtt.requestFocus() + binding.homePreviewPlayBtt.requestFocus() } } } @@ -658,7 +543,9 @@ class HomeParentItemAdapterPreview( params.height = 0 layoutParams = params } - } else fixPaddingStatusbarView(homeNonePadding) + } else { + fixPaddingStatusbarView(homeNonePadding) + } when (preview) { is Resource.Success -> { @@ -682,15 +569,6 @@ class HomeParentItemAdapterPreview( previewViewpager.isVisible = true previewViewpagerText.isVisible = true alternativeAccountPadding?.isVisible = false - (binding as? FragmentHomeHeadTvBinding)?.apply { - homePreviewInfoBtt.isVisible = true - } - // Explicitly bind the current item to ensure instant loading - val currentPos = previewViewpager.currentItem - val item = preview.value.second.getOrNull(currentPos) - if (item != null) { - onSelect(item, currentPos) - } } else -> { @@ -699,9 +577,6 @@ class HomeParentItemAdapterPreview( previewViewpager.isVisible = false previewViewpagerText.isVisible = false alternativeAccountPadding?.isVisible = true - (binding as? FragmentHomeHeadTvBinding)?.apply { - homePreviewInfoBtt.isVisible = false - } //previewHeader.isVisible = false } } @@ -770,19 +645,18 @@ class HomeParentItemAdapterPreview( } } - fun onViewAttachedToWindow() { + override fun onViewAttachedToWindow() { previewViewpager.registerOnPageChangeCallback(previewCallback) - previewViewpager.apply { + binding.root.findViewTreeLifecycleOwner()?.apply { observe(viewModel.preview) { updatePreview(it) } - /*if (binding is FragmentHomeHeadTvBinding) { + if (binding is FragmentHomeHeadTvBinding) { observe(viewModel.apiName) { name -> binding.homePreviewChangeApi.text = name - binding.homePreviewReloadProvider.isGone = (name == noneApi.name) } - }*/ + } observe(viewModel.resumeWatching) { updateResume(it) } @@ -798,7 +672,7 @@ class HomeParentItemAdapterPreview( } toggleListHolder?.isGone = visible.isEmpty() } - } + } ?: debugException { "Expected findViewTreeLifecycleOwner" } } } } diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeScrollAdapter.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeScrollAdapter.kt index e42e774b5..4c4dd2d84 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeScrollAdapter.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeScrollAdapter.kt @@ -1,27 +1,23 @@ package com.lagradost.cloudstream3.ui.home +import android.content.res.Configuration import android.view.LayoutInflater -import android.view.View import android.view.ViewGroup import androidx.core.view.isGone +import androidx.fragment.app.Fragment import com.lagradost.cloudstream3.LoadResponse import com.lagradost.cloudstream3.databinding.HomeScrollViewBinding import com.lagradost.cloudstream3.databinding.HomeScrollViewTvBinding -import com.lagradost.cloudstream3.ui.BaseDiffCallback import com.lagradost.cloudstream3.ui.NoStateAdapter import com.lagradost.cloudstream3.ui.ViewHolderState -import com.lagradost.cloudstream3.ui.result.ResultFragment.bindLogo 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.html import com.lagradost.cloudstream3.utils.ImageLoader.loadImage class HomeScrollAdapter( - val callback: ((View, Int, LoadResponse) -> Unit) -) : NoStateAdapter(diffCallback = BaseDiffCallback(itemSame = { a, b -> - a.uniqueUrl == b.uniqueUrl && a.name == b.name -})) { + fragment: Fragment +) : NoStateAdapter(fragment) { var hasMoreItems: Boolean = false override fun onCreateContent(parent: ViewGroup): ViewHolderState { @@ -35,26 +31,19 @@ class HomeScrollAdapter( return ViewHolderState(binding) } - override fun onClearView(holder: ViewHolderState) { - when (val binding = holder.view) { - is HomeScrollViewBinding -> { - clearImage(binding.homeScrollPreview) - } - - is HomeScrollViewTvBinding -> { - clearImage(binding.homeScrollPreview) - } - } - } - override fun onBindContent( holder: ViewHolderState, item: LoadResponse, position: Int, ) { val binding = holder.view + val itemView = holder.itemView + val isHorizontal = + binding is HomeScrollViewTvBinding || itemView.resources.configuration.orientation == Configuration.ORIENTATION_LANDSCAPE - val posterUrl = item.backgroundPosterUrl ?: item.posterUrl + val posterUrl = + if (isHorizontal) item.backgroundPosterUrl ?: item.posterUrl else item.posterUrl + ?: item.backgroundPosterUrl when (binding) { is HomeScrollViewBinding -> { @@ -64,21 +53,10 @@ class HomeScrollAdapter( isGone = item.tags.isNullOrEmpty() maxLines = 2 } - binding.homeScrollPreviewTitle.text = item.name.html() - - bindLogo( - url = item.logoUrl, - headers = item.posterHeaders, - titleView = binding.homeScrollPreviewTitle, - logoView = binding.homePreviewLogo - ) + binding.homeScrollPreviewTitle.text = item.name } is HomeScrollViewTvBinding -> { - binding.homeScrollPreview.isFocusable = false - binding.homeScrollPreview.setOnClickListener { view -> - callback.invoke(view ?: return@setOnClickListener, position, item) - } binding.homeScrollPreview.loadImage(posterUrl) } } 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..fccf1bb2c 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 @@ -7,14 +7,14 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope 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.AcraApplication.Companion.context +import com.lagradost.cloudstream3.AcraApplication.Companion.getKey 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 = @@ -535,12 +520,12 @@ 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()) if (preferredApiName != null) - _apiName.postValue(preferredApiName) + _apiName.postValue(preferredApiName!!) } } else { // if the api is found, then set it to it and save key 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..bfac72067 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 @@ -7,16 +7,22 @@ import android.content.res.Configuration import android.os.Bundle import android.os.Handler import android.os.Looper +import android.util.TypedValue +import android.view.LayoutInflater import android.view.View +import android.view.ViewGroup import android.view.ViewGroup.FOCUS_AFTER_DESCENDANTS import android.view.ViewGroup.FOCUS_BLOCK_DESCENDANTS import android.view.animation.AlphaAnimation +import android.widget.ImageView import android.widget.TextView +import android.widget.Toast import androidx.annotation.StringRes import androidx.appcompat.widget.SearchView import androidx.core.view.allViews import androidx.core.view.isGone import androidx.core.view.isVisible +import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels import androidx.preference.PreferenceManager import androidx.recyclerview.widget.RecyclerView @@ -24,33 +30,35 @@ import com.google.android.material.tabs.TabLayout import com.google.android.material.tabs.TabLayoutMediator import com.lagradost.cloudstream3.APIHolder import com.lagradost.cloudstream3.APIHolder.allProviders -import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey -import com.lagradost.cloudstream3.CloudStreamApp.Companion.openBrowser -import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey +import com.lagradost.cloudstream3.AcraApplication.Companion.getKey +import com.lagradost.cloudstream3.AcraApplication.Companion.openBrowser +import com.lagradost.cloudstream3.AcraApplication.Companion.setKey +import com.lagradost.cloudstream3.CommonActivity import com.lagradost.cloudstream3.MainActivity import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.SearchResponse import com.lagradost.cloudstream3.databinding.FragmentLibraryBinding import com.lagradost.cloudstream3.mvvm.Resource import com.lagradost.cloudstream3.mvvm.debugAssert +import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.mvvm.observe import com.lagradost.cloudstream3.syncproviders.SyncAPI import com.lagradost.cloudstream3.syncproviders.SyncIdName import com.lagradost.cloudstream3.ui.AutofitRecyclerView import com.lagradost.cloudstream3.ui.quicksearch.QuickSearchFragment import com.lagradost.cloudstream3.utils.txt -import com.lagradost.cloudstream3.ui.BaseFragment import com.lagradost.cloudstream3.ui.search.SEARCH_ACTION_LOAD import com.lagradost.cloudstream3.ui.search.SEARCH_ACTION_SHOW_METADATA +import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR import com.lagradost.cloudstream3.ui.settings.Globals.PHONE -import com.lagradost.cloudstream3.ui.settings.Globals.isLandscape +import com.lagradost.cloudstream3.ui.settings.Globals.TV import com.lagradost.cloudstream3.ui.settings.Globals.isLayout import com.lagradost.cloudstream3.utils.AppContextUtils.loadResult import com.lagradost.cloudstream3.utils.AppContextUtils.loadSearchResult import com.lagradost.cloudstream3.utils.AppContextUtils.reduceDragSensitivity import com.lagradost.cloudstream3.utils.DataStoreHelper.currentAccount import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showBottomDialog -import com.lagradost.cloudstream3.utils.UIHelper.fixSystemBarsPadding +import com.lagradost.cloudstream3.utils.UIHelper.fixPaddingStatusbar import com.lagradost.cloudstream3.utils.UIHelper.getSpanCount import java.util.concurrent.CopyOnWriteArrayList import kotlin.math.abs @@ -76,10 +84,10 @@ data class ProviderLibraryData( val apiName: String ) -class LibraryFragment : BaseFragment( - BaseFragment.BindingCreator.Bind(FragmentLibraryBinding::bind) -) { +class LibraryFragment : Fragment() { companion object { + + val listLibraryItems = mutableListOf() fun newInstance() = LibraryFragment() /** @@ -90,10 +98,35 @@ class LibraryFragment : BaseFragment( private val libraryViewModel: LibraryViewModel by activityViewModels() + var binding: FragmentLibraryBinding? = null private var toggleRandomButton = false - override fun pickLayout(): Int? = - if (isLayout(PHONE)) R.layout.fragment_library else R.layout.fragment_library_tv + override fun onCreateView( + inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? + ): View { + val layout = + if (isLayout(TV or EMULATOR)) R.layout.fragment_library_tv else R.layout.fragment_library + val root = inflater.inflate(layout, container, false) + binding = try { + FragmentLibraryBinding.bind(root) + } catch (t: Throwable) { + CommonActivity.showToast( + txt(R.string.unable_to_inflate, t.message ?: ""), + Toast.LENGTH_LONG + ) + logError(t) + null + } + + return root + + //return inflater.inflate(R.layout.fragment_library, container, false) + } + + override fun onDestroyView() { + binding = null + super.onDestroyView() + } override fun onSaveInstanceState(outState: Bundle) { binding?.viewpager?.currentItem?.let { currentItem -> @@ -102,52 +135,48 @@ 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() { 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 - } - - override fun fixLayout(view: View) { - fixSystemBarsPadding( - view, - padBottom = isLandscape(), - padLeft = !isLayout(PHONE) - ) + if (toggleRandomButton) { + listLibraryItems.clear() + listLibraryItems.addAll(pages[position].items) + binding?.libraryRandom?.isVisible = listLibraryItems.isNotEmpty() + } else { + binding?.libraryRandom?.isGone = true + } } @SuppressLint("ResourceType", "CutPasteId") - override fun onBindingCreated( - binding: FragmentLibraryBinding, - savedInstanceState: Bundle? - ) { - binding.sortFab.setOnClickListener(sortChangeClickListener) - binding.librarySort.setOnClickListener(sortChangeClickListener) + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + fixPaddingStatusbar(binding?.searchStatusBarPadding) - binding.libraryRoot.findViewById(androidx.appcompat.R.id.search_src_text) - ?.apply { - tag = "tv_no_focus_tag" - // Expand the Appbar when search bar is focused, fixing scroll up issue - setOnFocusChangeListener { _, _ -> - binding.searchBar.setExpanded(true) - } + binding?.sortFab?.setOnClickListener(sortChangeClickListener) + binding?.librarySort?.setOnClickListener(sortChangeClickListener) + + binding?.libraryRoot?.findViewById(androidx.appcompat.R.id.search_src_text)?.apply { + tag = "tv_no_focus_tag" + //Expand the Appbar when search bar is focused, fixing scroll up issue + setOnFocusChangeListener { _, _ -> + binding?.searchBar?.setExpanded(true) } + } + + // Set the color for the search exit icon to the correct theme text color + val searchExitIcon = + binding?.mainSearch?.findViewById(androidx.appcompat.R.id.search_close_btn) + val searchExitIconColor = TypedValue() + + activity?.theme?.resolveAttribute(android.R.attr.textColor, searchExitIconColor, true) + searchExitIcon?.setColorFilter(searchExitIconColor.data) val searchCallback = Runnable { - val newText = binding.mainSearch.query.toString() + val newText = binding?.mainSearch?.query?.toString() ?: return@Runnable libraryViewModel.sort(ListSorting.Query, newText) } - binding.mainSearch.setOnQueryTextListener(object : SearchView.OnQueryTextListener { + binding?.mainSearch?.setOnQueryTextListener(object : SearchView.OnQueryTextListener { override fun onQueryTextSubmit(query: String?): Boolean { libraryViewModel.sort(ListSorting.Query, query) return true @@ -163,11 +192,11 @@ class LibraryFragment : BaseFragment( return true } - binding.mainSearch.removeCallbacks(searchCallback) + binding?.mainSearch?.removeCallbacks(searchCallback) // Delay the execution of the search operation by 1 second (adjust as needed) // this prevents running search when the user is typing - binding.mainSearch.postDelayed(searchCallback, 1000) + binding?.mainSearch?.postDelayed(searchCallback, 1000) return true } @@ -175,12 +204,11 @@ class LibraryFragment : BaseFragment( libraryViewModel.reloadPages(false) - binding.listSelector.setOnClickListener { + binding?.listSelector?.setOnClickListener { val items = libraryViewModel.availableApiNames val currentItem = libraryViewModel.currentApiName.value - activity?.showBottomDialog( - items, + activity?.showBottomDialog(items, items.indexOf(currentItem), txt(R.string.select_library).asString(it.context), false, @@ -197,9 +225,17 @@ class LibraryFragment : BaseFragment( settingsManager.getBoolean( getString(R.string.random_button_key), false - ) - binding.libraryRandom.visibility = View.GONE - binding.libraryRandomButtonTv.visibility = View.GONE + ) && isLayout(PHONE) + binding?.libraryRandom?.visibility = View.GONE + } + + binding?.libraryRandom?.setOnClickListener { + if (listLibraryItems.isNotEmpty()) { + val listLibraryItem = listLibraryItems.random() + libraryViewModel.currentSyncApi?.syncIdName?.let { + loadLibraryItem(it, listLibraryItem.syncId, listLibraryItem) + } + } } /** @@ -210,13 +246,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, @@ -268,21 +305,22 @@ class LibraryFragment : BaseFragment( } } - binding.providerSelector.setOnClickListener { + binding?.providerSelector?.setOnClickListener { val syncName = libraryViewModel.currentSyncApi?.syncIdName ?: return@setOnClickListener activity?.showPluginSelectionDialog(syncName.name, syncName) } - binding.viewpager.setPageTransformer(LibraryScrollTransformer()) + binding?.viewpager?.setPageTransformer(LibraryScrollTransformer()) - binding.viewpager.adapter = ViewpagerAdapter( + binding?.viewpager?.adapter = ViewpagerAdapter( + fragment = this, { isScrollingDown: Boolean -> if (isScrollingDown) { - binding.sortFab.shrink() - binding.libraryRandom.shrink() + binding?.sortFab?.shrink() + binding?.libraryRandom?.shrink() } else { - binding.sortFab.extend() - binding.libraryRandom.extend() + binding?.sortFab?.extend() + binding?.libraryRandom?.extend() } }) callback@{ searchClickCallback -> // To prevent future accidents @@ -315,15 +353,15 @@ class LibraryFragment : BaseFragment( } } - binding.apply { + binding?.apply { viewpager.offscreenPageLimit = 2 viewpager.reduceDragSensitivity() searchBar.setExpanded(true) } val startLoading = Runnable { - binding.apply { - gridview.numColumns = root.context.getSpanCount() + binding?.apply { + gridview.numColumns = context?.getSpanCount() ?: 3 gridview.adapter = context?.let { LoadingPosterAdapter(it, 6 * 3) } libraryLoadingOverlay.isVisible = true @@ -333,7 +371,7 @@ class LibraryFragment : BaseFragment( } val stopLoading = Runnable { - binding.apply { + binding?.apply { gridview.adapter = null libraryLoadingOverlay.isVisible = false libraryLoadingShimmer.stopShimmer() @@ -349,7 +387,7 @@ class LibraryFragment : BaseFragment( val pages = resource.value val showNotice = pages.all { it.items.isEmpty() } - binding.apply { + binding?.apply { emptyListTextview.isVisible = showNotice if (showNotice) { if (libraryViewModel.availableApiNames.size > 1) { @@ -377,23 +415,10 @@ class LibraryFragment : BaseFragment( )*/ libraryViewModel.currentPage.value?.let { page -> - binding.viewpager.setCurrentItem(page, false) - binding.searchBar.setExpanded(true) + binding?.viewpager?.setCurrentItem(page, false) } - // 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() // Only stop loading after 300ms to hide the fade effect the viewpager produces when updating // Without this there would be a flashing effect: @@ -434,20 +459,21 @@ class LibraryFragment : BaseFragment( tab.view.nextFocusDownId = R.id.search_result_root tab.view.setOnClickListener { - val currentItem = binding.viewpager.currentItem + val currentItem = + binding?.viewpager?.currentItem ?: return@setOnClickListener val distance = abs(position - currentItem) hideViewpager(distance) } //Expand the appBar on tab focus tab.view.setOnFocusChangeListener { _, _ -> - binding.searchBar.setExpanded(true) + binding?.searchBar?.setExpanded(true) } }.attach() - binding.libraryTabLayout.addOnTabSelectedListener(object : + binding?.libraryTabLayout?.addOnTabSelectedListener(object : TabLayout.OnTabSelectedListener { override fun onTabSelected(tab: TabLayout.Tab?) { - binding.libraryTabLayout.selectedTabPosition.let { page -> + binding?.libraryTabLayout?.selectedTabPosition?.let { page -> libraryViewModel.switchPage(page) } } @@ -472,11 +498,11 @@ class LibraryFragment : BaseFragment( } observe(libraryViewModel.currentPage) { position -> - updateRandomVisibility(binding) - val all = binding.viewpager.allViews.toList() - .filterIsInstance() + updateRandom() + val all = binding?.viewpager?.allViews?.toList() + ?.filterIsInstance() - all.forEach { view -> + all?.forEach { view -> view.isVisible = view.tag == position view.isFocusable = view.tag == position @@ -486,6 +512,14 @@ class LibraryFragment : BaseFragment( view.descendantFocusability = FOCUS_BLOCK_DESCENDANTS } } + + /*binding?.viewpager?.registerOnPageChangeCallback(object : + ViewPager2.OnPageChangeCallback() { + override fun onPageSelected(position: Int) { + + super.onPageSelected(position) + } + })*/ } private fun loadLibraryItem( @@ -544,10 +578,10 @@ class LibraryFragment : BaseFragment( } + @SuppressLint("NotifyDataSetChanged") override fun onConfigurationChanged(newConfig: Configuration) { + binding?.viewpager?.adapter?.notifyDataSetChanged() super.onConfigurationChanged(newConfig) - val adapter = binding?.viewpager?.adapter ?: return - adapter.notifyItemRangeChanged(0, adapter.itemCount) } private val sortChangeClickListener = View.OnClickListener { view -> @@ -555,8 +589,7 @@ class LibraryFragment : BaseFragment( txt(it.stringRes).asString(view.context) } - activity?.showBottomDialog( - methods, + activity?.showBottomDialog(methods, libraryViewModel.sortingMethods.indexOf(libraryViewModel.currentSortingMethod), txt(R.string.sort_by).asString(view.context), false, diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/library/LibraryViewModel.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/library/LibraryViewModel.kt index 38f7fcf9d..f7713e9b2 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/library/LibraryViewModel.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/library/LibraryViewModel.kt @@ -4,8 +4,8 @@ import androidx.annotation.StringRes import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel -import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey -import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey +import com.lagradost.cloudstream3.AcraApplication.Companion.getKey +import com.lagradost.cloudstream3.AcraApplication.Companion.setKey import com.lagradost.cloudstream3.MainActivity import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.mvvm.Resource diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/library/PageAdapter.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/library/PageAdapter.kt index 066cf468d..a2570e684 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/library/PageAdapter.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/library/PageAdapter.kt @@ -1,34 +1,31 @@ package com.lagradost.cloudstream3.ui.library +import android.graphics.Color import android.view.LayoutInflater import android.view.ViewGroup import android.widget.FrameLayout +import androidx.core.graphics.ColorUtils import androidx.core.view.isVisible +import androidx.recyclerview.widget.RecyclerView import com.lagradost.cloudstream3.databinding.SearchResultGridExpandedBinding import com.lagradost.cloudstream3.syncproviders.SyncAPI 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.search.SearchClickCallback import com.lagradost.cloudstream3.ui.search.SearchResultBuilder +import com.lagradost.cloudstream3.utils.AppContextUtils +import com.lagradost.cloudstream3.utils.UIHelper.toPx import kotlin.math.roundToInt + class PageAdapter( + override val items: MutableList, private val resView: AutofitRecyclerView, val clickCallback: (SearchClickCallback) -> Unit ) : - NoStateAdapter(diffCallback = BaseDiffCallback(itemSame = { a, b -> - if (a.id != null || b.id != null) { - a.id == b.id - } else { - a.name == b.name && a.url == b.url - } - })) { - private val coverHeight: Int get() = (resView.itemWidth / 0.68).roundToInt() + AppContextUtils.DiffAdapter(items) { - override fun onCreateContent(parent: ViewGroup): ViewHolderState { - return ViewHolderState( + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { + return LibraryItemViewHolder( SearchResultGridExpandedBinding.inflate( LayoutInflater.from(parent.context), parent, @@ -37,45 +34,86 @@ class PageAdapter( ) } - override fun onClearView(holder: ViewHolderState) { - when (val binding = holder.view) { - is SearchResultGridExpandedBinding -> { - clearImage(binding.imageView) + override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { + when (holder) { + is LibraryItemViewHolder -> { + holder.bind(items[position], position) } } } - override fun onBindContent( - holder: ViewHolderState, - item: SyncAPI.LibraryItem, - position: Int - ) { - val binding = holder.view as? SearchResultGridExpandedBinding ?: return + private fun isDark(color: Int): Boolean { + return ColorUtils.calculateLuminance(color) < 0.5 + } - /** https://stackoverflow.com/questions/8817522/how-to-get-color-code-of-image-view */ - SearchResultBuilder.bind( - this@PageAdapter.clickCallback, - item, - position, - holder.itemView, - ) - - // See searchAdaptor for this, it basically fixes the height - val params = FrameLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - coverHeight - ) - if (params.height != binding.imageView.layoutParams.height || params.width != binding.imageView.layoutParams.width) { - binding.imageView.layoutParams = params + fun getDifferentColor(color: Int, ratio: Float = 0.7f): Int { + return if (isDark(color)) { + ColorUtils.blendARGB(color, Color.WHITE, ratio) + } else { + ColorUtils.blendARGB(color, Color.BLACK, ratio) } + } - val showProgress = item.episodesCompleted?.let{ it>0 } ?: false && item.episodesTotal != null - binding.watchProgress.isVisible = showProgress - if (showProgress) { - binding.watchProgress.max = item.episodesTotal - binding.watchProgress.progress = item.episodesCompleted + inner class LibraryItemViewHolder(val binding: SearchResultGridExpandedBinding) : + RecyclerView.ViewHolder(binding.root) { + + private val compactView = false//itemView.context.getGridIsCompact() + private val coverHeight: Int = + if (compactView) 80.toPx else (resView.itemWidth / 0.68).roundToInt() + + fun bind(item: SyncAPI.LibraryItem, position: Int) { + /** https://stackoverflow.com/questions/8817522/how-to-get-color-code-of-image-view */ + + SearchResultBuilder.bind( + this@PageAdapter.clickCallback, + item, + position, + itemView, + /*colorCallback = { palette -> + AcraApplication.context?.let { ctx -> + val defColor = ContextCompat.getColor(ctx, R.color.ratingColorBg) + var bg = palette.getDarkVibrantColor(defColor) + if (bg == defColor) { + bg = palette.getDarkMutedColor(defColor) + } + if (bg == defColor) { + bg = palette.getVibrantColor(defColor) + } + + val fg = + getDifferentColor(bg)//palette.getVibrantColor(ContextCompat.getColor(ctx,R.color.ratingColor)) + binding.textRating.apply { + setTextColor(ColorStateList.valueOf(fg)) + } + binding.textRating.compoundDrawables.getOrNull(0)?.setTint(fg) + binding.textRating.backgroundTintList = ColorStateList.valueOf(bg) + binding.watchProgress.apply { + progressTintList = ColorStateList.valueOf(fg) + progressBackgroundTintList = ColorStateList.valueOf(bg) + } + } + } + */ + ) + + // See searchAdaptor for this, it basically fixes the height + if (!compactView) { + binding.imageView.apply { + layoutParams = FrameLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + coverHeight + ) + } + } + + val showProgress = item.episodesCompleted != null && item.episodesTotal != null + binding.watchProgress.isVisible = showProgress + if (showProgress) { + binding.watchProgress.max = item.episodesTotal!! + binding.watchProgress.progress = item.episodesCompleted!! + } + + binding.imageText.text = item.name } - - binding.imageText.text = item.name } } \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/library/ViewpagerAdapter.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/library/ViewpagerAdapter.kt index 68b6eb273..0110187f6 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/library/ViewpagerAdapter.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/library/ViewpagerAdapter.kt @@ -40,19 +40,19 @@ class ViewpagerAdapterViewHolderState(val binding: LibraryViewpagerPageBinding) } class ViewpagerAdapter( + fragment: Fragment, val scrollCallback: (isScrollingDown: Boolean) -> Unit, val clickCallback: (SearchClickCallback) -> Unit -) : BaseAdapter( +) : BaseAdapter(fragment, id = "ViewpagerAdapter".hashCode(), diffCallback = BaseDiffCallback( - itemSame = { a, b -> - a.title == b.title - }, - contentSame = { a, b -> - a.items == b.items && a.title == b.title - } - )) { - + itemSame = { a, b -> + a.title == b.title + }, + contentSame = { a, b -> + a.items == b.items && a.title == b.title + } +)) { override fun onCreateContent(parent: ViewGroup): ViewHolderState { return ViewpagerAdapterViewHolderState( LibraryViewpagerPageBinding.inflate(LayoutInflater.from(parent.context), parent, false) @@ -66,8 +66,7 @@ class ViewpagerAdapter( ) { val binding = holder.view if (binding !is LibraryViewpagerPageBinding) return - (binding.pageRecyclerview.adapter as? PageAdapter)?.submitList(item.items) - binding.pageRecyclerview.scrollToPosition(0) + (binding.pageRecyclerview.adapter as? PageAdapter)?.updateList(item.items) } override fun onBindContent(holder: ViewHolderState, item: SyncAPI.Page, position: Int) { @@ -76,21 +75,21 @@ class ViewpagerAdapter( binding.pageRecyclerview.tag = position binding.pageRecyclerview.apply { - spanCount = binding.root.context.getSpanCount() + spanCount = + binding.root.context.getSpanCount() ?: 3 if (adapter == null) { // || rebind // Only add the items after it has been attached since the items rely on ItemWidth // Which is only determined after the recyclerview is attached. // If this fails then item height becomes 0 when there is only one item doOnAttach { adapter = PageAdapter( + item.items.toMutableList(), this, clickCallback - ).apply { - submitList(item.items) - } + ) } } else { - (adapter as? PageAdapter)?.submitList(item.items) + (adapter as? PageAdapter)?.updateList(item.items) // scrollToPosition(0) } @@ -101,7 +100,7 @@ class ViewpagerAdapter( //Expand the top Appbar based on scroll direction up/down, simulate phone behavior if (isLayout(TV or EMULATOR)) { binding.root.rootView.findViewById(R.id.search_bar) - ?.apply { + .apply { if (diff <= 0) setExpanded(true) else 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..5ba4c6a1d 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,61 @@ 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 androidx.annotation.OptIn +import android.widget.ProgressBar +import android.widget.Toast +import androidx.annotation.LayoutRes import androidx.annotation.StringRes -import androidx.media3.common.util.UnstableApi +import androidx.core.view.isGone +import androidx.core.view.isVisible +import androidx.fragment.app.Fragment +import androidx.media3.common.PlaybackException +import androidx.media3.exoplayer.ExoPlayer import androidx.media3.session.MediaSession +import androidx.media3.ui.AspectRatioFrameLayout +import androidx.media3.ui.DefaultTimeBar +import androidx.media3.ui.PlayerView import androidx.media3.ui.SubtitleView -import androidx.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.canEnterPipMode +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.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), @@ -30,132 +75,669 @@ const val NEXT_WATCH_EPISODE_PERCENTAGE = 90 // when the player should sync the progress of "watched", TODO MAKE SETTING 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 + var isBuffering = true + 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 - /** 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 + + keepScreenOn(!isPausedRightNow) + + isBuffering = CSPlayerLoading.IsBuffering == isPlaying + if (isBuffering) { + playerPausePlayHolderHolder?.isVisible = false + playerBuffering?.isVisible = true + } else { + playerPausePlayHolderHolder?.isVisible = true + playerBuffering?.isVisible = false + + 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() + canEnterPipMode = isPlayingRightNow && hasPipModeSupport + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + activity?.let { act -> + PlayerPipHelper.updatePIPModeActions( + act, + isPlayingRightNow, + 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 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_PARSING_CONTAINER_UNSUPPORTED, + PlaybackException.ERROR_CODE_IO_NO_PERMISSION, + PlaybackException.ERROR_CODE_IO_UNSPECIFIED -> { + 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 -> + // Resets subtitle delay on ended video + player.setSubtitleOffset(0) + + // 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() + playerEventListener = null + keyEventListener = null + canEnterPipMode = false + 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") + 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..ad216eee8 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 @@ -40,42 +37,33 @@ import androidx.media3.datasource.HttpDataSource import androidx.media3.datasource.cache.CacheDataSource 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.SingleSampleMediaSource import androidx.media3.exoplayer.text.TextOutput import androidx.media3.exoplayer.text.TextRenderer import androidx.media3.exoplayer.trackselection.DefaultTrackSelector import androidx.media3.exoplayer.trackselection.TrackSelector -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.AcraApplication.Companion.getKey +import com.lagradost.cloudstream3.AcraApplication.Companion.setKey import com.lagradost.cloudstream3.CommonActivity.activity import com.lagradost.cloudstream3.ErrorLoadingException import com.lagradost.cloudstream3.MainActivity.Companion.deleteFileOnExit @@ -87,38 +75,31 @@ 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 com.lagradost.cloudstream3.utils.SubtitleHelper.fromTwoLettersToLanguage +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 const val TAG = "CS3ExoPlayer" const val PREFERRED_AUDIO_LANGUAGE_KEY = "preferred_audio_language" @@ -135,7 +116,6 @@ const val toleranceAfterUs = 300_000L @OptIn(UnstableApi::class) class CS3IPlayer : IPlayer { - private var playerListener: Player.Listener? = null private var isPlaying = false private var exoPlayer: ExoPlayer? = null set(value) { @@ -192,6 +172,7 @@ class CS3IPlayer : IPlayer { val kty: String? = null, val licenseUrl: String? = null, val keyRequestParameters: HashMap, + val headers: Map = emptyMap(), ) override fun getDuration(): Long? = exoPlayer?.duration @@ -208,43 +189,45 @@ 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) + } } } - /** - * As initCallbacks and releaseCallbacks must always be done, - * we use this to say that the player is in use. - * */ - @Volatile - var isPlayerActive: Boolean = false - override fun releaseCallbacks() { eventHandler = null - if (isPlayerActive) { - isPlayerActive = false - activePlayers -= 1 - releaseCronetEngine() - } } - @AnyThread override fun initCallbacks( - @MainThread eventHandler: ((PlayerEvent) -> Unit), + eventHandler: ((PlayerEvent) -> Unit), requestedListeningPercentages: List?, ) { this.requestedListeningPercentages = requestedListeningPercentages this.eventHandler = eventHandler - if (!isPlayerActive) { - isPlayerActive = true - activePlayers += 1 + } + + // 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) + } } } @@ -261,10 +244,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 +357,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 +405,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 +418,7 @@ class CS3IPlayer : IPlayer { this.id?.stripTrackId(), this.label, this.language, - this.sampleMimeType, + this.sampleMimeType ) } @@ -456,35 +429,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 +463,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 +525,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) } } } @@ -608,10 +590,7 @@ class CS3IPlayer : IPlayer { // No documented exception, but just to be extra safe logError(t) } - playerListener?.let { - removeListener(it) - playerListener = null - } + stop() release() } @@ -658,62 +637,6 @@ class CS3IPlayer : IPlayer { } companion object { - private const val CRONET_TIMEOUT_MS = 15_000 - - /** - * Single shared engine, to minimize the overhead of maintaining many as: - * 1. Cpu time/Startup time - * 2. Mem consumption/GC - * 3. Disk usage, as we simply use the same folder - * */ - private var cronetEngine: CronetEngine? = null - - /** - * How many active sessions we have. - * - * However in reality it should never go negative or be more than 1, - * but this makes more sense architecturally. - * */ - @Volatile - private var activePlayers = 0 - - /** Unique monotonically increasing id to keep track of the last release call */ - @Volatile - private var cronetReleasedId = 0 - - fun releaseCronetEngine() { - if (cronetEngine == null) return - - // Delayed release, as we do not want to restart it when opening trailers ect - val id = ++cronetReleasedId - val posted = Handler(Looper.getMainLooper()).postDelayed({ - // This might get dropped, but that should be very rare - // and should not affect it. - releaseCronetEngineInstantly(id) - }, 60_000) // 1min timeout before release - - // If not posted, then run instantly - if (!posted) { - releaseCronetEngineInstantly(id) - } - } - - private fun releaseCronetEngineInstantly(id: Int) { - // We should release if and only if this was the last call, and - // there is no active players - if (activePlayers == 0 && id == cronetReleasedId) { - try { - cronetEngine?.shutdown() - } catch (t: Throwable) { - logError(t) - } finally { - Log.d(TAG, "CronetEngine shutdown") - // Even if it fails to shutdown, the GC should take care of it - cronetEngine = null - } - } - } - /** * Setting this variable is permanent across app sessions. **/ @@ -732,98 +655,42 @@ 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() - } - val source = OkHttpDataSource.Factory(client).setUserAgent(USER_AGENT) - - if (!headers.isNullOrEmpty()) { - source.setDefaultRequestProperties(headers) - } - return source - } - - fun tryCreateEngine(context: Context, diskCacheSize: Long): CronetEngine? { - // Fast case, no need to recreate it - cronetEngine?.let { - return it - } - - // https://gist.github.com/ShivamKumarJha/3c8398b47053ae05112d2a8f8b5de531 - return try { - val cacheDirectory = File(context.cacheDir, "CronetEngine") - cacheDirectory.deleteRecursively() - if (!cacheDirectory.exists()) { - cacheDirectory.mkdirs() - } - CronetEngine.Builder(context) - .enableBrotli(true) - .enableHttp2(true) - .enableQuic(true) - .setStoragePath(cacheDirectory.absolutePath) - .setLibraryLoader(null) - .enableHttpCache(CronetEngine.Builder.HTTP_CACHE_DISK, diskCacheSize) - .build().also { buildEngine -> - Log.d( - TAG, - "Created CronetEngine with cache at ${cacheDirectory.absolutePath}" - ) - cronetEngine = buildEngine - } - } catch (t: Throwable) { - logError(t) - // Something went wrong, so we use the backup okhttp - null + private fun createOnlineSource(headers: Map): HttpDataSource.Factory { + val source = OkHttpDataSource.Factory(app.baseClient).setUserAgent(USER_AGENT) + return source.apply { + setDefaultRequestProperties(headers) } } - private fun createVideoSource( - link: ExtractorLink, - engine: CronetEngine?, - interceptor: Interceptor?, - ): HttpDataSource.Factory { + private fun createOnlineSource(link: ExtractorLink): HttpDataSource.Factory { + val provider = getApiFromNameNull(link.source) + val interceptor = provider?.getVideoInterceptor(link) val userAgent = link.headers.entries.find { it.key.equals("User-Agent", ignoreCase = true) - }?.value ?: USER_AGENT + }?.value val source = if (interceptor == null) { - if (engine == null) { - Log.d(TAG, "Using DefaultHttpDataSource for $link") - OkHttpDataSource.Factory(app.baseClient).setUserAgent(userAgent) - } else { - Log.d(TAG, "Using CronetDataSource for $link") - CronetDataSource.Factory(engine, Executors.newSingleThreadExecutor()) - .setUserAgent(userAgent) - .setConnectionTimeoutMs(CRONET_TIMEOUT_MS) - .setReadTimeoutMs(CRONET_TIMEOUT_MS) - .setResetTimeoutOnRedirects(true) - .setHandleSetCookieRequests(true) - } + DefaultHttpDataSource.Factory() //TODO USE app.baseClient + .setUserAgent(userAgent ?: USER_AGENT) + .setAllowCrossProtocolRedirects(true) //https://stackoverflow.com/questions/69040127/error-code-io-bad-http-status-exoplayer-android } else { - Log.d(TAG, "Using OkHttpDataSource for $link") val client = app.baseClient.newBuilder() .addInterceptor(interceptor) .build() - OkHttpDataSource.Factory(client).setUserAgent(userAgent) + OkHttpDataSource.Factory(client).setUserAgent(userAgent ?: USER_AGENT) } // Do no include empty referer, if the provider wants those they can use the header map. val refererMap = if (link.referer.isBlank()) emptyMap() else mapOf("referer" to link.referer) - - // 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 +750,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 +812,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 +865,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)) } @@ -1070,57 +921,39 @@ class CS3IPlayer : IPlayer { subtitleOffset: Long, cacheSize: Long, videoBufferMs: Long, - onlineSource: HttpDataSource.Factory? = null, playWhenReady: Boolean = true, + cacheFactory: CacheDataSource.Factory? = null, trackSelector: TrackSelector? = null, /** * Sets the m3u8 preferred video quality, will not force stop anything with higher quality. * Does not work if trackSelector is defined. **/ - maxVideoHeight: Int? = null, - /** External audio tracks to merge with the video */ - audioSources: List = emptyList() + maxVideoHeight: Int? = null ): 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,8 +961,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() - .partition { it.bitmap != null } + val (bitmapCues, textCues) = cue.cues.filterNotNull().partition { it.bitmap != null } val styledBitmapCues = bitmapCues.map { bitmapCue -> bitmapCue @@ -1139,38 +971,16 @@ class CS3IPlayer : IPlayer { .build() } - // Reuse memory, to avoid many allocations - val set = HashSet() - val buffer = StringBuilder() - // Move cues into one single one // This is to prevent text overlap in vtt (and potentially other) subtitle files val styledTextCues = textCues.groupBy { // Groups cues which share the same positon it.lineAnchor to it.position.times(1000.0f).toInt() }.mapNotNull { (_, entries) -> - set.clear() - buffer.clear() - var count = 0 - for (x in entries) { - // Only allow non null text, otherwise we might have "a\n\nb" - val text = x.text ?: continue - - // Prevent duplicate entries, this often happens when the subtitle file - // uses multiple text lines as outlines. Most commonly found in fansubs - // with fancy subtitle styling. - if (!set.add(text)) { - continue - } - if (++count > 1) buffer.append('\n') - - // Trim to avoid weird formatting if the last line ends with a newline - buffer.append(text.trim()) + val combinedCueText = entries.joinToString("\n") { + it.text?.toString() ?: "" } - val combinedCueText = buffer.toString() - - // Use the style of the first entry as the base entries .firstOrNull() ?.buildUpon() @@ -1196,7 +1006,6 @@ class CS3IPlayer : IPlayer { CustomDecoder.subtitleOffset = subtitleOffset val decoder = CustomSubtitleDecoderFactory() - // @OptIn(ExperimentalApi::class) val currentTextRenderer = TextRenderer( customTextOutput, eventHandler.looper, @@ -1251,27 +1060,10 @@ class CS3IPlayer : IPlayer { // Because "Java rules" the media3 team hates to do open classes so we have to copy paste the entire thing to add a custom extractor // This includes the updated MKV extractor that enabled seeking in formats where the seek information is at the back of the file val extractorFactor = UpdatedDefaultExtractorsFactory() - .setFragmentedMp4ExtractorFlags(FragmentedMp4Extractor.FLAG_MERGE_FRAGMENTED_SIDX) - // Create an online connection with cache for all online sources - val dataSourceFactory = if (onlineSource == null) { - null - } else { - if (simpleCache == null) - simpleCache = getCache(context, simpleCacheSize) - - val cacheFactory = CacheDataSource.Factory().apply { - simpleCache?.let { setCache(it) } - setUpstreamDataSourceFactory(onlineSource) - } - cacheFactory - } - - val defaultMediaSourceFactory = if (dataSourceFactory != null) { - DefaultMediaSourceFactory(dataSourceFactory, extractorFactor) - } else { - DefaultMediaSourceFactory(context, extractorFactor) - } + val factory = + if (cacheFactory == null) DefaultMediaSourceFactory(context, extractorFactor) + else DefaultMediaSourceFactory(cacheFactory, extractorFactor) // If there is only one item then treat it as normal, if multiple: concatenate the items. val videoMediaSource = if (mediaItemSlices.size == 1) { @@ -1279,10 +1071,9 @@ 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") + val client = createOnlineSource(drm.headers) val drmCallback = LocalMediaDrmCallback("{\"keys\":[{\"kty\":\"${drm.kty}\",\"k\":\"${drm.key}\",\"kid\":\"${drm.kid}\"}],\"type\":\"temporary\"}".toByteArray()) val manager = DefaultDrmSessionManager.Builder() @@ -1300,11 +1091,10 @@ 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") + val client = createOnlineSource(drm.headers) val drmCallback = HttpMediaDrmCallback(drm.licenseUrl, client) val manager = DefaultDrmSessionManager.Builder() .setPlayClearSamplesWithoutKeys(true) @@ -1330,16 +1120,16 @@ class CS3IPlayer : IPlayer { } } } ?: run { - defaultMediaSourceFactory.createMediaSource(item.mediaItem) + factory.createMediaSource(item.mediaItem) } } 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( - defaultMediaSourceFactory.createMediaSource(item.mediaItem), + factory.createMediaSource(item.mediaItem), item.durationUs ) ) @@ -1349,11 +1139,11 @@ 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( - defaultMediaSourceFactory.createMediaSource(item.mediaItem), + factory.createMediaSource(item.mediaItem), item.durationUs ) ) @@ -1361,18 +1151,19 @@ class CS3IPlayer : IPlayer { source } } + + //println("PLAYBACK POS $playbackPosition") return exoPlayerBuilder.build().apply { setPlayWhenReady(playWhenReady) seekTo(currentWindow, playbackPosition) - // Merge video, subtitles and external audio tracks - val allSources = listOf(videoMediaSource) + subSources + audioSources setMediaSource( - MergingMediaSource(*allSources.toTypedArray()), + MergingMediaSource( + videoMediaSource, *subSources.toTypedArray() + ), playbackPosition ) setHandleAudioBecomingNoisy(true) setPlaybackSpeed(playBackSpeed) - this.addAnalyticsListener(tracksAnalyticsListener) } } @@ -1380,8 +1171,7 @@ class CS3IPlayer : IPlayer { context: Context, mediaSlices: List, subSources: List, - audioSources: List = emptyList(), - onlineSource: HttpDataSource.Factory? = null, + cacheFactory: CacheDataSource.Factory? = null ) { Log.i(TAG, "loadExo") val settingsManager = PreferenceManager.getDefaultSharedPreferences(context) @@ -1405,32 +1195,14 @@ class CS3IPlayer : IPlayer { cacheSize = cacheSize, videoBufferMs = videoBufferMs, playWhenReady = isPlaying, // this keep the current state of the player + cacheFactory = cacheFactory, subtitleOffset = currentSubtitleOffset, - maxVideoHeight = maxVideoHeight, - audioSources = audioSources, - onlineSource = onlineSource, + maxVideoHeight = maxVideoHeight ) 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 +1215,6 @@ class CS3IPlayer : IPlayer { return } - LiveHelper.registerPlayer(exoPlayer) - exoPlayer?.addListener(object : Player.Listener { override fun onTracksChanged(tracks: Tracks) { safe { @@ -1469,7 +1239,7 @@ class CS3IPlayer : IPlayer { return@mapNotNull SubtitleData( // Nicer looking displayed names - fromTagToLanguageName(format.language) + fromTwoLettersToLanguage(format.language!!) ?: format.language!!, format.label ?: "", // See setPreferredTextLanguage @@ -1487,19 +1257,13 @@ class CS3IPlayer : IPlayer { } } - // fixme: Use onPlaybackStateChanged(int) and onPlayWhenReadyChanged(boolean, int) instead. - @Suppress("OVERRIDE_DEPRECATION") + //fixme: Use onPlaybackStateChanged(int) and onPlayWhenReadyChanged(boolean, int) instead. override fun onPlayerStateChanged(playWhenReady: Boolean, playbackState: Int) { exoPlayer?.let { exo -> event( StatusEvent( wasPlaying = if (isPlaying) CSPlayerLoading.IsPlaying else CSPlayerLoading.IsPaused, - isPlaying = - when (playbackState) { - Player.STATE_ENDED -> CSPlayerLoading.IsEnded - Player.STATE_BUFFERING -> CSPlayerLoading.IsBuffering - else -> if (exo.isPlaying) CSPlayerLoading.IsPlaying else CSPlayerLoading.IsPaused - } + isPlaying = if (playbackState == Player.STATE_BUFFERING) CSPlayerLoading.IsBuffering else if (exo.isPlaying) CSPlayerLoading.IsPlaying else CSPlayerLoading.IsPaused ) ) isPlaying = exo.isPlaying @@ -1553,23 +1317,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)) } @@ -1598,6 +1345,9 @@ class CS3IPlayer : IPlayer { } Player.STATE_ENDED -> { + // Resets subtitle delay on ended video + setSubtitleOffset(0) + // Only play next episode if autoplay is on (default) if (PreferenceManager.getDefaultSharedPreferences(context) ?.getBoolean( @@ -1634,16 +1384,16 @@ class CS3IPlayer : IPlayer { onRenderFirst() updatedTime(source = PlayerEventSource.Player) } - }.also { playerListener = it }) + }) } catch (t: Throwable) { Log.e(TAG, "loadExo error", t) event(ErrorEvent(t)) } } - 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 +1402,7 @@ class CS3IPlayer : IPlayer { // onTimestampInvoked?.invoke(payload) } ?.setLooper(Looper.getMainLooper()) - ?.setPosition(timestamp.timestamp.startMs) + ?.setPosition(timestamp.startMs) //?.setPayload(timestamp) ?.setDeleteAfterDelivery(false) ?.send() @@ -1666,6 +1416,20 @@ class CS3IPlayer : IPlayer { } Log.i(TAG, "Rendered first frame") hasUsedFirstRender = true + val invalid = exoPlayer?.duration?.let { duration -> + // Only errors short playback when not playing downloaded files + duration < 20_000L && currentDownloadedFile == null + // Concatenated sources (non 1 periodCount) bypasses the invalid check as exoPlayer.duration gives only the current period + // If you can get the total time that'd be better, but this is already niche. + && exoPlayer?.currentTimeline?.periodCount == 1 + && exoPlayer?.isCurrentMediaItemLive != true + } ?: false + + if (invalid) { + releasePlayer(saveTime = false) + event(ErrorEvent(InvalidFileException("Too short playback"))) + return + } setPreferredSubtitles(currentSubtitles) val format = exoPlayer?.videoFormat @@ -1696,11 +1460,12 @@ class CS3IPlayer : IPlayer { val mediaItem = getMediaItem(MimeTypes.VIDEO_MP4, data.uri) val offlineSourceFactory = context.createOfflineSource() + val onlineSourceFactory = createOnlineSource(emptyMap()) val (subSources, activeSubtitles) = getSubSources( + onlineSourceFactory = onlineSourceFactory, offlineSourceFactory = offlineSourceFactory, - subHelper = subtitleHelper, - interceptor = null, + subtitleHelper, ) subtitleHelper.setActiveSubtitles(activeSubtitles.toSet()) @@ -1712,20 +1477,20 @@ class CS3IPlayer : IPlayer { } private fun getSubSources( + onlineSourceFactory: HttpDataSource.Factory?, offlineSourceFactory: DataSource.Factory?, subHelper: PlayerSubtitleHelper, - interceptor: Interceptor?, ): Pair, List> { val activeSubtitles = ArrayList() val subSources = subHelper.getAllSubtitles().mapNotNull { sub -> - val subConfig = MediaItem.SubtitleConfiguration.Builder(sub.getFixedUrl().toUri()) + val subConfig = MediaItem.SubtitleConfiguration.Builder(Uri.parse(sub.getFixedUrl())) .setMimeType(sub.mimeType) .setLanguage("_${sub.name}") .setId(sub.getId()) .setSelectionFlags(0) .build() when (sub.origin) { - SubtitleOrigin.DOWNLOADED_FILE, SubtitleOrigin.EMBEDDED_IN_VIDEO -> { + SubtitleOrigin.DOWNLOADED_FILE -> { if (offlineSourceFactory != null) { activeSubtitles.add(sub) SingleSampleMediaSource.Factory(offlineSourceFactory) @@ -1736,41 +1501,37 @@ class CS3IPlayer : IPlayer { } SubtitleOrigin.URL -> { - val dataSourceFactory = createOnlineSource(sub.headers, interceptor) - activeSubtitles.add(sub) - SingleSampleMediaSource.Factory(dataSourceFactory) - .createMediaSource(subConfig, TIME_UNSET) + if (onlineSourceFactory != null) { + activeSubtitles.add(sub) + SingleSampleMediaSource.Factory(onlineSourceFactory.apply { + if (sub.headers.isNotEmpty()) + this.setDefaultRequestProperties(sub.headers) + }) + .createMediaSource(subConfig, TIME_UNSET) + } else { + null + } + } + + SubtitleOrigin.EMBEDDED_IN_VIDEO -> { + if (offlineSourceFactory != null) { + activeSubtitles.add(sub) + SingleSampleMediaSource.Factory(offlineSourceFactory) + .createMediaSource(subConfig, TIME_UNSET) + } else { + null + } } } } return Pair(subSources, activeSubtitles) } - /** - * Creates audio media sources from ExtractorLink's audioTracks - * @param audioTracks List of audio tracks from ExtractorLink - * @return List of MediaSource for audio tracks - */ - private fun getAudioSources( - audioTracks: List, - interceptor: Interceptor?, - ): List { - return audioTracks.mapNotNull { audio -> - try { - val mediaItem = getMediaItem(MimeTypes.AUDIO_UNKNOWN, audio.url) - val dataSourceFactory = createOnlineSource(audio.headers, interceptor) - DefaultMediaSourceFactory(dataSourceFactory).createMediaSource(mediaItem) - } catch (e: Exception) { - Log.e(TAG, "Failed to create audio source for ${audio.url}: ${e.message}") - null - } - } - } - override fun isActive(): Boolean { return exoPlayer != null } + @MainThread private fun loadTorrent(context: Context, link: ExtractorLink) { ioSafe { @@ -1820,7 +1581,7 @@ class CS3IPlayer : IPlayer { defaultSet ) ?.mapNotNull { it.toIntOrNull() ?: return@mapNotNull null } - } catch (_: Throwable) { + } catch (e: Throwable) { null } ?: default @@ -1840,7 +1601,7 @@ class CS3IPlayer : IPlayer { // this causes a *bug* that restarts all torrents from 0 // but I would call this a feature releasePlayer() - loadExo(context, listOf(), listOf()) + loadExo(context, listOf(), listOf(), null) } event( StatusEvent( @@ -1893,7 +1654,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,10 +1676,11 @@ 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, + headers = link.getAllHeaders() ) ) ) @@ -1930,46 +1692,26 @@ 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 - ) - + val onlineSourceFactory = createOnlineSource(link) val offlineSourceFactory = context.createOfflineSource() val (subSources, activeSubtitles) = getSubSources( + onlineSourceFactory = onlineSourceFactory, offlineSourceFactory = offlineSourceFactory, - subHelper = subtitleHelper, - interceptor = interceptor, // Backwards compatibility, needs a new api to work properly - ) - - // 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 ) subtitleHelper.setActiveSubtitles(activeSubtitles.toSet()) - loadExo( - context = context, - mediaSlices = mediaItems, - subSources = subSources, - audioSources = audioSources, - onlineSource = onlineSourceFactory - ) + if (simpleCache == null) + simpleCache = getCache(context, simpleCacheSize) + + val cacheFactory = CacheDataSource.Factory().apply { + simpleCache?.let { setCache(it) } + setUpstreamDataSourceFactory(onlineSourceFactory) + } + + loadExo(context, mediaItems, subSources, cacheFactory) } catch (t: Throwable) { Log.e(TAG, "loadOnlinePlayer error", t) event(ErrorEvent(t)) @@ -1986,38 +1728,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..dfef0de00 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 @@ -34,8 +35,8 @@ import java.nio.charset.Charset /** * @param fallbackFormat used to create a decoder based on mimetype if the subtitle string is not * enough to identify the subtitle format. - */ -@OptIn(UnstableApi::class) + **/ +@UnstableApi class CustomDecoder(private val fallbackFormat: Format?) : SubtitleParser { companion object { fun updateForcedEncoding(context: Context) { @@ -52,15 +53,15 @@ class CustomDecoder(private val fallbackFormat: Format?) : SubtitleParser { } private const val DEFAULT_MARGIN: Float = 0.05f - const val SSA_ALIGNMENT_BOTTOM_LEFT = 1 - const val SSA_ALIGNMENT_BOTTOM_CENTER = 2 - const val SSA_ALIGNMENT_BOTTOM_RIGHT = 3 - const val SSA_ALIGNMENT_MIDDLE_LEFT = 4 - const val SSA_ALIGNMENT_MIDDLE_CENTER = 5 - const val SSA_ALIGNMENT_MIDDLE_RIGHT = 6 - const val SSA_ALIGNMENT_TOP_LEFT = 7 - const val SSA_ALIGNMENT_TOP_CENTER = 8 - const val SSA_ALIGNMENT_TOP_RIGHT = 9 + private const val SSA_ALIGNMENT_BOTTOM_LEFT = 1 + private const val SSA_ALIGNMENT_BOTTOM_CENTER = 2 + private const val SSA_ALIGNMENT_BOTTOM_RIGHT = 3 + private const val SSA_ALIGNMENT_MIDDLE_LEFT = 4 + private const val SSA_ALIGNMENT_MIDDLE_CENTER = 5 + private const val SSA_ALIGNMENT_MIDDLE_RIGHT = 6 + private const val SSA_ALIGNMENT_TOP_LEFT = 7 + private const val SSA_ALIGNMENT_TOP_CENTER = 8 + private const val SSA_ALIGNMENT_TOP_RIGHT = 9 /** Subtitle offset in milliseconds */ var subtitleOffset: Long = 0 @@ -107,7 +108,7 @@ class CustomDecoder(private val fallbackFormat: Format?) : SubtitleParser { } /** - * Fixes alignment for cues with {\anX}, + * Fixes alignment for cues with {\anX}, * this is common for .vtt that should be parsed as .srt * * ``` @@ -147,7 +148,37 @@ class CustomDecoder(private val fallbackFormat: Format?) : SubtitleParser { // exoplayer can already parse this, however for eg webvtt it fails locationRegex.find(trimmed)?.groupValues?.get(1)?.toIntOrNull()?.let { alignment -> // toLineAnchor - this.setSubtitleAlignment(alignment) + when (alignment) { + SSA_ALIGNMENT_BOTTOM_LEFT, SSA_ALIGNMENT_BOTTOM_CENTER, SSA_ALIGNMENT_BOTTOM_RIGHT -> Cue.ANCHOR_TYPE_END + SSA_ALIGNMENT_MIDDLE_LEFT, SSA_ALIGNMENT_MIDDLE_CENTER, SSA_ALIGNMENT_MIDDLE_RIGHT -> Cue.ANCHOR_TYPE_MIDDLE + SSA_ALIGNMENT_TOP_LEFT, SSA_ALIGNMENT_TOP_CENTER, SSA_ALIGNMENT_TOP_RIGHT -> Cue.ANCHOR_TYPE_START + else -> null + }?.let { anchor -> + setLineAnchor(anchor) + setLine( + computeDefaultLineOrPosition(anchor), Cue.LINE_TYPE_FRACTION + ) + } + // toPositionAnchor + when (alignment) { + SSA_ALIGNMENT_BOTTOM_LEFT, SSA_ALIGNMENT_MIDDLE_LEFT, SSA_ALIGNMENT_TOP_LEFT -> Cue.ANCHOR_TYPE_START + SSA_ALIGNMENT_BOTTOM_CENTER, SSA_ALIGNMENT_MIDDLE_CENTER, SSA_ALIGNMENT_TOP_CENTER -> Cue.ANCHOR_TYPE_MIDDLE + SSA_ALIGNMENT_BOTTOM_RIGHT, SSA_ALIGNMENT_MIDDLE_RIGHT, SSA_ALIGNMENT_TOP_RIGHT -> Cue.ANCHOR_TYPE_END + else -> null + }?.let { anchor -> + setPositionAnchor(anchor) + setPosition(computeDefaultLineOrPosition(anchor)) + } + + // toTextAlignment + when (alignment) { + SSA_ALIGNMENT_BOTTOM_LEFT, SSA_ALIGNMENT_MIDDLE_LEFT, SSA_ALIGNMENT_TOP_LEFT -> Layout.Alignment.ALIGN_NORMAL + SSA_ALIGNMENT_BOTTOM_CENTER, SSA_ALIGNMENT_MIDDLE_CENTER, SSA_ALIGNMENT_TOP_CENTER -> Layout.Alignment.ALIGN_CENTER + SSA_ALIGNMENT_BOTTOM_RIGHT, SSA_ALIGNMENT_MIDDLE_RIGHT, SSA_ALIGNMENT_TOP_RIGHT -> Layout.Alignment.ALIGN_OPPOSITE + else -> null + }?.let { anchor -> + setTextAlignment(anchor) + } } // remove all matches, so we do not display \anx @@ -155,42 +186,6 @@ class CustomDecoder(private val fallbackFormat: Format?) : SubtitleParser { setText(trimmed) return this } - - fun Cue.Builder.setSubtitleAlignment(alignment: Int?): Cue.Builder { - if (alignment == null) return this - when (alignment) { - SSA_ALIGNMENT_BOTTOM_LEFT, SSA_ALIGNMENT_BOTTOM_CENTER, SSA_ALIGNMENT_BOTTOM_RIGHT -> Cue.ANCHOR_TYPE_END - SSA_ALIGNMENT_MIDDLE_LEFT, SSA_ALIGNMENT_MIDDLE_CENTER, SSA_ALIGNMENT_MIDDLE_RIGHT -> Cue.ANCHOR_TYPE_MIDDLE - SSA_ALIGNMENT_TOP_LEFT, SSA_ALIGNMENT_TOP_CENTER, SSA_ALIGNMENT_TOP_RIGHT -> Cue.ANCHOR_TYPE_START - else -> null - }?.let { anchor -> - setLineAnchor(anchor) - setLine( - computeDefaultLineOrPosition(anchor), Cue.LINE_TYPE_FRACTION - ) - } - // toPositionAnchor - when (alignment) { - SSA_ALIGNMENT_BOTTOM_LEFT, SSA_ALIGNMENT_MIDDLE_LEFT, SSA_ALIGNMENT_TOP_LEFT -> Cue.ANCHOR_TYPE_START - SSA_ALIGNMENT_BOTTOM_CENTER, SSA_ALIGNMENT_MIDDLE_CENTER, SSA_ALIGNMENT_TOP_CENTER -> Cue.ANCHOR_TYPE_MIDDLE - SSA_ALIGNMENT_BOTTOM_RIGHT, SSA_ALIGNMENT_MIDDLE_RIGHT, SSA_ALIGNMENT_TOP_RIGHT -> Cue.ANCHOR_TYPE_END - else -> null - }?.let { anchor -> - setPositionAnchor(anchor) - setPosition(computeDefaultLineOrPosition(anchor)) - } - - // toTextAlignment - when (alignment) { - SSA_ALIGNMENT_BOTTOM_LEFT, SSA_ALIGNMENT_MIDDLE_LEFT, SSA_ALIGNMENT_TOP_LEFT -> Layout.Alignment.ALIGN_NORMAL - SSA_ALIGNMENT_BOTTOM_CENTER, SSA_ALIGNMENT_MIDDLE_CENTER, SSA_ALIGNMENT_TOP_CENTER -> Layout.Alignment.ALIGN_CENTER - SSA_ALIGNMENT_BOTTOM_RIGHT, SSA_ALIGNMENT_MIDDLE_RIGHT, SSA_ALIGNMENT_TOP_RIGHT -> Layout.Alignment.ALIGN_OPPOSITE - else -> null - }?.let { anchor -> - setTextAlignment(anchor) - } - return this - } } private var realDecoder: SubtitleParser? = null @@ -250,14 +245,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 @@ -391,7 +386,7 @@ class CustomSubtitleDecoderFactory : SubtitleDecoderFactory { /** * Decoders created here persists across reset() * Do not save state in the decoder which you want to reset (e.g subtitle offset) - */ + **/ override fun createDecoder(format: Format): SubtitleDecoder { val parser = CustomDecoder(format) // Allow garbage collection if player releases the decoder @@ -403,8 +398,8 @@ class CustomSubtitleDecoderFactory : SubtitleDecoderFactory { } } -/** We need to convert the newer SubtitleParser to an older SubtitleDecoder */ @OptIn(UnstableApi::class) +/** We need to convert the newer SubtitleParser to an older SubtitleDecoder */ class DelegatingSubtitleDecoder(name: String, private val parser: SubtitleParser) : SimpleSubtitleDecoder(name) { 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..16eb88327 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 @@ -1,25 +1,60 @@ package com.lagradost.cloudstream3.ui.player import android.net.Uri -import com.lagradost.cloudstream3.CloudStreamApp.Companion.context +import com.lagradost.cloudstream3.AcraApplication.Companion.context import com.lagradost.cloudstream3.CommonActivity.activity import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.ui.player.PlayerSubtitleHelper.Companion.toSubtitleMimeType import com.lagradost.cloudstream3.utils.ExtractorLink 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 +import kotlin.math.max +import kotlin.math.min class DownloadFileGenerator( - episodes: List -) : VideoGenerator(episodes) { + private val episodes: List, + private var currentIndex: Int = 0 +) : IGenerator { override val hasCache = false override val canSkipLoading = false - override fun getId(index: Int): Int? = this.videos.getOrNull(index)?.id + override fun hasNext(): Boolean { + return currentIndex < episodes.size - 1 + } + + override fun hasPrev(): Boolean { + return currentIndex > 0 + } + + override fun next() { + if (hasNext()) + currentIndex++ + } + + override fun prev() { + if (hasPrev()) + currentIndex-- + } + + override fun goto(index: Int) { + // clamps value + currentIndex = min(episodes.size - 1, max(0, index)) + } + + override fun getCurrentId(): Int? { + return episodes[currentIndex].id + } + + override fun getCurrent(offset: Int): Any? { + return episodes.getOrNull(currentIndex + offset) + } + + override fun getAll(): List? { + return null + } override suspend fun generateLinks( clearCache: Boolean, @@ -29,14 +64,14 @@ class DownloadFileGenerator( offset: Int, isCasting: Boolean ): Boolean { - val meta = videos.getOrNull(offset) ?: return false + val meta = episodes[currentIndex + offset] 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) } } @@ -55,19 +90,17 @@ class DownloadFileGenerator( getFolder(ctx, relative, meta.basePath)?.forEach { (name, uri) -> if (isMatchingSubtitle(name, display, cleanDisplay)) { val cleanName = cleanDisplayName(name) - val lastNum = Regex(" ([0-9]+)$") - val nameSuffix = lastNum.find(cleanName)?.groupValues?.get(1) ?: "" - val originalName = cleanName.removePrefix(cleanDisplay).replace(lastNum, "").trim() + val realName = cleanName.removePrefix(cleanDisplay) subtitleCallback( SubtitleData( - originalName.ifBlank { ctx.getString(R.string.default_subtitles) }, - nameSuffix, + realName.ifBlank { ctx.getString(R.string.default_subtitles) }, + "", uri.toString(), SubtitleOrigin.DOWNLOADED_FILE, name.toSubtitleMimeType(), emptyMap(), - fromLanguageToTagIETF(originalName, true) + null ) ) } 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..7fc297235 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 @@ -11,12 +11,9 @@ import com.lagradost.cloudstream3.mvvm.safe import com.lagradost.cloudstream3.ui.player.OfflinePlaybackHelper.playLink import com.lagradost.cloudstream3.ui.player.OfflinePlaybackHelper.playUri import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.attachBackPressedCallback -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 +26,52 @@ 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..794dd762d 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,36 @@ import com.lagradost.cloudstream3.utils.ExtractorLinkType class ExtractorLinkGenerator( private val links: List, private val subtitles: List, -) : NoVideoGenerator(null) { +) : IGenerator { + override val hasCache = false + override val canSkipLoading = true + + override fun getCurrentId(): Int? { + return null + } + + override fun hasNext(): Boolean { + return false + } + + override fun getAll(): List? { + return null + } + + override fun hasPrev(): Boolean { + return false + } + + override fun getCurrent(offset: Int): Any? { + return null + } + + override fun goto(index: Int) {} + + override fun next() {} + + override fun prev() {} + 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..22cd22d3c 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 @@ -5,45 +5,56 @@ import android.annotation.SuppressLint import android.app.Activity import android.app.Dialog import android.content.Context -import android.content.DialogInterface 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.animation.AccelerateDecelerateInterpolator +import android.view.WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES 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.google.android.material.button.MaterialButton 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.ui.player.GeneratorPlayer.Companion.subsProvidersIsActive @@ -53,38 +64,59 @@ 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.SingleSelectionHelper.showDialog 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 +import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.attachBackPressedCallback +import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.detachBackPressedCallback + +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 durationMode: Boolean by UserPreferenceDelegate("duration_mode", false) + // state of player UI protected var isShowing = 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 +125,21 @@ 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,115 +153,47 @@ open class FullScreenPlayer : AbstractPlayerFragment( 0L } - private var isShowingEpisodeOverlay: Boolean = false - private var previousPlayStatus: Boolean = false + //private var useSystemBrightness = false + protected var useTrueSystemBrightness = true + private val fullscreenNotch = true //TODO SETTING - override fun fixLayout(view: View) = Unit + private var statusBarHeight: Int? = null + private var navigationBarHeight: Int? = null - /** - * 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() - } - } + 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, + ) - /** 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 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 fun scheduleMetadataVisibility() { - val metadataScrim = playerBinding?.playerMetadataScrim ?: return - val ctx = metadataScrim.context ?: return - - 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() - } - } + 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)) + return root } override fun onDestroyView() { - playerHostView?.releaseOverlayLayoutListener() playerBinding = null super.onDestroyView() } @@ -242,12 +214,41 @@ open class FullScreenPlayer : AbstractPlayerFragment( throw NotImplementedError() } - open fun showEpisodesOverlay() { - throw NotImplementedError() - } + /** + * [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 - open fun isThereEpisodes(): Boolean { - return false + 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() { @@ -257,6 +258,7 @@ open class FullScreenPlayer : AbstractPlayerFragment( private fun animateLayoutChangesForSubtitles() = // Post here as bottomPlayerBar is gone the first frame => bottomPlayerBar.height = 0 playerBinding?.bottomPlayerBar?.post { + @OptIn(UnstableApi::class) val sView = subView ?: return@post val sStyle = CustomDecoder.style val binding = playerBinding ?: return@post @@ -281,12 +283,11 @@ open class FullScreenPlayer : AbstractPlayerFragment( if (isShowing) { updateUIVisibility() } else { - toggleEpisodesOverlay(false) playerBinding?.playerHolder?.postDelayed({ updateUIVisibility() }, 200) } val titleMove = if (isShowing) 0f else -50.toPx.toFloat() - playerBinding?.playerVideoTitleHolder?.let { + playerBinding?.playerVideoTitle?.let { ObjectAnimator.ofFloat(it, "translationY", titleMove).apply { duration = 200 start() @@ -298,19 +299,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 { @@ -318,15 +306,7 @@ open class FullScreenPlayer : AbstractPlayerFragment( start() } } - if (isLayout(PHONE)) { - playerBinding?.playerEpisodesButton?.let { - ObjectAnimator.ofFloat(it, "translationX", if (isShowing) 0f else 50.toPx.toFloat()) - .apply { - duration = 200 - start() - } - } - } + val fadeTo = if (isShowing) 1f else 0f val fadeAnimation = AlphaAnimation(1f - fadeTo, fadeTo) @@ -346,10 +326,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) @@ -358,10 +353,11 @@ open class FullScreenPlayer : AbstractPlayerFragment( } } + @OptIn(UnstableApi::class) 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 +373,7 @@ open class FullScreenPlayer : AbstractPlayerFragment( Configuration.ORIENTATION_PORTRAIT -> ActivityInfo.SCREEN_ORIENTATION_SENSOR_PORTRAIT - else -> playerHostView?.dynamicOrientation() ?: return + else -> dynamicOrientation() } activity.requestedOrientation = orientation } @@ -391,14 +387,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 +415,7 @@ open class FullScreenPlayer : AbstractPlayerFragment( else ActivityInfo.SCREEN_ORIENTATION_REVERSE_PORTRAIT - else -> orientation = playerHostView?.dynamicOrientation() ?: return + else -> orientation = dynamicOrientation() } activity.requestedOrientation = orientation } @@ -430,53 +426,55 @@ open class FullScreenPlayer : AbstractPlayerFragment( if (isLocked) { lockOrientation(this) } else { - if (ignoreDynamicOrientation || rotatedManually) { - // Restore when lock is disabled. + if (ignoreDynamicOrientation) { + // 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 - if (isLayout(TV or EMULATOR)) { - playerPausePlay?.requestFocus() - } - toggleEpisodesOverlay(show = false) - return@attachBackPressedCallback - } else if (isShowing && isLayout(TV or EMULATOR)) { - // netflix capture back and hide ~monke + // netflix capture back and hide ~monke + if (isShowing && isLayout(TV or EMULATOR)) { onClickChange() } else { activity?.popCurrentPage("FullScreenPlayer") } } - playerHostView?.requestUpdateBrightnessOverlayOnNextLayout() super.onResume() } @@ -486,7 +484,10 @@ open class FullScreenPlayer : AbstractPlayerFragment( } override fun onDestroy() { - playerHostView?.exitFullscreen() + exitFullscreen() + player.release() + player.releaseCallbacks() + player = CS3IPlayer() super.onDestroy() } @@ -519,21 +520,30 @@ open class FullScreenPlayer : AbstractPlayerFragment( val binding = SubtitleOffsetBinding.inflate(LayoutInflater.from(ctx), null, false) // Use dialog as opposed to alertdialog to get fullscreen - val dialog = Dialog(ctx, R.style.DialogFullscreenPlayer).apply { + val dialog = Dialog(ctx, R.style.AlertDialogCustomBlack).apply { setContentView(binding.root) } - this.selectSubtitlesDialog = dialog dialog.show() - val isPortrait = - ctx.resources.configuration.orientation == Configuration.ORIENTATION_PORTRAIT - fixSystemBarsPadding(binding.root, fixIme = isPortrait) + val beforeOffset = subtitleDelay - var currentOffset = subtitleDelay binding.apply { + var subtitleAdapter: SubtitleOffsetItemAdapter? = null + subtitleOffsetInput.doOnTextChanged { text, _, _, _ -> text?.toString()?.toLongOrNull()?.let { time -> - currentOffset = time + subtitleDelay = time + + // Scroll to the first active subtitle + val playerPosition = player.getPosition() ?: 0 + val totalPosition = playerPosition - subtitleDelay + 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) @@ -551,21 +561,19 @@ open class FullScreenPlayer : AbstractPlayerFragment( } } subtitleOffsetInput.text = - Editable.Factory.getInstance()?.newEditable(currentOffset.toString()) + Editable.Factory.getInstance()?.newEditable(beforeOffset.toString()) val subtitles = player.getSubtitleCues().toMutableList() subtitleOffsetRecyclerview.isVisible = subtitles.isNotEmpty() noSubtitlesLoadedNotice.isVisible = subtitles.isEmpty() - val initialSubtitlePosition = (player.getPosition() ?: 0) - currentOffset - val subtitleAdapter = - SubtitleOffsetItemAdapter(initialSubtitlePosition) { subtitleCue -> + val initialSubtitlePosition = (player.getPosition() ?: 0) - subtitleDelay + subtitleAdapter = + SubtitleOffsetItemAdapter(initialSubtitlePosition, subtitles) { subtitleCue -> val playerPosition = player.getPosition() ?: 0 subtitleOffsetInput.text = Editable.Factory.getInstance() ?.newEditable((playerPosition - subtitleCue.startTimeMs).toString()) - }.apply { - submitList(subtitles) } subtitleOffsetRecyclerview.adapter = subtitleAdapter @@ -599,109 +607,147 @@ 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 + subtitleDelay = beforeOffset dialog.dismissSafe(activity) } } } - @SuppressLint("SetTextI18n") - fun updateSpeedDialogBinding(binding: SpeedDialogBinding) { - val speed = player.getPlaybackSpeed() - binding.speedText.text = "%.2fx".format(speed).replace(".0x", "x") - // Android crashes if you don't round to an exact step size - binding.speedBar.value = - (speed.coerceIn(0.1f, 2.0f) / binding.speedBar.stepSize).roundToInt() - .toFloat() * binding.speedBar.stepSize - } private fun showSpeedDialog() { - val act = activity ?: return - val isPlaying = player.getIsPlaying() - player.handleEvent(CSPlayerEvent.Pause, PlayerEventSource.UI) + val speedsText = + listOf( + "0.5x", + "0.75x", + "0.85x", + "1x", + "1.15x", + "1.25x", + "1.4x", + "1.5x", + "1.75x", + "2x" + ) + val speedsNumbers = + listOf(0.5f, 0.75f, 0.85f, 1f, 1.15f, 1.25f, 1.4f, 1.5f, 1.75f, 2f) + val speedIndex = speedsNumbers.indexOf(player.getPlaybackSpeed()) - val binding: SpeedDialogBinding = SpeedDialogBinding.inflate( - LayoutInflater.from(act) - ) - - updateSpeedDialogBinding(binding) - for ((view, speed) in arrayOf( - binding.speed25 to 0.25f, - binding.speed100 to 1.0f, - binding.speed125 to 1.25f, - binding.speed150 to 1.5f, - binding.speed200 to 2.0f, - )) { - view.setOnClickListener { - setPlayBackSpeed(speed) - updateSpeedDialogBinding(binding) + activity?.let { act -> + act.showDialog( + speedsText, + speedIndex, + act.getString(R.string.player_speed), + false, + { + if (isFullScreenPlayer) + activity?.hideSystemUI() + }) { index -> + if (isFullScreenPlayer) + activity?.hideSystemUI() + setPlayBackSpeed(speedsNumbers[index]) } } + } - binding.speedMinus.setOnClickListener { - setPlayBackSpeed(maxOf((player.getPlaybackSpeed() - 0.1f), 0.1f)) - updateSpeedDialogBinding(binding) - } + fun resetRewindText() { + playerBinding?.exoRewText?.text = + getString(R.string.rew_text_regular_format).format(fastForwardTime / 1000) + } - binding.speedPlus.setOnClickListener { - setPlayBackSpeed(minOf((player.getPlaybackSpeed() + 0.1f), 2.0f)) - updateSpeedDialogBinding(binding) - } + fun resetFastForwardText() { + playerBinding?.exoFfwdText?.text = + getString(R.string.ffw_text_regular_format).format(fastForwardTime / 1000) + } - binding.speedBar.addOnChangeListener { _, value, fromUser -> - if (fromUser) { - setPlayBackSpeed(value) - updateSpeedDialogBinding(binding) + 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) } + } - val dismiss = DialogInterface.OnDismissListener { - activity?.hideSystemUI() - if (isPlaying) { - player.handleEvent(CSPlayerEvent.Play, PlayerEventSource.UI) + 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) } - selectSpeedDialog = null + player.seekTime(fastForwardTime) + } catch (e: Exception) { + logError(e) } - - // if (isLayout(PHONE)) { - // val builder = - // BottomSheetDialog(act, R.style.AlertDialogCustom) - // builder.setContentView(binding.root) - // builder.setOnDismissListener(dismiss) - // builder.show() - //} else { - val builder = - AlertDialog.Builder(act, R.style.AlertDialogCustom) - .setView(binding.root) - builder.setOnDismissListener(dismiss) - val dialog = builder.create() - this.selectSpeedDialog = dialog - dialog.show() - //} } 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() + playerBinding?.playerPausePlay?.requestFocus() } private fun toggleLock() { @@ -710,7 +756,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,37 +767,41 @@ open class FullScreenPlayer : AbstractPlayerFragment( } val fadeTo = if (isLocked) 0f else 1f - playerHostView?.gestureHelper?.animateCenterControls(fadeTo) playerBinding?.apply { - val fadeAnimation = AlphaAnimation(playerVideoTitleHolder.alpha, fadeTo).apply { + val fadeAnimation = AlphaAnimation(playerVideoTitle.alpha, fadeTo).apply { duration = 100 fillAfter = true } updateUIVisibility() + // MENUS + //centerMenu.startAnimation(fadeAnimation) + playerPausePlay.startAnimation(fadeAnimation) + playerFfwdHolder.startAnimation(fadeAnimation) + playerRewHolder.startAnimation(fadeAnimation) downloadBothHeader.startAnimation(fadeAnimation) - if (hasEpisodes) - playerEpisodesButton.startAnimation(fadeAnimation) - // player_media_route_button?.startAnimation(fadeAnimation) - // video_bar.startAnimation(fadeAnimation) + //if (hasEpisodes) + // player_episodes_button?.startAnimation(fadeAnimation) + //player_media_route_button?.startAnimation(fadeAnimation) + //video_bar.startAnimation(fadeAnimation) - // TITLE + //TITLE playerVideoTitleRez.startAnimation(fadeAnimation) - playerVideoInfo.startAnimation(fadeAnimation) playerEpisodeFiller.startAnimation(fadeAnimation) - playerVideoTitleHolder.startAnimation(fadeAnimation) + playerVideoTitle.startAnimation(fadeAnimation) playerTopHolder.startAnimation(fadeAnimation) // BOTTOM playerLockHolder.startAnimation(fadeAnimation) - // player_go_back_holder?.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 +812,22 @@ 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_episodes_button?.isVisible = !isGone && hasEpisodes + playerVideoTitle.isGone = togglePlayerTitleGone +// 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,237 +835,487 @@ 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() - } - - protected fun hidePlayerUI() { - if (isShowing) { - isShowing = false - animateLayoutChanges() - } - } - - /** PlayerView.Callbacks touch overrides */ - - override fun isUIShowing(): Boolean = isShowing - - override fun onSingleTap() { - onClickChange() - } - - override fun onTouchDown() { - if (isShowingEpisodeOverlay) toggleEpisodesOverlay(show = false) - } - - @SuppressLint("SetTextI18n") - override fun onSeekPreviewText(text: String?) { - playerBinding?.playerTimeText?.apply { - isVisible = text != null - if (text != null) this.text = text - } - } - - override fun onHidePlayerUI() { - hidePlayerUI() - } - - override fun onGestureEnd(hadSwipe: Boolean, wasUiShowing: Boolean) { - if (!player.getIsPlaying() && hadSwipe && wasUiShowing && !isShowing) { - isShowing = true - animateLayoutChanges() - } - autoHide() + currentTapIndex++ + delayHide() } override fun playerStatusChanged() { super.playerStatusChanged() - scheduleMetadataVisibility() + delayHide() } - // 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() + private fun delayHide() { + val index = currentTapIndex + playerBinding?.playerHolder?.postDelayed({ + if (!isCurrentTouchValid && isShowing && index == currentTapIndex && player.getIsPlaying()) { + onClickChange() + } + }, 2000) } - override fun onConfigurationChanged(newConfig: Configuration) { - super.onConfigurationChanged(newConfig) + // 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() + } + } - // 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() + 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 + + // this value is within the range [0,1] + 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 + ) + } + + 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 } } } - override fun resize(resize: PlayerResize, showToast: Boolean) { - super.resize(resize, showToast) - playerHostView?.requestUpdateBrightnessOverlayOnNextLayout() + 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, (brightness * 255).toInt() + ) + } catch (e: Exception) { + useTrueSystemBrightness = false + setBrightness(brightness) + } + } else { + try { + val lp = activity?.window?.attributes + lp?.screenBrightness = brightness + activity?.window?.attributes = lp + } catch (e: Exception) { + logError(e) + } + } } - 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) - } + private var isVolumeLocked: Boolean = false + private var hasShownVolumeToast: Boolean = false - KeyEvent.KEYCODE_A, KeyEvent.KEYCODE_MEDIA_SKIP_BACKWARD, KeyEvent.KEYCODE_MEDIA_REWIND -> { - player.handleEvent(CSPlayerEvent.SeekBack) - } + private var progressBarLeftHideRunnable: Runnable? = null + private var progressBarRightHideRunnable: Runnable? = null - KeyEvent.KEYCODE_MEDIA_NEXT, KeyEvent.KEYCODE_BUTTON_R1, KeyEvent.KEYCODE_N, KeyEvent.KEYCODE_NUMPAD_2, KeyEvent.KEYCODE_CHANNEL_UP -> { - player.handleEvent(CSPlayerEvent.NextEpisode) - } + // 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) - KeyEvent.KEYCODE_MEDIA_PREVIOUS, KeyEvent.KEYCODE_BUTTON_L1, KeyEvent.KEYCODE_B, KeyEvent.KEYCODE_NUMPAD_1, KeyEvent.KEYCODE_CHANNEL_DOWN -> { - player.handleEvent(CSPlayerEvent.PrevEpisode) - } + // if we can set the volume directly then do it + if (currentVolumeStep < maxVolumeStep || currentRequestedVolume <= 1.0f) { + currentRequestedVolume = + currentVolumeStep.toFloat() / maxVolumeStep.toFloat() - KeyEvent.KEYCODE_MEDIA_PAUSE -> { - player.handleEvent(CSPlayerEvent.Pause) + loudnessEnhancer?.release() + loudnessEnhancer = null } - - 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 } + } + + val holdhandler = Handler(Looper.getMainLooper()) + var hasTriggeredSpeedUp = false + val holdRunnable = Runnable { + player.setPlaybackSpeed(2.0f) + playerBinding?.playerSpeedupButton?.isGone = false + hasTriggeredSpeedUp = true + } + + @SuppressLint("SetTextI18n") + private fun handleMotionEvent(view: View?, event: MotionEvent?): Boolean { + if (event == null || view == null) return false + val currentTouch = Vector2(event.x, event.y) + val startTouch = currentTouchStart + + playerBinding?.apply { + playerIntroPlay.isGone = true + + 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 && player_episode_list?.isVisible == true) { + player_episode_list?.isVisible = 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 + } + + currentTouchStartTime = System.currentTimeMillis() + currentTouchStart = currentTouch + currentTouchLast = currentTouch + currentTouchStartPlayerTime = player.getPosition() + + getBrightness()?.let { + currentRequestedBrightness = it + } + verifyVolume() + } + } + + MotionEvent.ACTION_UP -> { + holdhandler.removeCallbacks(holdRunnable) + if (hasTriggeredSpeedUp) { + player.setPlaybackSpeed(DataStoreHelper.playBackSpeed) + playerSpeedupButton?.isGone = true + } + 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 + } + + // 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 + + // 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 + currentTouchAction = + if (startTouch.x < screenWidthWithOrientation / 2) { + // hide the UI if you hold brightness to show screen better, better UX + if (isShowing) { + isShowing = false + animateLayoutChanges() + } + + TouchAction.Brightness + } else { + 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 + currentRequestedBrightness = + min( + 1.0f, + max(currentRequestedBrightness + verticalAddition, 0.0f) + ) + + // this is to not spam request it, just in case it fucks over someone + if (lastRequested != currentRequestedBrightness) + setBrightness(currentRequestedBrightness) + + // max is set high to make it smooth + playerProgressbarRight.max = 100_000 + playerProgressbarRight.progress = + max(2_000, (currentRequestedBrightness * 100_000f).toInt()) + + playerProgressbarRightIcon.setImageResource( + brightnessIcons[min( // clamp the value just in case + brightnessIcons.size - 1, + max( + 0, + round(currentRequestedBrightness * (brightnessIcons.size - 1)).toInt() + ) + )] + ) + } + + TouchAction.Volume -> { + holdhandler.removeCallbacks(holdRunnable) + handleVolumeAdjustment( + verticalAddition, + false + ) + } + + else -> Unit + } + } + } + } + } + } + currentTouchLast = currentTouch return true } + @SuppressLint("GestureBackNavigation") private fun handleKeyEvent(event: KeyEvent, hasNavigated: Boolean): Boolean { if (hasNavigated) { autoHide() @@ -1026,9 +1324,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) { + onClickChange() + return true + } + } + + KeyEvent.KEYCODE_DPAD_LEFT -> { + if (!isShowing && !isLocked) { + player.seekTime(-androidTVInterfaceOffSeekTime) + return true + } else if (playerBinding?.playerPausePlay?.isFocused == true) { + player.seekTime(-androidTVInterfaceOnSeekTime) + return true + } + } + + KeyEvent.KEYCODE_DPAD_RIGHT -> { + if (!isShowing && !isLocked) { + 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)) { + /** + * 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,15 +1419,142 @@ 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 + + @OptIn(UnstableApi::class) + 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 playerBinding?.apply { playerSkipEpisode.isVisible = false @@ -1081,8 +1566,8 @@ open class FullScreenPlayer : AbstractPlayerFragment( updateLockUI() updateUIVisibility() animateLayoutChanges() - playerHostView?.gestureHelper?.resetFastForwardText() - playerHostView?.gestureHelper?.resetRewindText() + resetFastForwardText() + resetRewindText() } override fun onSaveInstanceState(outState: Bundle) { @@ -1091,35 +1576,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 +1692,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,31 +1710,58 @@ open class FullScreenPlayer : AbstractPlayerFragment( ctx.getString(R.string.rotate_video_key), false ) + autoPlayerRotateEnabled = settingsManager.getBoolean( + ctx.getString(R.string.auto_rotate_video_key), + false + ) 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 playerResizeBtt.isVisible = playerResizeEnabled - playerRotateBtt.isVisible = - if (isLayout(TV or EMULATOR)) false else playerRotateEnabled + playerRotateBtt.isVisible = playerRotateEnabled if (hideControlsNames) { hideControlsNames() } @@ -1175,13 +1771,13 @@ open class FullScreenPlayer : AbstractPlayerFragment( } playerBinding?.apply { + if (isLayout(TV or EMULATOR)) { mapOf( playerGoBack to playerGoBackText, playerRestart to playerRestartText, playerGoForward to playerGoForwardText, downloadHeaderToggle to downloadHeaderToggleText, - playerEpisodesButton to playerEpisodesButtonText ).forEach { (button, text) -> button.setOnFocusChangeListener { _, hasFocus -> if (!hasFocus) { @@ -1189,17 +1785,25 @@ open class FullScreenPlayer : AbstractPlayerFragment( text.isVisible = false return@setOnFocusChangeListener } - if (button.id == R.id.player_episodes_button) { - toggleEpisodesOverlay(show = true) - } else { - toggleEpisodesOverlay(show = false) - } text.isSelected = true text.isVisible = true } } } + playerPausePlay.setOnClickListener { + autoHide() + player.handleEvent(CSPlayerEvent.PlayPauseToggle) + } + + exoDuration.setOnClickListener { + setRemainingTimeCounter(true) + } + + timeLeft.setOnClickListener { + setRemainingTimeCounter(false) + } + skipChapterButton.setOnClickListener { player.handleEvent(CSPlayerEvent.SkipCurrentChapter) } @@ -1249,6 +1853,16 @@ open class FullScreenPlayer : AbstractPlayerFragment( showSubtitleOffsetDialog() } + playerRew.setOnClickListener { + autoHide() + rewind() + } + + playerFfwd.setOnClickListener { + autoHide() + fastForward() + } + playerGoBack.setOnClickListener { activity?.popCurrentPage("FullScreenPlayer") } @@ -1261,21 +1875,20 @@ open class FullScreenPlayer : AbstractPlayerFragment( showTracksDialogue() } - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - playerControlsScroll.setOnScrollChangeListener { _, _, _, _, _ -> - autoHide() - } + // 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) } - 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 -> { @@ -1284,9 +1897,11 @@ open class FullScreenPlayer : AbstractPlayerFragment( } return@setOnTouchListener false } - playerEpisodesButton.setOnClickListener { - toggleEpisodesOverlay(show = true) - } + } + // cs3 is peak media center + setRemainingTimeCounter(durationMode || isLayout(TV)) + playerBinding?.exoPosition?.doOnTextChanged { _, _, _, _ -> + updateRemainingTime() } // init UI try { @@ -1296,10 +1911,10 @@ open class FullScreenPlayer : AbstractPlayerFragment( } } + @SuppressLint("SourceLockedOrientationActivity") private fun toggleRotate() { activity?.let { toggleOrientationWithSensor(it) - rotatedManually = true } } @@ -1320,48 +1935,37 @@ 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 toggleEpisodesOverlay(show: Boolean) { - if (show && !isShowingEpisodeOverlay) { - previousPlayStatus = player.getIsPlaying() - player.handleEvent(CSPlayerEvent.Pause) - showEpisodesOverlay() - isShowingEpisodeOverlay = true - animateEpisodesOverlay(true) - } else if (isShowingEpisodeOverlay) { - if (previousPlayStatus) player.handleEvent(CSPlayerEvent.Play) - isShowingEpisodeOverlay = false - animateEpisodesOverlay(false) + 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 animateEpisodesOverlay(show: Boolean) { - playerBinding?.playerEpisodeOverlay?.let { overlay -> - overlay.animate().cancel() - (overlay.parent as? ViewGroup)?.layoutTransition = null // Disable layout transitions + private fun setRemainingTimeCounter(showRemaining: Boolean) { + durationMode = showRemaining + playerBinding?.exoDuration?.isInvisible = showRemaining + playerBinding?.timeLeft?.isVisible = showRemaining + } - val offset = 50 * overlay.resources.displayMetrics.density - - overlay.translationX = if (show) offset else 0f - playerBinding?.playerEpisodeOverlay?.isVisible = true - - overlay.animate() - .translationX(if (show) 0f else offset) - .alpha(if (show) 1f else 0f) - .setDuration(300) - .setInterpolator(AccelerateDecelerateInterpolator()).withEndAction { - if (!show) { - playerBinding?.playerEpisodeOverlay?.isGone = true - } - } - .start() + 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 } } } 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..be9d01bbe 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 @@ -38,16 +38,15 @@ import androidx.media3.common.Format.NO_VALUE import androidx.media3.common.MimeTypes import androidx.media3.common.Player import androidx.media3.common.util.UnstableApi +import androidx.media3.common.util.Util import androidx.media3.exoplayer.ExoPlayer import androidx.media3.ui.PlayerNotificationManager import androidx.media3.ui.PlayerNotificationManager.EXTRA_INSTANCE_ID import androidx.media3.ui.PlayerNotificationManager.MediaDescriptionAdapter import androidx.preference.PreferenceManager -import androidx.recyclerview.widget.LinearLayoutManager -import androidx.recyclerview.widget.RecyclerView import com.lagradost.cloudstream3.APIHolder.getApiFromNameNull -import com.lagradost.cloudstream3.CloudStreamApp -import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey +import com.lagradost.cloudstream3.AcraApplication +import com.lagradost.cloudstream3.AcraApplication.Companion.setKey import com.lagradost.cloudstream3.CommonActivity.showToast import com.lagradost.cloudstream3.LoadResponse import com.lagradost.cloudstream3.LoadResponse.Companion.getAniListId @@ -74,56 +73,42 @@ import com.lagradost.cloudstream3.mvvm.safe 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.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.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.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.ui.subtitles.SubtitlesFragment.Companion.getAutoSelectLanguageISO639_1 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.EpisodeSkip import com.lagradost.cloudstream3.utils.ExtractorLink import com.lagradost.cloudstream3.utils.ExtractorLinkType import com.lagradost.cloudstream3.utils.Qualities import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showDialog -import com.lagradost.cloudstream3.utils.SubtitleHelper.fromTagToEnglishLanguageName -import com.lagradost.cloudstream3.utils.SubtitleHelper.fromTagToLanguageName +import com.lagradost.cloudstream3.utils.SubtitleHelper.fromTwoLettersToLanguage import com.lagradost.cloudstream3.utils.SubtitleHelper.languages import com.lagradost.cloudstream3.utils.UIHelper.clipboardHelper 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.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.VideoDownloadManager.getImageBitmapFromUrl import com.lagradost.cloudstream3.utils.setText import com.lagradost.cloudstream3.utils.txt -import com.lagradost.cloudstream3.utils.videoskip.VideoSkipStamp import com.lagradost.safefile.SafeFile import kotlinx.coroutines.Job import kotlinx.coroutines.delay @@ -131,29 +116,21 @@ 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 -@OptIn(UnstableApi::class) + +@UnstableApi class GeneratorPlayer : FullScreenPlayer() { companion object { const val NOTIFICATION_ID = 2326 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,46 +141,54 @@ 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) { - val subtitleLanguageTagIETF = if (subtitle == null) { - "" // -> No Subtitles + private var binding: FragmentPlayerBinding? = 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 subtitleLanguage639 = if (subtitle == null) { + // "" is No Subtitles + "" + } else if (subtitle.languageCode != null) { + // Could be "English 4" which is why it is trimmed. + val trimmedLanguage = subtitle.languageCode.replace(Regex("\\d"), "").trim() + + languages.firstOrNull { language -> + language.languageName.equals(trimmedLanguage, ignoreCase = true) || + language.ISO_639_1 == subtitle.languageCode + }?.ISO_639_1 } else { - subtitle.getIETF_tag() + null } - if (subtitleLanguageTagIETF != null) { - Log.i(TAG, "Set SUBTITLE_AUTO_SELECT_KEY to '$subtitleLanguageTagIETF'") - setKey(SUBTITLE_AUTO_SELECT_KEY, subtitleLanguageTagIETF) - preferredAutoSelectSubtitles = subtitleLanguageTagIETF + if (subtitleLanguage639 != null) { + setKey(SUBTITLE_AUTO_SELECT_KEY, subtitleLanguage639) + preferredAutoSelectSubtitles = subtitleLanguage639 } } @@ -221,11 +206,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 +220,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 @@ -270,15 +254,21 @@ class GeneratorPlayer : FullScreenPlayer() { ): PendingIntent { val intent: Intent = Intent(action).setPackage(context.packageName) intent.putExtra(EXTRA_INSTANCE_ID, instanceId) - val pendingFlags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + val pendingFlags = if (Util.SDK_INT >= 23) { PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE - } else PendingIntent.FLAG_UPDATE_CURRENT + } else { + PendingIntent.FLAG_UPDATE_CURRENT + } return PendingIntent.getBroadcast(context, instanceId, intent, pendingFlags) } + @OptIn(UnstableApi::class) + @UnstableApi private var cachedPlayerNotificationManager: PlayerNotificationManager? = null + @OptIn(UnstableApi::class) + @UnstableApi private fun getMediaNotification(context: Context): PlayerNotificationManager { val cache = cachedPlayerNotificationManager if (cache != null) return cache @@ -355,13 +345,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 +370,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 +383,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 +485,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 = @@ -501,15 +495,15 @@ class GeneratorPlayer : FullScreenPlayer() { playerBinding?.downloadHeader?.isVisible = false playerBinding?.downloadHeaderToggle?.isVisible = isTorrent - if (!isLayout(PHONE)) { - playerBinding?.downloadBothHeader?.isVisible = isTorrent - } showDownloadProgress(DownloadEvent(0, 0, 0, null)) uiReset() currentSelectedLink = link + currentMeta = viewModel.getMeta() + nextMeta = viewModel.getNextMeta() // setEpisodes(viewModel.getAllMeta() ?: emptyList()) + isActive = true setPlayerDimen(null) setTitle() if (!sameEpisode) @@ -519,7 +513,6 @@ class GeneratorPlayer : FullScreenPlayer() { // load player context?.let { ctx -> val (url, uri) = link - val subtitles = viewModel.state.subtitles player.loadPlayer( ctx, sameEpisode, @@ -528,18 +521,43 @@ 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 ) } - if (!sameEpisode) { - player.addTimeStamps(emptyList()) // clear stamps - // Resets subtitle delay, as we watch some other content - player.setSubtitleOffset(0) + if (!sameEpisode) + player.addTimeStamps(listOf()) // clear stamps + } + + 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) } } @@ -577,7 +595,7 @@ class GeneratorPlayer : FullScreenPlayer() { if (entry.lang.isBlank() || !withLanguage) { return entry.name } - val language = fromTagToLanguageName(entry.lang.trim()) ?: entry.lang + val language = fromTwoLettersToLanguage(entry.lang.trim()) ?: entry.lang return "$language ${entry.name}" } @@ -587,15 +605,15 @@ class GeneratorPlayer : FullScreenPlayer() { val providers = subsProviders.toList() val isSingleProvider = subsProviders.size == 1 - val dialog = Dialog(context, R.style.DialogFullscreenPlayer) + val dialog = Dialog(context, R.style.AlertDialogCustomBlack) val binding = DialogOnlineSubtitlesBinding.inflate(LayoutInflater.from(context), null, false) dialog.setContentView(binding.root) - fixSystemBarsPadding(binding.root) var currentSubtitles: List = emptyList() var currentSubtitle: AbstractSubtitleEntities.SubtitleEntity? = null + val layout = R.layout.sort_bottom_single_choice_double_text val arrayAdapter = object : ArrayAdapter(dialog.context, layout) { @@ -621,6 +639,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) @@ -633,10 +652,9 @@ class GeneratorPlayer : FullScreenPlayer() { mainTextView?.text = item?.let { getName(it, false) } val language = - item?.let { fromTagToLanguageName(it.lang) ?: it.lang } ?: "" + item?.let { fromTwoLettersToLanguage(it.lang.trim()) ?: it.lang } ?: "" val providerSuffix = if (isSingleProvider || item == null) "" else " · ${item.source}" - @SuppressLint("SetTextI18n") secondaryTextView?.text = language + providerSuffix setHearingImpairedIcon(drawableEnd, position) @@ -656,7 +674,7 @@ class GeneratorPlayer : FullScreenPlayer() { currentSubtitle = currentSubtitles.getOrNull(position) ?: return@setOnItemClickListener } - var currentLanguageTagIETF: String = getAutoSelectLanguageTagIETF() + var currentLanguageTwoLetters: String = getAutoSelectLanguageISO639_1() fun setSubtitlesList(list: List) { @@ -721,7 +739,7 @@ class GeneratorPlayer : FullScreenPlayer() { aniListId = loadResponse?.getAniListId()?.toInt(), epNumber = currentTempMeta.episode, seasonNumber = currentTempMeta.season, - lang = currentLanguageTagIETF.ifBlank { null }, + lang = currentLanguageTwoLetters.ifBlank { null }, year = viewModel.currentSubtitleYear.value ) @@ -769,22 +787,15 @@ class GeneratorPlayer : FullScreenPlayer() { }) binding.searchFilter.setOnClickListener { view -> - val languagesTagName = - languages - .map { Pair(it.IETF_tag, it.nameNextToFlagEmoji()) } - .sortedBy { - it.second.substringAfter("\u00a0").lowercase() - } // name ignoring flag emoji - val (langTagsIETF, langNames) = languagesTagName.unzip() - + val lang639_1 = languages.map { it.ISO_639_1 } activity?.showDialog( - langNames, - langTagsIETF.indexOf(currentLanguageTagIETF), + languages.map { it.languageName }, + lang639_1.indexOf(currentLanguageTwoLetters), view?.context?.getString(R.string.subs_subtitle_languages) ?: return@setOnClickListener, true, { }) { index -> - currentLanguageTagIETF = langTagsIETF[index] + currentLanguageTwoLetters = lang639_1[index] binding.subtitlesSearch.setQuery(binding.subtitlesSearch.query, true) } } @@ -807,7 +818,7 @@ class GeneratorPlayer : FullScreenPlayer() { origin = resource.origin, mimeType = resource.url.toSubtitleMimeType(), headers = currentSubtitle.headers, - languageCode = currentSubtitle.lang + currentSubtitle.lang ) } if (subtitles.isEmpty()) { @@ -843,6 +854,7 @@ class GeneratorPlayer : FullScreenPlayer() { //dialog.subtitles_search_year?.setText(currentTempMeta.year) } + @OptIn(UnstableApi::class) private fun openSubPicker() { try { subsPathPicker.launch( @@ -868,21 +880,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), @@ -896,7 +909,7 @@ class GeneratorPlayer : FullScreenPlayer() { safe { // It lies, it can be null if file manager quits. if (uri == null) return@safe - val ctx = context ?: CloudStreamApp.context ?: return@safe + val ctx = context ?: AcraApplication.context ?: return@safe // RW perms for the path ctx.contentResolver.takePersistableUriPermission( uri, @@ -923,6 +936,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 +995,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,14 +1018,13 @@ 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 sourceDialog = Dialog(ctx, R.style.AlertDialogCustomBlack) val binding = PlayerSelectSourceAndSubsBinding.inflate(LayoutInflater.from(ctx), null, false) sourceDialog.setContentView(binding.root) - fixSystemBarsPadding(binding.root) selectSourceDialog = sourceDialog sourceDialog.show() @@ -1029,9 +1045,7 @@ class GeneratorPlayer : FullScreenPlayer() { binding.subtitleSettingsBtt.setOnClickListener { safe { - val subtitlesFragment = SubtitlesFragment() - subtitlesFragment.systemBarsAddPadding = true - subtitlesFragment.show(this.parentFragmentManager, "SubtitleSettings") + SubtitlesFragment().show(this.parentFragmentManager, "SubtitleSettings") } } @@ -1043,7 +1057,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 +1069,6 @@ class GeneratorPlayer : FullScreenPlayer() { loadFromOpenSubsFooter.setOnClickListener { shouldDismiss = false sourceDialog.dismissSafe(activity) - selectSourceDialog = null openOnlineSubPicker(it.context, currentLoadResponse) { dismiss() } @@ -1066,7 +1079,7 @@ class GeneratorPlayer : FullScreenPlayer() { val metadata = getMetaData() val queryName = metadata.name ?: currentLoadResponse?.name if (queryName != null) { - val currentLanguageTagIETF: String = getAutoSelectLanguageTagIETF() + val currentLanguageTwoLetters: String = getAutoSelectLanguageISO639_1() val loadFromFirstSubsFooter: TextView = layoutInflater.inflate( R.layout.sort_bottom_footer_add_choice, null ) as TextView @@ -1076,7 +1089,6 @@ class GeneratorPlayer : FullScreenPlayer() { loadFromFirstSubsFooter.setOnClickListener { sourceDialog.dismissSafe(activity) - selectSourceDialog = null showToast(R.string.loading) addFirstSub( SubtitleSearch( @@ -1087,7 +1099,7 @@ class GeneratorPlayer : FullScreenPlayer() { aniListId = currentLoadResponse?.getAniListId()?.toInt(), epNumber = metadata.episode, seasonNumber = metadata.season, - lang = currentLanguageTagIETF.ifBlank { null }, + lang = currentLanguageTwoLetters.ifBlank { null }, year = viewModel.currentSubtitleYear.value ) ) @@ -1101,7 +1113,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 @@ -1186,7 +1198,7 @@ class GeneratorPlayer : FullScreenPlayer() { subsOptionsArrayAdapter.clear() val subtitleOptions = - subtitlesGroupedList + subtitlesGrouped.entries.toList() .getOrNull(subtitleGroupIndex - 1)?.value?.map { subtitle -> val nameSuffix = subtitle.nameSuffix.html() nameSuffix.ifBlank { @@ -1252,7 +1264,6 @@ class GeneratorPlayer : FullScreenPlayer() { binding.cancelBtt.setOnClickListener { sourceDialog.dismissSafe(activity) - this.selectSourceDialog = null } fun setProfileName(profile: Int) { @@ -1266,28 +1277,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 - ) - } - }, + R.style.AlertDialogCustomBlack, + 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 +1314,6 @@ class GeneratorPlayer : FullScreenPlayer() { shouldDismiss = false sourceDialog.dismissSafe(activity) - selectSourceDialog = null val index = prefValues.indexOf(currentPrefMedia) activity?.showDialog( @@ -1336,15 +1334,18 @@ class GeneratorPlayer : FullScreenPlayer() { } binding.applyBtt.setOnClickListener { - var init = sourceIndex != startSource + var init = false + if (sourceIndex != startSource) { + init = true + } if (subtitleGroupIndex != subtitleGroupIndexStart || subtitleOptionIndex != subtitleOptionIndexStart) { - init = init or if (subtitleGroupIndex <= 0) { + init = init || if (subtitleGroupIndex <= 0) { noSubtitles() } else { subtitlesGroupedList.getOrNull(subtitleGroupIndex - 1)?.value?.getOrNull( subtitleOptionIndex )?.let { - setSubtitles(it, true) + setSubtitles(it) } ?: false } } @@ -1354,7 +1355,6 @@ class GeneratorPlayer : FullScreenPlayer() { } } sourceDialog.dismissSafe(activity) - selectSourceDialog = null } } } catch (e: Exception) { @@ -1377,14 +1377,11 @@ class GeneratorPlayer : FullScreenPlayer() { val currentAudioTracks = tracks.allAudioTracks val binding: PlayerSelectTracksBinding = PlayerSelectTracksBinding.inflate(LayoutInflater.from(ctx), null, false) - val trackDialog = Dialog(ctx, R.style.DialogFullscreenPlayer) - this.selectTrackDialog = trackDialog + val trackDialog = Dialog(ctx, R.style.AlertDialogCustomBlack) trackDialog.setContentView(binding.root) trackDialog.show() - fixSystemBarsPadding(binding.root) - - // selectTracksDialog = tracksDialog +// selectTracksDialog = tracksDialog val videosList = binding.videoTracksList val audioList = binding.autoTracksList @@ -1427,56 +1424,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} - [${fromTwoLettersToLanguage(format.language) ?: format.language}]" + else -> format.label + ?: format.language?.let { fromTwoLettersToLanguage(it) } + ?: format.language + ?: index.toString() } - ) + }) audioList.adapter = audioArrayAdapter audioList.choiceMode = AbsListView.CHOICE_MODE_SINGLE @@ -1491,15 +1461,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 +1475,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 +1484,7 @@ class GeneratorPlayer : FullScreenPlayer() { } } + override fun playerError(exception: Throwable) { val currentUrl = currentSelectedLink?.let { it.first?.url ?: it.second?.uri?.toString() } ?: "unknown" @@ -1546,94 +1514,35 @@ 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() - viewModel.loadLinksNext() - } + isNextEpisode = true + player.release() + viewModel.loadLinksNext() } override fun prevEpisode() { - if (viewModel.hasPrevEpisode() == true) { - isNextEpisode = true - releasePlayer() - viewModel.loadLinksPrev() - } + isNextEpisode = true + 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 @@ -1677,15 +1586,49 @@ class GeneratorPlayer : FullScreenPlayer() { viewModel.loadStamps(duration) } + viewModel.getId()?.let { + DataStoreHelper.setViewPos(it, position, duration) + } + val percentage = position * 100L / duration - DataStoreHelper.setViewPosAndResume( - viewModel.state.generatorState?.id, - position, - duration, - currentMeta, - nextMeta - ) + val nextEp = percentage >= NEXT_WATCH_EPISODE_PERCENTAGE + val resumeMeta = if (nextEp) nextMeta else currentMeta + if (resumeMeta == null && nextEp) { + // remove last watched as it is the last episode and you have watched too much + when (val newMeta = currentMeta) { + is ResultEpisode -> { + DataStoreHelper.removeLastWatched(newMeta.parentId) + } + + is ExtractorUri -> { + DataStoreHelper.removeLastWatched(newMeta.parentId) + } + } + } else { + // save resume + when (resumeMeta) { + is ResultEpisode -> { + DataStoreHelper.setLastWatched( + resumeMeta.parentId, + resumeMeta.id, + resumeMeta.episode, + resumeMeta.season, + isFromDownload = false + ) + } + + is ExtractorUri -> { + DataStoreHelper.setLastWatched( + resumeMeta.parentId, + resumeMeta.id, + resumeMeta.episode, + resumeMeta.season, + isFromDownload = true + ) + } + } + } var isOpVisible = false when (val meta = currentMeta) { @@ -1714,12 +1657,8 @@ class GeneratorPlayer : FullScreenPlayer() { playerBinding?.playerSkipEpisode?.isVisible = !isOpVisible && viewModel.hasNextEpisode() == true - else -> { - val hasNextEpisode = viewModel.hasNextEpisode() == true - playerBinding?.playerGoForward?.isVisible = hasNextEpisode - playerBinding?.playerGoForwardRoot?.isVisible = hasNextEpisode - } - + else -> + playerBinding?.playerGoForwardRoot?.isVisible = viewModel.hasNextEpisode() == true } if (percentage >= PRELOAD_NEXT_EPISODE_PERCENTAGE) { @@ -1731,28 +1670,33 @@ class GeneratorPlayer : FullScreenPlayer() { subtitles: Set, settings: Boolean, downloads: Boolean ): SubtitleData? { val langCode = preferredAutoSelectSubtitles ?: return null + val lang = fromTwoLettersToLanguage(langCode) ?: return null if (downloads) { - sortSubs(subtitles).firstOrNull { - it.origin == SubtitleOrigin.DOWNLOADED_FILE && it.matchesLanguageCode( - langCode - ) - }?.let { return it } + return subtitles.firstOrNull { sub -> + (sub.origin == SubtitleOrigin.DOWNLOADED_FILE && sub.name == context?.getString( + R.string.default_subtitles + )) + } } - if (!settings) return null + sortSubs(subtitles).firstOrNull { sub -> + val t = sub.name.replace(Regex("[^A-Za-z]"), " ").trim() + (settings) && t == lang || t.startsWith(lang) || t == langCode + }?.let { sub -> + return sub + } - return sortSubs(subtitles).firstOrNull { it.matchesLanguageCode(langCode) } + return null } private fun autoSelectFromSettings(): Boolean { - // auto select subtitle based on settings + // auto select subtitle based of 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 +1704,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 +1718,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() { @@ -1799,14 +1743,6 @@ class GeneratorPlayer : FullScreenPlayer() { } } - private fun getHeaderName(): String? { - return when (val meta = currentMeta) { - is ResultEpisode -> meta.headerName - is ExtractorUri -> meta.headerName - else -> null - } - } - private fun getPlayerVideoTitle(): String { var headerName: String? = null var subName: String? = null @@ -1853,6 +1789,8 @@ class GeneratorPlayer : FullScreenPlayer() { return "" } + + @SuppressLint("SetTextI18n") fun setTitle() { var playerVideoTitle = getPlayerVideoTitle() @@ -1871,105 +1809,29 @@ class GeneratorPlayer : FullScreenPlayer() { playerBinding?.playerEpisodeFillerHolder?.isVisible = isFiller ?: false playerBinding?.playerVideoTitle?.text = playerVideoTitle - playerBinding?.offlinePin?.isVisible = viewModel.generator is DownloadFileGenerator } + @SuppressLint("SetTextI18n") fun setPlayerDimen(widthHeight: Pair?) { - val resolution = widthHeight?.let { "${it.first}x${it.second}" } - val name = currentSelectedLink?.first?.name ?: currentSelectedLink?.second?.name - val title = getHeaderName() - - val result = listOfNotNull( - title?.takeIf { showTitle && it.isNotBlank() }, - name?.takeIf { showName && it.isNotBlank() }, - resolution?.takeIf { showResolution && it.isNotBlank() }, - ).joinToString(" - ") - - playerBinding?.playerVideoTitleRez?.apply { - text = result - isVisible = result.isNotBlank() + val extra = if (widthHeight != null) { + val (width, height) = widthHeight + "- ${width}x${height}" + } else { + "" } - } + val source = currentSelectedLink?.first?.name ?: currentSelectedLink?.second?.name ?: "NULL" - 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() } ?: "" + val title = when (titleRez) { + 0 -> "" + 1 -> extra + 2 -> source + 3 -> "$source $extra" 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 +1847,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 +1892,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 +1900,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 +1925,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) @@ -2064,143 +1943,26 @@ 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 - } - - override fun showEpisodesOverlay() { - try { - playerBinding?.apply { - playerEpisodeList.setRecycledViewPool(EpisodeAdapter.sharedPool) - playerEpisodeList.adapter = EpisodeAdapter( - false, - { episodeClick -> - if (episodeClick.action == ACTION_CLICK_DEFAULT) { - isNextEpisode = false - releasePlayer() - playerEpisodeOverlay.isGone = true - episodeClick.position?.let { viewModel.loadThisEpisode(it) } - } - }, - { downloadClickEvent -> - DownloadButtonSetup.handleDownloadClick(downloadClickEvent) - } - ) - playerEpisodeList.setLinearListLayout( - isHorizontal = false, - nextUp = FOCUS_SELF, - nextDown = FOCUS_SELF, - nextRight = FOCUS_SELF, - ) - val episodes = allMeta ?: emptyList() - (playerEpisodeList.adapter as? EpisodeAdapter)?.submitList(episodes) - - // Scroll to current episode - viewModel.state.generatorState?.index?.let { index -> - playerEpisodeList.scrollToPosition(index) - // Ensure focus on tv - if (isLayout(TV)) { - playerEpisodeList.post { - val viewHolder = - playerEpisodeList.findViewHolderForAdapterPosition(index) - viewHolder?.itemView?.requestFocus() - viewHolder?.itemView?.let { itemView -> - itemView.isFocusableInTouchMode = true - itemView.requestFocus() - } - } - } - } - - // update overlay season title - var lastTopIndex = -1 - playerEpisodeList.addOnScrollListener(object : RecyclerView.OnScrollListener() { - 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( - topItem.seasonData, - topItem.seasonIndex - ) - ) - } - } - } - }) - } - } catch (e: Exception) { - logError(e) - } - } - - @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 { - fromTagToEnglishLanguageName(it)?.lowercase() ?: return@mapNotNull null + langFilterList = langFromPrefMedia?.mapNotNull { + fromTwoLettersToLanguage(it)?.lowercase() ?: return@mapNotNull null } ?: listOf() } } @@ -2210,25 +1972,20 @@ class GeneratorPlayer : FullScreenPlayer() { sync.updateUserData() - preferredAutoSelectSubtitles = getAutoSelectLanguageTagIETF() + preferredAutoSelectSubtitles = getAutoSelectLanguageISO639_1() - 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 +1998,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 +2017,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 +2049,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.name.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() } } } @@ -2319,4 +2086,4 @@ inline fun Bundle.getSafeSerializable(key: String): T if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) getSerializable(key) as? T else getSerializable( key, T::class.java - ) + ) \ No newline at end of file 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..4aaee7bb7 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 @@ -25,27 +25,27 @@ val LOADTYPE_CHROMECAST = setOf( val LOADTYPE_ALL = ExtractorLinkType.entries.toSet() -abstract class NoVideoGenerator(val id : Int?) : VideoGenerator(emptyList()) { - override val hasCache = false - override val canSkipLoading = false - override fun getId(index: Int): Int? = id -} +interface IGenerator { + val hasCache: Boolean + val canSkipLoading: Boolean -abstract class VideoGenerator(val videos: List) { - abstract val hasCache: Boolean - abstract val canSkipLoading: Boolean - abstract fun getId(index : Int) : Int? + fun hasNext(): Boolean + fun hasPrev(): Boolean + fun next() + fun prev() + fun goto(index: Int) - fun hasNext(videoIndex : Int): Boolean = videoIndex < videos.lastIndex - fun hasPrev(videoIndex : Int): Boolean = videoIndex > 0 + 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 - @Throws - abstract suspend fun generateLinks( + /* 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..01f2b1702 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), @@ -28,7 +47,6 @@ enum class CSPlayerLoading { IsPaused, IsPlaying, IsBuffering, - IsEnded, } enum class PlayerEventSource { @@ -67,13 +85,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 +181,6 @@ interface Track { val id: String? val label: String? val language: String? - val sampleMimeType : String? } data class VideoTrack( @@ -172,23 +189,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 +214,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 +237,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 +248,7 @@ interface IPlayer { fun updateSubtitleStyle(style: SaveCaptionStyle) fun saveData() - fun addTimeStamps(timeStamps: List) + fun addTimeStamps(timeStamps: List) fun loadPlayer( context: Context, @@ -287,8 +301,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..4416ce3b9 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,36 @@ class LinkGenerator( private val links: List, private val extract: Boolean = true, private val refererUrl: String? = null, - id: Int? -) : NoVideoGenerator(id) { +) : IGenerator { + override val hasCache = false + override val canSkipLoading = true + + override fun getCurrentId(): Int? { + return null + } + + override fun hasNext(): Boolean { + return false + } + + override fun getAll(): List? { + return null + } + + override fun hasPrev(): Boolean { + return false + } + + override fun getCurrent(offset: Int): Any? { + return null + } + + override fun goto(index: Int) {} + + override fun next() {} + + override fun prev() {} + override suspend fun generateLinks( clearCache: Boolean, sourceTypes: Set, @@ -79,8 +107,37 @@ class LinkGenerator( class MinimalLinkGenerator( private val links: List, private val subs: List, - id: Int? -) : NoVideoGenerator(id) { + private val id : Int? = null +) : IGenerator { + override val hasCache = false + override val canSkipLoading = true + + override fun getCurrentId(): Int? { + return id + } + + override fun hasNext(): Boolean { + return false + } + + override fun getAll(): List? { + return null + } + + override fun hasPrev(): Boolean { + return false + } + + override fun getCurrent(offset: Int): Any? { + return null + } + + override fun goto(index: Int) {} + + override fun next() {} + + override fun prev() {} + 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..023cedd8a 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,37 @@ 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) - } - - fun hasPrevEpisode(): Boolean? { - return generator?.hasPrev(episodeIndex) + return generator?.hasNext() } fun preLoadNextLinks() { - val id = generator?.getId(episodeIndex) + val id = getId() // Do not preload if already loading if (id == currentLoadingEpisodeId) return @@ -241,15 +92,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 +113,119 @@ class PlayerGeneratorViewModel : ViewModel() { } } - fun loadThisEpisode(index: Int) { - episodeIndex = index - loadLinks() + fun getLoadResponse(): LoadResponse? { + return safe { (generator as? RepoLinkGenerator?)?.page } } - fun attachGenerator(newGenerator: VideoGenerator<*>, index: Int) { - Log.i(TAG, "attachGenerator with generator=$newGenerator and index=$index") - generator = newGenerator - episodeIndex = index + 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 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..7e9c39b01 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 @@ -1,205 +1,121 @@ package com.lagradost.cloudstream3.ui.player import android.app.Activity -import android.app.AppOpsManager import android.app.PendingIntent import android.app.PictureInPictureParams import android.app.RemoteAction -import android.content.Context import android.content.Intent -import android.content.pm.PackageManager import android.graphics.drawable.Icon import android.os.Build import android.util.Rational import androidx.annotation.RequiresApi import androidx.annotation.StringRes -import androidx.preference.PreferenceManager -import com.lagradost.cloudstream3.CommonActivity import com.lagradost.cloudstream3.R -import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.mvvm.safe import kotlin.math.roundToInt -object PlayerPipHelper { - /** Is pip (Player in Player) supported, and enabled? */ - fun Context.isPIPPossible() : Boolean { - return try { - this.hasPIPEnabled() && this.hasPIPFeature() - } catch (t : Throwable) { - // While both hasPIPEnabled and hasPIPFeature should never throw, this catches it just in case - logError(t) - false - } - } - - /** Is pip enabled in app settings? */ - private fun Context.hasPIPEnabled(): Boolean { - return try { - val settingsManager = PreferenceManager.getDefaultSharedPreferences(this) - settingsManager?.getBoolean( - getString(R.string.pip_enabled_key), - true - ) ?: true - } catch (e: Exception) { - logError(e) - false - } - } - - - /** - * Is pip supported by the OS? - * - * Source: - * https://stackoverflow.com/questions/52594181/how-to-know-if-user-has-disabled-picture-in-picture-feature-permission - * https://developer.android.com/guide/topics/ui/picture-in-picture - * */ - private fun Context.hasPIPFeature(): Boolean = - // OS Support - Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && - // Might have the feature, but OS blocked due to power drain - this.packageManager.hasSystemFeature(PackageManager.FEATURE_PICTURE_IN_PICTURE) && - // Might have been disabled by the user - this.hasPIPPermission() - - /** Is pip enabled in the OS settings? */ - private fun Context.hasPIPPermission(): Boolean { - val appOps = - getSystemService(Context.APP_OPS_SERVICE) as AppOpsManager - return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - appOps.checkOpNoThrow( - AppOpsManager.OPSTR_PICTURE_IN_PICTURE, - android.os.Process.myUid(), - packageName - ) == AppOpsManager.MODE_ALLOWED - } else true - } - - @RequiresApi(Build.VERSION_CODES.O) - private fun getPen(activity: Activity, code: Int): PendingIntent { - return PendingIntent.getBroadcast( - activity, - code, - Intent(ACTION_MEDIA_CONTROL).putExtra(EXTRA_CONTROL_TYPE, code), - PendingIntent.FLAG_IMMUTABLE - ) - } - - @RequiresApi(Build.VERSION_CODES.O) - private fun getRemoteAction( - activity: Activity, - id: Int, - @StringRes title: Int, - event: CSPlayerEvent - ): RemoteAction { - val text = activity.getString(title) - return RemoteAction( - Icon.createWithResource(activity, id), - text, - text, - getPen(activity, event.value) - ) - } - - fun updatePIPModeActions( - activity: Activity?, - status: CSPlayerLoading, - pipEnabled: Boolean, - aspectRatio: Rational? - ) { - // Is it even desired to enter pip mode right now if we ignore all settings? - // This does not check for isPIPPossible as that is deferred to later - val isPipDesired = when (status) { - CSPlayerLoading.IsBuffering, CSPlayerLoading.IsPlaying -> pipEnabled - else -> false - } - - // On lower api ver setPictureInPictureParams is not supported, - // so we enter pip manually in onUserLeaveHint - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { - CommonActivity.isPipDesired = isPipDesired - return - } - - if(activity == null) return - - val actions: ArrayList = ArrayList() - actions.add( - getRemoteAction( +class PlayerPipHelper { + companion object { + @RequiresApi(Build.VERSION_CODES.O) + private fun getPen(activity: Activity, code: Int): PendingIntent { + return PendingIntent.getBroadcast( activity, - R.drawable.baseline_headphones_24, - R.string.audio_singular, - CSPlayerEvent.PlayAsAudio + code, + Intent(ACTION_MEDIA_CONTROL).putExtra(EXTRA_CONTROL_TYPE, code), + PendingIntent.FLAG_IMMUTABLE ) - ) - /*actions.add( - getRemoteAction( - activity, - R.drawable.go_back_30, - R.string.go_back_30, - CSPlayerEvent.SeekBack - ) - )*/ + } - if (status == CSPlayerLoading.IsPlaying) { + @RequiresApi(Build.VERSION_CODES.O) + private fun getRemoteAction( + activity: Activity, + id: Int, + @StringRes title: Int, + event: CSPlayerEvent + ): RemoteAction { + val text = activity.getString(title) + return RemoteAction( + Icon.createWithResource(activity, id), + text, + text, + getPen(activity, event.value) + ) + } + + @RequiresApi(Build.VERSION_CODES.O) + fun updatePIPModeActions(activity: Activity, isPlaying: Boolean, aspectRatio: Rational?) { + val actions: ArrayList = ArrayList() actions.add( getRemoteAction( activity, - R.drawable.netflix_pause, - R.string.pause, - CSPlayerEvent.Pause + R.drawable.baseline_headphones_24, + R.string.audio_singluar, + CSPlayerEvent.PlayAsAudio ) ) - } else { - actions.add( + /*actions.add( getRemoteAction( activity, - R.drawable.ic_baseline_play_arrow_24, - R.string.pause, - CSPlayerEvent.Play + R.drawable.go_back_30, + R.string.go_back_30, + CSPlayerEvent.SeekBack ) - ) - } + )*/ - actions.add( - getRemoteAction( - activity, - R.drawable.go_forward_30, - R.string.go_forward_30, - CSPlayerEvent.SeekForward - ) - ) - - // Necessary to prevent crashing. - val mixAspectRatio = 0.41841f // ~1/2.39 - val maxAspectRatio = 2.39f // widescreen standard - val ratioAccuracy = 100000 // To convert the float to int - - // java.lang.IllegalArgumentException: setPictureInPictureParams: Aspect ratio is too extreme - // (must be between 0.418410 and 2.390000) - val fixedRational = - aspectRatio?.toFloat()?.coerceIn(mixAspectRatio, maxAspectRatio)?.let { - Rational((it * ratioAccuracy).roundToInt(), ratioAccuracy) + if (isPlaying) { + actions.add( + getRemoteAction( + activity, + R.drawable.netflix_pause, + R.string.pause, + CSPlayerEvent.Pause + ) + ) + } else { + actions.add( + getRemoteAction( + activity, + R.drawable.ic_baseline_play_arrow_24, + R.string.pause, + CSPlayerEvent.Play + ) + ) } - safe { - activity.setPictureInPictureParams( - PictureInPictureParams.Builder() - .apply { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - setSeamlessResizeEnabled(true) - setAutoEnterEnabled(isPipDesired && activity.isPIPPossible()) - } else { - // We enter pip manually in onUserLeaveHint as the smooth transition - // is not supported yet - CommonActivity.isPipDesired = isPipDesired - } - } - .setAspectRatio(fixedRational) - .setActions(actions) - .build() + actions.add( + getRemoteAction( + activity, + R.drawable.go_forward_30, + R.string.go_forward_30, + CSPlayerEvent.SeekForward + ) ) + + // Nessecary to prevent crashing. + val mixAspectRatio = 0.41841f // ~1/2.39 + val maxAspectRatio = 2.39f // widescreen standard + val ratioAccuracy = 100000 // To convert the float to int + + // java.lang.IllegalArgumentException: setPictureInPictureParams: Aspect ratio is too extreme (must be between 0.418410 and 2.390000) + val fixedRational = + aspectRatio?.toFloat()?.coerceIn(mixAspectRatio, maxAspectRatio)?.let { + Rational((it * ratioAccuracy).roundToInt(), ratioAccuracy) + } + + safe { + activity.setPictureInPictureParams( + PictureInPictureParams.Builder() + .apply { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + setSeamlessResizeEnabled(true) + setAutoEnterEnabled(isPlaying) + } + } + .setAspectRatio(fixedRational) + .setActions(actions) + .build() + ) + } } } - } 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..f1eb0468b 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 { @@ -31,7 +30,7 @@ enum class SubtitleOrigin { * @param nameSuffix An extra suffix added to the subtitle to make sure it is unique * @param url Url for the subtitle, when EMBEDDED_IN_VIDEO this variable is used as the real backend id * @param headers if empty it will use the base onlineDataSource headers else only the specified headers - * @param languageCode usually, tags such as "en", "es-mx", or "zh-hant-TW". But it could be something like "English 4" + * @param languageCode Not guaranteed to follow any standard. Could be something like "English 4" or "en". * */ data class SubtitleData( val originalName: String, @@ -42,23 +41,17 @@ data class SubtitleData( val headers: Map, val languageCode: String?, ) { + companion object { + fun constructName(originalName: String, nameSuffix: String) = "$originalName $nameSuffix" + } + /** Internal ID for exoplayer, unique for each link*/ fun getId(): String { return if (origin == SubtitleOrigin.EMBEDDED_IN_VIDEO) url 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" + val name = constructName(originalName, nameSuffix) /** * Gets the URL, but tries to fix it if it is malformed. @@ -111,7 +104,7 @@ class PlayerSubtitleHelper { origin = SubtitleOrigin.URL, mimeType = subtitleFile.url.toSubtitleMimeType(), headers = subtitleFile.headers ?: emptyMap(), - languageCode = subtitleFile.langTag ?: subtitleFile.lang + languageCode = subtitleFile.lang ) } } @@ -129,7 +122,7 @@ class PlayerSubtitleHelper { fun setSubStyle(style: SaveCaptionStyle) { Log.i(TAG, "SET STYLE = $style") subtitleView?.translationY = -style.elevation.toPx.toFloat() - setSubtitleViewStyle(subtitleView, style, true) + setSubtitleViewStyle(subtitleView, style) } fun initSubtitles(subView: SubtitleView?, subHolder: FrameLayout?, style: SaveCaptionStyle?) { 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/PreviewGenerator.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/PreviewGenerator.kt index 2893bcc47..30e8d99ad 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/PreviewGenerator.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/PreviewGenerator.kt @@ -9,7 +9,7 @@ import android.util.Log import androidx.annotation.WorkerThread import androidx.core.graphics.scale import androidx.preference.PreferenceManager -import com.lagradost.cloudstream3.CloudStreamApp +import com.lagradost.cloudstream3.AcraApplication import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.ui.settings.Globals.TV @@ -65,7 +65,7 @@ interface IPreviewGenerator { companion object { fun new(): IPreviewGenerator { - val userDisabled = CloudStreamApp.context?.let { ctx -> + val userDisabled = AcraApplication.context?.let { ctx -> PreferenceManager.getDefaultSharedPreferences(ctx)?.getBoolean( ctx.getString(R.string.preview_seekbar_key), true ) == false 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..bfddd9e0c 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, @@ -22,9 +22,10 @@ data class Cache( ) class RepoLinkGenerator( - episodes: List, + private val episodes: List, + private var currentIndex: Int = 0, val page: LoadResponse? = null, -) : VideoGenerator(episodes) { +) : IGenerator { companion object { const val TAG = "RepoLink" val cache: HashMap, Cache> = @@ -33,7 +34,44 @@ class RepoLinkGenerator( override val hasCache = true override val canSkipLoading = true - override fun getId(index: Int): Int? = videos.getOrNull(index)?.id + + override fun hasNext(): Boolean { + return currentIndex < episodes.size - 1 + } + + override fun hasPrev(): Boolean { + return currentIndex > 0 + } + + override fun next() { + Log.i(TAG, "next") + if (hasNext()) + currentIndex++ + } + + override fun prev() { + Log.i(TAG, "prev") + if (hasPrev()) + currentIndex-- + } + + override fun goto(index: Int) { + Log.i(TAG, "goto $index") + // clamps value + currentIndex = min(episodes.size - 1, max(0, index)) + } + + override fun getCurrentId(): Int { + return episodes[currentIndex].id + } + + override fun getCurrent(offset: Int): Any? { + return episodes.getOrNull(currentIndex + offset) + } + + override fun getAll(): List { + return episodes + } // 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() }) @@ -42,13 +80,14 @@ class RepoLinkGenerator( @Throws override suspend fun generateLinks( clearCache: Boolean, - sourceTypes: Set, + allowedTypes: Set, callback: (Pair) -> Unit, subtitleCallback: (SubtitleData) -> Unit, offset: Int, isCasting: Boolean, ): Boolean { - val current = videos.getOrNull(offset) ?: return false + val index = currentIndex + val current = episodes.getOrNull(index + offset) ?: return false val currentCache = synchronized(cache) { cache[current.apiName to current.id] ?: Cache( @@ -61,12 +100,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 currentSubsNames = mutableSetOf() // makes all subs names unique synchronized(currentCache) { val outdatedCache = @@ -77,23 +114,20 @@ 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 currentCache.linkCache.forEach { link -> currentLinksUrls.add(link.url) - if (sourceTypes.contains(link.type)) { + if (allowedTypes.contains(link.type)) { callback(link to null) } } currentCache.subtitleCache.forEach { sub -> currentSubsUrls.add(sub.url) - lastCountedSuffix.getOrPut(sub.originalName) { AtomicInteger(0) }.incrementAndGet() + currentSubsNames.add(sub.name) subtitleCallback(sub) } @@ -112,18 +146,25 @@ 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 fixedName = correctFile.name.html().toString().trim() + var name = fixedName + var count = 1 + while (currentSubsNames.contains(name)) { + count++ + name = + SubtitleData.constructName(originalName = fixedName, nameSuffix = "$count") + } + + currentSubsNames.add(name) val updatedFile = - correctFile.copy(originalName = nameDecoded, nameSuffix = "$suffixCount") + correctFile.copy(originalName = fixedName, nameSuffix = "$count") synchronized(currentCache) { if (currentCache.subtitleCache.add(updatedFile)) { @@ -134,13 +175,14 @@ 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)) { - if (sourceTypes.contains(link.type)) { + if (allowedTypes.contains(link.type)) { callback(Pair(link, null)) } diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/RoundedBackgroundColorSpan.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/RoundedBackgroundColorSpan.kt index 824b5d1a2..fcc5d8589 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/RoundedBackgroundColorSpan.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/RoundedBackgroundColorSpan.kt @@ -23,7 +23,6 @@ package com.lagradost.cloudstream3.ui.player import android.graphics.Canvas import android.graphics.Color import android.graphics.Paint -import android.os.Build import android.text.Layout.Alignment import android.text.StaticLayout import android.text.TextPaint @@ -59,16 +58,9 @@ class RoundedBackgroundColorSpan( return } + // we cant use StaticLayout.Builder() due to API val width = p.measureText(text, start, end) - val textLayout: StaticLayout = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - StaticLayout.Builder - .obtain(text, 0, text.length, TextPaint(p), width.toInt()) - .setAlignment(alignment) - .setLineSpacing(0.0f, 1.0f) - .setIncludePad(true) - .build() - } else { - @Suppress("DEPRECATION") + val textLayout = StaticLayout( text, TextPaint(p), @@ -78,7 +70,6 @@ class RoundedBackgroundColorSpan( 0.0f, true ) - } val center = (left + right).toFloat() * 0.5f diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/SubtitleOffsetItemAdapter.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/SubtitleOffsetItemAdapter.kt index fa65c322e..9e3e778be 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/SubtitleOffsetItemAdapter.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/SubtitleOffsetItemAdapter.kt @@ -5,10 +5,9 @@ import android.view.LayoutInflater import android.view.ViewGroup import android.view.animation.DecelerateInterpolator import androidx.core.view.isInvisible +import androidx.recyclerview.widget.RecyclerView import com.lagradost.cloudstream3.databinding.SubtitleOffsetItemBinding -import com.lagradost.cloudstream3.ui.BaseDiffCallback -import com.lagradost.cloudstream3.ui.NoStateAdapter -import com.lagradost.cloudstream3.ui.ViewHolderState +import com.lagradost.cloudstream3.utils.AppContextUtils import kotlin.math.roundToInt data class SubtitleCue(val startTimeMs: Long, val durationMs: Long, val text: List) { @@ -17,67 +16,25 @@ data class SubtitleCue(val startTimeMs: Long, val durationMs: Long, val text: Li class SubtitleOffsetItemAdapter( private var currentTimeMs: Long, + override val items: MutableList, val clickCallback: (SubtitleCue) -> Unit ) : - NoStateAdapter(diffCallback = BaseDiffCallback(itemSame = { a, b -> - a.startTimeMs == b.startTimeMs - })) { + AppContextUtils.DiffAdapter(items) { - override fun onCreateContent(parent: ViewGroup): ViewHolderState { + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { val inflater = LayoutInflater.from(parent.context) val binding = SubtitleOffsetItemBinding.inflate(inflater, parent, false) - return ViewHolderState(binding) + return SubtitleViewHolder(binding) } - override fun onBindContent(holder: ViewHolderState, item: SubtitleCue, position: Int) { - val binding = holder.view as? SubtitleOffsetItemBinding ?: return - - binding.root.setOnClickListener { - clickCallback.invoke(item) - } - - binding.subtitleText.text = item.text.joinToString("\n") - - val timeMs = currentTimeMs - val startTime = item.startTimeMs - val endTime = item.endTimeMs - - val newAlpha = if (timeMs >= startTime) 1f else 0.5f - ObjectAnimator.ofFloat( - binding.subtitleText, - "alpha", - binding.subtitleText.alpha, - newAlpha - ).apply { - interpolator = DecelerateInterpolator() - }.start() - - val showProgress = timeMs in startTime.. holder.bind(items[position]) } } fun getLatestActiveItem(position: Long): Int { - return immutableCurrentList.withIndex().lastOrNull { + return items.withIndex().lastOrNull { position >= it.value.startTimeMs }?.index ?: 0 } @@ -88,9 +45,7 @@ class SubtitleOffsetItemAdapter( val earlyTime = minOf(previousTime, timeMs) val lateTime = maxOf(previousTime, timeMs) - - // TODO Add binary search and notifyItemRangeChanged - val affectedItems = immutableCurrentList.withIndex().filter { cue -> + val affectedItems = items.withIndex().filter { cue -> // Padding is required in the range because changes can be done within one single subtitle range, // and that subtitle needs to be updated cue.value.startTimeMs in (earlyTime - cue.value.durationMs)..(lateTime + cue.value.durationMs) @@ -101,4 +56,57 @@ class SubtitleOffsetItemAdapter( this.notifyItemChanged(item.index) } } + + private inner class SubtitleViewHolder( + val binding: SubtitleOffsetItemBinding, + ) : + RecyclerView.ViewHolder(binding.root) { + + fun bind( + data: SubtitleCue + ) { + binding.root.setOnClickListener { + clickCallback.invoke(data) + } + + binding.subtitleText.text = data.text.joinToString("\n") + + val timeMs = currentTimeMs + val startTime = data.startTimeMs + val endTime = data.endTimeMs + + val newAlpha = if (timeMs >= startTime) 1f else 0.5f + ObjectAnimator.ofFloat( + binding.subtitleText, + "alpha", + binding.subtitleText.alpha, + newAlpha + ).apply { + interpolator = DecelerateInterpolator() + }.start() + + val showProgress = timeMs in startTime..?): UpdatedDefaultExtractorsFactory { - tsSubtitleFormats = subtitleFormats?.let { ImmutableList.copyOf(it) } + tsSubtitleFormats = ImmutableList.copyOf(subtitleFormats) return this } @@ -349,14 +335,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 +350,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 +457,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 +513,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..06b4c12c3 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 @@ -1,14 +1,3 @@ -@file:Suppress( - "ALL", - "DEPRECATION", - "RedundantVisibilityModifier", - "RemoveRedundantQualifierName", - "UNCHECKED_CAST", - "UNUSED", - "UNUSED_PARAMETER", - "UNUSED_VARIABLE" -) - /* * Copyright (C) 2016 The Android Open Source Project * @@ -41,20 +30,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.extractor.DolbyVisionConfig import androidx.media3.extractor.Extractor import androidx.media3.extractor.ExtractorInput import androidx.media3.extractor.ExtractorOutput @@ -63,18 +50,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,14 +63,13 @@ 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 /** Extracts data from the Matroska and WebM container formats. */ @UnstableApi -class UpdatedMatroskaExtractor private constructor( +class UpdatedMatroskaExtractor internal constructor( private val reader: EbmlReader, flags: @Flags Int, subtitleParserFactory: SubtitleParser.Factory @@ -128,8 +108,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 +120,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 +128,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 +207,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 +222,6 @@ class UpdatedMatroskaExtractor private constructor( encryptionSubsampleData = ParsableByteArray() supplementalData = ParsableByteArray() blockSampleSizes = IntArray(1) - pendingEndTracks = true } @Throws(IOException::class) @@ -272,17 +244,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 +330,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 +347,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 +359,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 +398,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 +427,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 +509,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 +529,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 +575,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 +621,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 +943,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 +1035,9 @@ class UpdatedMatroskaExtractor private constructor( } } - @Throws(ParserException::class) + @Throws( + ParserException::class + ) private fun assertInTrackEntry(id: Int) { if (currentTrack == null) { throw ParserException.createForMalformedContainer( @@ -1234,9 +1046,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 +1079,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 +1168,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 +1176,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 +1342,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 +1441,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 +1511,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 +1558,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 +1607,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 +1660,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 +1684,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 +1797,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 +1896,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 +1942,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 +2014,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 +2025,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 +2036,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 +2113,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 +2122,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 +2368,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 +2444,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 +2500,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 +2721,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 +2741,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 +2769,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 +2787,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 +2811,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/PriorityAdapter.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/source_priority/PriorityAdapter.kt index 11dd39105..ce457740d 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/source_priority/PriorityAdapter.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/source_priority/PriorityAdapter.kt @@ -2,9 +2,9 @@ package com.lagradost.cloudstream3.ui.player.source_priority import android.view.LayoutInflater import android.view.ViewGroup +import androidx.recyclerview.widget.RecyclerView import com.lagradost.cloudstream3.databinding.PlayerPrioritizeItemBinding -import com.lagradost.cloudstream3.ui.NoStateAdapter -import com.lagradost.cloudstream3.ui.ViewHolderState +import com.lagradost.cloudstream3.utils.AppContextUtils data class SourcePriority( val data: T, @@ -12,41 +12,41 @@ data class SourcePriority( var priority: Int ) -class PriorityAdapter() : - NoStateAdapter>() { - - override fun onCreateContent(parent: ViewGroup): ViewHolderState { - return ViewHolderState( - PlayerPrioritizeItemBinding.inflate( - LayoutInflater.from(parent.context), - parent, - false - ) +class PriorityAdapter(override val items: MutableList>) : + AppContextUtils.DiffAdapter>(items) { + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { + return PriorityViewHolder( + PlayerPrioritizeItemBinding.inflate(LayoutInflater.from(parent.context),parent,false), ) } - override fun onBindContent( - holder: ViewHolderState, - item: SourcePriority, - position: Int - ) { - val binding = holder.view as? PlayerPrioritizeItemBinding ?: return - binding.priorityText.text = item.name - - fun updatePriority() { - binding.priorityNumber.text = item.priority.toString() + override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { + when (holder) { + is PriorityViewHolder -> holder.bind(items[position]) } + } - updatePriority() - binding.addButton.setOnClickListener { - // If someone clicks til the integer limit then they deserve to crash. - item.priority++ - updatePriority() - } + class PriorityViewHolder( + val binding: PlayerPrioritizeItemBinding, + ) : RecyclerView.ViewHolder(binding.root) { + fun bind(item: SourcePriority) { + binding.priorityText.text = item.name + + fun updatePriority() { + binding.priorityNumber.text = item.priority.toString() + } - binding.subtractButton.setOnClickListener { - item.priority-- updatePriority() + binding.addButton.setOnClickListener { + // If someone clicks til the integer limit then they deserve to crash. + item.priority++ + updatePriority() + } + + binding.subtractButton.setOnClickListener { + item.priority-- + updatePriority() + } } } } \ 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..821bccd6a 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 @@ -10,25 +10,45 @@ import android.widget.TextView import androidx.core.content.ContextCompat import androidx.core.view.isVisible import androidx.palette.graphics.Palette +import androidx.recyclerview.widget.RecyclerView import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.databinding.PlayerQualityProfileItemBinding -import com.lagradost.cloudstream3.ui.BaseDiffCallback -import com.lagradost.cloudstream3.ui.NoStateAdapter -import com.lagradost.cloudstream3.ui.ViewHolderState +import com.lagradost.cloudstream3.utils.AppContextUtils import com.lagradost.cloudstream3.utils.ImageLoader.loadImage import com.lagradost.cloudstream3.utils.drawableToBitmap -import com.lagradost.cloudstream3.utils.setText class ProfilesAdapter( - val usedProfile: Int?, + override val items: MutableList, + val usedProfile: Int, val clickCallback: (oldIndex: Int?, newIndex: Int) -> Unit, ) : - NoStateAdapter(diffCallback = BaseDiffCallback(itemSame = { a, b -> - a.id == b.id - })) { + AppContextUtils.DiffAdapter( + items, + comparison = { first: QualityDataHelper.QualityProfile, second: QualityDataHelper.QualityProfile -> + first.id == second.id + }) { + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { + return ProfilesViewHolder( + PlayerQualityProfileItemBinding.inflate(LayoutInflater.from(parent.context),parent,false) + ) + } - companion object { - private val art = arrayOf( + override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { + when (holder) { + is ProfilesViewHolder -> holder.bind(items[position], position) + } + } + + private var currentItem: Pair? = null + + fun getCurrentProfile(): QualityDataHelper.QualityProfile? { + return currentItem?.second + } + + inner class ProfilesViewHolder( + val binding: PlayerQualityProfileItemBinding, + ) : RecyclerView.ViewHolder(binding.root) { + private val art = listOf( R.drawable.profile_bg_teal, R.drawable.profile_bg_blue, R.drawable.profile_bg_dark_blue, @@ -37,101 +57,67 @@ class ProfilesAdapter( R.drawable.profile_bg_red, R.drawable.profile_bg_orange, ) - } - override fun onCreateContent(parent: ViewGroup): ViewHolderState { - return ViewHolderState( - PlayerQualityProfileItemBinding.inflate( - LayoutInflater.from(parent.context), - parent, - false - ) - ) - } + fun bind(item: QualityDataHelper.QualityProfile, index: Int) { + val priorityText: TextView = binding.profileText + val profileBg: ImageView = binding.profileImageBackground + val wifiText: TextView = binding.textIsWifi + val dataText: TextView = binding.textIsMobileData + val outline: View = binding.outline + val cardView: View = binding.cardView - override fun onClearView(holder: ViewHolderState) { - when (val binding = holder.view) { - is PlayerQualityProfileItemBinding -> { - clearImage(binding.profileImageBackground) + priorityText.text = item.name.asString(itemView.context) + dataText.isVisible = item.type == QualityDataHelper.QualityProfileType.Data + wifiText.isVisible = item.type == QualityDataHelper.QualityProfileType.WiFi + + fun setCurrentItem() { + val prevIndex = currentItem?.first + // Prevent UI bug when re-selecting the item quickly + if (prevIndex == index) { + return + } + currentItem = index to item + clickCallback.invoke(prevIndex, index) } - } - } - override fun onBindContent( - holder: ViewHolderState, - item: QualityDataHelper.QualityProfile, - position: Int - ) { - val binding = holder.view as? PlayerQualityProfileItemBinding ?: return + outline.isVisible = currentItem?.second?.id == item.id + val drawableResId = art[index % art.size] + profileBg.loadImage(drawableResId) - val priorityText: TextView = binding.profileText - 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) - - fun setCurrentItem() { - val prevIndex = currentItem - // Prevent UI bug when re-selecting the item quickly - if (prevIndex == position) { - return - } - currentItem = position - clickCallback.invoke(prevIndex, position) - } - - outline.isVisible = currentItem == position - val drawableResId = art[position % art.size] - profileBg.loadImage(drawableResId) - - val drawable = ContextCompat.getDrawable(itemView.context, drawableResId) - if (drawable != null) { - // Convert Drawable to Bitmap - val bitmap = drawableToBitmap(drawable) - if (bitmap != null) { - // Use Palette to extract colors from the bitmap - Palette.from(bitmap).generate { palette -> - val color = palette?.getDarkVibrantColor( - ContextCompat.getColor( - itemView.context, - R.color.dubColorBg + val drawable = ContextCompat.getDrawable(itemView.context, drawableResId) + if (drawable != null) { + // Convert Drawable to Bitmap + val bitmap = drawableToBitmap(drawable) + if (bitmap != null) { + // Use Palette to extract colors from the bitmap + Palette.from(bitmap).generate { palette -> + val color = palette?.getDarkVibrantColor( + ContextCompat.getColor( + itemView.context, + R.color.dubColorBg + ) ) - ) - if (color != null) { - wifiText.backgroundTintList = ColorStateList.valueOf(color) - dataText.backgroundTintList = ColorStateList.valueOf(color) - downloadText.backgroundTintList = ColorStateList.valueOf(color) + if (color != null) { + wifiText.backgroundTintList = ColorStateList.valueOf(color) + dataText.backgroundTintList = ColorStateList.valueOf(color) + } } } } - } - val textStyle = - if (item.id == usedProfile) { - Typeface.BOLD - } else { - Typeface.NORMAL + val textStyle = + if (item.id == usedProfile) { + Typeface.BOLD + } else { + Typeface.NORMAL + } + + priorityText.setTypeface(null, textStyle) + + cardView.setOnClickListener { + setCurrentItem() } - - priorityText.setTypeface(null, textStyle) - - cardView.setOnClickListener { - setCurrentItem() } } - - private var currentItem: Int? = null - - fun getCurrentProfile(): QualityDataHelper.QualityProfile? { - return currentItem?.let { index -> immutableCurrentList.getOrNull(index) } - } } \ 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..0922bdb5a 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 @@ -1,32 +1,22 @@ 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.AcraApplication.Companion.getKey +import com.lagradost.cloudstream3.AcraApplication.Companion.removeKey +import com.lagradost.cloudstream3.AcraApplication.Companion.setKey import com.lagradost.cloudstream3.R 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..19e98138c 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,78 +2,47 @@ 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) - fixSystemBarsPadding(binding.root) + setContentView(binding.root)//R.layout.player_quality_profile_dialog) + /*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)) - } - (profilesRecyclerview.adapter as? ProfilesAdapter)?.submitList(getProfiles()) + currentlySelectedProfileText.text = getProfileName(usedProfile).asString(context) + (profilesRecyclerview.adapter as? ProfilesAdapter)?.updateList(getProfiles()) } profilesRecyclerview.adapter = ProfilesAdapter( + mutableListOf(), usedProfile, ) { oldIndex: Int?, newIndex: Int -> profilesRecyclerview.adapter?.notifyItemChanged(newIndex) @@ -96,52 +65,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..4c74ec80f 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,14 +8,14 @@ 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 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 @@ -24,10 +24,8 @@ class SourcePriorityDialog( private val updatedCallback: () -> Unit ) : Dialog(ctx, themeRes) { override fun show() { - val binding = - PlayerSelectSourcePriorityBinding.inflate(LayoutInflater.from(ctx), null, false) + val binding = PlayerSelectSourcePriorityBinding.inflate(LayoutInflater.from(ctx), null, false) setContentView(binding.root) - fixSystemBarsPadding(binding.root) val sourcesRecyclerView = binding.sortSources val qualitiesRecyclerView = binding.sortQualities val profileText = binding.profileTextEditable @@ -38,46 +36,45 @@ class SourcePriorityDialog( profileText.setText(QualityDataHelper.getProfileName(profile.id).asString(context)) profileText.hint = txt(R.string.profile_number, profile.id).asString(context) - sourcesRecyclerView.adapter = PriorityAdapter( - ).apply { - submitList(links.map { link -> + sourcesRecyclerView.adapter = PriorityAdapter( + links.map { link -> SourcePriority( null, link.source, QualityDataHelper.getSourcePriority(profile.id, link.source) ) - }.distinctBy { it.name }.sortedBy { -it.priority }) - } + }.distinctBy { it.name }.sortedBy { -it.priority }.toMutableList() + ) - qualitiesRecyclerView.adapter = PriorityAdapter( - ).apply { - submitList(Qualities.entries.mapNotNull { + qualitiesRecyclerView.adapter = PriorityAdapter( + Qualities.entries.mapNotNull { SourcePriority( it, Qualities.getStringByIntFull(it.value).ifBlank { return@mapNotNull null }, QualityDataHelper.getQualityPriority(profile.id, it) ) - }.sortedBy { -it.priority }) - } + }.sortedBy { -it.priority }.toMutableList() + ) @Suppress("UNCHECKED_CAST") // We know the types saveBtt.setOnClickListener { val qualityAdapter = qualitiesRecyclerView.adapter as? PriorityAdapter - val sourcesAdapter = sourcesRecyclerView.adapter as? PriorityAdapter + val sourcesAdapter = sourcesRecyclerView.adapter as? PriorityAdapter - val qualities = qualityAdapter?.immutableCurrentList ?: emptyList() - val sources = sourcesAdapter?.immutableCurrentList ?: emptyList() + val qualities = qualityAdapter?.items ?: emptyList() + val sources = sourcesAdapter?.items ?: emptyList() qualities.forEach { - QualityDataHelper.setQualityPriority(profile.id, it.data, it.priority) + val data = it.data as? Qualities ?: return@forEach + QualityDataHelper.setQualityPriority(profile.id, data, it.priority) } sources.forEach { QualityDataHelper.setSourcePriority(profile.id, it.name, it.priority) } - qualityAdapter?.submitList(qualities.sortedBy { -it.priority }) - sourcesAdapter?.submitList(sources.sortedBy { -it.priority }) + qualityAdapter?.updateList(qualities.sortedBy { -it.priority }) + sourcesAdapter?.updateList(sources.sortedBy { -it.priority }) val savedProfileName = profileText.text.toString() if (savedProfileName.isBlank()) { 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..12adc0400 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 @@ -2,7 +2,9 @@ package com.lagradost.cloudstream3.ui.quicksearch import android.app.Activity import android.content.Context +import android.content.res.Configuration import android.os.Bundle +import android.util.TypedValue import android.view.LayoutInflater import android.view.View import android.view.ViewGroup @@ -11,9 +13,9 @@ import android.widget.ImageView import androidx.appcompat.widget.SearchView import androidx.core.view.isGone import androidx.core.view.isVisible +import androidx.fragment.app.Fragment import androidx.lifecycle.ViewModelProvider import androidx.recyclerview.widget.GridLayoutManager -import androidx.recyclerview.widget.RecyclerView import com.google.android.material.bottomsheet.BottomSheetDialog import com.lagradost.cloudstream3.APIHolder.getApiFromNameNull import com.lagradost.cloudstream3.CommonActivity.activity @@ -23,35 +25,28 @@ import com.lagradost.cloudstream3.databinding.QuickSearchBinding import com.lagradost.cloudstream3.mvvm.Resource import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.mvvm.observe -import com.lagradost.cloudstream3.ui.BaseFragment import com.lagradost.cloudstream3.ui.home.HomeFragment import com.lagradost.cloudstream3.ui.home.HomeFragment.Companion.loadHomepageList -import com.lagradost.cloudstream3.ui.home.HomeViewModel import com.lagradost.cloudstream3.ui.home.ParentItemAdapter 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 import com.lagradost.cloudstream3.ui.settings.Globals.isLayout import com.lagradost.cloudstream3.utils.AppContextUtils.filterProviderByPreferredMedia import com.lagradost.cloudstream3.utils.AppContextUtils.filterSearchResultByFilmQuality -import com.lagradost.cloudstream3.utils.AppContextUtils.isRecyclerScrollable import com.lagradost.cloudstream3.utils.AppContextUtils.ownShow -import com.lagradost.cloudstream3.utils.Coroutines.ioSafe -import com.lagradost.cloudstream3.utils.UIHelper.fixSystemBarsPadding +import com.lagradost.cloudstream3.utils.UIHelper +import com.lagradost.cloudstream3.utils.UIHelper.fixPaddingStatusbar import com.lagradost.cloudstream3.utils.UIHelper.getSpanCount -import com.lagradost.cloudstream3.utils.UIHelper.hideKeyboard import com.lagradost.cloudstream3.utils.UIHelper.navigate import com.lagradost.cloudstream3.utils.UIHelper.popCurrentPage import java.util.concurrent.locks.ReentrantLock -class QuickSearchFragment : BaseFragment( - BaseFragment.BindingCreator.Inflate(QuickSearchBinding::inflate) -) { +class QuickSearchFragment : Fragment() { companion object { const val AUTOSEARCH_KEY = "autosearch" const val PROVIDER_KEY = "providers" @@ -90,29 +85,30 @@ class QuickSearchFragment : BaseFragment( private var providers: Set? = null private lateinit var searchViewModel: SearchViewModel + var binding: QuickSearchBinding? = null + private var bottomSheetDialog: BottomSheetDialog? = null - override fun fixLayout(view: View) { - fixSystemBarsPadding(view) - - // Fix grid - HomeFragment.currentSpan = view.context.getSpanCount() - binding?.quickSearchAutofitResults?.spanCount = HomeFragment.currentSpan - HomeFragment.configEvent.invoke() - } - override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? - ): View? { + ): View { activity?.window?.setSoftInputMode( WindowManager.LayoutParams.SOFT_INPUT_STATE_VISIBLE ) searchViewModel = ViewModelProvider(this)[SearchViewModel::class.java] bottomSheetDialog?.ownShow() - return super.onCreateView(inflater, container, savedInstanceState) + val localBinding = QuickSearchBinding.inflate(inflater, container, false) + binding = localBinding + return localBinding.root + //return inflater.inflate(R.layout.quick_search, container, false) + } + + override fun onDestroyView() { + binding = null + super.onDestroyView() } override fun onDestroy() { @@ -134,7 +130,25 @@ class QuickSearchFragment : BaseFragment( return false } - override fun onBindingCreated(binding: QuickSearchBinding) { + private fun fixGrid() { + activity?.getSpanCount()?.let { + HomeFragment.currentSpan = it + } + binding?.quickSearchAutofitResults?.spanCount = HomeFragment.currentSpan + HomeFragment.currentSpan = HomeFragment.currentSpan + HomeFragment.configEvent.invoke(HomeFragment.currentSpan) + } + + override fun onConfigurationChanged(newConfig: Configuration) { + super.onConfigurationChanged(newConfig) + fixGrid() + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + fixPaddingStatusbar(binding?.quickSearchRoot) + fixGrid() + arguments?.getStringArray(PROVIDER_KEY)?.let { providers = it.toSet() } @@ -144,101 +158,55 @@ class QuickSearchFragment : BaseFragment( getApiFromNameNull(providers?.first())?.hasQuickSearch ?: false } else false - val firstProvider = providers?.firstOrNull() - if (isSingleProvider && firstProvider != null) { - binding.quickSearchAutofitResults.apply { - setRecycledViewPool(SearchAdapter.sharedPool) + if (isSingleProvider) { + binding?.quickSearchAutofitResults?.apply { adapter = SearchAdapter( + ArrayList(), this, ) { callback -> SearchHelper.handleSearchClickCallback(callback) } } - binding.quickSearchAutofitResults.addOnScrollListener(object : - RecyclerView.OnScrollListener() { - var expandCount = 0 - - override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) { - super.onScrollStateChanged(recyclerView, newState) - - val adapter = recyclerView.adapter - if (adapter !is SearchAdapter) return - - val count = adapter.itemCount - val currentHasNext = adapter.hasNext - - if (!recyclerView.isRecyclerScrollable() && currentHasNext && expandCount != count) { - expandCount = count - ioSafe { - searchViewModel.expandAndReturn(firstProvider) - } - } - } - }) - try { - binding.quickSearch.queryHint = - getString(R.string.search_hint_site).format(firstProvider) + binding?.quickSearch?.queryHint = + getString(R.string.search_hint_site).format(providers?.first()) } catch (e: Exception) { logError(e) } } else { - binding.quickSearchMasterRecycler.setRecycledViewPool(ParentItemAdapter.sharedPool) - binding.quickSearchMasterRecycler.adapter = - ParentItemAdapter( - id = "quickSearchMasterRecycler".hashCode(), - { callback -> - SearchHelper.handleSearchClickCallback(callback) - //when (callback.action) { - //SEARCH_ACTION_LOAD -> { - // clickCallback?.invoke(callback) - //} - // else -> SearchHelper.handleSearchClickCallback(activity, callback) - //} - }, - { item -> - bottomSheetDialog = activity?.loadHomepageList(item, dismissCallback = { - bottomSheetDialog = null - }, expandCallback = { searchViewModel.expandAndReturn(it) }) - }, - expandCallback = { name -> - ioSafe { - searchViewModel.expandAndReturn(name) - } + binding?.quickSearchMasterRecycler?.adapter = + ParentItemAdapter(fragment = this, id = "quickSearchMasterRecycler".hashCode(), { callback -> + SearchHelper.handleSearchClickCallback(callback) + //when (callback.action) { + //SEARCH_ACTION_LOAD -> { + // clickCallback?.invoke(callback) + //} + // else -> SearchHelper.handleSearchClickCallback(activity, callback) + //} + }, { item -> + bottomSheetDialog = activity?.loadHomepageList(item, dismissCallback = { + bottomSheetDialog = null }) - binding.quickSearchMasterRecycler.layoutManager = GridLayoutManager(context, 1) + }) + binding?.quickSearchMasterRecycler?.layoutManager = GridLayoutManager(context, 1) } - binding.quickSearchAutofitResults.isVisible = isSingleProvider - binding.quickSearchMasterRecycler.isGone = isSingleProvider + binding?.quickSearchAutofitResults?.isVisible = isSingleProvider + binding?.quickSearchMasterRecycler?.isGone = isSingleProvider val listLock = ReentrantLock() observe(searchViewModel.currentSearch) { list -> try { // https://stackoverflow.com/questions/6866238/concurrent-modification-exception-adding-to-an-arraylist listLock.lock() - (binding.quickSearchMasterRecycler.adapter as? ParentItemAdapter)?.apply { - val newItems = list.map { ongoing -> - val dataList = ongoing.value.list - val dataListFiltered = - context?.filterSearchResultByFilmQuality(dataList) ?: dataList - - val homePageList = HomePageList( - ongoing.key, - dataListFiltered + (binding?.quickSearchMasterRecycler?.adapter as ParentItemAdapter?)?.apply { + updateList(list.map { ongoing -> + val ongoingList = HomePageList( + ongoing.apiName, + if (ongoing.data is Resource.Success) ongoing.data.value else ArrayList() ) - - val expandableList = HomeViewModel.ExpandableHomepageList( - homePageList, - ongoing.value.currentPage, - ongoing.value.hasNext - ) - - expandableList - } - - submitList(newItems) - //notifyDataSetChanged() + ongoingList + }) } } catch (e: Exception) { logError(e) @@ -248,12 +216,24 @@ class QuickSearchFragment : BaseFragment( } val searchExitIcon = - binding.quickSearch.findViewById(androidx.appcompat.R.id.search_close_btn) + binding?.quickSearch?.findViewById(androidx.appcompat.R.id.search_close_btn) - binding.quickSearch.setOnQueryTextListener(object : SearchView.OnQueryTextListener { + //val searchMagIcon = + // binding?.quickSearch?.findViewById(androidx.appcompat.R.id.search_mag_icon) + + // searchMagIcon?.scaleX = 0.65f + // searchMagIcon?.scaleY = 0.65f + + // Set the color for the search exit icon to the correct theme text color + val searchExitIconColor = TypedValue() + + activity?.theme?.resolveAttribute(android.R.attr.textColor, searchExitIconColor, true) + searchExitIcon?.setColorFilter(searchExitIconColor.data) + + binding?.quickSearch?.setOnQueryTextListener(object : SearchView.OnQueryTextListener { override fun onQueryTextSubmit(query: String): Boolean { if (search(context, query, false)) - hideKeyboard(binding.quickSearch) + UIHelper.hideKeyboard(binding?.quickSearch) return true } @@ -263,37 +243,41 @@ class QuickSearchFragment : BaseFragment( return true } }) - binding.quickSearchLoadingBar.alpha = 0f + binding?.quickSearchLoadingBar?.alpha = 0f observe(searchViewModel.searchResponse) { when (it) { is Resource.Success -> { it.value.let { data -> - val adapter = - (binding.quickSearchAutofitResults.adapter as? SearchAdapter) - adapter?.submitList( - context?.filterSearchResultByFilmQuality(data.list) ?: data.list + (binding?.quickSearchAutofitResults?.adapter as? SearchAdapter)?.updateList( + context?.filterSearchResultByFilmQuality(data) ?: data ) - adapter?.hasNext = data.hasNext } searchExitIcon?.alpha = 1f - binding.quickSearchLoadingBar.alpha = 0f + binding?.quickSearchLoadingBar?.alpha = 0f } is Resource.Failure -> { // Toast.makeText(activity, "Server error", Toast.LENGTH_LONG).show() searchExitIcon?.alpha = 1f - binding.quickSearchLoadingBar.alpha = 0f + binding?.quickSearchLoadingBar?.alpha = 0f } is Resource.Loading -> { searchExitIcon?.alpha = 0f - binding.quickSearchLoadingBar.alpha = 1f + binding?.quickSearchLoadingBar?.alpha = 1f } } } + + //quick_search.setOnQueryTextFocusChangeListener { _, b -> + // if (b) { + // // https://stackoverflow.com/questions/12022715/unable-to-show-keyboard-automatically-in-the-searchview + // UIHelper.showInputMethod(view.findFocus()) + // } + //} if (isLayout(PHONE or EMULATOR)) { - binding.quickSearchBack.apply { + binding?.quickSearchBack?.apply { isVisible = true setOnClickListener { activity?.popCurrentPage() @@ -302,11 +286,11 @@ class QuickSearchFragment : BaseFragment( } if (isLayout(TV)) { - binding.quickSearch.requestFocus() + binding?.quickSearch?.requestFocus() } arguments?.getString(AUTOSEARCH_KEY)?.let { - binding.quickSearch.setQuery(it, true) + binding?.quickSearch?.setQuery(it, true) arguments?.remove(AUTOSEARCH_KEY) } } 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..b9893193b 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,14 +6,12 @@ import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.core.view.isVisible +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.RecyclerView import com.lagradost.cloudstream3.ActorData import com.lagradost.cloudstream3.ActorRole import com.lagradost.cloudstream3.R 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 @@ -21,120 +19,163 @@ import com.lagradost.cloudstream3.utils.ImageLoader.loadImage class ActorAdaptor( private var nextFocusUpId: Int? = null, private val focusCallback: (View?) -> Unit = {} -) : NoStateAdapter(diffCallback = BaseDiffCallback(itemSame = { a, b -> - a.actor.name == b.actor.name -})) { - companion object { - val sharedPool = - newSharedPool { setMaxRecycledViews(CONTENT, 10) } - } +) : RecyclerView.Adapter() { + data class ActorMetaData( + var isInverted: Boolean, + val actor: ActorData, + ) - // Easier to store it here than to store it in the ActorData - val inverted: HashMap = hashMapOf() + private val actors: MutableList = mutableListOf() - override fun onCreateContent(parent: ViewGroup): ViewHolderState { - return ViewHolderState( - CastItemBinding.inflate(LayoutInflater.from(parent.context), parent, false) + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { + return CardViewHolder( + CastItemBinding.inflate(LayoutInflater.from(parent.context), parent, false), + focusCallback ) } - override fun onClearView(holder: ViewHolderState) { - when (val binding = holder.view) { - is CastItemBinding -> { - clearImage(binding.actorImage) + override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { + when (holder) { + is CardViewHolder -> { + holder.bind(actors[position].actor, actors[position].isInverted, position) { + actors[position].isInverted = !actors[position].isInverted + this.notifyItemChanged(position) + } } } } - override fun onBindContent(holder: ViewHolderState, item: ActorData, position: Int) { - when (val binding = holder.view) { - is CastItemBinding -> { - val itemView = binding.root - val isInverted = inverted.getOrDefault(item, false) + override fun getItemCount(): Int { + return actors.size + } - val (mainImg, vaImage) = if (!isInverted || item.voiceActor?.image.isNullOrBlank()) { - Pair(item.actor.image, item.voiceActor?.image) - } else { - Pair(item.voiceActor?.image, item.actor.image) - } + private fun updateActorList(newList: List) { + val diffResult = DiffUtil.calculateDiff( + ActorDiffCallback(this.actors, newList) + ) - // Fix tv focus escaping the recyclerview - if (position == 0) { - itemView.nextFocusLeftId = R.id.result_cast_items - } else if ((position - 1) == itemCount) { - itemView.nextFocusRightId = R.id.result_cast_items - } - nextFocusUpId?.let { - itemView.nextFocusUpId = it - } + actors.clear() + actors.addAll(newList) - itemView.setOnFocusChangeListener { v, hasFocus -> - if (hasFocus) { - focusCallback(v) - } - } + diffResult.dispatchUpdatesTo(this) + } - itemView.setOnClickListener { - inverted[item] = !isInverted - this.onUpdateContent(holder, getItem(position), position) - } + fun updateList(newList: List) { + if (actors.size >= newList.size) { + updateActorList(newList.mapIndexed { i, data -> actors[i].copy(actor = data) }) + } else { + updateActorList(newList.mapIndexed { i, data -> + if (i < actors.size) + actors[i].copy(actor = data) + else ActorMetaData(isInverted = false, actor = data) + }) + } + } - itemView.setOnLongClickListener { - if (isLayout(PHONE)) { - Intent(Intent.ACTION_WEB_SEARCH).apply { - putExtra(SearchManager.QUERY, item.actor.name) - }.also { intent -> - itemView.context.packageManager?.let { pm -> - if (intent.resolveActivity(pm) != null) { - itemView.context.startActivity(intent) - } + private inner class CardViewHolder( + val binding: CastItemBinding, + private val focusCallback: (View?) -> Unit = {} + ) : + RecyclerView.ViewHolder(binding.root) { + + fun bind(actor: ActorData, isInverted: Boolean, position: Int, callback: (Int) -> Unit) { + val (mainImg, vaImage) = if (!isInverted || actor.voiceActor?.image.isNullOrBlank()) { + Pair(actor.actor.image, actor.voiceActor?.image) + } else { + Pair(actor.voiceActor?.image, actor.actor.image) + } + + // Fix tv focus escaping the recyclerview + if (position == 0) { + itemView.nextFocusLeftId = R.id.result_cast_items + } else if ((position - 1) == itemCount) { + itemView.nextFocusRightId = R.id.result_cast_items + } + nextFocusUpId?.let { + itemView.nextFocusUpId = it + } + + itemView.setOnFocusChangeListener { v, hasFocus -> + if (hasFocus) { + focusCallback(v) + } + } + + itemView.setOnClickListener { + callback(position) + } + + itemView.setOnLongClickListener { + if (isLayout(PHONE)) { + Intent(Intent.ACTION_WEB_SEARCH).apply { + putExtra(SearchManager.QUERY, actor.actor.name) + }.also { intent -> + itemView.context.packageManager?.let { pm -> + if (intent.resolveActivity(pm) != null) { + itemView.context.startActivity(intent) } } } - true } + true + } - binding.apply { - actorImage.loadImage(mainImg) + binding.apply { + actorImage.loadImage(mainImg) - actorName.text = item.actor.name - item.role?.let { - actorExtra.context?.getString( - when (it) { - ActorRole.Main -> { - R.string.actor_main - } - - ActorRole.Supporting -> { - R.string.actor_supporting - } - - ActorRole.Background -> { - R.string.actor_background - } + actorName.text = actor.actor.name + actor.role?.let { + actorExtra.context?.getString( + when (it) { + ActorRole.Main -> { + R.string.actor_main + } + + ActorRole.Supporting -> { + R.string.actor_supporting + } + + ActorRole.Background -> { + R.string.actor_background } - )?.let { text -> - actorExtra.isVisible = true - actorExtra.text = text } - } ?: item.roleString?.let { + )?.let { text -> actorExtra.isVisible = true - actorExtra.text = it - } ?: run { - actorExtra.isVisible = false + actorExtra.text = text } + } ?: actor.roleString?.let { + actorExtra.isVisible = true + actorExtra.text = it + } ?: run { + actorExtra.isVisible = false + } - if (item.voiceActor == null) { - voiceActorImageHolder.isVisible = false - voiceActorName.isVisible = false - } else { - voiceActorName.text = item.voiceActor?.name - if (!vaImage.isNullOrEmpty()) - voiceActorImageHolder.isVisible = true - voiceActorImage.loadImage(vaImage) - } + if (actor.voiceActor == null) { + voiceActorImageHolder.isVisible = false + voiceActorName.isVisible = false + } else { + voiceActorName.text = actor.voiceActor?.name + if (!vaImage.isNullOrEmpty()) + voiceActorImageHolder.isVisible = true + voiceActorImage.loadImage(vaImage) } } } } +} + +class ActorDiffCallback( + private val oldList: List, + private val newList: List +) : + DiffUtil.Callback() { + override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int) = + oldList[oldItemPosition].actor.actor.name == newList[newItemPosition].actor.actor.name + + override fun getOldListSize() = oldList.size + + override fun getNewListSize() = newList.size + + override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int) = + oldList[oldItemPosition] == newList[newItemPosition] } \ No newline at end of file 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..565c4240d 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,37 +1,31 @@ package com.lagradost.cloudstream3.ui.result +import android.annotation.SuppressLint import android.content.Context import android.view.LayoutInflater import android.view.ViewGroup import androidx.core.view.isGone import androidx.core.view.isVisible -import androidx.core.view.setPadding import androidx.preference.PreferenceManager -import coil3.dispose +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.RecyclerView import com.lagradost.cloudstream3.APIHolder.unixTimeMS -import com.lagradost.cloudstream3.CommonActivity import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.actions.VideoClickActionHolder import com.lagradost.cloudstream3.databinding.ResultEpisodeBinding import com.lagradost.cloudstream3.databinding.ResultEpisodeLargeBinding import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.secondsToReadable -import com.lagradost.cloudstream3.ui.BaseDiffCallback -import com.lagradost.cloudstream3.ui.NoStateAdapter -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 import com.lagradost.cloudstream3.ui.settings.Globals.isLayout 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 @@ -44,6 +38,7 @@ import java.util.Locale * @see VideoClickActionHolder */ const val ACTION_PLAY_EPISODE_IN_PLAYER = 1 + const val ACTION_CHROME_CAST_EPISODE = 4 const val ACTION_CHROME_CAST_MIRROR = 5 @@ -64,74 +59,83 @@ const val ACTION_DOWNLOAD_EPISODE_SUBTITLE_MIRROR = 14 const val ACTION_MARK_AS_WATCHED = 18 const val TV_EP_SIZE = 400 -const val ACTION_MARK_WATCHED_UP_TO_THIS_EPISODE = 19 -data class EpisodeClickEvent(val position: Int?, val action: Int, val data: ResultEpisode) { - constructor(action: Int, data: ResultEpisode) : this(null, action, data) -} +data class EpisodeClickEvent(val action: Int, val data: ResultEpisode) class EpisodeAdapter( private val hasDownloadSupport: Boolean, private val clickCallback: (EpisodeClickEvent) -> Unit, private val downloadClickCallback: (DownloadClickEvent) -> Unit, -) : NoStateAdapter(diffCallback = BaseDiffCallback(itemSame = { a, b -> - a.id == b.id -}, contentSame = { a, b -> - a == b -})) { +) : RecyclerView.Adapter() { companion object { - const val HAS_POSTER: Int = 0 - const val HAS_NO_POSTER: Int = 1 fun getPlayerAction(context: Context): Int { val settingsManager = PreferenceManager.getDefaultSharedPreferences(context) - val playerPref = - settingsManager.getString(context.getString(R.string.player_default_key), "") - + val playerPref = settingsManager.getString(context.getString(R.string.player_default_key), "") + return VideoClickActionHolder.uniqueIdToId(playerPref) ?: ACTION_PLAY_EPISODE_IN_PLAYER } - - val sharedPool = - newSharedPool { - setMaxRecycledViews(HAS_POSTER or CONTENT, 10) - setMaxRecycledViews(HAS_NO_POSTER or CONTENT, 10) - } } - override fun onClearView(holder: ViewHolderState) { + var cardList: MutableList = mutableListOf() + + override fun onViewDetachedFromWindow(holder: RecyclerView.ViewHolder) { if (holder.itemView.hasFocus()) { holder.itemView.clearFocus() } - - when (val binding = holder.view) { - is ResultEpisodeLargeBinding -> { - clearImage(binding.episodePoster) - } - } - super.onClearView(holder) } - override fun customContentViewType(item: ResultEpisode): Int = - if (item.poster.isNullOrBlank() && item.description.isNullOrBlank()) HAS_NO_POSTER else HAS_POSTER + fun updateList(newList: List) { + val diffResult = DiffUtil.calculateDiff( + ResultDiffCallback(this.cardList, newList) + ) + + cardList.clear() + cardList.addAll(newList) + + diffResult.dispatchUpdatesTo(this) + } + + private fun getItem(position: Int): ResultEpisode { + return cardList[position] + } + + override fun getItemViewType(position: Int): Int { + val item = getItem(position) + return if (item.poster.isNullOrBlank() && item.description.isNullOrBlank()) 0 else 1 + } + + + // private val layout = R.layout.result_episode_both + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { + /*val layout = if (cardList.filter { it.poster != null }.size >= cardList.size / 2) + R.layout.result_episode_large + else R.layout.result_episode*/ - override fun onCreateCustomContent(parent: ViewGroup, viewType: Int): ViewHolderState { return when (viewType) { - HAS_NO_POSTER -> { - ViewHolderState( + 0 -> { + EpisodeCardViewHolderSmall( ResultEpisodeBinding.inflate( LayoutInflater.from(parent.context), parent, false - ) + ), + hasDownloadSupport, + clickCallback, + downloadClickCallback ) } - HAS_POSTER -> { - ViewHolderState( + 1 -> { + EpisodeCardViewHolderLarge( ResultEpisodeLargeBinding.inflate( LayoutInflater.from(parent.context), parent, false - ) + ), + hasDownloadSupport, + clickCallback, + downloadClickCallback ) } @@ -139,223 +143,252 @@ class EpisodeAdapter( } } - override fun onBindContent(holder: ViewHolderState, item: ResultEpisode, position: Int) { - val itemView = holder.itemView - when (val binding = holder.view) { - is ResultEpisodeLargeBinding -> { - val setWidth = - if (isLayout(TV or EMULATOR)) TV_EP_SIZE.toPx else ViewGroup.LayoutParams.MATCH_PARENT + override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { + when (holder) { + is EpisodeCardViewHolderLarge -> { + holder.bind(getItem(position)) + } - binding.apply { - episodeLinHolder.layoutParams.width = setWidth - episodeHolderLarge.layoutParams.width = setWidth - episodeHolder.layoutParams.width = setWidth + is EpisodeCardViewHolderSmall -> { + holder.bind(getItem(position)) + } + } + } - if (isLayout(PHONE or EMULATOR) && CommonActivity.appliedTheme == R.style.AmoledMode) { - episodeHolderLarge.radius = 0.0f - episodeHolder.setPadding(0) - } + override fun getItemCount(): Int { + return cardList.size + } - downloadButton.isVisible = hasDownloadSupport - downloadButton.setDefaultClickListener( - DownloadObjects.DownloadEpisodeCached( - name = item.name, - poster = item.poster, - episode = item.episode, - season = item.season, - id = item.id, - parentId = item.parentId, - score = item.score, - description = item.description, - cacheTime = System.currentTimeMillis(), - ), null - ) { - when (it.action) { - DOWNLOAD_ACTION_DOWNLOAD -> { - clickCallback.invoke( - EpisodeClickEvent( - position, - ACTION_DOWNLOAD_EPISODE, - item - ) - ) - } + class EpisodeCardViewHolderLarge( + val binding: ResultEpisodeLargeBinding, + private val hasDownloadSupport: Boolean, + private val clickCallback: (EpisodeClickEvent) -> Unit, + private val downloadClickCallback: (DownloadClickEvent) -> Unit, + ) : RecyclerView.ViewHolder(binding.root) { + var localCard: ResultEpisode? = null - DOWNLOAD_ACTION_LONG_CLICK -> { - clickCallback.invoke( - EpisodeClickEvent( - position, - ACTION_DOWNLOAD_MIRROR, - item - ) - ) - } + @SuppressLint("SetTextI18n") + fun bind(card: ResultEpisode) { + localCard = card + val setWidth = + if (isLayout(TV or EMULATOR)) TV_EP_SIZE.toPx else ViewGroup.LayoutParams.MATCH_PARENT - else -> { - downloadClickCallback.invoke(it) - } - } - } + binding.episodeLinHolder.layoutParams.width = setWidth + binding.episodeHolderLarge.layoutParams.width = setWidth + binding.episodeHolder.layoutParams.width = setWidth - 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 - episodeText.text = - name//if(card.isFiller == true) episodeText.context.getString(R.string.filler).format(name) else name - episodeText.isSelected = true // is needed for text repeating - - if (item.videoWatchState == VideoWatchState.Watched) { - // This cannot be done in getDisplayPosition() as when you have not watched something - // the duration and position is 0 - //episodeProgress.max = 1 - //episodeProgress.progress = 1 - episodePlayIcon.setImageResource(R.drawable.ic_baseline_check_24) - episodeProgress.isVisible = false - } else { - val displayPos = item.getDisplayPosition() - val durationSec = (item.duration / 1000).toInt() - val progressSec = (displayPos / 1000).toInt() - - if (displayPos >= item.duration && displayPos > 0) { - episodePlayIcon.setImageResource(R.drawable.ic_baseline_check_24) - episodeProgress.isVisible = false - } else { - episodePlayIcon.setImageResource(R.drawable.netflix_play) - episodeProgress.apply { - max = durationSec - progress = progressSec - isVisible = displayPos > 0L - } - } - } - - val posterVisible = !item.poster.isNullOrBlank() - if (posterVisible) { - val isUpcoming = item.airDate != null && unixTimeMS < item.airDate - episodePoster.loadImage(item.poster) { - if (isUpcoming) { - error { - // If the poster has an url, but it is faulty then - // we use the episodeUpcomingIcon if it is an upcoming episode - main { - // Make sure it is on the main thread - episodeUpcomingIcon.isVisible = true - } - - null // We only care about the runnable - } - } - } - } else { - // Clear the image - episodePoster.dispose() - } - episodePoster.isVisible = posterVisible - - val rating10p = item.score?.toFloat(10) - if (rating10p != null && rating10p > 0.1) { - episodeRating.text = episodeRating.context?.getString(R.string.rated_format) - ?.format(rating10p) // TODO Change rated_format to use card.score.toString() - } else { - episodeRating.text = "" - } - - episodeRating.isGone = episodeRating.text.isNullOrBlank() - - episodeDescript.apply { - text = item.description.html() - isGone = text.isNullOrBlank() - - var isExpanded = false - setOnClickListener { - if (isLayout(TV)) { - clickCallback.invoke( - EpisodeClickEvent( - position, - ACTION_SHOW_DESCRIPTION, - item - ) - ) - } else { - isExpanded = !isExpanded - maxLines = if (isExpanded) { - Integer.MAX_VALUE - } else 4 - } - } - } - - if (item.airDate != null) { - val isUpcoming = unixTimeMS < item.airDate - - if (isUpcoming) { - episodeProgress.isVisible = false - episodePlayIcon.isVisible = false - episodeUpcomingIcon.isVisible = !posterVisible - episodeDate.setText( - txt( - R.string.episode_upcoming_format, - secondsToReadable( - item.airDate.minus(unixTimeMS).div(1000).toInt(), - "" - ) - ) - ) - } else { - episodePlayIcon.isVisible = true - episodeUpcomingIcon.isVisible = false - - val formattedAirDate = SimpleDateFormat.getDateInstance( - DateFormat.LONG, - Locale.getDefault() - ).apply { - }.format(Date(item.airDate)) - - episodeDate.setText(txt(formattedAirDate)) - } - } else { - episodeUpcomingIcon.isVisible = false - episodePlayIcon.isVisible = true - episodeDate.isVisible = false - } - - episodeRuntime.setText( - txt( - item.runTime?.times(60L)?.toInt()?.let { secondsToReadable(it, "") } - ) - ) - - if (isLayout(EMULATOR or PHONE)) { - episodePoster.setOnClickListener { - clickCallback.invoke( - EpisodeClickEvent( - position, - ACTION_CLICK_DEFAULT, - item - ) - ) + binding.apply { + downloadButton.isVisible = hasDownloadSupport + downloadButton.setDefaultClickListener( + VideoDownloadHelper.DownloadEpisodeCached( + name = card.name, + poster = card.poster, + episode = card.episode, + season = card.season, + id = card.id, + parentId = card.parentId, + score = card.score, + description = card.description, + cacheTime = System.currentTimeMillis(), + ), null + ) { + when (it.action) { + DOWNLOAD_ACTION_DOWNLOAD -> { + clickCallback.invoke(EpisodeClickEvent(ACTION_DOWNLOAD_EPISODE, card)) } - episodePoster.setOnLongClickListener { - clickCallback.invoke( - EpisodeClickEvent( - position, - ACTION_SHOW_TOAST, - item - ) - ) - return@setOnLongClickListener true + DOWNLOAD_ACTION_LONG_CLICK -> { + clickCallback.invoke(EpisodeClickEvent(ACTION_DOWNLOAD_MIRROR, card)) + } + + else -> { + downloadClickCallback.invoke(it) } } } + val name = + if (card.name == null) "${episodeText.context.getString(R.string.episode)} ${card.episode}" else "${card.episode}. ${card.name}" + episodeFiller.isVisible = card.isFiller == true + episodeText.text = + name//if(card.isFiller == true) episodeText.context.getString(R.string.filler).format(name) else name + episodeText.isSelected = true // is needed for text repeating + + if (card.videoWatchState == VideoWatchState.Watched) { + // This cannot be done in getDisplayPosition() as when you have not watched something + // the duration and position is 0 + episodeProgress.max = 1 + episodeProgress.progress = 1 + episodeProgress.isVisible = true + } else { + val displayPos = card.getDisplayPosition() + episodeProgress.max = (card.duration / 1000).toInt() + episodeProgress.progress = (displayPos / 1000).toInt() + episodeProgress.isVisible = displayPos > 0L + } + + episodePoster.loadImage(card.poster) + + if (card.score != null) { + episodeRating.text = episodeRating.context?.getString(R.string.rated_format) + ?.format(card.score.toFloat(10)) // TODO Change rated_format to use card.score.toString() + } else { + episodeRating.text = "" + } + + episodeRating.isGone = episodeRating.text.isNullOrBlank() + + episodeDescript.apply { + text = card.description.html() + isGone = text.isNullOrBlank() + + var isExpanded = false + setOnClickListener { + if (isLayout(TV)) { + clickCallback.invoke(EpisodeClickEvent(ACTION_SHOW_DESCRIPTION, card)) + } else { + isExpanded = !isExpanded + maxLines = if (isExpanded) { + Integer.MAX_VALUE + } else 4 + } + } + } + + if (card.airDate != null) { + val isUpcoming = unixTimeMS < card.airDate + + if (isUpcoming) { + episodePlayIcon.isVisible = false + episodeUpcomingIcon.isVisible = !episodePoster.isVisible + episodeDate.setText( + com.lagradost.cloudstream3.utils.txt( + R.string.episode_upcoming_format, + secondsToReadable( + card.airDate.minus(unixTimeMS).div(1000).toInt(), + "" + ) + ) + ) + } else { + episodeUpcomingIcon.isVisible = false + + val formattedAirDate = SimpleDateFormat.getDateInstance( + DateFormat.LONG, + Locale.getDefault() + ).apply { + }.format(Date(card.airDate)) + + episodeDate.setText(txt(formattedAirDate)) + } + } else { + episodeDate.isVisible = false + } + + episodeRuntime.setText( + txt( + card.runTime?.times(60L)?.toInt()?.let { secondsToReadable(it, "") } + ) + ) + + if (isLayout(EMULATOR or PHONE)) { + episodePoster.setOnClickListener { + clickCallback.invoke(EpisodeClickEvent(ACTION_CLICK_DEFAULT, card)) + } + + episodePoster.setOnLongClickListener { + clickCallback.invoke(EpisodeClickEvent(ACTION_SHOW_TOAST, card)) + return@setOnLongClickListener true + } + } + } + + itemView.setOnClickListener { + clickCallback.invoke(EpisodeClickEvent(ACTION_CLICK_DEFAULT, card)) + } + + if (isLayout(TV)) { + itemView.isFocusable = true + itemView.isFocusableInTouchMode = true + //itemView.touchscreenBlocksFocus = false + } + + itemView.setOnLongClickListener { + clickCallback.invoke(EpisodeClickEvent(ACTION_SHOW_OPTIONS, card)) + return@setOnLongClickListener true + } + + //binding.resultEpisodeDownload.isVisible = hasDownloadSupport + //binding.resultEpisodeProgressDownloaded.isVisible = hasDownloadSupport + } + } + + class EpisodeCardViewHolderSmall( + val binding: ResultEpisodeBinding, + private val hasDownloadSupport: Boolean, + private val clickCallback: (EpisodeClickEvent) -> Unit, + private val downloadClickCallback: (DownloadClickEvent) -> Unit, + ) : RecyclerView.ViewHolder(binding.root) { + @SuppressLint("SetTextI18n") + fun bind(card: ResultEpisode) { + binding.episodeHolder.layoutParams.apply { + width = + if (isLayout(TV or EMULATOR)) TV_EP_SIZE.toPx else ViewGroup.LayoutParams.MATCH_PARENT + } + + binding.apply { + downloadButton.isVisible = hasDownloadSupport + downloadButton.setDefaultClickListener( + VideoDownloadHelper.DownloadEpisodeCached( + name = card.name, + poster = card.poster, + episode = card.episode, + season = card.season, + id = card.id, + parentId = card.parentId, + score = card.score, + description = card.description, + cacheTime = System.currentTimeMillis(), + ), null + ) { + when (it.action) { + DOWNLOAD_ACTION_DOWNLOAD -> { + clickCallback.invoke(EpisodeClickEvent(ACTION_DOWNLOAD_EPISODE, card)) + } + + DOWNLOAD_ACTION_LONG_CLICK -> { + clickCallback.invoke(EpisodeClickEvent(ACTION_DOWNLOAD_MIRROR, card)) + } + + else -> { + downloadClickCallback.invoke(it) + } + } + } + + val name = + if (card.name == null) "${episodeText.context.getString(R.string.episode)} ${card.episode}" else "${card.episode}. ${card.name}" + episodeFiller.isVisible = card.isFiller == true + episodeText.text = + name//if(card.isFiller == true) episodeText.context.getString(R.string.filler).format(name) else name + episodeText.isSelected = true // is needed for text repeating + + if (card.videoWatchState == VideoWatchState.Watched) { + // This cannot be done in getDisplayPosition() as when you have not watched something + // the duration and position is 0 + episodeProgress.max = 1 + episodeProgress.progress = 1 + episodeProgress.isVisible = true + } else { + val displayPos = card.getDisplayPosition() + episodeProgress.max = (card.duration / 1000).toInt() + episodeProgress.progress = (displayPos / 1000).toInt() + episodeProgress.isVisible = displayPos > 0L + } + itemView.setOnClickListener { - clickCallback.invoke(EpisodeClickEvent(position, ACTION_CLICK_DEFAULT, item)) + clickCallback.invoke(EpisodeClickEvent(ACTION_CLICK_DEFAULT, card)) } if (isLayout(TV)) { @@ -365,117 +398,29 @@ class EpisodeAdapter( } itemView.setOnLongClickListener { - clickCallback.invoke(EpisodeClickEvent(position, ACTION_SHOW_OPTIONS, item)) + clickCallback.invoke(EpisodeClickEvent(ACTION_SHOW_OPTIONS, card)) return@setOnLongClickListener true } - } - is ResultEpisodeBinding -> { - binding.episodeHolder.layoutParams.apply { - width = - if (isLayout(TV or EMULATOR)) TV_EP_SIZE.toPx else ViewGroup.LayoutParams.MATCH_PARENT - } - - binding.apply { - downloadButton.isVisible = hasDownloadSupport - downloadButton.setDefaultClickListener( - DownloadObjects.DownloadEpisodeCached( - name = item.name, - poster = item.poster, - episode = item.episode, - season = item.season, - id = item.id, - parentId = item.parentId, - score = item.score, - description = item.description, - cacheTime = System.currentTimeMillis(), - ), null - ) { - when (it.action) { - DOWNLOAD_ACTION_DOWNLOAD -> { - clickCallback.invoke( - EpisodeClickEvent( - position, - ACTION_DOWNLOAD_EPISODE, - item - ) - ) - } - - DOWNLOAD_ACTION_LONG_CLICK -> { - clickCallback.invoke( - EpisodeClickEvent( - position, - ACTION_DOWNLOAD_MIRROR, - item - ) - ) - } - - else -> { - downloadClickCallback.invoke(it) - } - } - } - - 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 - episodeText.text = - name//if(card.isFiller == true) episodeText.context.getString(R.string.filler).format(name) else name - episodeText.isSelected = true // is needed for text repeating - - if (item.videoWatchState == VideoWatchState.Watched) { - episodePlayIcon.setImageResource(R.drawable.ic_baseline_check_24) - episodeProgress.isVisible = false - } else { - val displayPos = item.getDisplayPosition() - val durationSec = (item.duration / 1000).toInt() - val progressSec = (displayPos / 1000).toInt() - - if (displayPos >= item.duration && displayPos > 0) { - episodePlayIcon.setImageResource(R.drawable.ic_baseline_check_24) - episodeProgress.isVisible = false - } else { - episodePlayIcon.setImageResource(R.drawable.play_button_transparent) - episodeProgress.apply { - max = durationSec - progress = progressSec - isVisible = displayPos > 0L - } - } - } - - itemView.setOnClickListener { - clickCallback.invoke( - EpisodeClickEvent( - position, - ACTION_CLICK_DEFAULT, - item - ) - ) - } - - if (isLayout(TV)) { - itemView.isFocusable = true - itemView.isFocusableInTouchMode = true - //itemView.touchscreenBlocksFocus = false - } - - itemView.setOnLongClickListener { - clickCallback.invoke(EpisodeClickEvent(position, ACTION_SHOW_OPTIONS, item)) - return@setOnLongClickListener true - } - - //binding.resultEpisodeDownload.isVisible = hasDownloadSupport - //binding.resultEpisodeProgressDownloaded.isVisible = hasDownloadSupport - } + //binding.resultEpisodeDownload.isVisible = hasDownloadSupport + //binding.resultEpisodeProgressDownloaded.isVisible = hasDownloadSupport } } } -} \ No newline at end of file +} + +class ResultDiffCallback( + private val oldList: List, + private val newList: List +) : + DiffUtil.Callback() { + override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int) = + oldList[oldItemPosition].id == newList[newItemPosition].id + + override fun getOldListSize() = oldList.size + + override fun getNewListSize() = newList.size + + override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int) = + oldList[oldItemPosition] == newList[newItemPosition] +} 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..eecd6262f 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,14 +2,11 @@ package com.lagradost.cloudstream3.ui.result import android.view.LayoutInflater import android.view.ViewGroup +import androidx.recyclerview.widget.DiffUtil +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 const val IMAGE_CLICK = 0 const val IMAGE_LONG_CLICK = 1 @@ -19,54 +16,89 @@ class ImageAdapter( val nextFocusUp: Int? = null, val nextFocusDown: Int? = null, ) : - NoStateAdapter( - diffCallback = BaseDiffCallback( - itemSame = Int::equals, - contentSame = Int::equals - ) - ) { - companion object { - val sharedPool = - newSharedPool { setMaxRecycledViews(CONTENT, 10) } - } + RecyclerView.Adapter() { + private val images: MutableList = mutableListOf() - override fun onCreateContent(parent: ViewGroup): ViewHolderState { - return ViewHolderState( + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { + return ImageViewHolder( + //result_mini_image ResultMiniImageBinding.inflate(LayoutInflater.from(parent.context), parent, false) + // LayoutInflater.from(parent.context).inflate(layout, parent, false) ) } - override fun onClearView(holder: ViewHolderState) { - val binding = holder.view as? ResultMiniImageBinding ?: return - clearImage(binding.root) + override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { + when (holder) { + is ImageViewHolder -> { + holder.bind(images[position], clickCallback, nextFocusUp, nextFocusDown) + } + } } - override fun onBindContent(holder: ViewHolderState, item: Int, position: Int) { - val binding = holder.view as? ResultMiniImageBinding ?: return + override fun getItemCount(): Int { + return images.size + } - binding.root.apply { - loadImage(item) - if (nextFocusDown != null) { - this.nextFocusDownId = nextFocusDown - } - if (nextFocusUp != null) { - this.nextFocusUpId = nextFocusUp - } - if (clickCallback != null) { - if (isLayout(TV)) { - isClickable = true - isLongClickable = true - isFocusable = true - isFocusableInTouchMode = true + override fun getItemId(position: Int): Long { + return images[position].toLong() + } + + fun updateList(newList: List) { + val diffResult = DiffUtil.calculateDiff( + DiffCallback(this.images, newList) + ) + + images.clear() + images.addAll(newList) + + diffResult.dispatchUpdatesTo(this) + } + + class ImageViewHolder(val binding: ResultMiniImageBinding) : + RecyclerView.ViewHolder(binding.root) { + fun bind( + img: Int, + clickCallback: ((Int) -> Unit)?, + nextFocusUp: Int?, + nextFocusDown: Int?, + ) { + binding.root.apply { + setImageResource(img) + if (nextFocusDown != null) { + this.nextFocusDownId = nextFocusDown } - setOnClickListener { - clickCallback.invoke(IMAGE_CLICK) + if (nextFocusUp != null) { + this.nextFocusUpId = nextFocusUp } - setOnLongClickListener { - clickCallback.invoke(IMAGE_LONG_CLICK) - return@setOnLongClickListener true + if (clickCallback != null) { + if (isLayout(TV)) { + isClickable = true + isLongClickable = true + isFocusable = true + isFocusableInTouchMode = true + } + setOnClickListener { + clickCallback.invoke(IMAGE_CLICK) + } + setOnLongClickListener { + clickCallback.invoke(IMAGE_LONG_CLICK) + return@setOnLongClickListener true + } } } } } +} + +class DiffCallback(private val oldList: List, private val newList: List) : + DiffUtil.Callback() { + override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int) = + oldList[oldItemPosition] == newList[newItemPosition] + + override fun getOldListSize() = oldList.size + + override fun getNewListSize() = newList.size + + override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int) = + oldList[oldItemPosition] == newList[newItemPosition] } \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/result/LinearListLayout.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/result/LinearListLayout.kt index 3a0edba2f..b4e3062b4 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/result/LinearListLayout.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/result/LinearListLayout.kt @@ -8,8 +8,6 @@ import com.lagradost.cloudstream3.CommonActivity import com.lagradost.cloudstream3.CommonActivity.activity import com.lagradost.cloudstream3.FocusDirection import com.lagradost.cloudstream3.mvvm.logError -import com.lagradost.cloudstream3.ui.settings.Globals.TV -import com.lagradost.cloudstream3.ui.settings.Globals.isLayout const val FOCUS_SELF = View.NO_ID - 1 const val FOCUS_INHERIT = FOCUS_SELF - 1 @@ -23,17 +21,18 @@ fun RecyclerView?.setLinearListLayout( ) { if (this == null) return val ctx = this.context ?: return - this.layoutManager = (this.layoutManager as? LinearListLayout ?: LinearListLayout(ctx)).apply { - if (isHorizontal) setHorizontal() else setVertical() - nextFocusLeft = - if (nextLeft == FOCUS_INHERIT) this@setLinearListLayout.nextFocusLeftId else nextLeft - nextFocusRight = - if (nextRight == FOCUS_INHERIT) this@setLinearListLayout.nextFocusRightId else nextRight - nextFocusUp = - if (nextUp == FOCUS_INHERIT) this@setLinearListLayout.nextFocusUpId else nextUp - nextFocusDown = - if (nextDown == FOCUS_INHERIT) this@setLinearListLayout.nextFocusDownId else nextDown - } + this.layoutManager = + LinearListLayout(ctx).apply { + if (isHorizontal) setHorizontal() else setVertical() + nextFocusLeft = + if (nextLeft == FOCUS_INHERIT) this@setLinearListLayout.nextFocusLeftId else nextLeft + nextFocusRight = + if (nextRight == FOCUS_INHERIT) this@setLinearListLayout.nextFocusRightId else nextRight + nextFocusUp = + if (nextUp == FOCUS_INHERIT) this@setLinearListLayout.nextFocusUpId else nextUp + nextFocusDown = + if (nextDown == FOCUS_INHERIT) this@setLinearListLayout.nextFocusDownId else nextDown + } } open class LinearListLayout(context: Context?) : @@ -105,33 +104,13 @@ open class LinearListLayout(context: Context?) : } } - fun redirectRecycleToFirstItem(focused: View): View? { - return when (focused) { - is RecyclerView -> { - (focused.layoutManager as? LinearListLayout)?.let { focusedLayoutManager -> - val firstPosition = focusedLayoutManager.findFirstVisibleItemPosition() - val firstView = focusedLayoutManager.findViewByPosition(firstPosition) - firstView - } ?: focused - } - - else -> focused - } - } - override fun onInterceptFocusSearch(focused: View, direction: Int): View? { val dir = if (orientation == HORIZONTAL) { - if (direction == View.FOCUS_DOWN) getNextDirection( - focused, - FocusDirection.Down - )?.let { newFocus -> - return redirectRecycleToFirstItem(newFocus) + if (direction == View.FOCUS_DOWN) getNextDirection(focused, FocusDirection.Down)?.let { newFocus -> + return newFocus } - if (direction == View.FOCUS_UP) getNextDirection( - focused, - FocusDirection.Up - )?.let { newFocus -> - return redirectRecycleToFirstItem(newFocus) + if (direction == View.FOCUS_UP) getNextDirection(focused, FocusDirection.Up)?.let { newFocus -> + return newFocus } if (direction == View.FOCUS_DOWN || direction == View.FOCUS_UP) { @@ -150,16 +129,10 @@ open class LinearListLayout(context: Context?) : } ret } else { - if (direction == View.FOCUS_RIGHT) getNextDirection( - focused, - FocusDirection.End - )?.let { newFocus -> + if (direction == View.FOCUS_RIGHT) getNextDirection(focused, FocusDirection.End)?.let { newFocus -> return newFocus } - if (direction == View.FOCUS_LEFT) getNextDirection( - focused, - FocusDirection.Start - )?.let { newFocus -> + if (direction == View.FOCUS_LEFT) getNextDirection(focused, FocusDirection.Start)?.let { newFocus -> return newFocus } @@ -178,15 +151,9 @@ open class LinearListLayout(context: Context?) : // if out of bounds then refocus as specified return if (lookFor >= itemCount) { - getNextDirection( - focused, - if (orientation == HORIZONTAL) FocusDirection.End else FocusDirection.Down - ) + getNextDirection(focused, if(orientation == HORIZONTAL) FocusDirection.End else FocusDirection.Down) } else if (lookFor < 0) { - getNextDirection( - focused, - if (orientation == HORIZONTAL) FocusDirection.Start else FocusDirection.Up - ) + getNextDirection(focused, if(orientation == HORIZONTAL) FocusDirection.Start else FocusDirection.Up) } else { getViewFromPos(lookFor) ?: run { scrollToPosition(lookFor) @@ -199,38 +166,6 @@ open class LinearListLayout(context: Context?) : } } - override fun requestChildRectangleOnScreen( - parent: RecyclerView, - child: View, - rect: android.graphics.Rect, - immediate: Boolean, - focusedChildVisible: Boolean - ): Boolean { - if (isLayout(TV) && orientation == HORIZONTAL) { - val dx = when { - isLayoutRTL -> getDecoratedRight(child) - (parent.width - parent.paddingRight) - else -> getDecoratedLeft(child) - parent.paddingLeft - } - return if (dx != 0) { - when { - immediate -> parent.scrollBy(dx, 0) - else -> parent.smoothScrollBy(dx, 0) - } - true - } else { - false - } - } else { - return super.requestChildRectangleOnScreen( - parent, - child, - rect, - immediate, - focusedChildVisible - ) - } - } - /*override fun onRequestChildFocus( parent: RecyclerView, state: RecyclerView.State, diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultFragment.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultFragment.kt index cbf94fd97..f0d6a5087 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultFragment.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultFragment.kt @@ -1,17 +1,12 @@ package com.lagradost.cloudstream3.ui.result import android.os.Bundle -import android.widget.ImageView -import android.widget.TextView -import androidx.core.view.isVisible import androidx.fragment.app.Fragment import androidx.preference.PreferenceManager -import coil3.dispose import com.lagradost.cloudstream3.DubStatus import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.Score import com.lagradost.cloudstream3.SearchResponse -import com.lagradost.cloudstream3.SeasonData import com.lagradost.cloudstream3.TvType import com.lagradost.cloudstream3.ui.result.EpisodeAdapter.Companion.getPlayerAction import com.lagradost.cloudstream3.utils.AppContextUtils.getApiDubstatusSettings @@ -19,8 +14,6 @@ import com.lagradost.cloudstream3.utils.DataStoreHelper import com.lagradost.cloudstream3.utils.DataStoreHelper.getVideoWatchState import com.lagradost.cloudstream3.utils.DataStoreHelper.getViewPos import com.lagradost.cloudstream3.utils.Event -import com.lagradost.cloudstream3.utils.ImageLoader.loadImage -import com.lagradost.cloudstream3.utils.UiImage const val START_ACTION_RESUME_LATEST = 1 const val START_ACTION_LOAD_EP = 2 @@ -60,7 +53,6 @@ data class ResultEpisode( val totalEpisodeIndex: Int? = null, val airDate: Long? = null, val runTime: Int? = null, - val seasonData: SeasonData? = null, ) fun ResultEpisode.getRealPosition(): Long { @@ -98,33 +90,31 @@ fun buildResultEpisode( totalEpisodeIndex: Int? = null, airDate: Long? = null, runTime: Int? = null, - seasonData: SeasonData? = null, ): ResultEpisode { val posDur = getViewPos(id) val videoWatchState = getVideoWatchState(id) ?: VideoWatchState.None return ResultEpisode( - headerName = headerName, - name = name, - poster = poster, - episode = episode, - seasonIndex = seasonIndex, - season = season, - data = data, - apiName = apiName, - id = id, - index = index, - position = posDur?.position ?: 0, - duration = posDur?.duration ?: 0, - score = rating, - description = description, - isFiller = isFiller, - tvType = tvType, - parentId = parentId, - videoWatchState = videoWatchState, - totalEpisodeIndex = totalEpisodeIndex, - airDate = airDate, - runTime = runTime, - seasonData = seasonData + headerName, + name, + poster, + episode, + seasonIndex, + season, + data, + apiName, + id, + index, + posDur?.position ?: 0, + posDur?.duration ?: 0, + rating, + description, + isFiller, + tvType, + parentId, + videoWatchState, + totalEpisodeIndex, + airDate, + runTime, ) } @@ -168,7 +158,7 @@ object ResultFragment { fun newInstance( url: String, apiName: String, - name: String, + name : String, startAction: Int = 0, startValue: Int = 0 ): Bundle { @@ -183,10 +173,9 @@ object ResultFragment { } fun updateUI(id: Int? = null) { - // updateUIListener?.invoke() + // updateUIListener?.invoke() updateUIEvent.invoke(id) } - val updateUIEvent = Event() //private var updateUIListener: (() -> Unit)? = null @@ -214,7 +203,10 @@ object ResultFragment { override fun onResume() { afterPluginsLoadedEvent += ::reloadViewModel super.onResume() - activity?.setNavigationBarColorCompat(R.attr.primaryBlackBackground) + activity?.let { + it.window?.navigationBarColor = + it.colorFromAttribute(R.attr.primaryBlackBackground) + } } override fun onDestroy() { @@ -231,52 +223,14 @@ object ResultFragment { data class StoredData( val url: String, val apiName: String, - val name: String, + val name : String, val showFillers: Boolean, val dubStatus: DubStatus, val start: AutoResume?, val playerAction: Int, - val restart: Boolean, + val restart : Boolean, ) - fun bindLogo( - url: String?, - headers: Map?, - logoView: ImageView, - titleView: TextView - ) { - // Cancel it, as we want to remove the listener onSuccess race condition - logoView.dispose() - - if (url.isNullOrBlank()) { - logoView.isVisible = false - titleView.isVisible = true - return - } - - logoView.isVisible = true - titleView.isVisible = false - - logoView.loadImage( - imageData = UiImage.Image(url, headers = headers), - builder = { - listener( - onSuccess = { _, _ -> - logoView.isVisible = true - titleView.isVisible = false - }, - onError = { _, _ -> - logoView.isVisible = false - titleView.isVisible = true - }, - onCancel = { - // If we manually cancel, then it should not do anything - } - ) - } - ) - } - fun Fragment.getStoredData(): StoredData? { val context = this.context ?: this.activity ?: return null val settingsManager = PreferenceManager.getDefaultSharedPreferences(context) @@ -346,6 +300,8 @@ object ResultFragment { context?.updateHasTrailers() activity?.loadCache() + //activity?.fixPaddingStatusbar(result_barstatus) + /* val backParameter = result_back.layoutParams as FrameLayout.LayoutParams backParameter.setMargins( backParameter.leftMargin, @@ -355,6 +311,8 @@ object ResultFragment { ) result_back.layoutParams = backParameter*/ + // activity?.fixPaddingStatusbar(result_toolbar) + val storedData = (activity ?: context)?.let { getStoredData(it) } 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..9c39767a2 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 @@ -5,10 +5,10 @@ import android.app.Dialog import android.content.Intent import android.content.res.ColorStateList 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 +17,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 @@ -40,77 +39,53 @@ import com.lagradost.cloudstream3.MainActivity.Companion.afterPluginsLoadedEvent import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.Score import com.lagradost.cloudstream3.SearchResponse -import com.lagradost.cloudstream3.base64Encode 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 import com.lagradost.cloudstream3.utils.AppContextUtils.openBrowser 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 import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showBottomDialogInstant import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showDialog +import com.lagradost.cloudstream3.utils.UIHelper import com.lagradost.cloudstream3.utils.UIHelper.clipboardHelper 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.hideKeyboard import com.lagradost.cloudstream3.utils.UIHelper.popCurrentPage 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,115 +93,48 @@ 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//FragmentResultBinding.bind(binding.root.findViewById(R.id.fragment_result)) + 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?) { super.onCreate(savedInstanceState) + PanelsChildGestureRegionObserver.Provider.get().apply { resultBinding?.resultCastItems?.let { register(it) } } } - var currentTrailers: List> = emptyList() + var currentTrailers: List = emptyList() var currentTrailerIndex = 0 override fun nextMirror() { @@ -240,35 +148,33 @@ 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() } } private fun loadTrailer(index: Int? = null) { - val isSuccess = - currentTrailers.getOrNull(index ?: currentTrailerIndex) - ?.let { (extractedTrailerLink, _) -> - context?.let { ctx -> - player.onPause() - player.loadPlayer( - ctx, - false, - extractedTrailerLink, - null, - startPosition = 0L, - subtitles = emptySet(), - subtitle = null, - autoPlay = false, - preview = false - ) - true - } ?: run { - false - } + currentTrailers.getOrNull(index ?: currentTrailerIndex)?.let { trailer -> + context?.let { ctx -> + player.onPause() + player.loadPlayer( + ctx, + false, + trailer, + null, + startPosition = 0L, + subtitles = emptySet(), + subtitle = null, + autoPlay = false, + preview = false + ) + true } ?: run { + false + } + } ?: run { false } //result_trailer_thumbnail?.setImageBitmap(result_poster_background?.drawable?.toBitmap()) @@ -277,17 +183,6 @@ open class ResultFragmentPhone : BaseFragment( // result_trailer_loading?.isVisible = isSuccess val turnVis = !isSuccess && !isFullScreenPlayer resultBinding?.apply { - // If we load a trailer, then cancel the big logo and only show the small title - if (isSuccess) { - // This is still a bit of a race condition, but it should work if we have the - // trailers observe after the page observe! - bindLogo( - url = null, - headers = null, - logoView = backgroundPosterWatermarkBadge, - titleView = resultTitle - ) - } resultSmallscreenHolder.isVisible = turnVis resultPosterBackgroundHolder.apply { val fadeIn: Animation = AlphaAnimation(alpha, if (turnVis) 1.0f else 0.0f).apply { @@ -323,10 +218,10 @@ open class ResultFragmentPhone : BaseFragment( //} } - private fun setTrailers(trailers: List>?) { + private fun setTrailers(trailers: List?) { context?.updateHasTrailers() if (!LoadResponse.isTrailersEnabled) return - currentTrailers = trailers?.sortedBy { -it.first.quality } ?: emptyList() + currentTrailers = trailers?.sortedBy { -it.quality } ?: emptyList() loadTrailer() } @@ -340,13 +235,11 @@ open class ResultFragmentPhone : BaseFragment( } updateUIEvent -= ::updateUI - playerHostView?.release() - playerBinding = null + binding = null resultBinding?.resultScroll?.setOnClickListener(null) resultBinding = null syncBinding = null recommendationBinding = null - activity?.detachBackPressedCallback(this@ResultFragmentPhone.toString()) super.onDestroyView() } @@ -365,6 +258,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) { @@ -406,10 +300,10 @@ open class ResultFragmentPhone : BaseFragment( override fun onResume() { afterPluginsLoadedEvent += ::reloadViewModel - activity?.setNavigationBarColorCompat(R.attr.primaryBlackBackground) - context?.let { ctx -> - playerHostView?.onResume(ctx) - playerHostView?.setupKeyEventListener() + activity?.let { + @Suppress("DEPRECATION") + it.window?.navigationBarColor = + it.colorFromAttribute(R.attr.primaryBlackBackground) } super.onResume() PanelsChildGestureRegionObserver.Provider.get() @@ -418,44 +312,25 @@ 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 - - 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 ===== + UIHelper.fixPaddingStatusbar(binding?.resultTopBar) 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 +348,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 +360,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 +390,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 @@ -526,20 +401,13 @@ open class ResultFragmentPhone : BaseFragment( }.apply { this.orientation = RecyclerView.HORIZONTAL }*/ - resultCastItems.setRecycledViewPool(ActorAdaptor.sharedPool) resultCastItems.adapter = ActorAdaptor() - resultEpisodes.setRecycledViewPool(EpisodeAdapter.sharedPool) + resultEpisodes.adapter = 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) @@ -562,7 +430,7 @@ open class ResultFragmentPhone : BaseFragment( activity?.showDialog( names.map { it.second }, viewModel.selectedSortingIndex.value ?: -1, - ctx.getString(R.string.sort_by), + "", false, {}) { itemId -> viewModel.setSort(names[itemId].first) @@ -574,9 +442,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,37 +456,25 @@ open class ResultFragmentPhone : BaseFragment( }) } - binding.apply { + binding?.apply { resultOverlappingPanels.setStartPanelLockState(OverlappingPanelsLayout.LockState.CLOSE) resultOverlappingPanels.setEndPanelLockState(OverlappingPanelsLayout.LockState.CLOSE) resultBack.setOnClickListener { activity?.popCurrentPage() } - activity?.attachBackPressedCallback(this@ResultFragmentPhone.toString()) { - if (resultOverlappingPanels.getSelectedPanel().ordinal == 1) { - runDefault() - } 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, clickCallback = { action -> if (action == IMAGE_CLICK || action == IMAGE_LONG_CLICK) { - if (resultOverlappingPanels.getSelectedPanel().ordinal == 1) { - resultOverlappingPanels.openStartPanel() - } else resultOverlappingPanels.closePanels() + if (binding?.resultOverlappingPanels?.getSelectedPanel()?.ordinal == 1) { + binding?.resultOverlappingPanels?.openStartPanel() + } else { + binding?.resultOverlappingPanels?.closePanels() + } } }) - */ resultSubscribe.setOnClickListener { viewModel.toggleSubscriptionStatus(context) { newStatus: Boolean? -> if (newStatus == null) return@toggleSubscriptionStatus @@ -678,7 +534,12 @@ open class ResultFragmentPhone : BaseFragment( CastContext.getSharedInstance(act.applicationContext) { it.run() }.addOnCompleteListener { - isGone = !it.isSuccessful + isGone = if (it.isSuccessful) { + it.result.castState == CastState.NO_DEVICES_AVAILABLE + } else { + true + } + } // this shit leaks for some reason //castContext.addCastStateListener { state -> @@ -694,8 +555,8 @@ open class ResultFragmentPhone : BaseFragment( playerBinding?.apply { playerOpenSource.setOnClickListener { - currentTrailers.getOrNull(currentTrailerIndex)?.let { (_, ogTrailerLink) -> - context?.openBrowser(ogTrailerLink) + currentTrailers.getOrNull(currentTrailerIndex)?.let { + context?.openBrowser(it.url) } } } @@ -703,9 +564,9 @@ open class ResultFragmentPhone : BaseFragment( recommendationBinding?.apply { resultRecommendationsList.apply { spanCount = 3 - setRecycledViewPool(SearchAdapter.sharedPool) adapter = SearchAdapter( + ArrayList(), this, ) { callback -> SearchHelper.handleSearchClickCallback(callback) @@ -729,13 +590,10 @@ open class ResultFragmentPhone : BaseFragment( resultBinding?.apply { if (resume == null) { resultResumeParent.isVisible = false - resultPlayParent.isVisible = true - resultResumeProgressHolder.isVisible = false return@observeNullable } resultResumeParent.isVisible = true resume.progress?.let { progress -> - resultNextSeriesButton.isVisible = false resultResumeSeriesTitle.apply { isVisible = !resume.isMovie text = @@ -745,11 +603,8 @@ open class ResultFragmentPhone : BaseFragment( resume.result.season ) } - if (resume.isMovie) { - resultPlayParent.isGone = true - resultResumeSeriesProgressText.isVisible = true - resultResumeSeriesProgressText.setText(progress.progressLeft) - } + + resultResumeSeriesProgressText.setText(progress.progressLeft) resultResumeSeriesProgress.apply { isVisible = true this.max = progress.maxProgress @@ -758,30 +613,25 @@ open class ResultFragmentPhone : BaseFragment( resultResumeProgressHolder.isVisible = true } ?: run { resultResumeProgressHolder.isVisible = false - if (!resume.isMovie) { - resultNextSeriesButton.isVisible = true - resultNextSeriesButton.text = context?.getNameFull( - resume.result.name, - resume.result.episode, - resume.result.season - ) - } resultResumeSeriesProgress.isVisible = false resultResumeSeriesTitle.isVisible = false resultResumeSeriesProgressText.isVisible = false } + resultResumeSeriesButton.isVisible = !resume.isMovie resultResumeSeriesButton.setOnClickListener { - resumeAction(storedData, resume) - } - resultNextSeriesButton.setOnClickListener { - resumeAction(storedData, resume) + viewModel.handleAction( + EpisodeClickEvent( + storedData.playerAction, //?: ACTION_PLAY_EPISODE_IN_PLAYER, + resume.result + ) + ) } } } 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 +640,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 +653,11 @@ open class ResultFragmentPhone : BaseFragment( R.drawable.ic_baseline_favorite_border_24 } - binding.resultFavorite.setImageResource(drawable) + binding?.resultFavorite?.setImageResource(drawable) + } + + observe(viewModel.trailers) { trailers -> + setTrailers(trailers.flatMap { it.mirros }) // I dont care about subtitles yet! } observeNullable(viewModel.episodes) { episodes -> @@ -811,58 +665,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() - } + (resultEpisodes.adapter as? EpisodeAdapter)?.updateList(episodes.value) } } } @@ -886,17 +690,8 @@ open class ResultFragmentPhone : BaseFragment( ) return@setOnLongClickListener true } - resultResumeSeriesButton.setOnLongClickListener { - viewModel.handleAction( - EpisodeClickEvent(ACTION_SHOW_OPTIONS, ep) - ) - 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 +708,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) @@ -968,13 +770,6 @@ open class ResultFragmentPhone : BaseFragment( } } - bindLogo( - url = d.logoUrl, - headers = d.posterHeaders, - titleView = resultTitle, - logoView = backgroundPosterWatermarkBadge - ) - var isExpanded = false resultDescription.apply { setTextHtml(d.plotText) @@ -991,15 +786,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)?.updateList(d.actors ?: emptyList()) if (d.contentRatingText == null) { // If there is no rating to display, we don't want an empty gap @@ -1013,7 +801,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) @@ -1022,23 +810,15 @@ open class ResultFragmentPhone : BaseFragment( resultShare.setOnClickListener { try { val i = Intent(Intent.ACTION_SEND) - val nameBase64 = - base64Encode(d.apiName.toString().toByteArray(Charsets.UTF_8)) - val urlBase64 = base64Encode(d.url.toByteArray(Charsets.UTF_8)) - val encodedUri = URLEncoder.encode( - "$APP_STRING_SHARE:$nameBase64?$urlBase64", - "UTF-8" - ) - val redirectUrl = - "https://recloudstream.github.io/csredirect?redirectto=$encodedUri" i.type = "text/plain" i.putExtra(Intent.EXTRA_SUBJECT, d.title) - i.putExtra(Intent.EXTRA_TEXT, redirectUrl) + i.putExtra(Intent.EXTRA_TEXT, d.url) startActivity(Intent.createChooser(i, d.title)) } catch (e: Exception) { logError(e) } } + setUrl(d.url) resultBookmarkFab.apply { isVisible = true @@ -1048,11 +828,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 @@ -1099,17 +878,14 @@ open class ResultFragmentPhone : BaseFragment( } } - observe(viewModel.trailers) { trailers -> - setTrailers(trailers.flatMap { it.mirros }) // I don't care about subtitles yet! - } - observe(syncModel.synced) { list -> syncBinding?.resultSyncNames?.text = list.filter { it.isSynced && it.hasAccount }.joinToString { it.name } val newList = list.filter { it.isSynced && it.hasAccount } - binding.resultMiniSync.isVisible = newList.isNotEmpty() + binding?.resultMiniSync?.isVisible = newList.isNotEmpty() + (binding?.resultMiniSync?.adapter as? ImageAdapter)?.updateList(newList.mapNotNull { it.icon }) } @@ -1204,7 +980,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) @@ -1233,14 +1009,14 @@ open class ResultFragmentPhone : BaseFragment( syncBinding?.apply { resultSyncCheck.choiceMode = AbsListView.CHOICE_MODE_SINGLE resultSyncCheck.adapter = arrayAdapter - setListViewHeightBasedOnItems(resultSyncCheck) + UIHelper.setListViewHeightBasedOnItems(resultSyncCheck) resultSyncCheck.setOnItemClickListener { _, _, which, _ -> syncModel.setStatus(which - 1) } resultSyncRating.addOnChangeListener { it, value, fromUser -> - if (fromUser) syncModel.setScore(Score.from(value, it.valueTo.roundToInt())) + if(fromUser) syncModel.setScore(Score.from(value, it.valueTo.roundToInt())) } resultSyncAddEpisode.setOnClickListener { @@ -1265,7 +1041,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 +1096,6 @@ open class ResultFragmentPhone : BaseFragment( viewModel.skipLoading() } isVisible = true - @SuppressLint("SetTextI18n") text = "${context.getString(R.string.skip_loading)} (${load.linksLoaded})" } } @@ -1390,7 +1165,7 @@ open class ResultFragmentPhone : BaseFragment( activity?.showDialog( names.map { it.second }, names.indexOfFirst { it.second == selectEpisodeRange }, - ctx.getString(R.string.episodes), + "", false, {}) { itemId -> viewModel.changeRange(names[itemId].first) @@ -1411,7 +1186,7 @@ open class ResultFragmentPhone : BaseFragment( activity?.showDialog( names.map { it.second }, names.indexOfFirst { it.second == selectSeason }, - ctx.getString(R.string.season), + "", false, {}) { itemId -> viewModel.changeSeason(names[itemId].first) @@ -1428,20 +1203,7 @@ open class ResultFragmentPhone : BaseFragment( } } - private fun resumeAction( - storedData: ResultFragment.StoredData, - resume: ResumeWatchingStatus - ) { - viewModel.handleAction( - EpisodeClickEvent( - storedData.playerAction, //?: ACTION_PLAY_EPISODE_IN_PLAYER, - resume.result - ) - ) - } - override fun onPause() { - playerHostView?.releaseKeyEventListener() super.onPause() PanelsChildGestureRegionObserver.Provider.get() .addGestureRegionsUpdateListener(gestureRegionsListener) @@ -1455,7 +1217,7 @@ open class ResultFragmentPhone : BaseFragment( root.isGone = isInvalid root.post { rec?.let { list -> - (resultRecommendationsList.adapter as? SearchAdapter)?.submitList(list.filter { it.apiName == matchAgainst }) + (resultRecommendationsList.adapter as? SearchAdapter)?.updateList(list.filter { it.apiName == matchAgainst }) } } } @@ -1499,4 +1261,4 @@ open class ResultFragmentPhone : BaseFragment( } } } -} +} \ No newline at end of file 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..e5ca2e4e1 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 @@ -14,6 +14,7 @@ import androidx.core.view.isGone import androidx.core.view.isInvisible import androidx.core.view.isVisible import androidx.core.widget.NestedScrollView +import androidx.fragment.app.Fragment import androidx.lifecycle.ViewModelProvider import androidx.recyclerview.widget.RecyclerView import com.google.android.material.bottomsheet.BottomSheetDialog @@ -29,24 +30,20 @@ import com.lagradost.cloudstream3.mvvm.Resource import com.lagradost.cloudstream3.mvvm.observe import com.lagradost.cloudstream3.mvvm.observeNullable import com.lagradost.cloudstream3.services.SubscriptionWorkManager -import com.lagradost.cloudstream3.ui.BaseFragment import com.lagradost.cloudstream3.ui.WatchType import com.lagradost.cloudstream3.ui.download.DownloadButtonSetup import com.lagradost.cloudstream3.ui.player.ExtractorLinkGenerator import com.lagradost.cloudstream3.ui.player.GeneratorPlayer import com.lagradost.cloudstream3.ui.player.NEXT_WATCH_EPISODE_PERCENTAGE 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.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 -import com.lagradost.cloudstream3.utils.AppContextUtils.getNameFull import com.lagradost.cloudstream3.utils.AppContextUtils.html import com.lagradost.cloudstream3.utils.AppContextUtils.isRtl import com.lagradost.cloudstream3.utils.AppContextUtils.loadCache @@ -56,24 +53,21 @@ import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.detachBackPres import com.lagradost.cloudstream3.utils.ImageLoader.loadImage import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showBottomDialog import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showBottomDialogInstant +import com.lagradost.cloudstream3.utils.UIHelper +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.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.getImageFromDrawable import com.lagradost.cloudstream3.utils.setText import com.lagradost.cloudstream3.utils.setTextHtml -import com.lagradost.cloudstream3.utils.txt - -class ResultFragmentTv : BaseFragment( - BindingCreator.Inflate(FragmentResultTvBinding::inflate) -) { +class ResultFragmentTv : Fragment() { private lateinit var viewModel: ResultViewModel2 + private var binding: FragmentResultTvBinding? = null override fun onDestroyView() { + binding = null updateUIEvent -= ::updateUI activity?.detachBackPressedCallback(this@ResultFragmentTv.toString()) super.onDestroyView() @@ -83,13 +77,15 @@ class ResultFragmentTv : BaseFragment( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? - ): View? { + ): View { viewModel = ViewModelProvider(this)[ResultViewModel2::class.java] viewModel.EPISODE_RANGE_SIZE = 50 updateUIEvent += ::updateUI - return super.onCreateView(inflater, container, savedInstanceState) + val localBinding = FragmentResultTvBinding.inflate(inflater, container, false) + binding = localBinding + return localBinding.root } private fun updateUI(id: Int?) { @@ -123,7 +119,7 @@ class ResultFragmentTv : BaseFragment( } private fun RecyclerView?.update(data: List) { - (this?.adapter as? SelectAdaptor?)?.submitList(data) + (this?.adapter as? SelectAdaptor?)?.updateSelectionList(data) this?.isVisible = data.size > 1 } @@ -156,14 +152,14 @@ class ResultFragmentTv : BaseFragment( resultRecommendationsList.isGone = isInvalid resultRecommendationsHolder.isGone = isInvalid val matchAgainst = validApiName ?: rec?.firstOrNull()?.apiName - (resultRecommendationsList.adapter as? SearchAdapter)?.submitList(rec?.filter { it.apiName == matchAgainst } + (resultRecommendationsList.adapter as? SearchAdapter)?.updateList(rec?.filter { it.apiName == matchAgainst } ?: emptyList()) rec?.map { it.apiName }?.distinct()?.let { apiNames -> // very dirty selection resultRecommendationsFilterSelection.isVisible = apiNames.size > 1 resultRecommendationsFilterSelection.update(apiNames.map { - txt( + com.lagradost.cloudstream3.utils.txt( it ) to it }) @@ -192,7 +188,11 @@ class ResultFragmentTv : BaseFragment( } override fun onResume() { - activity?.setNavigationBarColorCompat(R.attr.primaryBlackBackground) + activity?.let { + @Suppress("DEPRECATION") + it.window?.navigationBarColor = + it.colorFromAttribute(R.attr.primaryBlackBackground) + } afterPluginsLoadedEvent += ::reloadViewModel super.onResume() } @@ -250,12 +250,10 @@ class ResultFragmentTv : BaseFragment( } } - override fun fixLayout(view: View) { - fixSystemBarsPadding(view, padTop = false) - } - @SuppressLint("SetTextI18n") - override fun onBindingCreated(binding: FragmentResultTvBinding) { + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + // ===== setup ===== val storedData = getStoredData() ?: return activity?.window?.decorView?.clearFocus() @@ -273,7 +271,7 @@ class ResultFragmentTv : BaseFragment( // ===== ===== ===== var comingSoon = false - binding.apply { + binding?.apply { //episodesShadow.rotationX = 180.0f//if(episodesShadow.isRtl()) 180.0f else 0.0f // parallax on background @@ -285,7 +283,7 @@ class ResultFragmentTv : BaseFragment( if (!hasFocus) return@setOnFocusChangeListener toggleEpisodes(false) - binding.apply { + binding?.apply { val views = listOf( resultPlayMovieButton, resultPlaySeriesButton, @@ -306,7 +304,7 @@ class ResultFragmentTv : BaseFragment( redirectToEpisodes.setOnFocusChangeListener { _, hasFocus -> if (!hasFocus) return@setOnFocusChangeListener toggleEpisodes(true) - binding.apply { + binding?.apply { val views = listOf( resultDubSelection, resultSeasonSelection, @@ -410,24 +408,24 @@ class ResultFragmentTv : BaseFragment( resultCastItems.setOnFocusChangeListener { _, hasFocus -> // Always escape focus - if (hasFocus) binding.resultBookmarkButton.requestFocus() + if (hasFocus) binding?.resultBookmarkButton?.requestFocus() } //resultBack.setOnClickListener { // activity?.popCurrentPage() //} resultRecommendationsList.spanCount = 8 - resultRecommendationsList.setRecycledViewPool(SearchAdapter.sharedPool) resultRecommendationsList.adapter = SearchAdapter( + ArrayList(), resultRecommendationsList, ) { callback -> - if (callback.action == SEARCH_ACTION_FOCUSED) { + if (callback.action == SEARCH_ACTION_FOCUSED) toggleEpisodes(false) - } else SearchHelper.handleSearchClickCallback(callback) + else + SearchHelper.handleSearchClickCallback(callback) } - resultEpisodes.setRecycledViewPool(EpisodeAdapter.sharedPool) resultEpisodes.adapter = EpisodeAdapter( false, @@ -439,7 +437,8 @@ class ResultFragmentTv : BaseFragment( } ) - resultCastItems.layoutManager = object : LinearListLayout(root.context) { + resultCastItems.layoutManager = object : LinearListLayout(view.context) { + override fun onRequestChildFocus( parent: RecyclerView, state: RecyclerView.State, @@ -455,48 +454,35 @@ class ResultFragmentTv : BaseFragment( super.onRequestChildFocus(parent, state, child, focused) } } - }.apply { setHorizontal() } - - val aboveCast = listOf( - binding.resultEpisodesShow, - binding.resultBookmark, - binding.resultFavorite, - binding.resultSubscribe, - ).firstOrNull { it.isVisible } - - resultCastItems.setRecycledViewPool(ActorAdaptor.sharedPool) - resultCastItems.adapter = ActorAdaptor(aboveCast?.id) { - toggleEpisodes(false) + }.apply { + setHorizontal() } - if (isLayout(EMULATOR)) { - episodesShadow.setOnClickListener { - toggleEpisodes(false) - } + val aboveCast = listOf( + binding?.resultEpisodesShow, + binding?.resultBookmark, + binding?.resultFavorite, + binding?.resultSubscribe, + ).firstOrNull { + it?.isVisible == true + } + resultCastItems.adapter = ActorAdaptor(aboveCast?.id) { + toggleEpisodes(false) } } observeNullable(viewModel.resumeWatching) { resume -> - binding.apply { + binding?.apply { + if (resume == null) { return@observeNullable } - resultResumeSeries.isVisible = true resultPlayMovie.isVisible = false resultPlaySeries.isVisible = false // show progress no matter if series or movie resume.progress?.let { progress -> - resultResumeSeriesTitle.apply { - isVisible = !resume.isMovie - text = - if (resume.isMovie) null else context?.getNameFull( - resume.result.name, - resume.result.episode, - resume.result.season - ) - } resultResumeSeriesProgressText.setText(progress.progressLeft) resultResumeSeriesProgress.apply { isVisible = true @@ -548,18 +534,17 @@ class ResultFragmentTv : BaseFragment( observe(viewModel.trailers) { trailersLinks -> context?.updateHasTrailers() if (!LoadResponse.isTrailersEnabled) return@observe - val extractedTrailerLinks = trailersLinks.flatMap { it.mirros } - .map { (extractedTrailerLink, _) -> extractedTrailerLink } - binding.apply { - resultPlayTrailer.isGone = extractedTrailerLinks.isEmpty() + val trailers = trailersLinks.flatMap { it.mirros } + binding?.apply { + resultPlayTrailer.isGone = trailers.isEmpty() resultPlayTrailerButton.setOnClickListener { - if (extractedTrailerLinks.isEmpty()) return@setOnClickListener + if (trailers.isEmpty()) return@setOnClickListener activity.navigate( R.id.global_to_navigation_player, GeneratorPlayer.newInstance( ExtractorLinkGenerator( - extractedTrailerLinks, + trailers, emptyList() - ), 0 + ) ) ) } @@ -567,13 +552,16 @@ class ResultFragmentTv : BaseFragment( } observe(viewModel.watchStatus) { watchType -> - binding.apply { + binding?.apply { resultBookmarkText.setText(watchType.stringRes) resultBookmarkButton.apply { + val drawable = if (watchType.stringRes == R.string.type_none) { R.drawable.outline_bookmark_add_24 - } else R.drawable.ic_baseline_bookmark_24 + } else { + R.drawable.ic_baseline_bookmark_24 + } setIconResource(drawable) setOnClickListener { view -> @@ -591,13 +579,19 @@ class ResultFragmentTv : BaseFragment( } observeNullable(viewModel.favoriteStatus) { isFavorite -> - binding.resultFavorite.isVisible = isFavorite != null - binding.resultFavoriteButton.apply { + + binding?.resultFavorite?.isVisible = isFavorite != null + + binding?.resultFavoriteButton?.apply { + if (isFavorite == null) return@observeNullable val drawable = if (isFavorite) { R.drawable.ic_baseline_favorite_24 - } else R.drawable.ic_baseline_favorite_border_24 + } else { + R.drawable.ic_baseline_favorite_border_24 + } + setIconResource(drawable) setOnClickListener { @@ -606,13 +600,15 @@ class ResultFragmentTv : BaseFragment( val message = if (newStatus) { R.string.favorite_added - } else R.string.favorite_removed + } else { + R.string.favorite_removed + } val name = (viewModel.page.value as? Resource.Success)?.value?.title - ?: txt(R.string.no_data) + ?: com.lagradost.cloudstream3.utils.txt(R.string.no_data) .asStringNull(context) ?: "" CommonActivity.showToast( - txt( + com.lagradost.cloudstream3.utils.txt( message, name ), Toast.LENGTH_SHORT @@ -621,22 +617,28 @@ class ResultFragmentTv : BaseFragment( } } - binding.resultFavoriteText.apply { + binding?.resultFavoriteText?.apply { val text = if (isFavorite == true) { R.string.unfavorite - } else R.string.favorite + } else { + R.string.favorite + } setText(text) } } observeNullable(viewModel.subscribeStatus) { isSubscribed -> - binding.resultSubscribe.isVisible = isSubscribed != null && isLayout(EMULATOR) - binding.resultSubscribeButton.apply { + binding?.resultSubscribe?.isVisible = isSubscribed != null && isLayout(EMULATOR) + binding?.resultSubscribeButton?.apply { + if (isSubscribed == null) return@observeNullable val drawable = if (isSubscribed) { R.drawable.ic_baseline_notifications_active_24 - } else R.drawable.baseline_notifications_none_24 + } else { + R.drawable.baseline_notifications_none_24 + } + setIconResource(drawable) setOnClickListener { @@ -647,13 +649,15 @@ class ResultFragmentTv : BaseFragment( // Kinda icky to have this here, but it works. SubscriptionWorkManager.enqueuePeriodicWork(context) R.string.subscription_new - } else R.string.subscription_deleted + } else { + R.string.subscription_deleted + } val name = (viewModel.page.value as? Resource.Success)?.value?.title - ?: txt(R.string.no_data) + ?: com.lagradost.cloudstream3.utils.txt(R.string.no_data) .asStringNull(context) ?: "" CommonActivity.showToast( - txt( + com.lagradost.cloudstream3.utils.txt( message, name ), Toast.LENGTH_SHORT @@ -661,10 +665,12 @@ class ResultFragmentTv : BaseFragment( } } - binding.resultSubscribeText.apply { + binding?.resultSubscribeText?.apply { val text = if (isSubscribed) { R.string.action_unsubscribe - } else R.string.action_subscribe + } else { + R.string.action_subscribe + } setText(text) } } @@ -675,8 +681,10 @@ class ResultFragmentTv : BaseFragment( return@observeNullable } - binding.apply { + binding?.apply { + (data as? Resource.Success)?.value?.let { (_, ep) -> + resultPlayMovieButton.setOnClickListener { viewModel.handleAction( EpisodeClickEvent(ACTION_CLICK_DEFAULT, ep) @@ -690,9 +698,10 @@ class ResultFragmentTv : BaseFragment( } resultPlayMovie.isVisible = !comingSoon && resultResumeSeries.isGone - if (comingSoon) { + if (comingSoon) resultBookmarkButton.requestFocus() - } else resultPlayMovieButton.requestFocus() + else + resultPlayMovieButton.requestFocus() // Stops last button right focus resultSearchButton.nextFocusRightId = R.id.result_search_Button @@ -761,26 +770,26 @@ class ResultFragmentTv : BaseFragment( observeNullable(viewModel.episodesCountText) { count -> - binding.resultEpisodesText.setText(count) + binding?.resultEpisodesText.setText(count) } observe(viewModel.selectedRangeIndex) { selected -> - binding.resultRangeSelection.select(selected) + binding?.resultRangeSelection.select(selected) } observe(viewModel.selectedSeasonIndex) { selected -> - binding.resultSeasonSelection.select(selected) + binding?.resultSeasonSelection.select(selected) } observe(viewModel.selectedDubStatusIndex) { selected -> - binding.resultDubSelection.select(selected) + binding?.resultDubSelection.select(selected) } observe(viewModel.rangeSelections) { - binding.resultRangeSelection.update(it) + binding?.resultRangeSelection.update(it) } observe(viewModel.dubSubSelections) { - binding.resultDubSelection.update(it) + binding?.resultDubSelection.update(it) } observe(viewModel.seasonSelections) { - binding.resultSeasonSelection.update(it) + binding?.resultSeasonSelection.update(it) } observe(viewModel.recommendations) { recommendations -> setRecommendations(recommendations, null) @@ -788,7 +797,7 @@ class ResultFragmentTv : BaseFragment( if (isLayout(TV)) { observe(viewModel.episodeSynopsis) { description -> - context?.let { ctx -> + view.context?.let { ctx -> val builder: AlertDialog.Builder = AlertDialog.Builder(ctx, R.style.AlertDialogCustom) builder.setMessage(description.html()) @@ -805,11 +814,15 @@ class ResultFragmentTv : BaseFragment( var hasLoadedEpisodesOnce = false observeNullable(viewModel.episodes) { episodes -> if (episodes == null) return@observeNullable - binding.apply { - if (comingSoon) resultBookmarkButton.requestFocus() + + binding?.apply { + + if (comingSoon) + resultBookmarkButton.requestFocus() // resultEpisodeLoading.isVisible = episodes is Resource.Loading if (episodes is Resource.Success) { + val lastWatchedIndex = episodes.value.indexOfLast { ep -> ep.getWatchProgress() >= NEXT_WATCH_EPISODE_PERCENTAGE.toFloat() / 100.0f || ep.videoWatchState == VideoWatchState.Watched } @@ -852,14 +865,14 @@ class ResultFragmentTv : BaseFragment( } - (resultEpisodes.adapter as? EpisodeAdapter)?.submitList(episodes.value) + (resultEpisodes.adapter as? EpisodeAdapter)?.updateList(episodes.value) } } } observeNullable(viewModel.page) { data -> if (data == null) return@observeNullable - binding.apply { + binding?.apply { when (data) { is Resource.Success -> { val d = data.value @@ -889,7 +902,7 @@ class ResultFragmentTv : BaseFragment( Integer.MAX_VALUE } else 10 } else { - context?.let { ctx -> + view.context?.let { ctx -> val builder: AlertDialog.Builder = AlertDialog.Builder(ctx, R.style.AlertDialogCustom) builder.setMessage(d.plotText.asString(ctx).html()) @@ -909,32 +922,21 @@ class ResultFragmentTv : BaseFragment( R.drawable.profile_bg_red, R.drawable.profile_bg_teal ).random() + //Change poster crop area to 20% from Top + backgroundPoster.cropYCenterOffsetPct = 0.20F backgroundPoster.loadImage(d.posterBackgroundImage) { error { getImageFromDrawable(context ?: return@error null, error) } } - - bindLogo( - url = d.logoUrl, - headers = d.posterHeaders, - titleView = resultTitle, - logoView = backgroundPosterWatermarkBadgeHolder - ) - comingSoon = d.comingSoon 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 + UIHelper.populateChips(resultTag, d.tags) + resultCastItems.isGone = d.actors.isNullOrEmpty() + (resultCastItems.adapter as? ActorAdaptor)?.updateList( + d.actors ?: emptyList() ) - 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 @@ -945,7 +947,9 @@ class ResultFragmentTv : BaseFragment( } } - is Resource.Loading -> {} + is Resource.Loading -> { + + } is Resource.Failure -> { resultErrorText.text = @@ -962,4 +966,4 @@ class ResultFragmentTv : BaseFragment( } } } -} +} \ No newline at end of file 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..5253974e8 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultTrailerPlayer.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultTrailerPlayer.kt @@ -3,76 +3,40 @@ package com.lagradost.cloudstream3.ui.result import android.animation.ValueAnimator 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 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 +46,18 @@ class ResultTrailerPlayer : ResultFragmentPhone() { } private fun fixPlayerSize() { - binding?.apply { - if (isFullScreenPlayer) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - 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) { - 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 +65,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 +102,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 +114,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 +151,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..c445c49a1 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.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.AcraApplication.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.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,23 +44,18 @@ 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.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 import com.lagradost.cloudstream3.utils.DataStoreHelper.getAllFavorites @@ -121,30 +81,8 @@ 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.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 kotlinx.coroutines.* import java.util.concurrent.TimeUnit /** This starts at 1 */ @@ -177,7 +115,6 @@ data class ResultData( val posterImage: String?, val posterBackgroundImage: String?, - val logoUrl: String?, val plotText: UiText, val apiName: UiText, val ratingText: UiText?, @@ -303,7 +240,6 @@ fun LoadResponse.toResultData(repo: APIRepository): ResultData { plot!! ), backgroundPosterUrl = backgroundPosterUrl, - logoUrl = logoUrl, title = name, typeText = txt( when (type) { @@ -319,12 +255,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()), @@ -398,7 +333,6 @@ data class ResumeWatchingStatus( data class LinkLoadingResult( val links: List, val subs: List, - val syncData: HashMap ) sealed class SelectPopup { @@ -449,7 +383,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, var subtitles: List = emptyList(), ) @@ -479,8 +413,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 @@ -584,29 +518,6 @@ class ResultViewModel2 : ViewModel() { return this?.firstOrNull { it.season == season } } - fun seasonToTxt(seasonData: SeasonData?, season: Int?): UiText? { - if (season == 0) { - return txt(R.string.no_season) - } - - // If displaySeason is null then only show the name! - return if (seasonData?.name != null && seasonData.displaySeason == null) { - txt(seasonData.name) - } else { - val suffix = seasonData?.name?.let { " $it" } ?: "" - txt( - R.string.season_format, - txt(R.string.season), - seasonData?.displaySeason ?: season, - suffix - ) - } - } - - private fun List?.getSeasonTxt(season: Int?): UiText? = - seasonToTxt(getSeason(season), season) - - private fun filterName(name: String?): String? { if (name == null) return null Regex("^[eE]pisode [0-9]*(.*)").find(name)?.groupValues?.get(1)?.let { @@ -725,6 +636,226 @@ 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.getDownloadSubsLanguageISO639_1() + subs?.let { subsList -> + subsList.filter { + downloadList.contains( + SubtitleHelper.fromLanguageToTwoLetters( + it.name, + true + ) + ) + } + .map { ExtractorSubtitleLink(it.name, it.url, "", it.headers) }.take(3) + .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, + allowedTypes = 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 +1034,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. * @@ -1228,7 +1337,7 @@ class ResultViewModel2 : ViewModel() { // TODO Add skip loading here loadLinks(result, isVisible = true, sourceTypes, isCasting = isCasting) { links -> // Could not find a better way to do this - //val context = CloudStreamApp.context + //val context = AcraApplication.context postPopup( text, links.links.map { txt("${it.name} ${Qualities.getStringByInt(it.quality)}") } @@ -1282,7 +1391,7 @@ class ResultViewModel2 : ViewModel() { updatePage() tempGenerator.generateLinks( clearCache, - sourceTypes = sourceTypes, + allowedTypes = sourceTypes, callback = { (link, _) -> if (link != null) { links += link @@ -1293,10 +1402,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) @@ -1304,11 +1412,7 @@ class ResultViewModel2 : ViewModel() { _loadedLinks.postValue(null) } - return LinkLoadingResult( - sortUrls(links), - sortSubs(subs), - HashMap(currentResponse?.syncData ?: emptyMap()) - ) + return LinkLoadingResult(sortUrls(links), sortSubs(subs)) } fun handleAction(click: EpisodeClickEvent) = @@ -1320,40 +1424,6 @@ class ResultViewModel2 : ViewModel() { _episodeSynopsis.postValue(null) } - private fun markEpisodes( - editor: Editor, - episodeIds: Array, - watchState: VideoWatchState - ) { - val watchStateString = watchState.toJson() - episodeIds.forEach { - if (getVideoWatchState(it.toInt()) != watchState) { - editor.setKeyRaw( - getFolderName("$currentAccount/$VIDEO_WATCH_STATE", it), - watchStateString - ) - } - } - } - - private fun getEpisodesIdsBySeason(season: Int): HashMap> { - val result = currentEpisodes.entries - .asSequence() - .filter { it.key.season <= season && it.key.dubStatus == preferDubStatus } - .flatMap { entry -> - entry.value.asSequence().map { entry.key.season to it.id.toString() } - } - .groupBy({ it.first }, { it.second }) - .mapValues { (_, ids) -> ids.toTypedArray() } - .toMap(HashMap()) - - if (season != 0) { - result.remove(0) - } - return result - } - - private suspend fun handleEpisodeClickEvent(click: EpisodeClickEvent) { when (click.action) { ACTION_SHOW_OPTIONS -> { @@ -1369,6 +1439,7 @@ class ResultViewModel2 : ViewModel() { } options.add(txt(R.string.episode_action_play_in_app) to ACTION_PLAY_EPISODE_IN_PLAYER) + options.addAll( listOf( txt(R.string.episode_action_auto_download) to ACTION_DOWNLOAD_EPISODE, @@ -1390,14 +1461,9 @@ class ResultViewModel2 : ViewModel() { val watchedText = if (isWatched) R.string.action_remove_from_watched else R.string.action_mark_as_watched - val markUpToText = - if (isWatched) R.string.action_remove_mark_watched_up_to_this_episode - else R.string.action_mark_watched_up_to_this_episode - options.add(txt(watchedText) to ACTION_MARK_AS_WATCHED) - - options.add(txt(markUpToText) to ACTION_MARK_WATCHED_UP_TO_THIS_EPISODE) } + postPopup( txt( activity?.getNameFull( @@ -1471,17 +1537,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 +1557,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 +1570,8 @@ class ResultViewModel2 : ViewModel() { response.url, listOf(result.links[index]), result.subs, - ).toWrapper() - ) + ) + } showToast( R.string.download_started, Toast.LENGTH_SHORT @@ -1544,25 +1610,28 @@ 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 } - + val data = currentResponse?.syncData?.toList() ?: emptyList() + val list = + HashMap().apply { putAll(data) } + 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 ) ) } @@ -1571,37 +1640,17 @@ class ResultViewModel2 : ViewModel() { ACTION_MARK_AS_WATCHED -> { val isWatched = getVideoWatchState(click.data.id) == VideoWatchState.Watched + if (isWatched) { setVideoWatchState(click.data.id, VideoWatchState.None) } else { setVideoWatchState(click.data.id, VideoWatchState.Watched) } + // Kinda dirty to reload all episodes :( reloadEpisodes() } - ACTION_MARK_WATCHED_UP_TO_THIS_EPISODE -> ioSafe { - val editor = context?.let { it1 -> editor(it1, false) } - - if (editor != null) { - val (clickSeason, clickEpisode) = click.data.let { - (it.season ?: 0) to it.episode - } - val watchState = - if (getVideoWatchState(click.data.id) == VideoWatchState.Watched) VideoWatchState.None else VideoWatchState.Watched - val seasons = getEpisodesIdsBySeason(clickSeason) - - seasons.keys.forEach { currentSeason -> - var episodeIds = seasons[currentSeason] ?: emptyArray() - if (currentSeason == clickSeason) episodeIds = - episodeIds.sliceArray(0 until clickEpisode) - markEpisodes(editor, episodeIds, watchState) - } - editor.apply() - reloadEpisodes() - } - } - else -> { val action = VideoClickActionHolder.getActionById(click.action) ?: return @@ -1686,13 +1735,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 +1761,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 +1779,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) -> @@ -1756,7 +1803,6 @@ class ResultViewModel2 : ViewModel() { // set posters, might fuck up due to headers idk posterUrl = posterUrl ?: res?.image backgroundPosterUrl = backgroundPosterUrl ?: res?.cover - logoUrl = logoUrl }, { if (meta == null) return@runAllAsync @@ -1831,10 +1877,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) { @@ -1896,10 +1943,7 @@ class ResultViewModel2 : ViewModel() { return when (sorting) { EpisodeSortType.NUMBER_ASC -> episodes.sortedBy { it.episode } EpisodeSortType.NUMBER_DESC -> episodes.sortedByDescending { it.episode } - EpisodeSortType.RATING_HIGH_LOW -> episodes.sortedByDescending { - it.score?.toDouble() ?: 0.0 - } - + EpisodeSortType.RATING_HIGH_LOW -> episodes.sortedByDescending { it.score?.toDouble() ?: 0.0 } EpisodeSortType.RATING_LOW_HIGH -> episodes.sortedBy { it.score?.toDouble() ?: 0.0 } EpisodeSortType.DATE_NEWEST -> episodes.sortedByDescending { it.airDate } EpisodeSortType.DATE_OLDEST -> episodes.sortedBy { it.airDate } @@ -1918,7 +1962,6 @@ class ResultViewModel2 : ViewModel() { val text = txt( when (response.type) { TvType.Torrent -> R.string.play_torrent_button - TvType.TvSeries -> R.string.play_full_series_button else -> { if (response.type.isLiveStream()) R.string.play_livestream_button @@ -2034,8 +2077,29 @@ class ResultViewModel2 : ViewModel() { ) _selectedSeason.postValue( + if (isMovie || currentSeasons.size <= 1) null else - (currentResponse as? EpisodeResponse)?.seasonNames.getSeasonTxt(indexer.season) + when (indexer.season) { + 0 -> txt(R.string.no_season) + else -> { + val seasonNames = (currentResponse as? EpisodeResponse)?.seasonNames + val seasonData = seasonNames.getSeason(indexer.season) + + // If displaySeason is null then only show the name! + if (seasonData?.name != null && seasonData.displaySeason == null) { + txt(seasonData.name) + } else { + val suffix = seasonData?.name?.let { " $it" } ?: "" + txt( + R.string.season_format, + txt(R.string.season), + seasonData?.displaySeason ?: indexer.season, + suffix + ) + } + } + } + ) _selectedRangeIndex.postValue( @@ -2171,8 +2235,8 @@ class ResultViewModel2 : ViewModel() { ) { _episodes.postValue(Resource.Loading()) - if (updateFillers) { - updateFillers(loadResponse) + if (updateFillers && loadResponse is AnimeLoadResponse) { + updateFillers(loadResponse.name) } val allEpisodes = when (loadResponse) { @@ -2205,7 +2269,7 @@ class ResultViewModel2 : ViewModel() { filterName(i.name), i.posterUrl, episode, - i.season, + seasonData?.season ?: i.season, if (seasonData != null) seasonData.displaySeason else i.season, i.data, loadResponse.apiName, @@ -2213,13 +2277,12 @@ class ResultViewModel2 : ViewModel() { index, i.score, i.description, - fillers.contains(episode), + fillers.getOrDefault(episode, false), loadResponse.type, mainId, totalIndex, airDate = i.date, runTime = i.runTime, - seasonData = seasonData, ) val season = eps.seasonIndex ?: 0 @@ -2262,7 +2325,7 @@ class ResultViewModel2 : ViewModel() { filterName(episode.name), episode.posterUrl, episodeIndex, - episode.season, + seasonData?.season ?: episode.season, if (seasonData != null) seasonData.displaySeason else episode.season, episode.data, loadResponse.apiName, @@ -2276,7 +2339,6 @@ class ResultViewModel2 : ViewModel() { totalIndex, airDate = episode.date, runTime = episode.runTime, - seasonData = seasonData, ) val season = ep.seasonIndex ?: 0 @@ -2375,7 +2437,21 @@ class ResultViewModel2 : ViewModel() { _dubSubSelections.postValue(dubSelection.map { txt(it) to it }) if (loadResponse is EpisodeResponse) { _seasonSelections.postValue(seasonsSelection.map { seasonNumber -> - loadResponse.seasonNames.getSeasonTxt(seasonNumber) to seasonNumber + val seasonData = loadResponse.seasonNames.getSeason(seasonNumber) + val fixedSeasonNumber = seasonData?.displaySeason ?: seasonNumber + val suffix = seasonData?.name?.let { " $it" } ?: "" + // If displaySeason is null then only show the name! + val name = if (seasonData?.name != null && seasonData.displaySeason == null) { + txt(seasonData.name) + } else { + txt( + R.string.season_format, + txt(R.string.season), + fixedSeasonNumber, + suffix + ) + } + name to seasonNumber }) } @@ -2453,34 +2529,25 @@ 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(it) }) && 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 - ) + newExtractorLink( + "", + "Trailer", + trailerData.extractorUrl, + type = INFER_TYPE + ) { + this.referer = trailerData.referer ?: "" + this.quality = Qualities.Unknown.value + this.headers = trailerData.headers + } ) to arrayListOf() } else { links to subs @@ -2564,7 +2631,6 @@ class ResultViewModel2 : ViewModel() { override var syncData: MutableMap = mutableMapOf(), override var posterHeaders: Map? = null, override var backgroundPosterUrl: String? = null, - override var logoUrl: String? = null, override var contentRating: String? = null, override var uniqueUrl: String = url, val id: Int?, @@ -2678,7 +2744,7 @@ class ResultViewModel2 : ViewModel() { setKey( DOWNLOAD_HEADER_CACHE, mainId.toString(), - DownloadObjects.DownloadHeaderCached( + VideoDownloadHelper.DownloadHeaderCached( apiName = apiName, url = validUrl, type = loadResponse.type, @@ -2706,4 +2772,4 @@ class ResultViewModel2 : ViewModel() { } } } -} +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/result/SelectAdaptor.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/result/SelectAdaptor.kt index 4231819dd..ad5d89d18 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/result/SelectAdaptor.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/result/SelectAdaptor.kt @@ -2,11 +2,10 @@ package com.lagradost.cloudstream3.ui.result import android.view.LayoutInflater import android.view.ViewGroup +import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.RecyclerView +import com.google.android.material.button.MaterialButton import com.lagradost.cloudstream3.databinding.ResultSelectionBinding -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.TV import com.lagradost.cloudstream3.ui.settings.Globals.isLayout import com.lagradost.cloudstream3.utils.UiText @@ -14,56 +13,93 @@ import com.lagradost.cloudstream3.utils.setText typealias SelectData = Pair -class SelectAdaptor(val callback: (Any) -> Unit) : - NoStateAdapter(diffCallback = BaseDiffCallback(itemSame = { a, b -> - a.second == b.second - }, contentSame = { a, b -> - a == b - })) { +class SelectAdaptor(val callback: (Any) -> Unit) : RecyclerView.Adapter() { + private val selection: MutableList = mutableListOf() private var selectedIndex: Int = -1 - override fun onCreateContent(parent: ViewGroup): ViewHolderState { - return ViewHolderState( - ResultSelectionBinding.inflate( - LayoutInflater.from(parent.context), - parent, - false - ) + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { + return SelectViewHolder( + ResultSelectionBinding.inflate(LayoutInflater.from(parent.context), parent, false), + + //LayoutInflater.from(parent.context).inflate(R.layout.result_selection, parent, false), ) } - override fun onBindContent(holder: ViewHolderState, item: SelectData, position: Int) { - when (val binding = holder.view) { - is ResultSelectionBinding -> { - binding.root.apply { - if (isLayout(TV)) { - isFocusable = true - isFocusableInTouchMode = true - } - - isSelected = position == selectedIndex - setText(item.first) - setOnClickListener { - callback.invoke(item.second) - } - } + override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { + when (holder) { + is SelectViewHolder -> { + holder.bind(selection[position], position == selectedIndex, callback) } } } - override fun onViewDetachedFromWindow(holder: ViewHolderState) { - if (holder.itemView.hasFocus()) { + override fun onViewDetachedFromWindow(holder: RecyclerView.ViewHolder) { + if(holder.itemView.hasFocus()) { holder.itemView.clearFocus() } } + override fun getItemCount(): Int { + return selection.size + } + fun select(newIndex: Int, recyclerView: RecyclerView?) { - if (recyclerView == null) return - if (newIndex == selectedIndex) return + if(recyclerView == null) return + if(newIndex == selectedIndex) return val oldIndex = selectedIndex selectedIndex = newIndex notifyItemChanged(selectedIndex) notifyItemChanged(oldIndex) } + + fun updateSelectionList(newList: List) { + val diffResult = DiffUtil.calculateDiff( + SelectDataCallback(this.selection, newList) + ) + + selection.clear() + selection.addAll(newList) + + diffResult.dispatchUpdatesTo(this) + } + + + private class SelectViewHolder( + binding: ResultSelectionBinding, + ) : + RecyclerView.ViewHolder(binding.root) { + private val item: MaterialButton = binding.root + + fun bind( + data: SelectData, isSelected: Boolean, callback: (Any) -> Unit + ) { + if (isLayout(TV)) { + item.isFocusable = true + item.isFocusableInTouchMode = true + } + + item.isSelected = isSelected + item.setText(data.first) + item.setOnClickListener { + callback.invoke(data.second) + } + } + } } + +class SelectDataCallback( + private val oldList: List, + private val newList: List +) : + DiffUtil.Callback() { + override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int) = + oldList[oldItemPosition].second == newList[newItemPosition].second + + override fun getOldListSize() = oldList.size + + override fun getNewListSize() = newList.size + + override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int) = + oldList[oldItemPosition] == newList[newItemPosition] +} \ 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..f318401c0 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,15 +4,15 @@ import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.widget.FrameLayout +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.RecyclerView +import androidx.viewbinding.ViewBinding import com.lagradost.cloudstream3.SearchResponse import com.lagradost.cloudstream3.databinding.SearchResultGridBinding import com.lagradost.cloudstream3.databinding.SearchResultGridExpandedBinding 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 com.lagradost.cloudstream3.utils.UIHelper.toPx import kotlin.math.roundToInt /** Click */ @@ -31,28 +31,13 @@ class SearchClickCallback( ) class SearchAdapter( + private val cardList: MutableList, private val resView: AutofitRecyclerView, - private val isHorizontal:Boolean = false, private val clickCallback: (SearchClickCallback) -> Unit, -) : NoStateAdapter(diffCallback = BaseDiffCallback(itemSame = { a, b -> - if (a.id != null || b.id != null) { - a.id == b.id - } else { - a.name == b.name - } -})) { - companion object { - val sharedPool = - newSharedPool { setMaxRecycledViews(CONTENT, 10) } - } - +) : RecyclerView.Adapter() { var hasNext: Boolean = false - private val coverRatio = if(isHorizontal) 1.8 else 0.68 - - private val coverHeight: Int get() = (resView.itemWidth / coverRatio).roundToInt() - - override fun onCreateContent(parent: ViewGroup): ViewHolderState { + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { val inflater = LayoutInflater.from(parent.context) val layout = @@ -64,36 +49,84 @@ class SearchAdapter( inflater, parent, false - ) - return ViewHolderState(layout) - } + ) //R.layout.search_result_grid_expanded else R.layout.search_result_grid - override fun onClearView(holder: ViewHolderState) { - clearImage( - when (val binding = holder.view) { - is SearchResultGridExpandedBinding -> binding.imageView - is SearchResultGridBinding -> binding.imageView - else -> null - } + + + return CardViewHolder( + layout, + clickCallback, + resView ) } - override fun onBindContent(holder: ViewHolderState, item: SearchResponse, position: Int) { - val imageView = when (val binding = holder.view) { + override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { + when (holder) { + is CardViewHolder -> { + holder.bind(cardList[position], position) + } + } + } + + override fun getItemCount(): Int { + return cardList.size + } + + fun updateList(newList: List) { + val diffResult = DiffUtil.calculateDiff( + SearchResponseDiffCallback(this.cardList, newList) + ) + + cardList.clear() + cardList.addAll(newList) + + diffResult.dispatchUpdatesTo(this) + } + + class CardViewHolder( + val binding: ViewBinding, + private val clickCallback: (SearchClickCallback) -> Unit, + resView: AutofitRecyclerView + ) : + RecyclerView.ViewHolder(binding.root) { + + private val compactView = false//itemView.context.getGridIsCompact() + private val coverHeight: Int = + if (compactView) 80.toPx else (resView.itemWidth / 0.68).roundToInt() + + private val cardView = when(binding) { is SearchResultGridExpandedBinding -> binding.imageView is SearchResultGridBinding -> binding.imageView else -> null } - if (imageView != null) { - val params = FrameLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - coverHeight - ) - if (imageView.layoutParams.width != params.width || imageView.layoutParams.height != params.height) { - imageView.layoutParams = params + fun bind(card: SearchResponse, position: Int) { + if (!compactView) { + cardView?.apply { + layoutParams = FrameLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + coverHeight + ) + } } + + SearchResultBuilder.bind(clickCallback, card, position, itemView) } - SearchResultBuilder.bind(clickCallback, item, position, holder.view.root) } +} + +class SearchResponseDiffCallback( + private val oldList: List, + private val newList: List +) : + DiffUtil.Callback() { + override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int) = + oldList[oldItemPosition].name == newList[newItemPosition].name + + override fun getOldListSize() = oldList.size + + override fun getNewListSize() = newList.size + + override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int) = + oldList[oldItemPosition] == newList[newItemPosition] } \ No newline at end of file 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..1922e4fae 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 @@ -3,9 +3,11 @@ package com.lagradost.cloudstream3.ui.search import android.app.Activity import android.content.Intent import android.content.DialogInterface +import android.content.res.Configuration import android.speech.RecognizerIntent import android.speech.SpeechRecognizer import android.os.Bundle +import android.util.TypedValue import android.view.LayoutInflater import android.view.View import android.view.ViewGroup @@ -18,20 +20,20 @@ import android.widget.TextView import androidx.appcompat.app.AlertDialog import androidx.appcompat.widget.SearchView import androidx.core.view.isVisible +import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels import androidx.preference.PreferenceManager import androidx.recyclerview.widget.GridLayoutManager -import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView 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 import com.lagradost.cloudstream3.APIHolder.getApiFromNameNull +import com.lagradost.cloudstream3.AcraApplication.Companion.removeKey +import com.lagradost.cloudstream3.AcraApplication.Companion.removeKeys import com.lagradost.cloudstream3.AllLanguagesName import com.lagradost.cloudstream3.AnimeSearchResponse -import com.lagradost.cloudstream3.CloudStreamApp.Companion.removeKey -import com.lagradost.cloudstream3.CloudStreamApp.Companion.removeKeys import com.lagradost.cloudstream3.CommonActivity.showToast import com.lagradost.cloudstream3.HomePageList import com.lagradost.cloudstream3.MainAPI @@ -47,21 +49,16 @@ import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.mvvm.observe import com.lagradost.cloudstream3.ui.APIRepository import com.lagradost.cloudstream3.ui.BaseAdapter -import com.lagradost.cloudstream3.ui.BaseFragment import com.lagradost.cloudstream3.ui.home.HomeFragment import com.lagradost.cloudstream3.ui.home.HomeFragment.Companion.bindChips import com.lagradost.cloudstream3.ui.home.HomeFragment.Companion.currentSpan import com.lagradost.cloudstream3.ui.home.HomeFragment.Companion.loadHomepageList import com.lagradost.cloudstream3.ui.home.HomeFragment.Companion.updateChips -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 import com.lagradost.cloudstream3.utils.AppContextUtils.filterProviderByPreferredMedia import com.lagradost.cloudstream3.utils.AppContextUtils.filterSearchResultByFilmQuality @@ -70,23 +67,18 @@ import com.lagradost.cloudstream3.utils.AppContextUtils.getApiSettings 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.Coroutines.ioSafe 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.fixPaddingStatusbar import com.lagradost.cloudstream3.utils.UIHelper.getSpanCount import com.lagradost.cloudstream3.utils.UIHelper.hideKeyboard import java.util.Locale import java.util.concurrent.locks.ReentrantLock -class SearchFragment : BaseFragment( - BaseFragment.BindingCreator.Bind(FragmentSearchBinding::bind) -) { +class SearchFragment : Fragment() { companion object { fun List.filterSearchResponse(): List { return this.filter { response -> @@ -105,13 +97,14 @@ class SearchFragment : BaseFragment( fun newInstance(query: String): Bundle { return Bundle().apply { - if (query.isNotBlank()) putString(SEARCH_QUERY, query) + if(query.isNotBlank()) putString(SEARCH_QUERY, query) } } } private val searchViewModel: SearchViewModel by activityViewModels() private var bottomSheetDialog: BottomSheetDialog? = null + var binding: FragmentSearchBinding? = null private val speechRecognizerLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> @@ -125,9 +118,6 @@ class SearchFragment : BaseFragment( } } - override fun pickLayout(): Int? = - if (isLayout(TV or EMULATOR)) R.layout.fragment_search_tv else R.layout.fragment_search - override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, @@ -137,13 +127,37 @@ class SearchFragment : BaseFragment( WindowManager.LayoutParams.SOFT_INPUT_STATE_VISIBLE ) bottomSheetDialog?.ownShow() - return super.onCreateView(inflater, container, savedInstanceState) + + + binding = try { + val layout = if (isLayout(TV or EMULATOR)) R.layout.fragment_search_tv else R.layout.fragment_search + val root = inflater.inflate(layout, container, false) + FragmentSearchBinding.bind(root) + } catch (t : Throwable) { + FragmentSearchBinding.inflate(inflater) + } + + return binding?.root + } + + private fun fixGrid() { + activity?.getSpanCount()?.let { + currentSpan = it + } + binding?.searchAutofitResults?.spanCount = currentSpan + currentSpan = currentSpan + HomeFragment.configEvent.invoke(currentSpan) + } + + override fun onConfigurationChanged(newConfig: Configuration) { + super.onConfigurationChanged(newConfig) + fixGrid() } override fun onDestroyView() { hideKeyboard() bottomSheetDialog?.ownHide() - activity?.detachBackPressedCallback("SearchFragment") + binding = null super.onDestroyView() } @@ -168,7 +182,7 @@ class SearchFragment : BaseFragment( fun search(query: String?) { if (query == null) return // don't resume state from prev search - (binding?.searchMasterRecycler?.adapter as? BaseAdapter<*, *>)?.clearState() + (binding?.searchMasterRecycler?.adapter as? BaseAdapter<*,*>)?.clear() context?.let { ctx -> val default = enumValues().sorted().filter { it != TvType.NSFW } .map { it.ordinal.toString() }.toSet() @@ -217,59 +231,42 @@ class SearchFragment : BaseFragment( } } - override fun fixLayout(view: View) { - fixSystemBarsPadding( - view, - padBottom = isLandscape(), - padLeft = isLayout(TV or EMULATOR) - ) - // Fix grid - currentSpan = view.context.getSpanCount() - binding?.searchAutofitResults?.spanCount = currentSpan - HomeFragment.configEvent.invoke() - } + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) - override fun onBindingCreated( - binding: FragmentSearchBinding, - savedInstanceState: Bundle? - ) { + fixPaddingStatusbar(binding?.searchRoot) + fixGrid() reloadRepos() - binding.apply { - val adapter = + + binding?.apply { + val adapter: RecyclerView.Adapter = SearchAdapter( + ArrayList(), searchAutofitResults, ) { callback -> SearchHelper.handleSearchClickCallback(callback) } - searchRoot.findViewById(androidx.appcompat.R.id.search_src_text)?.tag = - "tv_no_focus_tag" - searchAutofitResults.setRecycledViewPool(SearchAdapter.sharedPool) + searchRoot.findViewById(androidx.appcompat.R.id.search_src_text)?.tag = "tv_no_focus_tag" searchAutofitResults.adapter = adapter searchLoadingBar.alpha = 0f } - binding.voiceSearch.setOnClickListener { searchView -> + binding?.voiceSearch?.setOnClickListener { searchView -> searchView?.context?.let { ctx -> try { if (!SpeechRecognizer.isRecognitionAvailable(ctx)) { showToast(R.string.speech_recognition_unavailable) } else { val intent = Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH).apply { - putExtra( - RecognizerIntent.EXTRA_LANGUAGE_MODEL, - RecognizerIntent.LANGUAGE_MODEL_FREE_FORM - ) + putExtra(RecognizerIntent.EXTRA_LANGUAGE_MODEL, RecognizerIntent.LANGUAGE_MODEL_FREE_FORM) putExtra(RecognizerIntent.EXTRA_LANGUAGE, Locale.getDefault()) - putExtra( - RecognizerIntent.EXTRA_PROMPT, - ctx.getString(R.string.begin_speaking) - ) + putExtra(RecognizerIntent.EXTRA_PROMPT, ctx.getString(R.string.begin_speaking)) } speechRecognizerLauncher.launch(intent) } - } catch (_: Throwable) { + } catch (_ : Throwable) { // launch may throw showToast(R.string.speech_recognition_unavailable) } @@ -277,11 +274,21 @@ class SearchFragment : BaseFragment( } val searchExitIcon = - binding.mainSearch.findViewById(androidx.appcompat.R.id.search_close_btn) + binding?.mainSearch?.findViewById(androidx.appcompat.R.id.search_close_btn) + // val searchMagIcon = + // binding?.mainSearch?.findViewById(androidx.appcompat.R.id.search_mag_icon) + // searchMagIcon.scaleX = 0.65f + // searchMagIcon.scaleY = 0.65f + + // Set the color for the search exit icon to the correct theme text color + val searchExitIconColor = TypedValue() + + activity?.theme?.resolveAttribute(android.R.attr.textColor, searchExitIconColor, true) + searchExitIcon?.setColorFilter(searchExitIconColor.data) selectedApis = DataStoreHelper.searchPreferenceProviders.toMutableSet() - binding.searchFilter.setOnClickListener { searchView -> + binding?.searchFilter?.setOnClickListener { searchView -> searchView?.context?.let { ctx -> val validAPIs = ctx.filterProviderByPreferredMedia(hasHomePageIsRequired = false) var currentValidApis = listOf() @@ -293,12 +300,11 @@ class SearchFragment : BaseFragment( builder.behavior.state = BottomSheetBehavior.STATE_EXPANDED - val selectMainpageBinding: HomeSelectMainpageBinding = - HomeSelectMainpageBinding.inflate( - builder.layoutInflater, - null, - false - ) + val selectMainpageBinding: HomeSelectMainpageBinding = HomeSelectMainpageBinding.inflate( + builder.layoutInflater, + null, + false + ) builder.setContentView(selectMainpageBinding.root) builder.show() builder.let { dialog -> @@ -367,10 +373,7 @@ class SearchFragment : BaseFragment( if (selectedSearchTypes.toSet() != list.toSet()) { selectedSearchTypes.clear() selectedSearchTypes.addAll(list) - updateChips( - binding.tvtypesChipsScroll.tvtypesChips, - selectedSearchTypes - ) + updateChips(binding?.tvtypesChipsScroll?.tvtypesChips, selectedSearchTypes) } } @@ -396,8 +399,8 @@ class SearchFragment : BaseFragment( selectedApis = currentSelectedApis // run search when dialog is close - if (previousSelectedApis != selectedApis.toSet() || previousSelectedSearchTypes != selectedSearchTypes.toSet()) { - search(binding.mainSearch.query.toString()) + if(previousSelectedApis != selectedApis.toSet() || previousSelectedSearchTypes != selectedSearchTypes.toSet()) { + search(binding?.mainSearch?.query?.toString()) } } updateList(selectedSearchTypes.toList()) @@ -407,31 +410,19 @@ 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)) { - binding.searchFilter.isFocusable = true - binding.searchFilter.isFocusableInTouchMode = true + 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 { + binding?.mainSearch?.setOnQueryTextListener(object : SearchView.OnQueryTextListener { override fun onQueryTextSubmit(query: String): Boolean { search(query) - searchViewModel.clearSuggestions() - binding.mainSearch.let { + binding?.mainSearch?.let { hideKeyboard(it) } @@ -444,49 +435,76 @@ 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 + binding?.apply { + 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.currentHistory) { list -> + binding?.searchClearCallHistory?.isVisible = list.isNotEmpty() + (binding?.searchHistoryRecycler?.adapter as? SearchHistoryAdaptor?)?.updateList(list) + } + + searchViewModel.updateHistory() + observe(searchViewModel.searchResponse) { when (it) { is Resource.Success -> { it.value.let { data -> - val list = data.list - if (list.isNotEmpty()) { - (binding.searchAutofitResults.adapter as? SearchAdapter)?.submitList( - list - ) + if (data.isNotEmpty()) { + (binding?.searchAutofitResults?.adapter as? SearchAdapter)?.updateList(data) } } searchExitIcon?.alpha = 1f - binding.searchLoadingBar.alpha = 0f + binding?.searchLoadingBar?.alpha = 0f } - is Resource.Failure -> { // Toast.makeText(activity, "Server error", Toast.LENGTH_LONG).show() searchExitIcon?.alpha = 1f - binding.searchLoadingBar.alpha = 0f + binding?.searchLoadingBar?.alpha = 0f } - is Resource.Loading -> { searchExitIcon?.alpha = 0f - binding.searchLoadingBar.alpha = 1f + binding?.searchLoadingBar?.alpha = 1f } } } @@ -496,33 +514,20 @@ 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 + (binding?.searchMasterRecycler?.adapter as ParentItemAdapter?)?.apply { + val newItems = list.map { ongoing -> + val dataList = + if (ongoing.data is Resource.Success) ongoing.data.value else ArrayList() val dataListFiltered = context?.filterSearchResultByFilmQuality(dataList) ?: dataList - - val homePageList = HomePageList( - providerName, + val ongoingList = HomePageList( + ongoing.apiName, dataListFiltered ) - - HomeViewModel.ExpandableHomepageList( - homePageList, - providerData.currentPage, - providerData.hasNext - ) + ongoingList } + updateList(newItems) - submitList(newItems) //notifyDataSetChanged() } } catch (e: Exception) { @@ -542,123 +547,52 @@ class SearchFragment : BaseFragment( //main_search.onActionViewExpanded()*/ val masterAdapter = - ParentItemAdapter(id = "masterAdapter".hashCode(), { callback -> + ParentItemAdapter(fragment = this, id = "masterAdapter".hashCode(), { callback -> SearchHelper.handleSearchClickCallback(callback) }, { item -> bottomSheetDialog = activity?.loadHomepageList(item, dismissCallback = { bottomSheetDialog = null - }, expandCallback = { name -> searchViewModel.expandAndReturn(name) }) - }, expandCallback = { name -> - ioSafe { - searchViewModel.expandAndReturn(name) - } + }) }) - val historyAdapter = SearchHistoryAdaptor { click -> + val historyAdapter = SearchHistoryAdaptor(mutableListOf()) { click -> val searchItem = click.item when (click.clickAction) { SEARCH_HISTORY_OPEN -> { - if (searchItem == null) return@SearchHistoryAdaptor searchViewModel.clearSearch() if (searchItem.type.isNotEmpty()) - updateChips( - binding.tvtypesChipsScroll.tvtypesChips, - searchItem.type.toMutableList() - ) - binding.mainSearch.setQuery(searchItem.searchText, true) + updateChips(binding?.tvtypesChipsScroll?.tvtypesChips, searchItem.type.toMutableList()) + binding?.mainSearch?.setQuery(searchItem.searchText, true) } - 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 { + 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) searchMasterRecycler.layoutManager = GridLayoutManager(context, 1) // Automatically search the specified query, this allows the app search to launch from intent - var sq = - arguments?.getString(SEARCH_QUERY) ?: savedInstanceState?.getString(SEARCH_QUERY) - if (sq.isNullOrBlank()) { + var sq = arguments?.getString(SEARCH_QUERY) ?: savedInstanceState?.getString(SEARCH_QUERY) + if(sq.isNullOrBlank()) { sq = MainActivity.nextSearchQuery } 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) @@ -666,37 +600,18 @@ class SearchFragment : BaseFragment( } } - observe(searchViewModel.currentHistory) { list -> - (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() + // SubtitlesFragment.push(activity) + //searchViewModel.search("iron man") + //(activity as AppCompatActivity).loadResult("https://shiro.is/overlord-dubbed", "overlord-dubbed", "Shiro") +/* + (activity as AppCompatActivity?)?.supportFragmentManager.beginTransaction() + .setCustomAnimations(R.anim.enter_anim, + R.anim.exit_anim, + R.anim.pop_enter, + R.anim.pop_exit) + .add(R.id.homeRoot, PlayerFragment.newInstance(PlayerData(0, null,0))) + .commit()*/ } + } 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..4ef5fa698 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,11 @@ package com.lagradost.cloudstream3.ui.search import android.view.LayoutInflater import android.view.ViewGroup -import androidx.core.view.isGone +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.RecyclerView 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,73 +16,84 @@ 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 cardList: MutableList, 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( +) : RecyclerView.Adapter() { + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { + return CardViewHolder( SearchHistoryItemBinding.inflate(LayoutInflater.from(parent.context), parent, false), + clickCallback, ) } - override fun onBindContent( - holder: ViewHolderState, - item: SearchHistoryItem, - position: Int - ) { - val binding = holder.view as? SearchHistoryItemBinding ?: return - binding.apply { - homeHistoryTitle.text = item.searchText - - homeHistoryRemove.setOnClickListener { - clickCallback.invoke(SearchHistoryCallback(item, SEARCH_HISTORY_REMOVE)) - } - homeHistoryTab.setOnClickListener { - clickCallback.invoke(SearchHistoryCallback(item, SEARCH_HISTORY_OPEN)) + override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { + when (holder) { + is CardViewHolder -> { + holder.bind(cardList[position]) } } } - - override fun onCreateFooter(parent: ViewGroup): ViewHolderState { - return ViewHolderState( - SearchHistoryFooterBinding.inflate(LayoutInflater.from(parent.context), parent, false) - ) + + override fun getItemCount(): Int { + return cardList.size } - - 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)) + + fun updateList(newList: List) { + val diffResult = DiffUtil.calculateDiff( + SearchHistoryDiffCallback(this.cardList, newList) + ) + + cardList.clear() + cardList.addAll(newList) + + diffResult.dispatchUpdatesTo(this) + } + + class CardViewHolder( + val binding: SearchHistoryItemBinding, + private val clickCallback: (SearchHistoryCallback) -> Unit, + ) : + RecyclerView.ViewHolder(binding.root) { + // private val removeButton: ImageView = itemView.home_history_remove + // private val openButton: View = itemView.home_history_tab + // private val title: TextView = itemView.home_history_title + + fun bind(card: SearchHistoryItem) { + binding.apply { + homeHistoryTitle.text = card.searchText + + homeHistoryRemove.setOnClickListener { + clickCallback.invoke(SearchHistoryCallback(card, SEARCH_HISTORY_REMOVE)) + } + homeHistoryTab.setOnClickListener { + clickCallback.invoke(SearchHistoryCallback(card, SEARCH_HISTORY_OPEN)) + } } } } } + +class SearchHistoryDiffCallback( + private val oldList: List, + private val newList: List +) : + DiffUtil.Callback() { + override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int) = + oldList[oldItemPosition].searchText == newList[newItemPosition].searchText + + override fun getOldListSize() = oldList.size + + override fun getNewListSize() = newList.size + + override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int) = + oldList[oldItemPosition] == newList[newItemPosition] +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchResultBuilder.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchResultBuilder.kt index fd99b8d4b..56f726fc1 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchResultBuilder.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchResultBuilder.kt @@ -2,7 +2,6 @@ package com.lagradost.cloudstream3.ui.search import android.annotation.SuppressLint import android.content.Context -import android.content.res.ColorStateList import android.view.View import android.widget.ImageView import android.widget.ProgressBar @@ -22,12 +21,10 @@ import com.lagradost.cloudstream3.syncproviders.SyncAPI import com.lagradost.cloudstream3.ui.settings.Globals.TV import com.lagradost.cloudstream3.ui.settings.Globals.isLayout import com.lagradost.cloudstream3.utils.AppContextUtils.getNameFull -import com.lagradost.cloudstream3.utils.AppContextUtils.getShortSeasonText import com.lagradost.cloudstream3.utils.DataStoreHelper import com.lagradost.cloudstream3.utils.DataStoreHelper.fixVisual import com.lagradost.cloudstream3.utils.ImageLoader.loadImage import com.lagradost.cloudstream3.utils.SubtitleHelper -import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute import com.lagradost.cloudstream3.utils.getImageFromDrawable object SearchResultBuilder { @@ -67,7 +64,6 @@ object SearchResultBuilder { val bar: ProgressBar? = itemView.findViewById(R.id.watchProgress) val playImg: ImageView? = itemView.findViewById(R.id.search_item_download_play) - val episodeText: TextView? = itemView.findViewById(R.id.episode_text) // Do logic @@ -77,12 +73,10 @@ object SearchResultBuilder { textIsSub?.isVisible = false textFlag?.isVisible = false rating?.isVisible = false - episodeText?.isVisible = false val showSub = showCache[textIsDub?.context?.getString(R.string.show_sub_key)] ?: false val showDub = showCache[textIsDub?.context?.getString(R.string.show_dub_key)] ?: false val showTitle = showCache[cardText?.context?.getString(R.string.show_title_key)] ?: false - val showEpisodeText = showCache[cardText?.context?.getString(R.string.show_episode_text_key)] ?: false val showHd = showCache[textQuality?.context?.getString(R.string.show_hd_key)] ?: false val showRatingView = showCache[textQuality?.context?.getString(R.string.show_rating_key)] ?: false @@ -132,11 +126,18 @@ object SearchResultBuilder { cardText?.text = card.name cardText?.isVisible = showTitle cardView.isVisible = true - if (!card.posterUrl.isNullOrEmpty()) { - cardView.loadImage(card.posterUrl, card.posterHeaders) { - error { getImageFromDrawable(itemView.context, R.drawable.default_cover) } - } - } else cardView.loadImage(R.drawable.default_cover) + cardView.loadImage(card.posterUrl, card.posterHeaders) { + error { getImageFromDrawable(itemView.context, R.drawable.default_cover) } + /* + createPaletteAsync is currently disabled as we use hardware acceleration on images + val posterUrl = card.posterUrl + if (posterUrl != null && colorCallback != null) { + this.listener(onSuccess = { _,success -> + val bitmap = success.image.toBitmap() + createPaletteAsync(posterUrl, bitmap, colorCallback) + }) + }*/ + } fun click(view: View?) { clickCallback.invoke( @@ -258,12 +259,12 @@ object SearchResultBuilder { bar?.progress = (pos.position / 1000).toInt() bar?.visibility = View.VISIBLE } + playImg?.visibility = View.VISIBLE - if (card.type?.isMovieType() == false && showEpisodeText) { - episodeText?.context?.getShortSeasonText(card.episode, card.season)?.let {text-> - episodeText.text = text - episodeText.isVisible = true - } + + if (card.type?.isMovieType() == false) { + cardText?.text = + cardText?.context?.getNameFull(card.name, card.episode, card.season) } } @@ -302,29 +303,5 @@ object SearchResultBuilder { } } } - - // This is the logic for making the rounded corners more round on the top and bottom element - // a bit dirty to do memory allocation, but it makes it more extensible and is easier to reason about - // then a large if statement - - // Requires that the ordering here is the same as in the xml - val boxes = arrayListOf() - for (view in arrayOf(textIsDub, textIsSub, rating)) { - if (view?.isVisible == true) { - boxes.add(view) - } - } - if (boxes.size == 1) { - boxes[0].setBackgroundResource(R.drawable.bg_color_both) - } else if (boxes.size > 1) { - boxes[0].setBackgroundResource(R.drawable.bg_color_top) - for (i in 1 until boxes.size) { - boxes[i].setBackgroundResource(R.drawable.bg_color_center) - } - boxes[boxes.size - 1].setBackgroundResource(R.drawable.bg_color_bottom) - } - textIsDub?.apply { - backgroundTintList = ColorStateList.valueOf(context.colorFromAttribute(R.attr.textColor)) - } } -} +} \ No newline at end of file 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..839b9d3f8 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 @@ -5,70 +5,51 @@ import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.lagradost.cloudstream3.APIHolder.apis -import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey -import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKeys -import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey -import com.lagradost.cloudstream3.HomePageList +import com.lagradost.cloudstream3.AcraApplication.Companion.getKey +import com.lagradost.cloudstream3.AcraApplication.Companion.getKeys +import com.lagradost.cloudstream3.AcraApplication.Companion.setKey import com.lagradost.cloudstream3.SearchResponse import com.lagradost.cloudstream3.amap import com.lagradost.cloudstream3.mvvm.Resource -import com.lagradost.cloudstream3.mvvm.debugAssert -import com.lagradost.cloudstream3.mvvm.debugWarning import com.lagradost.cloudstream3.mvvm.launchSafe import com.lagradost.cloudstream3.ui.APIRepository -import com.lagradost.cloudstream3.ui.home.HomeViewModel 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 - -data class ExpandableSearchList( - var list: List, var currentPage: Int, var hasNext: Boolean, +data class OnGoingSearch( + val apiName: String, + val data: Resource> ) const val SEARCH_HISTORY_KEY = "search_history" class SearchViewModel : ViewModel() { - private val _searchResponse: MutableLiveData> = + private val _searchResponse: MutableLiveData>> = MutableLiveData() - val searchResponse: LiveData> get() = _searchResponse + val searchResponse: LiveData>> get() = _searchResponse - private val _currentSearch: MutableLiveData> = - MutableLiveData() - val currentSearch: LiveData> get() = _currentSearch + private val _currentSearch: MutableLiveData> = MutableLiveData() + val currentSearch: LiveData> get() = _currentSearch 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))) - _currentSearch.postValue(emptyMap()) - expandableSearches.clear() + _searchResponse.postValue(Resource.Success(ArrayList())) + _currentSearch.postValue(emptyList()) } - var lastQuery: String? = null - - /** Save which providers can searched again and which search result page they are on. - * Maps provider name to search list. - * @see [HomeViewModel.expandable] */ - private val expandableSearches: MutableMap = mutableMapOf() - private var currentSearchIndex = 0 private var onGoingSearch: Job? = null fun reloadRepos() { - repos = apis.withLock { apis.map { APIRepository(it) } } + repos = synchronized(apis) { apis.map { APIRepository(it) } } } fun searchAndCancel( @@ -82,117 +63,13 @@ class SearchViewModel : ViewModel() { onGoingSearch = search(query, providersActive, ignoreSettings, isQuickSearch) } - fun updateHistory() = ioSafe { - val items = getKeys("$currentAccount/$SEARCH_HISTORY_KEY")?.mapNotNull { - getKey(it) - }?.sortedByDescending { it.searchedAt } ?: emptyList() - _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 + fun updateHistory() = viewModelScope.launch { + ioSafe { + val items = getKeys("$currentAccount/$SEARCH_HISTORY_KEY")?.mapNotNull { + getKey(it) + }?.sortedByDescending { it.searchedAt } ?: emptyList() + _currentHistory.postValue(items) } - - 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 - suspend fun expandAndReturn(name: String): HomeViewModel.ExpandableHomepageList? { - if (lock.contains(name)) return null - val query = lastQuery ?: return null - val repo = repos.find { it.name == name } ?: return null - - lock += name - - expandableSearches[name]?.let { current -> - debugAssert({ !current.hasNext }) { - "Expand called when not needed" - } - - val nextPage = current.currentPage + 1 - val next = repo.search(query, nextPage) - if (next is Resource.Success) { - val nextValue = next.value - expandableSearches[name]?.apply { - this.hasNext = nextValue.hasNext - this.currentPage = nextPage - - debugWarning({ nextValue.items.any { outer -> this.list.any { it.url == outer.url } } }) { - "Expanded search contained an item that was previously already in the list.\nQuery = $query, ${nextValue.items} = ${this.list}" - } - - // just to be sure we are not adding the same shit for some reason - // Avoids weird behavior in the recyclerview by recreating the list - this.list = (this.list + nextValue.items).distinctBy { it.url } - } ?: debugWarning { - "Expanded an item not in search load named $name, current list is ${expandableSearches.keys}" - } - } else { - current.hasNext = false - } - - _searchResponse.postValue(Resource.Success(bundleSearch(expandableSearches))) - _currentSearch.postValue(expandableSearches) - } - - lock -= name - - val item = expandableSearches[name] ?: return null - return HomeViewModel.ExpandableHomepageList( - HomePageList(name, item.list), - item.currentPage, - item.hasNext - ) - } - - private fun bundleSearch(lists: MutableMap): ExpandableSearchList { - if (lists.size == 1) { - return lists.values.first() - } - - val list = ArrayList() - val nestedList = - lists.map { it.value.list } - - // I do it this way to move the relevant search results to the top - var index = 0 - while (true) { - var added = 0 - for (sublist in nestedList) { - if (sublist.size > index) { - list.add(sublist[index]) - added++ - } - } - if (added == 0) break - index++ - } - - return ExpandableSearchList(list, 1, false) } private fun search( @@ -223,30 +100,43 @@ class SearchViewModel : ViewModel() { } _searchResponse.postValue(Resource.Loading()) - _currentSearch.postValue(emptyMap()) - expandableSearches.clear() - lastQuery = query + + _currentSearch.postValue(ArrayList()) withContext(Dispatchers.IO) { // This interrupts UI otherwise + val currentList = ArrayList() + repos.filter { a -> (ignoreSettings || (providersActive.isEmpty() || providersActive.contains(a.name))) && (!isQuickSearch || a.hasQuickSearch) }.amap { a -> // Parallel - val search = if (isQuickSearch) a.quickSearch(query) else a.search(query, 1) + val search = if (isQuickSearch) a.quickSearch(query) else a.search(query) if (currentSearchIndex != currentIndex) return@amap - if (search is Resource.Success) { - val searchValue = search.value - expandableSearches[a.name] = - ExpandableSearchList(searchValue.items, 1, searchValue.hasNext) - } - - _currentSearch.postValue(expandableSearches) + currentList.add(OnGoingSearch(a.name, search)) + _currentSearch.postValue(currentList) } if (currentSearchIndex != currentIndex) return@withContext // this should prevent rewrite of existing data bug - _currentSearch.postValue(expandableSearches) - val list = bundleSearch(expandableSearches) + _currentSearch.postValue(currentList) + val list = ArrayList() + val nestedList = + currentList.map { it.data } + .filterIsInstance>>().map { it.value } + + // I do it this way to move the relevant search results to the top + var index = 0 + while (true) { + var added = 0 + for (sublist in nestedList) { + if (sublist.size > index) { + list.add(sublist[index]) + added++ + } + } + if (added == 0) break + index++ + } _searchResponse.postValue(Resource.Success(list)) } diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/AccountAdapter.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/AccountAdapter.kt index be8b4180c..e68dcc513 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/AccountAdapter.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/AccountAdapter.kt @@ -1,56 +1,75 @@ package com.lagradost.cloudstream3.ui.settings +import android.annotation.SuppressLint 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.R import com.lagradost.cloudstream3.databinding.AccountSingleBinding import com.lagradost.cloudstream3.syncproviders.AuthData -import com.lagradost.cloudstream3.ui.BaseDiffCallback -import com.lagradost.cloudstream3.ui.NoStateAdapter -import com.lagradost.cloudstream3.ui.ViewHolderState import com.lagradost.cloudstream3.utils.ImageLoader.loadImage class AccountClickCallback(val action: Int, val view: View, val card: AuthData) class AccountAdapter( + private val cardList: Array, private val clickCallback: (AccountClickCallback) -> Unit ) : - NoStateAdapter( - diffCallback = BaseDiffCallback(itemSame = { a, b -> - a.user.id == b.user.id - }) - ) { + RecyclerView.Adapter() { - override fun onCreateContent(parent: ViewGroup): ViewHolderState { - return ViewHolderState( + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { + return CardViewHolder( AccountSingleBinding.inflate( LayoutInflater.from(parent.context), parent, false - ) + ), //LayoutInflater.from(parent.context).inflate(layout, parent, false), + + clickCallback ) } - override fun onClearView(holder: ViewHolderState) { - val binding = holder.view as? AccountSingleBinding ?: return - clearImage(binding.accountProfilePicture) + override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { + when (holder) { + is CardViewHolder -> { + holder.bind(cardList[position], position) + } + } } - override fun onBindContent(holder: ViewHolderState, item: AuthData, position: Int) { - val binding = holder.view as? AccountSingleBinding ?: return - binding.apply { - accountName.text = item.user.name - ?: "${binding.accountName.context.getString(R.string.account)} ${position + 1}" - accountProfilePicture.isVisible = true - accountProfilePicture.loadImage( - item.user.profilePicture, - headers = item.user.profilePictureHeaders - ) + override fun getItemCount(): Int { + return cardList.size + } - root.setOnClickListener { - clickCallback.invoke(AccountClickCallback(0, root, item)) + override fun getItemId(position: Int): Long { + return cardList[position].user.id.toLong() + } + + class CardViewHolder( + val binding: AccountSingleBinding?, + private val clickCallback: (AccountClickCallback) -> Unit + ) : + RecyclerView.ViewHolder(binding?.root!!) { + + @SuppressLint("StringFormatInvalid") + fun bind(card: AuthData, position: Int) { + // just in case name is null account index will show, should never happened + binding?.apply { + accountName.text = card.user.name ?: "%s %d".format( + binding.accountName.context.getString(R.string.account), + position + 1 + ) + accountProfilePicture.isVisible = true + accountProfilePicture.loadImage( + card.user.profilePicture, + headers = card.user.profilePictureHeaders + ) + + itemView.setOnClickListener { + clickCallback.invoke(AccountClickCallback(0, itemView, card)) + } } } } diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/Globals.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/Globals.kt index 93e469a4d..aa513d87a 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/Globals.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/Globals.kt @@ -3,7 +3,6 @@ package com.lagradost.cloudstream3.ui.settings import android.app.UiModeManager import android.content.Context import android.content.res.Configuration -import android.content.res.Resources import android.os.Build import androidx.preference.PreferenceManager import com.lagradost.cloudstream3.R @@ -45,11 +44,6 @@ object Globals { layoutId = layoutIntCorrected() } - /** Returns true if the current orientation is landscape. */ - fun isLandscape(): Boolean = - isLayout(TV or EMULATOR) || - Resources.getSystem().configuration.orientation == Configuration.ORIENTATION_LANDSCAPE - /** Returns true if the layout is any of the flags, * so isLayout(TV or EMULATOR) is a valid statement for checking if the layout is in the emulator * or tv. Auto will become the "TV" or the "PHONE" layout. diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/LogcatAdapter.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/LogcatAdapter.kt index 365990646..7fcfefb7b 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/LogcatAdapter.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/LogcatAdapter.kt @@ -2,30 +2,25 @@ package com.lagradost.cloudstream3.ui.settings import android.view.LayoutInflater import android.view.ViewGroup +import androidx.recyclerview.widget.RecyclerView import com.lagradost.cloudstream3.databinding.ItemLogcatBinding -import com.lagradost.cloudstream3.ui.BaseDiffCallback -import com.lagradost.cloudstream3.ui.NoStateAdapter -import com.lagradost.cloudstream3.ui.ViewHolderState -class LogcatAdapter() : NoStateAdapter( - diffCallback = BaseDiffCallback( - itemSame = String::equals, - contentSame = String::equals - ) -) { - override fun onCreateContent(parent: ViewGroup): ViewHolderState { - return ViewHolderState( - ItemLogcatBinding.inflate( - LayoutInflater.from(parent.context), - parent, - false - ) - ) +class LogcatAdapter( + private val logs: List +) : RecyclerView.Adapter() { + + inner class LogViewHolder( + val binding: ItemLogcatBinding + ) : RecyclerView.ViewHolder(binding.root) + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): LogViewHolder { + val binding = ItemLogcatBinding.inflate(LayoutInflater.from(parent.context), parent, false) + return LogViewHolder(binding) } - override fun onBindContent(holder: ViewHolderState, item: String, position: Int) { - (holder.view as? ItemLogcatBinding)?.apply { - logText.text = item - } + override fun onBindViewHolder(holder: LogViewHolder, position: Int) { + holder.binding.logText.text = logs[position] } + + override fun getItemCount(): Int = logs.count() } \ No newline at end of file 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..f216219de 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 @@ -14,10 +14,11 @@ import androidx.core.content.edit import androidx.core.view.isGone import androidx.core.view.isVisible import androidx.fragment.app.FragmentActivity +import androidx.preference.PreferenceFragmentCompat import androidx.preference.PreferenceManager import androidx.preference.SwitchPreference import androidx.recyclerview.widget.RecyclerView -import com.lagradost.cloudstream3.CloudStreamApp.Companion.openBrowser +import com.lagradost.cloudstream3.AcraApplication.Companion.openBrowser import com.lagradost.cloudstream3.CommonActivity.onDialogDismissedEvent import com.lagradost.cloudstream3.CommonActivity.showToast import com.lagradost.cloudstream3.ErrorLoadingException @@ -28,19 +29,15 @@ 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 import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR import com.lagradost.cloudstream3.ui.settings.Globals.TV import com.lagradost.cloudstream3.ui.settings.Globals.PHONE @@ -69,7 +66,7 @@ import com.lagradost.cloudstream3.utils.setText import com.lagradost.cloudstream3.utils.txt import qrcode.QRCode -class SettingsAccount : BasePreferenceFragmentCompat(), BiometricCallback { +class SettingsAccount : PreferenceFragmentCompat(), BiometricCallback { companion object { /** Used by nginx plugin too */ @SuppressLint("StringFormatInvalid") @@ -139,11 +136,9 @@ class SettingsAccount : BasePreferenceFragmentCompat(), BiometricCallback { dialog?.dismissSafe(activity) } - val adapter = AccountAdapter { + val adapter = AccountAdapter(accounts) { dialog?.dismissSafe(activity) api.accountId = it.card.user.id - }.apply { - submitList(accounts.toList()) } val list = dialog.findViewById(R.id.account_list) list?.adapter = adapter @@ -465,12 +460,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..1e4689bf6 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 @@ -2,7 +2,9 @@ package com.lagradost.cloudstream3.ui.settings import android.os.Bundle import android.util.Log +import android.view.LayoutInflater import android.view.View +import android.view.ViewGroup import android.widget.ImageView import androidx.annotation.StringRes import androidx.core.view.children @@ -16,21 +18,17 @@ import com.lagradost.cloudstream3.BuildConfig import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.databinding.MainSettingsBinding import com.lagradost.cloudstream3.mvvm.logError -import com.lagradost.cloudstream3.mvvm.safe import com.lagradost.cloudstream3.syncproviders.AccountManager import com.lagradost.cloudstream3.syncproviders.AuthRepo -import com.lagradost.cloudstream3.ui.BaseFragment import com.lagradost.cloudstream3.ui.home.HomeFragment.Companion.errorProfilePic 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.DataStoreHelper -import com.lagradost.cloudstream3.utils.GitInfo.currentCommitHash import com.lagradost.cloudstream3.utils.ImageLoader.loadImage +import com.lagradost.cloudstream3.utils.UIHelper import com.lagradost.cloudstream3.utils.UIHelper.clipboardHelper -import com.lagradost.cloudstream3.utils.UIHelper.fixSystemBarsPadding import com.lagradost.cloudstream3.utils.UIHelper.navigate import com.lagradost.cloudstream3.utils.UIHelper.toPx import com.lagradost.cloudstream3.utils.getImageFromDrawable @@ -42,9 +40,7 @@ import java.util.Date import java.util.Locale import java.util.TimeZone -class SettingsFragment : BaseFragment( - BaseFragment.BindingCreator.Inflate(MainSettingsBinding::inflate) -) { +class SettingsFragment : Fragment() { companion object { fun PreferenceFragmentCompat?.getPref(id: Int): Preference? { if (this == null) return null @@ -126,6 +122,7 @@ class SettingsFragment : BaseFragment( } } } + UIHelper.fixPaddingStatusbar(settingsToolbar) } fun Fragment?.setUpToolbar(@StringRes title: Int) { @@ -138,20 +135,11 @@ class SettingsFragment : BaseFragment( setNavigationIcon(R.drawable.ic_baseline_arrow_back_24) children.firstOrNull { it is ImageView }?.tag = getString(R.string.tv_no_focus_tag) setNavigationOnClickListener { - safe { activity?.onBackPressedDispatcher?.onBackPressed() } + activity?.onBackPressedDispatcher?.onBackPressed() } } } - } - - fun Fragment.setSystemBarsPadding() { - view?.let { - fixSystemBarsPadding( - it, - padLeft = isLayout(TV or EMULATOR), - padBottom = isLandscape() - ) - } + UIHelper.fixPaddingStatusbar(settingsToolbar) } fun getFolderSize(dir: File): Long { @@ -169,15 +157,24 @@ class SettingsFragment : BaseFragment( } } - override fun fixLayout(view: View) { - fixSystemBarsPadding( - view, - padBottom = isLandscape(), - padLeft = isLayout(TV or EMULATOR) - ) + override fun onDestroyView() { + binding = null + super.onDestroyView() } - override fun onBindingCreated(binding: MainSettingsBinding) { + var binding: MainSettingsBinding? = null + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ): View { + val localBinding = MainSettingsBinding.inflate(inflater, container, false) + binding = localBinding + return localBinding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { fun navigate(id: Int) { activity?.navigate(id, Bundle()) } @@ -191,13 +188,13 @@ class SettingsFragment : BaseFragment( val login = syncApi.authUser() val pic = login?.profilePicture ?: continue - binding.settingsProfilePic.let { imageView -> + binding?.settingsProfilePic?.let { imageView -> imageView.loadImage(pic) { // Fallback to random error drawable error { getImageFromDrawable(context ?: return@error null, errorProfilePic) } } } - binding.settingsProfileText.text = login.name + binding?.settingsProfileText?.text = login.name return true // sync profile exists } return false // not syncing @@ -216,11 +213,11 @@ class SettingsFragment : BaseFragment( null } - binding.settingsProfilePic.loadImage(currentAccount?.image) - binding.settingsProfileText.text = currentAccount?.name + binding?.settingsProfilePic?.loadImage(currentAccount?.image) + binding?.settingsProfileText?.text = currentAccount?.name } - binding.apply { + binding?.apply { listOf( settingsGeneral to R.id.action_navigation_global_to_navigation_settings_general, settingsPlayer to R.id.action_navigation_global_to_navigation_settings_player, @@ -247,18 +244,16 @@ class SettingsFragment : BaseFragment( } } - val appVersion = BuildConfig.VERSION_NAME - val commitHash = activity?.currentCommitHash() ?: "" + val appVersion = getString(R.string.app_version) + val commitInfo = getString(R.string.commit_hash) val buildTimestamp = SimpleDateFormat.getDateTimeInstance(DateFormat.LONG, DateFormat.LONG, Locale.getDefault() ).apply { timeZone = TimeZone.getTimeZone("UTC") }.format(Date(BuildConfig.BUILD_DATE)).replace("UTC", "") - binding.appVersion.text = appVersion - binding.buildDate.text = buildTimestamp - binding.commitHash.text = commitHash - binding.appVersionInfo.setOnLongClickListener { - clipboardHelper(txt(R.string.extension_version), "$appVersion $commitHash $buildTimestamp") + binding?.buildDate?.text = buildTimestamp + binding?.appVersionInfo?.setOnLongClickListener { + 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..e82481ffa 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 @@ -2,19 +2,18 @@ package com.lagradost.cloudstream3.ui.settings import android.content.Context import android.net.Uri +import android.os.Build import android.os.Bundle import android.view.View 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.PreferenceFragmentCompat import androidx.preference.PreferenceManager import com.fasterxml.jackson.annotation.JsonProperty import com.lagradost.cloudstream3.APIHolder.allProviders -import com.lagradost.cloudstream3.CloudStreamApp -import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey -import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey +import com.lagradost.cloudstream3.AcraApplication +import com.lagradost.cloudstream3.AcraApplication.Companion.getKey +import com.lagradost.cloudstream3.AcraApplication.Companion.setKey import com.lagradost.cloudstream3.CommonActivity import com.lagradost.cloudstream3.CommonActivity.showToast import com.lagradost.cloudstream3.MainActivity @@ -25,7 +24,6 @@ import com.lagradost.cloudstream3.databinding.AddSiteInputBinding import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.mvvm.safe import com.lagradost.cloudstream3.network.initClient -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.Globals.beneneCount @@ -45,99 +43,90 @@ 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 java.util.Locale +import com.lagradost.cloudstream3.utils.VideoDownloadManager +import com.lagradost.cloudstream3.utils.VideoDownloadManager.getBasePath // Change local language settings in the app. fun getCurrentLocale(context: Context): String { - val conf = context.resources.configuration - return ConfigurationCompat.getLocales(conf).get(0)?.toLanguageTag() ?: "en" + val res = context.resources + val conf = res.configuration + + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + conf?.locales?.get(0)?.toString() ?: "en" + } else { + @Suppress("DEPRECATION") + conf?.locale?.toString() ?: "en" + } } -/** - * List of app supported languages. - * Language code shall be a IETF BCP 47 conformant tag - * - * See locales on: - * https://github.com/unicode-org/cldr-json/blob/main/cldr-json/cldr-core/availableLocales.json - * https://www.iana.org/assignments/language-subtag-registry/language-subtag-registry - * https://android.googlesource.com/platform/frameworks/base/+/android-16.0.0_r2/core/res/res/values/locale_config.xml - * https://iso639-3.sil.org/code_tables/639/data/all -*/ +// idk, if you find a way of automating this it would be great +// https://www.iemoji.com/view/emoji/1794/flags/antarctica +// Emoji Character Encoding Data --> C/C++/Java Src +// https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes leave blank for auto val appLanguages = arrayListOf( /* begin language list */ - Pair("Afrikaans", "af"), - Pair("Azərbaycan dili", "az"), - Pair("Bahasa Indonesia", "in"), - Pair("Bahasa Melayu", "ms"), - Pair("Deutsch", "de"), - Pair("English", "en"), - Pair("Español", "es"), - Pair("Esperanto", "eo"), - Pair("Français", "fr"), - Pair("Galego", "gl"), - Pair("hrvatski", "hr"), - Pair("Italiano", "it"), - Pair("Latviešu valoda", "lv"), - Pair("Lietuvių kalba", "lt"), - Pair("Magyar", "hu"), - Pair("Malti", "mt"), - Pair("mmmm... monke", "qt"), - Pair("Nederlands", "nl"), - Pair("Norsk bokmål", "no"), - Pair("Norsk nynorsk", "nn"), - Pair("Polski", "pl"), - Pair("Português", "pt"), - Pair("Português (Brasil)", "pt-BR"), - Pair("Română", "ro"), - Pair("Slovenčina", "sk"), - Pair("Soomaaliga", "so"), - Pair("Svenska", "sv"), - Pair("Tagalog", "tl"), - Pair("Tiếng Việt", "vi"), - Pair("Türkçe", "tr"), - Pair("Wikang Filipino", "fil"), - Pair("Čeština", "cs"), - Pair("Ελληνικά", "el"), - Pair("български", "bg"), - Pair("македонски", "mk"), - Pair("русский", "ru"), - Pair("українська", "uk"), - Pair("עברית", "iw"), - Pair("اردو", "ur"), - Pair("العربية", "ar"), - Pair("اللهجة النجدية", "ars"), - Pair("عربي شامي", "apc"), - Pair("فارسی", "fa"), - Pair("کوردیی ناوەندی", "ckb"), - Pair("नेपाली", "ne"), - Pair("हिन्दी", "hi"), - Pair("অসমীয়া", "as"), - Pair("বাংলা", "bn"), - Pair("ଓଡ଼ିଆ", "or"), - Pair("தமிழ்", "ta"), - Pair("ಕನ್ನಡ", "kn"), - Pair("മലയാളം", "ml"), - Pair("ဗမာစာ", "my"), - Pair("ትግርኛ", "ti"), - Pair("አማርኛ", "am"), - Pair("中文", "zh"), - Pair("日本語 (にほんご)", "ja"), - Pair("正體中文(臺灣)", "zh-TW"), - Pair("한국어", "ko"), + Triple("", "Afrikaans", "af"), + Triple("", "عربي شامي", "ajp"), + Triple("", "አማርኛ", "am"), + Triple("", "العربية", "ar"), + Triple("", "اللهجة النجدية", "ars"), + Triple("", "অসমীয়া", "as"), + Triple("", "azərbaycan dili", "az"), + Triple("", "български", "bg"), + Triple("", "বাংলা", "bn"), + Triple("\uD83C\uDDE7\uD83C\uDDF7", "português brasileiro", "bp"), + Triple("", "čeština", "cs"), + Triple("", "Deutsch", "de"), + Triple("", "Ελληνικά", "el"), + Triple("", "English", "en"), + Triple("", "Esperanto", "eo"), + Triple("", "español", "es"), + Triple("", "فارسی", "fa"), + Triple("", "fil", "fil"), + Triple("", "français", "fr"), + Triple("", "galego", "gl"), + Triple("", "हिन्दी", "hi"), + Triple("", "hrvatski", "hr"), + Triple("", "magyar", "hu"), + Triple("\uD83C\uDDEE\uD83C\uDDE9", "Bahasa Indonesia", "in"), + Triple("", "italiano", "it"), + Triple("\uD83C\uDDEE\uD83C\uDDF1", "עברית", "iw"), + Triple("", "日本語 (にほんご)", "ja"), + Triple("", "ಕನ್ನಡ", "kn"), + Triple("", "한국어", "ko"), + Triple("", "lietuvių kalba", "lt"), + Triple("", "latviešu valoda", "lv"), + Triple("", "македонски", "mk"), + Triple("", "മലയാളം", "ml"), + Triple("", "bahasa Melayu", "ms"), + Triple("", "Malti", "mt"), + Triple("", "ဗမာစာ", "my"), + Triple("", "नेपाली", "ne"), + Triple("", "Nederlands", "nl"), + Triple("", "norsk nynorsk", "nn"), + Triple("", "norsk bokmål", "no"), + Triple("", "ଓଡ଼ିଆ", "or"), + Triple("", "polski", "pl"), + Triple("\uD83C\uDDF5\uD83C\uDDF9", "português", "pt"), + Triple("\uD83E\uDD8D", "mmmm... monke", "qt"), + Triple("", "română", "ro"), + Triple("", "русский", "ru"), + Triple("", "slovenčina", "sk"), + Triple("", "Soomaaliga", "so"), + Triple("", "svenska", "sv"), + Triple("", "தமிழ்", "ta"), + Triple("", "ትግርኛ", "ti"), + Triple("", "Tagalog", "tl"), + Triple("", "Türkçe", "tr"), + Triple("", "українська", "uk"), + Triple("", "اردو", "ur"), + Triple("", "Tiếng Việt", "vi"), + Triple("", "中文", "zh"), + Triple("\uD83C\uDDF9\uD83C\uDDFC", "正體中文(臺灣)", "zh-rTW"), /* end language list */ -).sortedBy { it.first.lowercase(Locale.ROOT) } // ye, we go alphabetical, so ppl don't put their lang on top +).sortedBy { it.second.lowercase() } //ye, we go alphabetical, so ppl don't put their lang on top -fun Pair.nameNextToFlagEmoji(): String { - // fallback to [A][A] -> [?] question mak flag - val flag = SubtitleHelper.getFlagFromIso(this.second) ?: "\ud83c\udde6\ud83c\udde6" - - return "$flag\u00a0${this.first}" // \u00a0 non-breaking space -} - -class SettingsGeneral : BasePreferenceFragmentCompat() { +class SettingsGeneral : PreferenceFragmentCompat() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) setUpToolbar(R.string.category_general) @@ -156,21 +145,14 @@ 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() - PreferenceManager.getDefaultSharedPreferences(context).edit { - putString(getString(R.string.download_path_key), uri.toString()) - putString(context.getString(R.string.download_path_key_visual), visual) - } - } - } - private val pathPicker = getChooseFolderLauncher { uri, path -> - pickDownloadPath(uri, path) + val context = context ?: AcraApplication.context ?: return@getChooseFolderLauncher + (path ?: uri.toString()).let { + PreferenceManager.getDefaultSharedPreferences(context).edit() + .putString(getString(R.string.download_path_key), uri.toString()) + .putString(getString(R.string.download_path_key_visual), it) + .apply() + } } override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { @@ -184,20 +166,22 @@ class SettingsGeneral : BasePreferenceFragmentCompat() { } getPref(R.string.locale_key)?.setOnPreferenceClickListener { pref -> + val tempLangs = appLanguages.toMutableList() val current = getCurrentLocale(pref.context) - val languageTagsIETF = appLanguages.map { it.second } - val languageNames = appLanguages.map { it.nameNextToFlagEmoji() } - val currentIndex = languageTagsIETF.indexOf(current) + val languageCodes = tempLangs.map { (_, _, iso) -> iso } + val languageNames = tempLangs.map { (emoji, name, iso) -> + val flag = emoji.ifBlank { SubtitleHelper.getFlagFromIso(iso) ?: "ERROR" } + "$flag $name" + } + val index = languageCodes.indexOf(current) activity?.showDialog( - languageNames, currentIndex, getString(R.string.app_language), true, { } - ) { selectedLangIndex -> + languageNames, index, getString(R.string.app_language), true, { } + ) { languageIndex -> try { - val langTagIETF = languageTagsIETF[selectedLangIndex] - CommonActivity.setLocale(activity, langTagIETF) - settingsManager.edit { - putString(getString(R.string.locale_key), langTagIETF) - } + val code = languageCodes[languageIndex] + CommonActivity.setLocale(activity, code) + settingsManager.edit().putString(getString(R.string.locale_key), code).apply() activity?.recreate() } catch (e: Exception) { logError(e) @@ -219,7 +203,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, @@ -243,7 +227,7 @@ class SettingsGeneral : BasePreferenceFragmentCompat() { val url = binding.siteUrlInput.text?.toString() val lang = binding.siteLangInput.text?.toString() val realLang = if (lang.isNullOrBlank()) provider.lang else lang - if (url.isNullOrBlank() || name.isNullOrBlank()) { + if (url.isNullOrBlank() || name.isNullOrBlank() || realLang.length != 2) { showToast(R.string.error_invalid_data, Toast.LENGTH_SHORT) return@setOnClickListener } @@ -328,8 +312,8 @@ class SettingsGeneral : BasePreferenceFragmentCompat() { getString(R.string.dns_pref), true, {}) { - settingsManager.edit { putInt(getString(R.string.dns_pref), prefValues[it]) } - (context ?: CloudStreamApp.context)?.let { ctx -> app.initClient(ctx) } + settingsManager.edit().putInt(getString(R.string.dns_pref), prefValues[it]).apply() + (context ?: AcraApplication.context)?.let { ctx -> app.initClient(ctx) } } return@setOnPreferenceClickListener true } @@ -337,7 +321,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 { @@ -353,24 +337,18 @@ class SettingsGeneral : BasePreferenceFragmentCompat() { } ?: emptyList() } - settingsManager.edit { putBoolean(getString(R.string.jsdelivr_proxy_key), getKey(getString(R.string.jsdelivr_proxy_key), false) ?: false) } + settingsManager.edit().putBoolean(getString(R.string.jsdelivr_proxy_key), getKey(getString(R.string.jsdelivr_proxy_key), false) ?: false).apply() getPref(R.string.jsdelivr_proxy_key)?.setOnPreferenceChangeListener { _, newValue -> setKey(getString(R.string.jsdelivr_proxy_key), newValue) 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)), @@ -389,10 +367,10 @@ class SettingsGeneral : BasePreferenceFragmentCompat() { // Sets both visual and actual paths. // key = used path // visual = visual path - settingsManager.edit { - putString(getString(R.string.download_path_key), dirs[it]) - putString(getString(R.string.download_path_key_visual), dirs[it]) - } + settingsManager.edit() + .putString(getString(R.string.download_path_key), dirs[it]) + .putString(getString(R.string.download_path_key_visual), dirs[it]) + .apply() } } return@setOnPreferenceClickListener true @@ -415,12 +393,10 @@ class SettingsGeneral : BasePreferenceFragmentCompat() { if (beneneCount%20 == 0) { activity?.navigate(R.id.action_navigation_settings_general_to_easterEggMonkeFragment) } - settingsManager.edit { - putInt( - getString(R.string.benene_count), - beneneCount - ) - } + settingsManager.edit().putInt( + getString(R.string.benene_count), + beneneCount + ).apply() it.summary = getString(R.string.benene_count_text).format(beneneCount) } catch (e: Exception) { logError(e) 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..0f7a24d15 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 @@ -3,13 +3,11 @@ package com.lagradost.cloudstream3.ui.settings import android.os.Bundle import android.text.format.Formatter.formatShortFileSize import android.view.View -import androidx.core.content.edit +import androidx.preference.PreferenceFragmentCompat import androidx.preference.PreferenceManager 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,21 +20,18 @@ 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() { +class SettingsPlayer : PreferenceFragmentCompat() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) setUpToolbar(R.string.category_player) setPaddingBottom() setToolBarScrollFlags() } - override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { hideKeyboard() setPreferencesFromResource(R.xml.settings_player, rootKey) @@ -48,11 +43,12 @@ class SettingsPlayer : BasePreferenceFragmentCompat() { R.string.pref_category_gestures_key, R.string.rotate_video_key, R.string.auto_rotate_video_key, - R.string.speedup_key + R.string.speedup_key, + R.string.pip_enabled_key ), TV or EMULATOR ) - + getPref(R.string.preview_seekbar_key)?.hideOn(TV) getPref(R.string.pref_category_android_tv_key)?.hideOn(PHONE) @@ -68,11 +64,10 @@ class SettingsPlayer : BasePreferenceFragmentCompat() { prefValues.indexOf(currentPrefSize), getString(R.string.video_buffer_length_settings), true, - {} - ) { - settingsManager.edit { - putInt(getString(R.string.video_buffer_length_key), prefValues[it]) - } + {}) { + settingsManager.edit() + .putInt(getString(R.string.video_buffer_length_key), prefValues[it]) + .apply() } return@setOnPreferenceClickListener true } @@ -87,11 +82,10 @@ class SettingsPlayer : BasePreferenceFragmentCompat() { prefValues.indexOf(current), getString(R.string.limit_title), true, - {} - ) { - settingsManager.edit { - putInt(getString(R.string.prefer_limit_title_key), prefValues[it]) - } + {}) { + settingsManager.edit() + .putInt(getString(R.string.prefer_limit_title_key), prefValues[it]) + .apply() } return@setOnPreferenceClickListener true } @@ -106,48 +100,30 @@ class SettingsPlayer : BasePreferenceFragmentCompat() { prefValues.indexOf(current), getString(R.string.software_decoding), true, - {} - ) { - settingsManager.edit { - putInt(getString(R.string.software_decoding_key), prefValues[it]) - } + {}) { + settingsManager.edit() + .putInt(getString(R.string.software_decoding_key), prefValues[it]) + .apply() } 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), - {} - ) { selected -> - settingsManager.edit { - for ((index, key) in keys.withIndex()) { - putBoolean(key, selected.contains(index)) - } - } + true, + {}) { + settingsManager.edit() + .putInt(getString(R.string.prefer_limit_title_rez_key), prefValues[it]) + .apply() } - - true + return@setOnPreferenceClickListener true } getPref(R.string.hide_player_control_names_key)?.hideOn(TV) @@ -169,11 +145,9 @@ class SettingsPlayer : BasePreferenceFragmentCompat() { prefValues.indexOf(currentQuality), getString(R.string.watch_quality_pref), true, - {} - ) { - settingsManager.edit { - putInt(getString(R.string.quality_pref_key), prefValues[it]) - } + {}) { + settingsManager.edit().putInt(getString(R.string.quality_pref_key), prefValues[it]) + .apply() } return@setOnPreferenceClickListener true } @@ -195,11 +169,9 @@ class SettingsPlayer : BasePreferenceFragmentCompat() { prefValues.indexOf(currentQuality), getString(R.string.watch_quality_pref_data), true, - {} - ) { - settingsManager.edit { - putInt(getString(R.string.quality_pref_mobile_data_key), prefValues[it]) - } + {}) { + settingsManager.edit().putInt(getString(R.string.quality_pref_mobile_data_key), prefValues[it]) + .apply() } return@setOnPreferenceClickListener true } @@ -214,19 +186,15 @@ 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(), prefValues.indexOf(current), getString(R.string.player_pref), true, - {} - ) { - settingsManager.edit { - putString(getString(R.string.player_default_key), prefValues[it]) - } + {}) { + settingsManager.edit().putString(getString(R.string.player_default_key), prefValues[it]).apply() } return@setOnPreferenceClickListener true } @@ -241,21 +209,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) @@ -268,11 +221,10 @@ class SettingsPlayer : BasePreferenceFragmentCompat() { prefValues.indexOf(currentPrefSize), getString(R.string.video_buffer_disk_settings), true, - {} - ) { - settingsManager.edit { - putInt(getString(R.string.video_buffer_disk_key), prefValues[it]) - } + {}) { + settingsManager.edit() + .putInt(getString(R.string.video_buffer_disk_key), prefValues[it]) + .apply() } return@setOnPreferenceClickListener true } @@ -288,11 +240,10 @@ class SettingsPlayer : BasePreferenceFragmentCompat() { prefValues.indexOf(currentPrefSize), getString(R.string.video_buffer_size_settings), true, - {} - ) { - settingsManager.edit { - putInt(getString(R.string.video_buffer_size_key), prefValues[it]) - } + {}) { + settingsManager.edit() + .putInt(getString(R.string.video_buffer_size_key), prefValues[it]) + .apply() } return@setOnPreferenceClickListener true } @@ -300,20 +251,20 @@ class SettingsPlayer : BasePreferenceFragmentCompat() { getPref(R.string.video_buffer_clear_key)?.let { pref -> val cacheDir = context?.cacheDir ?: return@let - fun updateSummary() { + fun updateSummery() { try { - pref.summary = formatShortFileSize(pref.context, getFolderSize(cacheDir)) + pref.summary = formatShortFileSize(view?.context, getFolderSize(cacheDir)) } catch (e: Exception) { logError(e) } } - updateSummary() + updateSummery() pref.setOnPreferenceClickListener { try { cacheDir.deleteRecursively() - updateSummary() + updateSummery() } catch (e: Exception) { logError(e) } 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..cb7d25fd7 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 @@ -2,13 +2,12 @@ package com.lagradost.cloudstream3.ui.settings import android.os.Bundle import android.view.View -import androidx.core.content.edit -import androidx.navigation.fragment.findNavController import androidx.navigation.NavOptions +import androidx.navigation.fragment.findNavController +import androidx.preference.PreferenceFragmentCompat import androidx.preference.PreferenceManager import com.lagradost.cloudstream3.* import com.lagradost.cloudstream3.ui.APIRepository -import com.lagradost.cloudstream3.ui.BasePreferenceFragmentCompat import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.getPref import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setPaddingBottom import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setToolBarScrollFlags @@ -17,10 +16,10 @@ import com.lagradost.cloudstream3.utils.AppContextUtils.getApiDubstatusSettings import com.lagradost.cloudstream3.utils.AppContextUtils.getApiProviderLangSettings import com.lagradost.cloudstream3.utils.DataStoreHelper import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showMultiDialog -import com.lagradost.cloudstream3.utils.SubtitleHelper.getNameNextToFlagEmoji +import com.lagradost.cloudstream3.utils.SubtitleHelper import com.lagradost.cloudstream3.utils.UIHelper.hideKeyboard -class SettingsProviders : BasePreferenceFragmentCompat() { +class SettingsProviders : PreferenceFragmentCompat() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) setUpToolbar(R.string.category_providers) @@ -47,15 +46,13 @@ class SettingsProviders : BasePreferenceFragmentCompat() { names, currentList, getString(R.string.display_subbed_dubbed_settings), - {} - ) { selectedList -> + {}) { selectedList -> APIRepository.dubStatusActive = selectedList.map { dublist[it] }.toHashSet() - settingsManager.edit { - putStringSet( - getString(R.string.display_sub_key), - selectedList.map { names[it] }.toMutableSet() - ) - } + + settingsManager.edit().putStringSet( + this.getString(R.string.display_sub_key), + selectedList.map { names[it] }.toMutableSet() + ).apply() } } @@ -94,46 +91,50 @@ class SettingsProviders : BasePreferenceFragmentCompat() { names, currentList, getString(R.string.preferred_media_settings), - {} - ) { selectedList -> - settingsManager.edit { - putStringSet( - getString(R.string.prefer_media_type_key), - selectedList.map { it.toString() }.toMutableSet() - ) - } + {}) { selectedList -> + settingsManager.edit().putStringSet( + this.getString(R.string.prefer_media_type_key), + selectedList.map { it.toString() }.toMutableSet() + ).apply() DataStoreHelper.currentHomePage = null - //(context ?: CloudStreamApp.context)?.let { ctx -> app.initClient(ctx) } + //(context ?: AcraApplication.context)?.let { ctx -> app.initClient(ctx) } } return@setOnPreferenceClickListener true } 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() } + activity?.getApiProviderLangSettings()?.let { current -> + val languages = synchronized(APIHolder.apis) { + APIHolder.apis.map { it.lang }.toSet() + .sortedBy { SubtitleHelper.fromTwoLettersToLanguage(it) } + AllLanguagesName } - val currentIndexList = currentLangTags.map { langTag -> - languagesTagName.indexOfFirst { lang -> lang.first == langTag } + val currentList = current.map { + languages.indexOf(it) + } + + val names = languages.map { + if (it == AllLanguagesName) { + Pair(it, getString(R.string.all_languages_preference)) + } else { + val emoji = SubtitleHelper.getFlagFromIso(it) + val name = SubtitleHelper.fromTwoLettersToLanguage(it) + val fullName = "$emoji $name" + Pair(it, fullName) + } } activity?.showMultiDialog( - languagesTagName.map { it.second }, - currentIndexList, + names.map { it.second }, + currentList, getString(R.string.provider_lang_settings), - {} - ) { selectedList -> - settingsManager.edit { - putStringSet( - getString(R.string.provider_lang_key), - selectedList.map { languagesTagName[it].first }.toSet() - ) - } - // APIRepository.providersActive = it.context.getApiSettings() + {}) { selectedList -> + settingsManager.edit().putStringSet( + this.getString(R.string.provider_lang_key), + selectedList.map { names[it].first }.toMutableSet() + ).apply() + //APIRepository.providersActive = it.context.getApiSettings() } } 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..6446ae75d 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 @@ -3,22 +3,18 @@ package com.lagradost.cloudstream3.ui.settings import android.os.Build import android.os.Bundle import android.view.View -import androidx.core.content.edit +import androidx.preference.PreferenceFragmentCompat import androidx.preference.PreferenceManager import androidx.preference.SeekBarPreference -import com.lagradost.cloudstream3.CloudStreamApp.Companion.getActivity +import com.lagradost.cloudstream3.AcraApplication.Companion.getActivity import com.lagradost.cloudstream3.MainActivity 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 @@ -31,7 +27,7 @@ import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showMultiDialog import com.lagradost.cloudstream3.utils.UIHelper.hideKeyboard import com.lagradost.cloudstream3.utils.UIHelper.toPx -class SettingsUI : BasePreferenceFragmentCompat() { +class SettingsUI : PreferenceFragmentCompat() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) setUpToolbar(R.string.category_ui) @@ -44,27 +40,14 @@ class SettingsUI : BasePreferenceFragmentCompat() { setPreferencesFromResource(R.xml.settings_ui, rootKey) val settingsManager = PreferenceManager.getDefaultSharedPreferences(requireContext()) - (getPref(R.string.overscan_key)?.hideOn(PHONE or EMULATOR) as? SeekBarPreference)?.setOnPreferenceChangeListener { pref, newValue -> + getPref(R.string.random_button_key)?.hideOn(EMULATOR or TV) + + (getPref(R.string.overscan_key)?.hideOn(PHONE or EMULATOR) as? SeekBarPreference)?.setOnPreferenceChangeListener { perf, newValue -> val padding = (newValue as? Int)?.toPx ?: return@setOnPreferenceChangeListener true - (pref.context.getActivity() as? MainActivity)?.binding?.homeRoot?.setPadding(padding, padding, padding, padding) + (perf.context.getActivity() as? MainActivity)?.binding?.homeRoot?.setPadding(padding, padding, padding, padding) return@setOnPreferenceChangeListener true } - getPref(R.string.bottom_title_key)?.setOnPreferenceChangeListener { _, _ -> - HomeChildItemAdapter.sharedPool.clear() - ParentItemAdapter.sharedPool.clear() - SearchAdapter.sharedPool.clear() - true - } - - getPref(R.string.poster_size_key)?.setOnPreferenceChangeListener { _, newValue -> - HomeChildItemAdapter.sharedPool.clear() - ParentItemAdapter.sharedPool.clear() - SearchAdapter.sharedPool.clear() - context?.let { HomeChildItemAdapter.updatePosterSize(it, newValue as? Int) } - true - } - getPref(R.string.poster_ui_key)?.setOnPreferenceClickListener { val prefNames = resources.getStringArray(R.array.poster_ui_options) val keys = resources.getStringArray(R.array.poster_ui_options_values) @@ -80,13 +63,12 @@ class SettingsUI : BasePreferenceFragmentCompat() { prefNames.toList(), prefValues, getString(R.string.poster_ui_settings), - {} - ) { list -> - settingsManager.edit { - for ((i, key) in keys.withIndex()) { - putBoolean(key, list.contains(i)) - } + {}) { list -> + val edit = settingsManager.edit() + for ((i, key) in keys.withIndex()) { + edit.putBoolean(key, list.contains(i)) } + edit.apply() SearchResultBuilder.updateCache(it.context) } @@ -108,9 +90,9 @@ class SettingsUI : BasePreferenceFragmentCompat() { dismissCallback = {}, callback = { try { - settingsManager.edit { - putInt(getString(R.string.app_layout_key), prefValues[it]) - } + settingsManager.edit() + .putInt(getString(R.string.app_layout_key), prefValues[it]) + .apply() context?.updateTv() activity?.recreate() } catch (e: Exception) { @@ -150,12 +132,11 @@ class SettingsUI : BasePreferenceFragmentCompat() { prefValues.indexOf(currentLayout), getString(R.string.app_theme_settings), true, - {} - ) { + {}) { try { - settingsManager.edit { - putString(getString(R.string.app_theme_key), prefValues[it]) - } + settingsManager.edit() + .putString(getString(R.string.app_theme_key), prefValues[it]) + .apply() activity?.recreate() } catch (e: Exception) { logError(e) @@ -188,12 +169,11 @@ class SettingsUI : BasePreferenceFragmentCompat() { prefValues.indexOf(currentLayout), getString(R.string.primary_color_settings), true, - {} - ) { + {}) { try { - settingsManager.edit { - putString(getString(R.string.primary_color_key), prefValues[it]) - } + settingsManager.edit() + .putString(getString(R.string.primary_color_key), prefValues[it]) + .apply() activity?.recreate() } catch (e: Exception) { logError(e) @@ -215,14 +195,11 @@ class SettingsUI : BasePreferenceFragmentCompat() { names, currentList, getString(R.string.pref_filter_search_quality), - {} - ) { selectedList -> - settingsManager.edit { - putStringSet( - getString(R.string.pref_filter_search_quality_key), - selectedList.map { it.toString() }.toMutableSet() - ) - } + {}) { selectedList -> + settingsManager.edit().putStringSet( + this.getString(R.string.pref_filter_search_quality_key), + selectedList.map { it.toString() }.toMutableSet() + ).apply() } return@setOnPreferenceClickListener true @@ -240,9 +217,9 @@ class SettingsUI : BasePreferenceFragmentCompat() { showApply = true, dismissCallback = {}, callback = { selectedOption -> - settingsManager.edit { - putInt(getString(R.string.confirm_exit_key), prefValues[selectedOption]) - } + settingsManager.edit() + .putInt(getString(R.string.confirm_exit_key), prefValues[selectedOption]) + .apply() } ) return@setOnPreferenceClickListener true 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..bacca67ec 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 @@ -5,13 +5,12 @@ import android.os.Bundle import android.view.View import android.widget.Toast import androidx.appcompat.app.AlertDialog -import androidx.core.content.edit import androidx.navigation.fragment.findNavController +import androidx.preference.PreferenceFragmentCompat import androidx.preference.PreferenceManager import androidx.recyclerview.widget.LinearLayoutManager +import com.lagradost.cloudstream3.AcraApplication import com.lagradost.cloudstream3.AutoDownloadMode -import com.lagradost.cloudstream3.BuildConfig -import com.lagradost.cloudstream3.CloudStreamApp import com.lagradost.cloudstream3.CommonActivity.showToast import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.app @@ -21,8 +20,8 @@ import com.lagradost.cloudstream3.mvvm.safe import com.lagradost.cloudstream3.network.initClient 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 @@ -32,14 +31,13 @@ import com.lagradost.cloudstream3.ui.settings.utils.getChooseFolderLauncher import com.lagradost.cloudstream3.utils.BackupUtils import com.lagradost.cloudstream3.utils.BackupUtils.restorePrompt import com.lagradost.cloudstream3.utils.Coroutines.ioSafe -import com.lagradost.cloudstream3.utils.InAppUpdater.installPreReleaseIfNeeded -import com.lagradost.cloudstream3.utils.InAppUpdater.runAutoUpdate +import com.lagradost.cloudstream3.utils.InAppUpdater.Companion.runAutoUpdate import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showBottomDialog 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 @@ -49,7 +47,7 @@ import java.text.SimpleDateFormat import java.util.Date import java.util.Locale -class SettingsUpdates : BasePreferenceFragmentCompat() { +class SettingsUpdates : PreferenceFragmentCompat() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) setUpToolbar(R.string.category_updates) @@ -58,17 +56,16 @@ class SettingsUpdates : BasePreferenceFragmentCompat() { } private val pathPicker = getChooseFolderLauncher { uri, path -> - if(uri == null) return@getChooseFolderLauncher - - val context = context ?: CloudStreamApp.context ?: return@getChooseFolderLauncher + val context = context ?: AcraApplication.context ?: return@getChooseFolderLauncher (path ?: uri.toString()).let { - PreferenceManager.getDefaultSharedPreferences(context).edit { - putString(getString(R.string.backup_path_key), uri.toString()) - putString(getString(R.string.backup_dir_key), it) - } + PreferenceManager.getDefaultSharedPreferences(context).edit() + .putString(getString(R.string.backup_path_key), uri.toString()) + .putString(getString(R.string.backup_dir_key), it) + .apply() } } + @Suppress("DEPRECATION_ERROR") override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { hideKeyboard() setPreferencesFromResource(R.xml.settings_updates, rootKey) @@ -89,13 +86,11 @@ class SettingsUpdates : BasePreferenceFragmentCompat() { prefValues.indexOf(current), getString(R.string.backup_frequency), true, - {} - ) { index -> - settingsManager.edit { - putInt(getString(R.string.automatic_backup_key), prefValues[index]) - } + {}) { index -> + settingsManager.edit() + .putInt(getString(R.string.automatic_backup_key), prefValues[index]).apply() BackupWorkManager.enqueuePeriodicWork( - context ?: CloudStreamApp.context, + context ?: AcraApplication.context, prefValues[index].toLong() ) } @@ -122,8 +117,7 @@ class SettingsUpdates : BasePreferenceFragmentCompat() { dirs.indexOf(currentDir), getString(R.string.backup_path_title), true, - {} - ) { + {}) { // Last = custom if (it == dirs.size) { try { @@ -135,10 +129,10 @@ class SettingsUpdates : BasePreferenceFragmentCompat() { // Sets both visual and actual paths. // path = used uri // dir = dir path - settingsManager.edit { - putString(getString(R.string.backup_path_key), dirs[it]) - putString(getString(R.string.backup_dir_key), dirs[it]) - } + settingsManager.edit() + .putString(getString(R.string.backup_path_key), dirs[it]) + .putString(getString(R.string.backup_dir_key), dirs[it]) + .apply() } } return@setOnPreferenceClickListener true @@ -163,7 +157,7 @@ class SettingsUpdates : BasePreferenceFragmentCompat() { logError(e) // kinda ironic } - val adapter = LogcatAdapter().apply { submitList(logList) } + val adapter = LogcatAdapter(logList) binding.logcatRecyclerView.layoutManager = LinearLayoutManager(pref.context) binding.logcatRecyclerView.adapter = adapter @@ -207,21 +201,19 @@ 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(), prefValues.indexOf(currentInstaller), getString(R.string.apk_installer_settings), true, - {} - ) { num -> + {}) { num -> try { - settingsManager.edit { - putInt(getString(R.string.apk_installer_key), prefValues[num]) - } + settingsManager.edit() + .putInt(getString(R.string.apk_installer_key), prefValues[num]) + .apply() } catch (e: Exception) { logError(e) } @@ -229,29 +221,18 @@ class SettingsUpdates : BasePreferenceFragmentCompat() { return@setOnPreferenceClickListener true } - getPref(R.string.manual_check_update_key)?.let { pref -> - pref.summary = BuildConfig.VERSION_NAME - pref.setOnPreferenceClickListener { - ioSafe { - if (activity?.runAutoUpdate(false) == false) { - activity?.runOnUiThread { - showToast( - R.string.no_update_found, - Toast.LENGTH_SHORT - ) - } + getPref(R.string.manual_check_update_key)?.setOnPreferenceClickListener { + ioSafe { + if (activity?.runAutoUpdate(false) == false) { + activity?.runOnUiThread { + showToast( + R.string.no_update_found, + Toast.LENGTH_SHORT + ) } } - return@setOnPreferenceClickListener true - } - } - - getPref(R.string.install_prerelease_key)?.let { pref -> - pref.isVisible = BuildConfig.FLAVOR == "stable" - pref.setOnPreferenceClickListener { - activity?.installPreReleaseIfNeeded() - return@setOnPreferenceClickListener true } + return@setOnPreferenceClickListener true } getPref(R.string.auto_download_plugins_key)?.setOnPreferenceClickListener { @@ -266,12 +247,10 @@ class SettingsUpdates : BasePreferenceFragmentCompat() { prefValues.indexOf(current), getString(R.string.automatic_plugin_download_mode_title), true, - {} - ) { num -> - settingsManager.edit { - putInt(getString(R.string.auto_download_plugins_key), prefValues[num]) - } - (context ?: CloudStreamApp.context)?.let { ctx -> app.initClient(ctx) } + {}) { num -> + settingsManager.edit() + .putInt(getString(R.string.auto_download_plugins_key), prefValues[num]).apply() + (context ?: AcraApplication.context)?.let { ctx -> app.initClient(ctx) } } return@setOnPreferenceClickListener true } 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..9c5229212 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 @@ -4,8 +4,10 @@ import android.content.ClipboardManager import android.content.Context import android.content.DialogInterface import android.os.Build +import android.os.Bundle import android.view.LayoutInflater import android.view.View +import android.view.ViewGroup import android.widget.LinearLayout import android.widget.Toast import androidx.appcompat.app.AlertDialog @@ -13,6 +15,7 @@ import androidx.core.view.isGone import androidx.core.view.isVisible import androidx.core.view.marginBottom import androidx.core.view.marginTop +import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels import androidx.navigation.fragment.findNavController import com.lagradost.cloudstream3.CommonActivity.showToast @@ -23,12 +26,11 @@ import com.lagradost.cloudstream3.databinding.FragmentExtensionsBinding import com.lagradost.cloudstream3.mvvm.observe import com.lagradost.cloudstream3.mvvm.observeNullable import com.lagradost.cloudstream3.plugins.RepositoryManager -import com.lagradost.cloudstream3.ui.BaseFragment import com.lagradost.cloudstream3.ui.result.FOCUS_SELF import com.lagradost.cloudstream3.ui.result.setLinearListLayout +import com.lagradost.cloudstream3.utils.setText import com.lagradost.cloudstream3.ui.settings.Globals.TV import com.lagradost.cloudstream3.ui.settings.Globals.isLayout -import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setSystemBarsPadding import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setToolBarScrollFlags import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setUpToolbar import com.lagradost.cloudstream3.utils.AppContextUtils.addRepositoryDialog @@ -36,13 +38,23 @@ import com.lagradost.cloudstream3.utils.AppContextUtils.setDefaultFocus import com.lagradost.cloudstream3.utils.Coroutines.ioSafe import com.lagradost.cloudstream3.utils.Coroutines.main import com.lagradost.cloudstream3.utils.UIHelper.dismissSafe -import com.lagradost.cloudstream3.utils.setText -class ExtensionsFragment : BaseFragment( - BaseFragment.BindingCreator.Inflate(FragmentExtensionsBinding::inflate) -) { +class ExtensionsFragment : Fragment() { + var binding: FragmentExtensionsBinding? = null + override fun onDestroyView() { + binding = null + super.onDestroyView() + } - private val extensionViewModel: ExtensionsViewModel by activityViewModels() + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ): View { + val localBinding = FragmentExtensionsBinding.inflate(inflater, container, false) + binding = localBinding + return localBinding.root//inflater.inflate(R.layout.fragment_extensions, container, false) + } private fun View.setLayoutWidth(weight: Int) { val param = LinearLayout.LayoutParams( @@ -53,6 +65,8 @@ class ExtensionsFragment : BaseFragment( this.layoutParams = param } + private val extensionViewModel: ExtensionsViewModel by activityViewModels() + override fun onResume() { super.onResume() afterRepositoryLoadedEvent += ::reloadRepositories @@ -68,25 +82,24 @@ class ExtensionsFragment : BaseFragment( extensionViewModel.loadRepositories() } - override fun fixLayout(view: View) { - setSystemBarsPadding() - } + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + //context?.fixPaddingStatusbar(extensions_root) - override fun onBindingCreated(binding: FragmentExtensionsBinding) { setUpToolbar(R.string.extensions) setToolBarScrollFlags() - binding.repoRecyclerView.apply { + binding?.repoRecyclerView?.apply { setLinearListLayout( isHorizontal = false, - nextUp = R.id.settings_toolbar, // FOCUS_SELF, // back has no id so we cant :pensive: + nextUp = R.id.settings_toolbar, //FOCUS_SELF, // back has no id so we cant :pensive: nextDown = R.id.plugin_storage_appbar, nextRight = FOCUS_SELF, nextLeft = R.id.nav_rail_view ) if (!isLayout(TV)) - binding.addRepoButton.let { button -> + binding?.addRepoButton?.let { button -> button.post { setPadding( paddingLeft, @@ -100,10 +113,10 @@ class ExtensionsFragment : BaseFragment( if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { setOnScrollChangeListener { _, _, scrollY, _, oldScrollY -> val dy = scrollY - oldScrollY - if (dy > 0) { // check for scroll down - binding.addRepoButton.shrink() // hide + if (dy > 0) { //check for scroll down + binding?.addRepoButton?.shrink() // hide } else if (dy < -5) { - binding.addRepoButton.extend() // show + binding?.addRepoButton?.extend() // show } } } @@ -119,14 +132,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 ?: view.context) val dialogClickListener = DialogInterface.OnClickListener { _, which -> when (which) { DialogInterface.BUTTON_POSITIVE -> { ioSafe { - RepositoryManager.removeRepository(uiContext.applicationContext, repo) + RepositoryManager.removeRepository(view.context, repo) extensionViewModel.loadStats() extensionViewModel.loadRepositories() } @@ -137,7 +149,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() @@ -146,15 +160,37 @@ class ExtensionsFragment : BaseFragment( } observe(extensionViewModel.repositories) { - binding.repoRecyclerView.isVisible = it.isNotEmpty() - binding.blankRepoScreen.isVisible = it.isEmpty() - (binding.repoRecyclerView.adapter as? RepoAdapter)?.submitList(it.toList()) + binding?.repoRecyclerView?.isVisible = it.isNotEmpty() + binding?.blankRepoScreen?.isVisible = it.isEmpty() + (binding?.repoRecyclerView?.adapter as? RepoAdapter)?.updateList(it) } + /*binding?.repoRecyclerView?.apply { + context?.let { ctx -> + layoutManager = LinearRecycleViewLayoutManager(ctx, nextFocusUpId, nextFocusDownId) + } + }*/ + +// list_repositories?.setOnClickListener { +// // Open webview on tv if browser fails +// val isTv = isTvSettings() +// openBrowser(PUBLIC_REPOSITORIES_LIST, isTv, this) +// +// // Set clipboard on TV because the browser might not exist or work properly +// if (isTv) { +// val serviceClipboard = +// (activity?.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager?) +// ?: return@setOnClickListener +// val clip = ClipData.newPlainText("Repository url", PUBLIC_REPOSITORIES_LIST) +// serviceClipboard.setPrimaryClip(clip) +// } +// } + observeNullable(extensionViewModel.pluginStats) { value -> - binding.apply { + binding?.apply { if (value == null) { pluginStorageAppbar.isVisible = false + return@observeNullable } @@ -174,7 +210,7 @@ class ExtensionsFragment : BaseFragment( } } - binding.pluginStorageAppbar.setOnClickListener { + binding?.pluginStorageAppbar?.setOnClickListener { findNavController().navigate( R.id.navigation_settings_extensions_to_navigation_settings_plugins, PluginsFragment.newInstance( @@ -194,24 +230,24 @@ class ExtensionsFragment : BaseFragment( val dialog = builder.create() dialog.show() - (activity?.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager)?.primaryClip?.getItemAt( + (activity?.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager?)?.primaryClip?.getItemAt( 0 - )?.text?.toString()?.let { copiedText -> - if (copiedText.contains(RepoAdapter.SHAREABLE_REPO_SEPARATOR)) { - // text is of format : - val (name, url) = copiedText.split(RepoAdapter.SHAREABLE_REPO_SEPARATOR, limit = 2) - binding.repoUrlInput.setText(url.trim()) - binding.repoNameInput.setText(name.trim()) - } else { - binding.repoUrlInput.setText(copiedText) - } + )?.text?.toString()?.let { copy -> + binding.repoUrlInput.setText(copy) } +// dialog.list_repositories?.setOnClickListener { +// // Open webview on tv if browser fails +// openBrowser(PUBLIC_REPOSITORIES_LIST, isTvSettings(), this) +// dialog.dismissSafe() +// } + +// dialog.text2?.text = provider.name 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) @@ -251,7 +287,7 @@ class ExtensionsFragment : BaseFragment( } val isTv = isLayout(TV) - binding.apply { + binding?.apply { addRepoButton.isGone = isTv addRepoButtonImageviewHolder.isVisible = isTv @@ -264,4 +300,4 @@ class ExtensionsFragment : BaseFragment( } reloadRepositories() } -} +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/ExtensionsViewModel.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/ExtensionsViewModel.kt index 482251b78..6d5e2ce27 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/ExtensionsViewModel.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/ExtensionsViewModel.kt @@ -4,7 +4,7 @@ import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import com.fasterxml.jackson.annotation.JsonProperty -import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey +import com.lagradost.cloudstream3.AcraApplication.Companion.getKey import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.amap import com.lagradost.cloudstream3.mvvm.debugAssert 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..15228b260 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,26 +8,29 @@ import android.view.ViewGroup import androidx.appcompat.app.AppCompatActivity import androidx.core.view.isGone import androidx.core.view.isVisible -import androidx.viewbinding.ViewBinding -import com.lagradost.cloudstream3.CloudStreamApp.Companion.getActivity +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.RecyclerView +import com.lagradost.cloudstream3.AcraApplication.Companion.getActivity import com.lagradost.cloudstream3.PROVIDER_STATUS_DOWN 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.ui.BaseDiffCallback -import com.lagradost.cloudstream3.ui.NoStateAdapter -import com.lagradost.cloudstream3.ui.ViewHolderState -import com.lagradost.cloudstream3.ui.newSharedPool +import com.lagradost.cloudstream3.plugins.VotingApi.getVotes +import com.lagradost.cloudstream3.utils.setText +import com.lagradost.cloudstream3.utils.txt 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.SubtitleHelper.fromTwoLettersToLanguage +import com.lagradost.cloudstream3.utils.SubtitleHelper.getFlagFromIso import com.lagradost.cloudstream3.utils.UIHelper.toPx import com.lagradost.cloudstream3.utils.getImageFromDrawable -import com.lagradost.cloudstream3.utils.setText -import com.lagradost.cloudstream3.utils.txt +import org.junit.Assert +import org.junit.Test import java.text.DecimalFormat import kotlin.math.floor import kotlin.math.log10 @@ -38,171 +41,60 @@ data class PluginViewData( val isDownloaded: Boolean, ) -class RepositoryViewHolderState(view: ViewBinding) : ViewHolderState(view) { - // Store how many times this has called recycled, this is used to correctly sync text in jobs - var recycleCount = 0 -} - class PluginAdapter( val iconClickCallback: (Plugin) -> Unit -) : NoStateAdapter(diffCallback = BaseDiffCallback(itemSame = { a, b -> - a.plugin.second.internalName == b.plugin.second.internalName && a.plugin.first == b.plugin.first -})) { - override fun onCreateContent(parent: ViewGroup): ViewHolderState { - val layout = if (isLayout(TV)) R.layout.repository_item_tv else R.layout.repository_item +) : + RecyclerView.Adapter() { + private val plugins: MutableList = mutableListOf() + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { + val layout = if(isLayout(TV)) R.layout.repository_item_tv else R.layout.repository_item val inflated = LayoutInflater.from(parent.context).inflate(layout, parent, false) - return RepositoryViewHolderState( + return PluginViewHolder( RepositoryItemBinding.bind(inflated) // may crash ) } - override fun onClearView(holder: ViewHolderState) { - if (holder is RepositoryViewHolderState) { - holder.recycleCount += 1 - } - when (val binding = holder.view) { - is RepositoryItemBinding -> { - clearImage(binding.entryIcon) + override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { + when (holder) { + is PluginViewHolder -> { + holder.bind(plugins[position]) } } } - @SuppressLint("SetTextI18n") - override fun onBindContent(holder: ViewHolderState, item: PluginViewData, position: Int) { - val binding = holder.view as? RepositoryItemBinding ?: return - val itemView = holder.itemView + override fun getItemCount(): Int { + return plugins.size + } - val metadata = item.plugin.second - val disabled = metadata.status == PROVIDER_STATUS_DOWN - val name = metadata.name.removeSuffix("Provider") - val alpha = if (disabled) 0.6f else 1f - val isLocal = !item.plugin.second.url.startsWith("http") - binding.mainText.alpha = alpha - binding.subText.alpha = alpha - - val drawableInt = if (item.isDownloaded) - R.drawable.ic_baseline_delete_outline_24 - else R.drawable.netflix_download - - binding.nsfwMarker.isVisible = metadata.tvTypes?.contains(TvType.NSFW.name) ?: false - binding.actionButton.setImageResource(drawableInt) - - binding.actionButton.setOnClickListener { - iconClickCallback.invoke(item.plugin) - } - itemView.setOnClickListener { - if (isLocal) return@setOnClickListener - - val sheet = PluginDetailsFragment(item) - val activity = itemView.context.getActivity() as AppCompatActivity - sheet.show(activity.supportFragmentManager, "PluginDetails") - } - //if (itemView.context?.isTrueTvSettings() == false) { - // val siteUrl = metadata.repositoryUrl - // if (siteUrl != null && siteUrl.isNotBlank() && siteUrl != "NONE") { - // itemView.setOnClickListener { - // openBrowser(siteUrl) - // } - // } - //} - - if (item.isDownloaded) { - // On local plugins page the filepath is provided instead of url. - val plugin = - (PluginManager.urlPlugins[metadata.url] - ?: (PluginManager.plugins[metadata.url])) as? com.lagradost.cloudstream3.plugins.Plugin - - if (plugin?.openSettings != null) { - binding.actionSettings.isVisible = true - binding.actionSettings.setOnClickListener { - try { - plugin.openSettings?.invoke(itemView.context) - } catch (e: Throwable) { - Log.e( - "PluginAdapter", - "Failed to open $name settings: ${ - Log.getStackTraceString(e) - }" - ) - } - } - } else { - binding.actionSettings.isVisible = false - } - } else { - binding.actionSettings.isVisible = false - } - - val url = metadata.iconUrl?.replace( - "%size%", - "$iconSize" - )?.replace( - "%exact_size%", - "$iconSizeExact" + fun updateList(newList: List) { + val diffResult = DiffUtil.calculateDiff( + PluginDiffCallback(this.plugins, newList) ) - if (url.isNullOrBlank()) { - binding.entryIcon.loadImage(R.drawable.ic_baseline_extension_24) - } else { - binding.entryIcon.loadImage( - url - ) { error(getImageFromDrawable(itemView.context, R.drawable.ic_baseline_extension_24)) } + plugins.clear() + plugins.addAll(newList) + + diffResult.dispatchUpdatesTo(this) + } + + /* + private var storedPlugins: Array = reloadStoredPlugins() + + private fun reloadStoredPlugins(): Array { + return PluginManager.getPluginsOnline().also { storedPlugins = it } + }*/ + + // Clear coil image because setImageResource doesn't override + override fun onViewRecycled(holder: RecyclerView.ViewHolder) { + if (holder is PluginViewHolder) { + holder.binding.entryIcon.loadImage(R.drawable.ic_github_logo) } - - binding.extVersion.isVisible = true - binding.extVersion.text = "v${metadata.version}" - - if (metadata.language.isNullOrBlank()) { - binding.langIcon.isVisible = false - } else { - binding.langIcon.isVisible = true - binding.langIcon.text = getNameNextToFlagEmoji(metadata.language) ?: metadata.language - } - - //val oldRecycleCount = (holder as? RepositoryViewHolderState)?.recycleCount - - binding.extVotes.isVisible = false - - // Disable this for now as the vote api is down, this will also significantly improve the lag - // from doing all these network requests - /*if (!isLocal) { - ioSafe { - metadata.getVotes().main { votes -> - val currentRecycleCount = (holder as? RepositoryViewHolderState)?.recycleCount - - // Only set the text if the view is correctly rendered - if (currentRecycleCount == oldRecycleCount) { - binding.extVotes.setText(txt(R.string.extension_rating, prettyCount(votes))) - binding.extVotes.isVisible = true - } - } - } - }*/ - - if (metadata.fileSize != null) { - binding.extFilesize.isVisible = true - binding.extFilesize.text = formatShortFileSize(itemView.context, metadata.fileSize) - } else { - binding.extFilesize.isVisible = false - } - - binding.mainText.setText( - if (disabled) txt( - R.string.single_plugin_disabled, - name - ) else txt(name) - ) - - binding.subText.isGone = metadata.description.isNullOrBlank() - binding.subText.text = metadata.description.html() + super.onViewRecycled(holder) } companion object { - // A high count as we can render in the entire list as the same time - val sharedPool = - newSharedPool { setMaxRecycledViews(CONTENT, 15) } - private tailrec fun findClosestBase2(target: Int, current: Int = 16, max: Int = 512): Int { if (current >= max) return max if (current >= target) return current @@ -211,14 +103,14 @@ class PluginAdapter( // DO NOT MOVE, as running this test will result in ExceptionInInitializerError on prerelease due to static variables using Resources.getSystem() // this test function is only to show how the function works - /*@Test + @Test fun testFindClosestBase2() { Assert.assertEquals(16, findClosestBase2(0)) Assert.assertEquals(256, findClosestBase2(170)) Assert.assertEquals(256, findClosestBase2(256)) Assert.assertEquals(512, findClosestBase2(257)) Assert.assertEquals(512, findClosestBase2(700)) - }*/ + } private val iconSizeExact = 32.toPx private val iconSize by lazy { @@ -239,4 +131,136 @@ class PluginAdapter( } } } + + inner class PluginViewHolder(val binding: RepositoryItemBinding) : + RecyclerView.ViewHolder(binding.root) { + + @SuppressLint("SetTextI18n") + fun bind( + data: PluginViewData, + ) { + val metadata = data.plugin.second + val disabled = metadata.status == PROVIDER_STATUS_DOWN + val name = metadata.name.removeSuffix("Provider") + val alpha = if (disabled) 0.6f else 1f + val isLocal = !data.plugin.second.url.startsWith("http") + binding.mainText.alpha = alpha + binding.subText.alpha = alpha + + val drawableInt = if (data.isDownloaded) + R.drawable.ic_baseline_delete_outline_24 + else R.drawable.netflix_download + + binding.nsfwMarker.isVisible = metadata.tvTypes?.contains(TvType.NSFW.name) ?: false + binding.actionButton.setImageResource(drawableInt) + + binding.actionButton.setOnClickListener { + iconClickCallback.invoke(data.plugin) + } + itemView.setOnClickListener { + if (isLocal) return@setOnClickListener + + val sheet = PluginDetailsFragment(data) + val activity = itemView.context.getActivity() as AppCompatActivity + sheet.show(activity.supportFragmentManager, "PluginDetails") + } + //if (itemView.context?.isTrueTvSettings() == false) { + // val siteUrl = metadata.repositoryUrl + // if (siteUrl != null && siteUrl.isNotBlank() && siteUrl != "NONE") { + // itemView.setOnClickListener { + // openBrowser(siteUrl) + // } + // } + //} + + if (data.isDownloaded) { + // On local plugins page the filepath is provided instead of url. + val plugin = + (PluginManager.urlPlugins[metadata.url] ?: (PluginManager.plugins[metadata.url])) as? com.lagradost.cloudstream3.plugins.Plugin + + if (plugin?.openSettings != null) { + binding.actionSettings.isVisible = true + binding.actionSettings.setOnClickListener { + try { + plugin.openSettings!!.invoke(itemView.context) + } catch (e: Throwable) { + Log.e( + "PluginAdapter", + "Failed to open $name settings: ${ + Log.getStackTraceString(e) + }" + ) + } + } + } else { + binding.actionSettings.isVisible = false + } + } else { + binding.actionSettings.isVisible = false + } + + binding.entryIcon.loadImage( + metadata.iconUrl?.replace( + "%size%", + "$iconSize" + )?.replace( + "%exact_size%", + "$iconSizeExact" + ) + ) { error(getImageFromDrawable(itemView.context, R.drawable.ic_baseline_extension_24)) } + + binding.extVersion.isVisible = true + binding.extVersion.text = "v${metadata.version}" + + if (metadata.language.isNullOrBlank()) { + binding.langIcon.isVisible = false + } else { + binding.langIcon.isVisible = true + binding.langIcon.text = + "${getFlagFromIso(metadata.language)} ${fromTwoLettersToLanguage(metadata.language)}" + } + + binding.extVotes.isVisible = false + if (!isLocal) { + ioSafe { + metadata.getVotes().main { + binding.extVotes.setText(txt(R.string.extension_rating, prettyCount(it))) + binding.extVotes.isVisible = true + } + } + } + + + if (metadata.fileSize != null) { + binding.extFilesize.isVisible = true + binding.extFilesize.text = formatShortFileSize(itemView.context, metadata.fileSize) + } else { + binding.extFilesize.isVisible = false + } + binding.mainText.setText( + if (disabled) txt( + R.string.single_plugin_disabled, + name + ) else txt(name) + ) + binding.subText.isGone = metadata.description.isNullOrBlank() + binding.subText.text = metadata.description.html() + } + } +} + +class PluginDiffCallback( + private val oldList: List, + private val newList: List +) : + DiffUtil.Callback() { + override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int) = + oldList[oldItemPosition].plugin.second.internalName == newList[newItemPosition].plugin.second.internalName && oldList[oldItemPosition].plugin.first == newList[newItemPosition].plugin.first + + override fun getOldListSize() = oldList.size + + override fun getNewListSize() = newList.size + + override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int) = + oldList[oldItemPosition] == newList[newItemPosition] } \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/PluginDetailsFragment.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/PluginDetailsFragment.kt index 0dcbece6c..80be3cf4b 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/PluginDetailsFragment.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/PluginDetailsFragment.kt @@ -1,36 +1,33 @@ package com.lagradost.cloudstream3.ui.settings.extensions import android.content.res.ColorStateList +import android.os.Bundle import android.text.format.Formatter.formatFileSize import android.util.Log +import android.view.LayoutInflater import android.view.View +import android.view.ViewGroup import androidx.core.view.isVisible -import com.lagradost.cloudstream3.CloudStreamApp.Companion.openBrowser +import com.google.android.material.bottomsheet.BottomSheetDialogFragment +import com.lagradost.cloudstream3.AcraApplication.Companion.openBrowser +import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.databinding.FragmentPluginDetailsBinding import com.lagradost.cloudstream3.plugins.PluginManager import com.lagradost.cloudstream3.plugins.VotingApi.canVote import com.lagradost.cloudstream3.plugins.VotingApi.getVotes import com.lagradost.cloudstream3.plugins.VotingApi.hasVoted import com.lagradost.cloudstream3.plugins.VotingApi.vote -import com.lagradost.cloudstream3.R -import com.lagradost.cloudstream3.ui.BaseBottomSheetDialogFragment -import com.lagradost.cloudstream3.ui.BaseFragment -import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR -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.Coroutines.ioSafe import com.lagradost.cloudstream3.utils.Coroutines.main -import com.lagradost.cloudstream3.utils.getImageFromDrawable import com.lagradost.cloudstream3.utils.ImageLoader.loadImage -import com.lagradost.cloudstream3.utils.SubtitleHelper.getNameNextToFlagEmoji +import com.lagradost.cloudstream3.utils.SubtitleHelper.fromTwoLettersToLanguage +import com.lagradost.cloudstream3.utils.SubtitleHelper.getFlagFromIso import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute -import com.lagradost.cloudstream3.utils.UIHelper.fixSystemBarsPadding import com.lagradost.cloudstream3.utils.UIHelper.toPx +import com.lagradost.cloudstream3.utils.getImageFromDrawable -class PluginDetailsFragment(val data: PluginViewData) : BaseBottomSheetDialogFragment( - BaseFragment.BindingCreator.Inflate(FragmentPluginDetailsBinding::inflate) -) { + +class PluginDetailsFragment(val data: PluginViewData) : BottomSheetDialogFragment() { companion object { private tailrec fun findClosestBase2(target: Int, current: Int = 16, max: Int = 512): Int { @@ -45,17 +42,26 @@ class PluginDetailsFragment(val data: PluginViewData) : BaseBottomSheetDialogFra } } - override fun fixLayout(view: View) { - fixSystemBarsPadding( - view, - padBottom = isLandscape(), - padLeft = isLayout(TV or EMULATOR) - ) + override fun onDestroyView() { + binding = null + super.onDestroyView() } - override fun onBindingCreated(binding: FragmentPluginDetailsBinding) { + var binding: FragmentPluginDetailsBinding? = null + override fun onCreateView( + inflater: LayoutInflater, container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + val localBinding = FragmentPluginDetailsBinding.inflate(inflater, container, false) + binding = localBinding + return localBinding.root + //return inflater.inflate(R.layout.fragment_plugin_details, container, false) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) val metadata = data.plugin.second - binding.apply { + binding?.apply { pluginIcon.loadImage(metadata.iconUrl?.replace("%size%", "$iconSize") ?.replace("%exact_size%", "$iconSizeExact")) { error { getImageFromDrawable(context ?: return@error null , R.drawable.ic_baseline_extension_24) } @@ -79,9 +85,9 @@ class PluginDetailsFragment(val data: PluginViewData) : BaseBottomSheetDialogFra ", " ) pluginLang.text = if (metadata.language == null) - getString(R.string.no_data) - else - getNameNextToFlagEmoji(metadata.language) ?: metadata.language + getString(R.string.no_data) + else + "${getFlagFromIso(metadata.language)} ${fromTwoLettersToLanguage(metadata.language)}" githubBtn.setOnClickListener { if (metadata.repositoryUrl != null) { 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..4878049b4 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 @@ -1,62 +1,70 @@ package com.lagradost.cloudstream3.ui.settings.extensions import android.os.Bundle +import android.view.LayoutInflater import android.view.View +import android.view.ViewGroup import androidx.appcompat.widget.SearchView import androidx.core.view.isVisible +import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels import com.lagradost.cloudstream3.AllLanguagesName import com.lagradost.cloudstream3.BuildConfig -import com.lagradost.cloudstream3.databinding.FragmentPluginsBinding -import com.lagradost.cloudstream3.mvvm.observe import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.TvType -import com.lagradost.cloudstream3.ui.BaseFragment +import com.lagradost.cloudstream3.databinding.FragmentPluginsBinding +import com.lagradost.cloudstream3.mvvm.observe 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 -import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setSystemBarsPadding import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setToolBarScrollFlags import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setUpToolbar +import com.lagradost.cloudstream3.ui.settings.appLanguages import com.lagradost.cloudstream3.utils.AppContextUtils.getApiProviderLangSettings import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showMultiDialog -import com.lagradost.cloudstream3.utils.SubtitleHelper.getNameNextToFlagEmoji +import com.lagradost.cloudstream3.utils.SubtitleHelper import com.lagradost.cloudstream3.utils.UIHelper.toPx const val PLUGINS_BUNDLE_NAME = "name" const val PLUGINS_BUNDLE_URL = "url" const val PLUGINS_BUNDLE_LOCAL = "isLocal" -class PluginsFragment : BaseFragment( - BaseFragment.BindingCreator.Inflate(FragmentPluginsBinding::inflate) -) { - - private val pluginViewModel: PluginsViewModel by activityViewModels() +class PluginsFragment : Fragment() { + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ): View { + val localBinding = FragmentPluginsBinding.inflate(inflater, container, false) + binding = localBinding + return localBinding.root//inflater.inflate(R.layout.fragment_plugins, container, false) + } override fun onDestroyView() { - pluginViewModel.clear() // clear for the next observe + binding = null super.onDestroyView() } - override fun fixLayout(view: View) { - setSystemBarsPadding() - } + private val pluginViewModel: PluginsViewModel by activityViewModels() + var binding: FragmentPluginsBinding? = null + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) - override fun onBindingCreated(binding: FragmentPluginsBinding) { // Since the ViewModel is getting reused the tvTypes must be cleared between uses pluginViewModel.tvTypes.clear() - pluginViewModel.selectedLanguages = listOf() - pluginViewModel.clear() + pluginViewModel.languages = listOf() + pluginViewModel.search(null) // Filter by language set on preferred media activity?.let { val providerLangs = it.getApiProviderLangSettings().toList() if (!providerLangs.contains(AllLanguagesName)) { - pluginViewModel.selectedLanguages = mutableListOf("none") + providerLangs + pluginViewModel.languages = mutableListOf("none") + providerLangs + //Log.i("DevDebug", "providerLang => ${pluginViewModel.languages.toJson()}") } } @@ -64,16 +72,16 @@ class PluginsFragment : BaseFragment( val url = arguments?.getString(PLUGINS_BUNDLE_URL) val isLocal = arguments?.getBoolean(PLUGINS_BUNDLE_LOCAL) == true // download all extensions button - val downloadAllButton = binding.settingsToolbar.menu?.findItem(R.id.download_all) + val downloadAllButton = binding?.settingsToolbar?.menu?.findItem(R.id.download_all) if (url == null || name == null) { - dispatchBackPressed() + activity?.onBackPressedDispatcher?.onBackPressed() return } setToolBarScrollFlags() setUpToolbar(name) - binding.settingsToolbar.apply { + binding?.settingsToolbar?.apply { setOnMenuItemClickListener { menuItem -> when (menuItem?.itemId) { R.id.download_all -> { @@ -81,35 +89,24 @@ class PluginsFragment : BaseFragment( } R.id.lang_filter -> { - val languagesTagName = pluginViewModel.pluginLanguages - .map { langTag -> - Pair( - langTag, - getNameNextToFlagEmoji(langTag) ?: langTag - ) + val tempLangs = appLanguages.toMutableList() + val languageCodes = + mutableListOf("none") + tempLangs.map { (_, _, iso) -> iso } + val languageNames = + mutableListOf(getString(R.string.no_data)) + tempLangs.map { (emoji, name, iso) -> + val flag = + emoji.ifBlank { SubtitleHelper.getFlagFromIso(iso) ?: "ERROR" } + "$flag $name" } - .sortedBy { - it.second.substringAfter("\u00a0").lowercase() - } // name ignoring flag emoji - .toMutableList() - - // Move "none" to 1st position as it's special code to indicate unknown/missing language - if (languagesTagName.remove(Pair("none", "none"))) { - languagesTagName.add(0, Pair("none", getString(R.string.no_data))) - } - - val currentIndexList = pluginViewModel.selectedLanguages.map { langTag -> - languagesTagName.indexOfFirst { lang -> lang.first == langTag } - } + val selectedList = + pluginViewModel.languages.map { languageCodes.indexOf(it) } activity?.showMultiDialog( - languagesTagName.map { it.second }, - currentIndexList, + languageNames, + selectedList, getString(R.string.provider_lang_settings), - {} - ) { selectedList -> - pluginViewModel.selectedLanguages = - selectedList.map { languagesTagName[it].first } + {}) { newList -> + pluginViewModel.languages = newList.map { languageCodes[it] } pluginViewModel.updateFilteredPlugins() } } @@ -127,7 +124,7 @@ class PluginsFragment : BaseFragment( if (searchView?.isIconified == false) { searchView.isIconified = true } else { - dispatchBackPressed() + activity?.onBackPressedDispatcher?.onBackPressed() } } searchView?.setOnQueryTextFocusChangeListener { _, hasFocus -> @@ -152,46 +149,46 @@ class PluginsFragment : BaseFragment( // Because onActionViewCollapsed doesn't wanna work we need this workaround :( - binding.pluginRecyclerView.apply { - setLinearListLayout( - isHorizontal = false, - nextDown = FOCUS_SELF, - nextRight = FOCUS_SELF, - ) - setRecycledViewPool(PluginAdapter.sharedPool) - adapter = - PluginAdapter { - pluginViewModel.handlePluginAction(activity, url, it, isLocal) - } - } + binding?.pluginRecyclerView?.setLinearListLayout( + isHorizontal = false, + nextDown = FOCUS_SELF, + nextRight = FOCUS_SELF, + ) + + binding?.pluginRecyclerView?.adapter = + PluginAdapter { + pluginViewModel.handlePluginAction(activity, url, it, isLocal) + } if (isLayout(TV or EMULATOR)) { // Scrolling down does not reveal the whole RecyclerView on TV, add to bypass that. - binding.pluginRecyclerView.setPadding(0, 0, 0, 200.toPx) + binding?.pluginRecyclerView?.setPadding(0, 0, 0, 200.toPx) } observe(pluginViewModel.filteredPlugins) { (scrollToTop, list) -> - (binding.pluginRecyclerView.adapter as? PluginAdapter)?.submitList(list) - if (scrollToTop) { - binding.pluginRecyclerView.scrollToPosition(0) - } + (binding?.pluginRecyclerView?.adapter as? PluginAdapter)?.updateList(list) + + if (scrollToTop) + binding?.pluginRecyclerView?.scrollToPosition(0) } if (isLocal) { // No download button and no categories on local downloadAllButton?.isVisible = false - binding.settingsToolbar.menu?.findItem(R.id.lang_filter)?.isVisible = false + binding?.settingsToolbar?.menu?.findItem(R.id.lang_filter)?.isVisible = false pluginViewModel.updatePluginListLocal() - binding.tvtypesChipsScroll.root.isVisible = false + binding?.tvtypesChipsScroll?.root?.isVisible = false } else { pluginViewModel.updatePluginList(context, url) - binding.tvtypesChipsScroll.root.isVisible = true + binding?.tvtypesChipsScroll?.root?.isVisible = true // not needed for users but may be useful for devs downloadAllButton?.isVisible = BuildConfig.DEBUG + + bindChips( - binding.tvtypesChipsScroll.tvtypesChips, + binding?.tvtypesChipsScroll?.tvtypesChips, emptyList(), TvType.entries.toList(), callback = { list -> 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..a6f914898 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,10 +23,9 @@ 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 typealias Plugin = Pair /** * The boolean signifies if the plugin list should be scrolled to the top, used for searching. @@ -37,28 +36,13 @@ class PluginsViewModel : ViewModel() { /** plugins is an unaltered list of plugins */ private var plugins: List = emptyList() - set(value) { - // Also set all the plugin languages for easier filtering - value.map { pluginViewData -> - val language = pluginViewData.plugin.second.language?.lowercase() - pluginLanguages.add( - when { - language.isNullOrBlank() -> "none" - else -> language.lowercase() - } - ) - // not sorting as most likely this is a language tag instead of name - } - field = value - } - var pluginLanguages = mutableSetOf() // set to avoid duplicates /** filteredPlugins is a subset of plugins following the current search query and tv type selection */ private var _filteredPlugins = MutableLiveData() var filteredPlugins: LiveData = _filteredPlugins val tvTypes = mutableListOf() - var selectedLanguages = listOf() + var languages = listOf() private var currentQuery: String? = null companion object { @@ -128,7 +112,6 @@ class PluginsViewModel : ViewModel() { PluginManager.downloadPlugin( activity, metadata.url, - metadata.fileHash, metadata.internalName, repo, metadata.status != PROVIDER_STATUS_DOWN @@ -180,7 +163,6 @@ class PluginsViewModel : ViewModel() { PluginManager.downloadPlugin( activity, metadata.url, - metadata.fileHash, metadata.internalName, repo, isEnabled @@ -231,12 +213,12 @@ class PluginsViewModel : ViewModel() { } private fun List.filterLang(): List { - if (selectedLanguages.isEmpty()) return this // do not filter + if (languages.isEmpty()) return this return this.filter { if (it.plugin.second.language == null) { - return@filter selectedLanguages.contains("none") + return@filter languages.contains("none") } - selectedLanguages.contains(it.plugin.second.language?.lowercase()) + languages.contains(it.plugin.second.language) } } @@ -245,12 +227,7 @@ class PluginsViewModel : ViewModel() { // Return list to base state if no query this.sortedBy { it.plugin.second.name } } else { - this.sortedBy { - -Levenshtein.partialRatio( - it.plugin.second.name.lowercase(), - query.lowercase() - ) - } + this.sortedBy { -FuzzySearch.partialRatio(it.plugin.second.name.lowercase(), query.lowercase()) } } } @@ -260,13 +237,6 @@ class PluginsViewModel : ViewModel() { ) } - fun clear() { - currentQuery = null - _filteredPlugins.postValue( - false to emptyList() - ) - } - fun updatePluginList(context: Context?, repositoryUrl: String) = viewModelScope.launchSafe { if (context == null) return@launchSafe Log.i(TAG, "updatePluginList = $repositoryUrl") diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/RepoAdapter.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/RepoAdapter.kt index 0f9bf5f58..42550091a 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/RepoAdapter.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/RepoAdapter.kt @@ -2,19 +2,19 @@ package com.lagradost.cloudstream3.ui.settings.extensions import android.view.LayoutInflater import android.view.ViewGroup +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.RecyclerView +import androidx.viewbinding.ViewBinding import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.databinding.RepositoryItemBinding import com.lagradost.cloudstream3.databinding.RepositoryItemTvBinding import com.lagradost.cloudstream3.plugins.RepositoryManager.PREBUILT_REPOSITORIES -import com.lagradost.cloudstream3.ui.BaseDiffCallback -import com.lagradost.cloudstream3.ui.NoStateAdapter -import com.lagradost.cloudstream3.ui.ViewHolderState +import com.lagradost.cloudstream3.utils.txt import com.lagradost.cloudstream3.ui.settings.Globals.TV import com.lagradost.cloudstream3.ui.settings.Globals.isLayout import com.lagradost.cloudstream3.utils.ImageLoader.loadImage import com.lagradost.cloudstream3.utils.UIHelper.clipboardHelper import com.lagradost.cloudstream3.utils.getImageFromDrawable -import com.lagradost.cloudstream3.utils.txt class RepoAdapter( val isSetup: Boolean, @@ -22,11 +22,10 @@ class RepoAdapter( val imageClickCallback: RepoAdapter.(RepositoryData) -> Unit, /** In setup mode the trash icons will be replaced with download icons */ ) : - NoStateAdapter(diffCallback = BaseDiffCallback(itemSame = { a, b -> - a.url == b.url - })) { + RecyclerView.Adapter() { + private val repositories: MutableList = mutableListOf() - override fun onCreateContent(parent: ViewGroup): ViewHolderState { + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { val layout = if (isLayout(TV)) RepositoryItemTvBinding.inflate( LayoutInflater.from(parent.context), parent, @@ -35,97 +34,130 @@ class RepoAdapter( LayoutInflater.from(parent.context), parent, false + ) //R.layout.repository_item_tv else R.layout.repository_item + return RepoViewHolder( + layout ) - return ViewHolderState(layout) } - override fun onClearView(holder: ViewHolderState) { - when (val binding = holder.view) { - is RepositoryItemBinding -> clearImage(binding.entryIcon) - is RepositoryItemTvBinding -> clearImage(binding.entryIcon) + override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { + when (holder) { + is RepoViewHolder -> { + holder.bind(repositories[position]) + } } } - override fun onBindContent(holder: ViewHolderState, item: RepositoryData, position: Int) { - val isPrebuilt = PREBUILT_REPOSITORIES.contains(item) - val drawable = - if (isSetup) R.drawable.netflix_download else R.drawable.ic_baseline_delete_outline_24 - when (val binding = holder.view) { - is RepositoryItemTvBinding -> { - binding.apply { - // Only shows icon if on setup or if it isn't a prebuilt repo. - // No delete buttons on prebuilt repos. - if (!isPrebuilt || isSetup) { - actionButton.setImageResource(drawable) - } + override fun getItemCount(): Int { + return repositories.size + } - actionButton.setOnClickListener { - imageClickCallback(item) - } + fun updateList(newList: Array) { + val diffResult = DiffUtil.calculateDiff( + RepoDiffCallback(this.repositories, newList) + ) - repositoryItemRoot.setOnClickListener { - clickCallback(item) - } - mainText.text = item.name - subText.text = item.url - if (!item.iconUrl.isNullOrEmpty()) { - entryIcon.loadImage(item.iconUrl) { - error( - getImageFromDrawable( - binding.root.context, - R.drawable.ic_github_logo - ) - ) + repositories.clear() + repositories.addAll(newList) + + diffResult.dispatchUpdatesTo(this) + } + + // Clear coil image because setImageResource doesn't override + override fun onViewRecycled(holder: RecyclerView.ViewHolder) { + if (holder is RepoViewHolder) { + when(holder.binding){ + is RepositoryItemBinding -> holder.binding.entryIcon.loadImage(R.drawable.ic_github_logo) + is RepositoryItemTvBinding -> holder.binding.entryIcon.loadImage(R.drawable.ic_github_logo) + } + } + super.onViewRecycled(holder) + } + + inner class RepoViewHolder( + val binding: ViewBinding + ) : + RecyclerView.ViewHolder(binding.root) { + fun bind( + repositoryData: RepositoryData + ) { + val isPrebuilt = PREBUILT_REPOSITORIES.contains(repositoryData) + val drawable = + if (isSetup) R.drawable.netflix_download else R.drawable.ic_baseline_delete_outline_24 + when (binding) { + is RepositoryItemTvBinding -> { + binding.apply { + // Only shows icon if on setup or if it isn't a prebuilt repo. + // No delete buttons on prebuilt repos. + if (!isPrebuilt || isSetup) { + actionButton.setImageResource(drawable) + } + + actionButton.setOnClickListener { + imageClickCallback(repositoryData) + } + + repositoryItemRoot.setOnClickListener { + clickCallback(repositoryData) + } + mainText.text = repositoryData.name + subText.text = repositoryData.url + if(!repositoryData.iconUrl.isNullOrEmpty()){ + entryIcon.loadImage(repositoryData.iconUrl){ + error(getImageFromDrawable(itemView.context,R.drawable.ic_github_logo)) + } } - } else { - entryIcon.loadImage(R.drawable.ic_github_logo) } } - } - is RepositoryItemBinding -> { - binding.apply { - // Only shows icon if on setup or if it isn't a prebuilt repo. - // No delete buttons on prebuilt repos. - if (!isPrebuilt || isSetup) { - actionButton.setImageResource(drawable) - } - - actionButton.setOnClickListener { - imageClickCallback(item) - } - - repositoryItemRoot.setOnClickListener { - clickCallback(item) - } - - repositoryItemRoot.setOnLongClickListener { - val shareableRepoData = - "${item.name}$SHAREABLE_REPO_SEPARATOR\n ${item.url}" - clipboardHelper(txt(R.string.repo_copy_label), shareableRepoData) - true - } - - mainText.text = item.name - subText.text = item.url - if (!item.iconUrl.isNullOrEmpty()) { - entryIcon.loadImage(item.iconUrl) { - error( - getImageFromDrawable( - binding.root.context, - R.drawable.ic_github_logo - ) - ) + is RepositoryItemBinding -> { + binding.apply { + // Only shows icon if on setup or if it isn't a prebuilt repo. + // No delete buttons on prebuilt repos. + if (!isPrebuilt || isSetup) { + actionButton.setImageResource(drawable) + } + + actionButton.setOnClickListener { + imageClickCallback(repositoryData) + } + + repositoryItemRoot.setOnClickListener { + clickCallback(repositoryData) + } + + repositoryItemRoot.setOnLongClickListener { + val shareableRepoData = "${repositoryData.name} : \n ${repositoryData.url}" + clipboardHelper(txt(R.string.repo_copy_label), shareableRepoData) + true + } + + mainText.text = repositoryData.name + subText.text = repositoryData.url + if(!repositoryData.iconUrl.isNullOrEmpty()){ + entryIcon.loadImage(repositoryData.iconUrl){ + error(getImageFromDrawable(itemView.context,R.drawable.ic_github_logo)) + } } - } else { - entryIcon.loadImage(R.drawable.ic_github_logo) } } } } } +} - companion object { - const val SHAREABLE_REPO_SEPARATOR = " : " - } +class RepoDiffCallback( + private val oldList: List, + private val newList: Array +) : + DiffUtil.Callback() { + override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int) = + oldList[oldItemPosition].url == newList[newItemPosition].url + + override fun getOldListSize() = oldList.size + + override fun getNewListSize() = newList.size + + override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int) = + oldList[oldItemPosition] == newList[newItemPosition] } \ No newline at end of file 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..921ac0674 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 @@ -1,35 +1,41 @@ package com.lagradost.cloudstream3.ui.settings.testing +import android.os.Bundle +import android.view.LayoutInflater import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.databinding.FragmentTestingBinding import com.lagradost.cloudstream3.mvvm.safe import com.lagradost.cloudstream3.mvvm.observe import com.lagradost.cloudstream3.mvvm.observeNullable -import com.lagradost.cloudstream3.ui.BaseFragment import com.lagradost.cloudstream3.ui.settings.Globals.TV import com.lagradost.cloudstream3.ui.settings.Globals.isLayout -import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setSystemBarsPadding import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setToolBarScrollFlags import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setUpToolbar -class TestFragment : BaseFragment( - BaseFragment.BindingCreator.Inflate(FragmentTestingBinding::inflate) -) { + +class TestFragment : Fragment() { private val testViewModel: TestViewModel by activityViewModels() + var binding: FragmentTestingBinding? = null - override fun fixLayout(view: View) { - setSystemBarsPadding() + override fun onDestroyView() { + binding = null + super.onDestroyView() } - override fun onBindingCreated(binding: FragmentTestingBinding) { + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { setUpToolbar(R.string.category_provider_test) setToolBarScrollFlags() + super.onViewCreated(view, savedInstanceState) - binding.apply { - providerTestRecyclerView.adapter = TestResultAdapter() + binding?.apply { + providerTestRecyclerView.adapter = TestResultAdapter( + mutableListOf() + ) testViewModel.init() if (testViewModel.isRunningTest) { @@ -40,10 +46,10 @@ 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( + (providerTestRecyclerView.adapter as? TestResultAdapter)?.updateList( newItems ) } @@ -90,4 +96,13 @@ class TestFragment : BaseFragment( } } } + + override fun onCreateView( + inflater: LayoutInflater, container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + val localBinding = FragmentTestingBinding.inflate(inflater, container, false) + binding = localBinding + return localBinding.root//inflater.inflate(R.layout.fragment_testing, container, false) + } } \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/testing/TestResultAdapter.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/testing/TestResultAdapter.kt index c53ff1fcf..bad58a0e7 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/testing/TestResultAdapter.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/testing/TestResultAdapter.kt @@ -7,6 +7,7 @@ import android.widget.ImageView import android.widget.TextView import android.widget.Toast import androidx.core.content.ContextCompat +import androidx.recyclerview.widget.RecyclerView import com.lagradost.cloudstream3.CommonActivity.showToast import com.lagradost.cloudstream3.MainAPI import com.lagradost.cloudstream3.R @@ -14,117 +15,103 @@ import com.lagradost.cloudstream3.databinding.ProviderTestItemBinding import com.lagradost.cloudstream3.mvvm.getAllMessages import com.lagradost.cloudstream3.mvvm.getStackTracePretty import com.lagradost.cloudstream3.plugins.PluginManager -import com.lagradost.cloudstream3.ui.BaseDiffCallback -import com.lagradost.cloudstream3.ui.NoStateAdapter -import com.lagradost.cloudstream3.ui.ViewHolderState +import com.lagradost.cloudstream3.utils.AppContextUtils import com.lagradost.cloudstream3.utils.Coroutines.ioSafe import com.lagradost.cloudstream3.utils.Coroutines.runOnMainThread import com.lagradost.cloudstream3.utils.SubtitleHelper.getFlagFromIso import com.lagradost.cloudstream3.utils.TestingUtils import java.io.File -class TestResultAdapter() : - NoStateAdapter>( - diffCallback = BaseDiffCallback( - itemSame = { a, b -> - a.first.name == b.first.name && a.first.mainUrl == b.first.mainUrl - }, - contentSame = { a, b -> - a == b - }) - ) { - companion object { - private fun String.lastLine(): String? { - return this.lines().lastOrNull { it.isNotBlank() } - } - } - - override fun onClearView(holder: ViewHolderState) { - val binding = holder.view as? ProviderTestItemBinding ?: return - clearImage(binding.actionButton) - } - - override fun onCreateContent(parent: ViewGroup): ViewHolderState { - return ViewHolderState( - ProviderTestItemBinding.inflate( - LayoutInflater.from(parent.context), - parent, - false - ) +class TestResultAdapter(override val items: MutableList>) : + AppContextUtils.DiffAdapter>(items) { + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { + return ProviderTestViewHolder( + ProviderTestItemBinding.inflate(LayoutInflater.from(parent.context), parent, false) + //LayoutInflater.from(parent.context) + // .inflate(R.layout.provider_test_item, parent, false), ) } - override fun onBindContent( - holder: ViewHolderState, - item: Pair, - position: Int - ) { - val binding = holder.view as? ProviderTestItemBinding ?: return - val (api, result) = item - - val itemView = holder.itemView - - val languageText: TextView = binding.langIcon - val providerTitle: TextView = binding.mainText - val statusText: TextView = binding.passedFailedMarker - val failDescription: TextView = binding.failDescription - val logButton: ImageView = binding.actionButton - - languageText.text = getFlagFromIso(api.lang) - providerTitle.text = api.name - - val (resultText, resultColor) = if (result.success) { - if (result.log.any { it.level == TestingUtils.Logger.LogLevel.Warning }) { - R.string.test_warning to R.color.colorTestWarning - } else { - R.string.test_passed to R.color.colorTestPass + override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { + when (holder) { + is ProviderTestViewHolder -> { + val item = items[position] + holder.bind(item.first, item.second) } - } else { - R.string.test_failed to R.color.colorTestFail + } + } + + inner class ProviderTestViewHolder(binding: ProviderTestItemBinding) : + RecyclerView.ViewHolder(binding.root) { + private val languageText: TextView = binding.langIcon + private val providerTitle: TextView = binding.mainText + private val statusText: TextView = binding.passedFailedMarker + private val failDescription: TextView = binding.failDescription + private val logButton: ImageView = binding.actionButton + + private fun String.lastLine(): String? { + return this.lines().lastOrNull { it.isNotBlank() } } - statusText.setText(resultText) - statusText.setTextColor(ContextCompat.getColor(itemView.context, resultColor)) + fun bind(api: MainAPI, result: TestingUtils.TestResultProvider) { + languageText.text = getFlagFromIso(api.lang) + providerTitle.text = api.name - val stackTrace = result.exception?.getStackTracePretty(false)?.ifBlank { null } - val messages = result.exception?.getAllMessages()?.ifBlank { null } - val resultLog = result.log.joinToString("\n") - val fullLog = - resultLog + - (messages?.let { "\n\nError: $it" } ?: "") + - (stackTrace?.let { "\n\n$it" } ?: "") + val (resultText, resultColor) = if (result.success) { + if (result.log.any { it.level == TestingUtils.Logger.LogLevel.Warning }) { + R.string.test_warning to R.color.colorTestWarning + } else { + R.string.test_passed to R.color.colorTestPass + } + } else { + R.string.test_failed to R.color.colorTestFail + } - failDescription.text = messages?.lastLine() ?: resultLog.lastLine() + statusText.setText(resultText) + statusText.setTextColor(ContextCompat.getColor(itemView.context, resultColor)) - logButton.setOnClickListener { - val builder: AlertDialog.Builder = - AlertDialog.Builder(it.context, R.style.AlertDialogCustom) - builder.setMessage(fullLog) - .setTitle(R.string.test_log) - // Ok button just closes the dialog - .setPositiveButton(R.string.ok) { _, _ -> } + val stackTrace = result.exception?.getStackTracePretty(false)?.ifBlank { null } + val messages = result.exception?.getAllMessages()?.ifBlank { null } + val resultLog = result.log.joinToString("\n") + val fullLog = + resultLog + + (messages?.let { "\n\nError: $it" } ?: "") + + (stackTrace?.let { "\n\n$it" } ?: "") - api.sourcePlugin?.let { path -> - val pluginFile = File(path) - // Cannot delete a deleted plugin - if (!pluginFile.exists()) return@let + failDescription.text = messages?.lastLine() ?: resultLog.lastLine() - builder.setNegativeButton(R.string.delete_plugin) { _, _ -> - ioSafe { - val success = PluginManager.deletePlugin(pluginFile) + logButton.setOnClickListener { + val builder: AlertDialog.Builder = + AlertDialog.Builder(it.context, R.style.AlertDialogCustom) + builder.setMessage(fullLog) + .setTitle(R.string.test_log) + // Ok button just closes the dialog + .setPositiveButton(R.string.ok) { _, _ -> } - runOnMainThread { - if (success) { - showToast(R.string.plugin_deleted, Toast.LENGTH_SHORT) - } else { - showToast(R.string.error, Toast.LENGTH_SHORT) + api.sourcePlugin?.let { path -> + val pluginFile = File(path) + // Cannot delete a deleted plugin + if (!pluginFile.exists()) return@let + + builder.setNegativeButton(R.string.delete_plugin) { _, _ -> + ioSafe { + val success = PluginManager.deletePlugin(pluginFile) + + runOnMainThread { + if (success) { + showToast(R.string.plugin_deleted, Toast.LENGTH_SHORT) + } else { + showToast(R.string.error, Toast.LENGTH_SHORT) + } } } } } - } - builder.show() + builder.show() + } } } + + } \ No newline at end of file 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..9e126b7a6 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 @@ -4,17 +4,14 @@ import android.content.Intent import android.net.Uri import androidx.activity.result.contract.ActivityResultContracts import androidx.fragment.app.Fragment -import com.lagradost.cloudstream3.CloudStreamApp +import com.lagradost.cloudstream3.AcraApplication 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 - } - val context = context ?: CloudStreamApp.context ?: return@registerForActivityResult + if (uri == null) return@registerForActivityResult + val context = context ?: AcraApplication.context ?: return@registerForActivityResult // RW perms for the path val flags = Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION 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..0dccd5cc4 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 @@ -1,8 +1,11 @@ package com.lagradost.cloudstream3.ui.setup import android.os.Bundle +import android.view.LayoutInflater import android.view.View +import android.view.ViewGroup import androidx.core.view.isVisible +import androidx.fragment.app.Fragment import androidx.navigation.fragment.findNavController import com.lagradost.cloudstream3.APIHolder.apis import com.lagradost.cloudstream3.MainActivity.Companion.afterRepositoryLoadedEvent @@ -11,15 +14,13 @@ import com.lagradost.cloudstream3.databinding.FragmentSetupExtensionsBinding import com.lagradost.cloudstream3.mvvm.safe import com.lagradost.cloudstream3.plugins.RepositoryManager import com.lagradost.cloudstream3.plugins.RepositoryManager.PREBUILT_REPOSITORIES -import com.lagradost.cloudstream3.ui.BaseFragment import com.lagradost.cloudstream3.ui.settings.extensions.PluginsViewModel import com.lagradost.cloudstream3.ui.settings.extensions.RepoAdapter import com.lagradost.cloudstream3.utils.Coroutines.main -import com.lagradost.cloudstream3.utils.UIHelper.fixSystemBarsPadding +import com.lagradost.cloudstream3.utils.UIHelper.fixPaddingStatusbar -class SetupFragmentExtensions : BaseFragment( - BaseFragment.BindingCreator.Inflate(FragmentSetupExtensionsBinding::inflate) -) { + +class SetupFragmentExtensions : Fragment() { companion object { const val SETUP_EXTENSION_BUNDLE_IS_SETUP = "isSetup" @@ -33,6 +34,24 @@ class SetupFragmentExtensions : BaseFragment( } } + var binding: FragmentSetupExtensionsBinding? = null + + override fun onDestroyView() { + binding = null + super.onDestroyView() + } + + override fun onCreateView( + inflater: LayoutInflater, container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + val localBinding = FragmentSetupExtensionsBinding.inflate(inflater, container, false) + binding = localBinding + return localBinding.root + //return inflater.inflate(R.layout.fragment_setup_extensions, container, false) + } + + override fun onResume() { super.onResume() afterRepositoryLoadedEvent += ::setRepositories @@ -43,21 +62,18 @@ class SetupFragmentExtensions : BaseFragment( afterRepositoryLoadedEvent -= ::setRepositories } - override fun fixLayout(view: View) { - fixSystemBarsPadding(view) - } - private fun setRepositories(success: Boolean = true) { main { val repositories = RepositoryManager.getRepositories() + PREBUILT_REPOSITORIES val hasRepos = repositories.isNotEmpty() binding?.repoRecyclerView?.isVisible = hasRepos binding?.blankRepoScreen?.isVisible = !hasRepos +// view_public_repositories_button?.isVisible = hasRepos if (hasRepos) { binding?.repoRecyclerView?.adapter = RepoAdapter(true, {}, { PluginsViewModel.downloadAll(activity, it.url, null) - }).apply { submitList(repositories.toList()) } + }).apply { updateList(repositories) } } // else { // list_repositories?.setOnClickListener { @@ -68,12 +84,19 @@ class SetupFragmentExtensions : BaseFragment( } } - override fun onBindingCreated(binding: FragmentSetupExtensionsBinding) { + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + fixPaddingStatusbar(binding?.setupRoot) val isSetup = arguments?.getBoolean(SETUP_EXTENSION_BUNDLE_IS_SETUP) ?: false +// view_public_repositories_button?.setOnClickListener { +// openBrowser(PUBLIC_REPOSITORIES_LIST, isTvSettings(), this) +// } + safe { + // val ctx = context ?: return@safe setRepositories() - binding.apply { + binding?.apply { if (!isSetup) { nextBtt.setText(R.string.setup_done) } @@ -84,7 +107,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 { @@ -100,4 +123,6 @@ class SetupFragmentExtensions : BaseFragment( } } } -} + + +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/setup/SetupFragmentLanguage.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/setup/SetupFragmentLanguage.kt index e96a662c3..a908db55a 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/setup/SetupFragmentLanguage.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/setup/SetupFragmentLanguage.kt @@ -1,45 +1,62 @@ package com.lagradost.cloudstream3.ui.setup +import android.os.Bundle +import android.view.LayoutInflater import android.view.View +import android.view.ViewGroup import android.widget.AbsListView import android.widget.ArrayAdapter import androidx.core.content.ContextCompat -import androidx.core.content.edit +import androidx.fragment.app.Fragment import androidx.navigation.fragment.findNavController import androidx.preference.PreferenceManager +import com.lagradost.cloudstream3.AcraApplication.Companion.setKey import com.lagradost.cloudstream3.BuildConfig -import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey import com.lagradost.cloudstream3.CommonActivity +import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.databinding.FragmentSetupLanguageBinding import com.lagradost.cloudstream3.mvvm.safe import com.lagradost.cloudstream3.plugins.PluginManager -import com.lagradost.cloudstream3.R -import com.lagradost.cloudstream3.ui.BaseFragment import com.lagradost.cloudstream3.ui.settings.appLanguages import com.lagradost.cloudstream3.ui.settings.getCurrentLocale -import com.lagradost.cloudstream3.ui.settings.nameNextToFlagEmoji -import com.lagradost.cloudstream3.utils.UIHelper.fixSystemBarsPadding +import com.lagradost.cloudstream3.utils.SubtitleHelper +import com.lagradost.cloudstream3.utils.UIHelper.fixPaddingStatusbar const val HAS_DONE_SETUP_KEY = "HAS_DONE_SETUP" -class SetupFragmentLanguage : BaseFragment( - BaseFragment.BindingCreator.Inflate(FragmentSetupLanguageBinding::inflate) -) { +class SetupFragmentLanguage : Fragment() { + var binding: FragmentSetupLanguageBinding? = null - override fun fixLayout(view: View) { - fixSystemBarsPadding(view) + override fun onDestroyView() { + binding = null + super.onDestroyView() } - override fun onBindingCreated(binding: FragmentSetupLanguageBinding) { + override fun onCreateView( + inflater: LayoutInflater, container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + val localBinding = FragmentSetupLanguageBinding.inflate(inflater, container, false) + binding = localBinding + return localBinding.root + //return inflater.inflate(R.layout.fragment_setup_language, container, false) + } + + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + // We don't want a crash for all users safe { + fixPaddingStatusbar(binding?.setupRoot) + val ctx = context ?: return@safe val settingsManager = PreferenceManager.getDefaultSharedPreferences(ctx) val arrayAdapter = ArrayAdapter(ctx, R.layout.sort_bottom_single_choice) - binding.apply { + binding?.apply { // Icons may crash on some weird android versions? safe { val drawable = when { @@ -51,21 +68,24 @@ class SetupFragmentLanguage : BaseFragment( } val current = getCurrentLocale(ctx) - val languageTagsIETF = appLanguages.map { it.second } - val languageNames = appLanguages.map { it.nameNextToFlagEmoji() } - val currentIndex = languageTagsIETF.indexOf(current) + val languageCodes = appLanguages.map { it.third } + val languageNames = appLanguages.map { (emoji, name, iso) -> + val flag = emoji.ifBlank { SubtitleHelper.getFlagFromIso(iso) ?: "ERROR" } + "$flag $name" + } + val index = languageCodes.indexOf(current) arrayAdapter.addAll(languageNames) listview1.adapter = arrayAdapter listview1.choiceMode = AbsListView.CHOICE_MODE_SINGLE - listview1.setItemChecked(currentIndex, true) + listview1.setItemChecked(index, true) - listview1.setOnItemClickListener { _, _, selectedLangIndex, _ -> - val langTagIETF = languageTagsIETF[selectedLangIndex] - CommonActivity.setLocale(activity, langTagIETF) - settingsManager.edit { - putString(getString(R.string.locale_key), langTagIETF) - } + listview1.setOnItemClickListener { _, _, position, _ -> + val code = languageCodes[position] + CommonActivity.setLocale(activity, code) + settingsManager.edit().putString(getString(R.string.locale_key), code) + .apply() + activity?.recreate() } nextBtt.setOnClickListener { @@ -88,6 +108,7 @@ class SetupFragmentLanguage : BaseFragment( findNavController().navigate(R.id.navigation_home) } } + } } -} +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/setup/SetupFragmentLayout.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/setup/SetupFragmentLayout.kt index 4a8e784a1..85eabefa4 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/setup/SetupFragmentLayout.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/setup/SetupFragmentLayout.kt @@ -1,27 +1,45 @@ package com.lagradost.cloudstream3.ui.setup +import android.os.Bundle +import android.view.LayoutInflater import android.view.View +import android.view.ViewGroup import android.widget.AbsListView import android.widget.ArrayAdapter -import androidx.core.content.edit +import androidx.fragment.app.Fragment import androidx.navigation.fragment.findNavController import androidx.preference.PreferenceManager -import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey +import com.lagradost.cloudstream3.AcraApplication.Companion.setKey import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.databinding.FragmentSetupLayoutBinding import com.lagradost.cloudstream3.mvvm.safe -import com.lagradost.cloudstream3.ui.BaseFragment -import com.lagradost.cloudstream3.utils.UIHelper.fixSystemBarsPadding +import com.lagradost.cloudstream3.utils.UIHelper.fixPaddingStatusbar +import org.acra.ACRA -class SetupFragmentLayout : BaseFragment( - BaseFragment.BindingCreator.Inflate(FragmentSetupLayoutBinding::inflate) -) { - override fun fixLayout(view: View) { - fixSystemBarsPadding(view) +class SetupFragmentLayout : Fragment() { + + var binding: FragmentSetupLayoutBinding? = null + + override fun onDestroyView() { + binding = null + super.onDestroyView() } - override fun onBindingCreated(binding: FragmentSetupLayoutBinding) { + override fun onCreateView( + inflater: LayoutInflater, container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + val localBinding = FragmentSetupLayoutBinding.inflate(inflater, container, false) + binding = localBinding + return localBinding.root + //return inflater.inflate(R.layout.fragment_setup_layout, container, false) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + fixPaddingStatusbar(binding?.setupRoot) + safe { val ctx = context ?: return@safe @@ -37,7 +55,7 @@ class SetupFragmentLayout : BaseFragment( ArrayAdapter(ctx, R.layout.sort_bottom_single_choice) arrayAdapter.addAll(prefNames.toList()) - binding.apply { + binding?.apply { listview1.adapter = arrayAdapter listview1.choiceMode = AbsListView.CHOICE_MODE_SINGLE listview1.setItemChecked( @@ -45,11 +63,28 @@ class SetupFragmentLayout : BaseFragment( ) listview1.setOnItemClickListener { _, _, position, _ -> - settingsManager.edit { - putInt(getString(R.string.app_layout_key), prefValues[position]) - } + settingsManager.edit() + .putInt(getString(R.string.app_layout_key), prefValues[position]) + .apply() activity?.recreate() } + /*acraSwitch.setOnCheckedChangeListener { _, enableCrashReporting -> + // Use same pref as in settings + settingsManager.edit().putBoolean(ACRA.PREF_DISABLE_ACRA, !enableCrashReporting) + .apply() + val text = + if (enableCrashReporting) R.string.bug_report_settings_off else R.string.bug_report_settings_on + crashReportingText.text = getText(text) + } + + val enableCrashReporting = !settingsManager.getBoolean(ACRA.PREF_DISABLE_ACRA, true) + + acraSwitch.isChecked = enableCrashReporting + crashReportingText.text = + getText( + if (enableCrashReporting) R.string.bug_report_settings_off else R.string.bug_report_settings_on + )*/ + nextBtt.setOnClickListener { setKey(HAS_DONE_SETUP_KEY, true) @@ -62,4 +97,4 @@ class SetupFragmentLayout : BaseFragment( } } } -} +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/setup/SetupFragmentMedia.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/setup/SetupFragmentMedia.kt index 8da121daa..9db967dcb 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/setup/SetupFragmentMedia.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/setup/SetupFragmentMedia.kt @@ -1,30 +1,47 @@ package com.lagradost.cloudstream3.ui.setup +import android.os.Bundle +import android.view.LayoutInflater import android.view.View +import android.view.ViewGroup import android.widget.AbsListView import android.widget.ArrayAdapter -import androidx.core.content.edit import androidx.core.util.forEach +import androidx.fragment.app.Fragment import androidx.navigation.fragment.findNavController import androidx.preference.PreferenceManager import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.TvType import com.lagradost.cloudstream3.databinding.FragmentSetupMediaBinding import com.lagradost.cloudstream3.mvvm.safe -import com.lagradost.cloudstream3.ui.BaseFragment import com.lagradost.cloudstream3.utils.DataStoreHelper -import com.lagradost.cloudstream3.utils.UIHelper.fixSystemBarsPadding +import com.lagradost.cloudstream3.utils.UIHelper.fixPaddingStatusbar -class SetupFragmentMedia : BaseFragment( - BaseFragment.BindingCreator.Inflate(FragmentSetupMediaBinding::inflate) -) { - override fun fixLayout(view: View) { - fixSystemBarsPadding(view) +class SetupFragmentMedia : Fragment() { + var binding: FragmentSetupMediaBinding? = null + + override fun onDestroyView() { + binding = null + super.onDestroyView() } - override fun onBindingCreated(binding: FragmentSetupMediaBinding) { + override fun onCreateView( + inflater: LayoutInflater, container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + val localBinding = FragmentSetupMediaBinding.inflate(inflater, container, false) + binding = localBinding + return localBinding.root + //return inflater.inflate(R.layout.fragment_setup_media, container, false) + } + + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) safe { + fixPaddingStatusbar(binding?.setupRoot) + val ctx = context ?: return@safe val settingsManager = PreferenceManager.getDefaultSharedPreferences(ctx) @@ -35,7 +52,7 @@ class SetupFragmentMedia : BaseFragment( val selected = mutableListOf() arrayAdapter.addAll(names) - binding.apply { + binding?.apply { listview1.let { it.adapter = arrayAdapter it.choiceMode = AbsListView.CHOICE_MODE_MULTIPLE @@ -54,9 +71,9 @@ class SetupFragmentMedia : BaseFragment( val itemVal = TvType.valueOf(item) itemVal.ordinal.toString() }.toSet() - settingsManager.edit { - putStringSet(getString(R.string.prefer_media_type_key), prefValues) - } + settingsManager.edit() + .putStringSet(getString(R.string.prefer_media_type_key), prefValues) + .apply() // Regenerate set homepage DataStoreHelper.currentHomePage = null @@ -73,4 +90,4 @@ class SetupFragmentMedia : BaseFragment( } } } -} +} \ No newline at end of file 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..353e735e9 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 @@ -1,31 +1,47 @@ package com.lagradost.cloudstream3.ui.setup +import android.os.Bundle +import android.view.LayoutInflater import android.view.View +import android.view.ViewGroup import android.widget.AbsListView import android.widget.ArrayAdapter -import androidx.core.content.edit import androidx.core.util.forEach +import androidx.fragment.app.Fragment import androidx.navigation.fragment.findNavController import androidx.preference.PreferenceManager -import com.lagradost.cloudstream3.AllLanguagesName import com.lagradost.cloudstream3.APIHolder +import com.lagradost.cloudstream3.AllLanguagesName +import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.databinding.FragmentSetupProviderLanguagesBinding import com.lagradost.cloudstream3.mvvm.safe -import com.lagradost.cloudstream3.R -import com.lagradost.cloudstream3.ui.BaseFragment import com.lagradost.cloudstream3.utils.AppContextUtils.getApiProviderLangSettings -import com.lagradost.cloudstream3.utils.SubtitleHelper.getNameNextToFlagEmoji -import com.lagradost.cloudstream3.utils.UIHelper.fixSystemBarsPadding +import com.lagradost.cloudstream3.utils.SubtitleHelper +import com.lagradost.cloudstream3.utils.UIHelper.fixPaddingStatusbar -class SetupFragmentProviderLanguage : BaseFragment( - BaseFragment.BindingCreator.Inflate(FragmentSetupProviderLanguagesBinding::inflate) -) { +class SetupFragmentProviderLanguage : Fragment() { + var binding: FragmentSetupProviderLanguagesBinding? = null - override fun fixLayout(view: View) { - fixSystemBarsPadding(view) + override fun onDestroyView() { + binding = null + super.onDestroyView() } - override fun onBindingCreated(binding: FragmentSetupProviderLanguagesBinding) { + override fun onCreateView( + inflater: LayoutInflater, container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + val localBinding = FragmentSetupProviderLanguagesBinding.inflate(inflater, container, false) + binding = localBinding + return localBinding.root + //return inflater.inflate(R.layout.fragment_setup_provider_languages, container, false) + } + + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + fixPaddingStatusbar(binding?.setupRoot) + safe { val ctx = context ?: return@safe @@ -34,47 +50,51 @@ class SetupFragmentProviderLanguage : BaseFragment(ctx, R.layout.sort_bottom_single_choice) - val currentLangTags = ctx.getApiProviderLangSettings() + val current = ctx.getApiProviderLangSettings() + val langs = synchronized(APIHolder.apis) { APIHolder.apis.map { it.lang }.toSet() + .sortedBy { SubtitleHelper.fromTwoLettersToLanguage(it) } + AllLanguagesName} - 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() } // name ignoring flag emoji - } + val currentList = + current.map { langs.indexOf(it) }.filter { it != -1 } // TODO LOOK INTO - val currentIndexList = currentLangTags.map { langTag -> - languagesTagName.indexOfFirst { lang -> lang.first == langTag } - }.filter { it > -1 } - - arrayAdapter.addAll(languagesTagName.map { it.second }) - binding.apply { - listview1.adapter = arrayAdapter - listview1.choiceMode = AbsListView.CHOICE_MODE_MULTIPLE - currentIndexList.forEach { - listview1.setItemChecked(it, true) - } - - listview1.setOnItemClickListener { _, _, _, _ -> - val selectedLanguages = mutableSetOf() - listview1.checkedItemPositions?.forEach { key, value -> - if (value) selectedLanguages.add(languagesTagName[key].first) - } - settingsManager.edit { - putStringSet( - ctx.getString(R.string.provider_lang_key), - selectedLanguages.toSet() - ) - } - } - - nextBtt.setOnClickListener { - findNavController().navigate(R.id.navigation_setup_provider_languages_to_navigation_setup_media) - } - - prevBtt.setOnClickListener { - findNavController().popBackStack() + val languageNames = langs.map { + if (it == AllLanguagesName) { + getString(R.string.all_languages_preference) + } else { + val emoji = SubtitleHelper.getFlagFromIso(it) + val name = SubtitleHelper.fromTwoLettersToLanguage(it) + "$emoji $name" } } + + arrayAdapter.addAll(languageNames) + binding?.apply { + listview1.adapter = arrayAdapter + listview1.choiceMode = AbsListView.CHOICE_MODE_MULTIPLE + currentList.forEach { + listview1.setItemChecked(it, true) + } + + listview1.setOnItemClickListener { _, _, _, _ -> + val currentLanguages = mutableListOf() + listview1.checkedItemPositions?.forEach { key, value -> + if (value) currentLanguages.add(langs[key]) + } + settingsManager.edit().putStringSet( + ctx.getString(R.string.provider_lang_key), + currentLanguages.toSet() + ).apply() + } + + nextBtt.setOnClickListener { + findNavController().navigate(R.id.navigation_setup_provider_languages_to_navigation_setup_media) + } + + prevBtt.setOnClickListener { + findNavController().popBackStack() + } } } } -} + + +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/subtitles/ChromecastSubtitlesFragment.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/subtitles/ChromecastSubtitlesFragment.kt index f9b1cb1fe..c76a218e5 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/subtitles/ChromecastSubtitlesFragment.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/subtitles/ChromecastSubtitlesFragment.kt @@ -7,12 +7,13 @@ import android.graphics.Color import android.os.Bundle import android.util.DisplayMetrics import android.util.TypedValue +import android.view.LayoutInflater import android.view.View +import android.view.ViewGroup import android.widget.TextView import android.widget.Toast -import androidx.annotation.OptIn +import androidx.fragment.app.Fragment import androidx.media3.common.text.Cue -import androidx.media3.common.util.UnstableApi import com.fasterxml.jackson.annotation.JsonProperty import com.google.android.gms.cast.TextTrackStyle.EDGE_TYPE_DEPRESSED import com.google.android.gms.cast.TextTrackStyle.EDGE_TYPE_DROP_SHADOW @@ -20,21 +21,19 @@ import com.google.android.gms.cast.TextTrackStyle.EDGE_TYPE_NONE import com.google.android.gms.cast.TextTrackStyle.EDGE_TYPE_OUTLINE import com.google.android.gms.cast.TextTrackStyle.EDGE_TYPE_RAISED import com.jaredrummler.android.colorpicker.ColorPickerDialog -import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey +import com.lagradost.cloudstream3.AcraApplication.Companion.getKey import com.lagradost.cloudstream3.CommonActivity.onColorSelectedEvent import com.lagradost.cloudstream3.CommonActivity.onDialogDismissedEvent import com.lagradost.cloudstream3.CommonActivity.showToast import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.databinding.ChromecastSubtitleSettingsBinding -import com.lagradost.cloudstream3.ui.BaseFragment import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR 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.DataStore.setKey import com.lagradost.cloudstream3.utils.Event import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showDialog -import com.lagradost.cloudstream3.utils.UIHelper.fixSystemBarsPadding +import com.lagradost.cloudstream3.utils.UIHelper.fixPaddingStatusbar import com.lagradost.cloudstream3.utils.UIHelper.hideSystemUI import com.lagradost.cloudstream3.utils.UIHelper.navigate import com.lagradost.cloudstream3.utils.UIHelper.popCurrentPage @@ -51,10 +50,8 @@ data class SaveChromeCaptionStyle( @JsonProperty("fontScale") var fontScale: Float = 1.05f, @JsonProperty("windowColor") var windowColor: Int = Color.TRANSPARENT, ) - -class ChromecastSubtitlesFragment : BaseFragment( - BaseFragment.BindingCreator.Inflate(ChromecastSubtitleSettingsBinding::inflate) -) { +@androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class) +class ChromecastSubtitlesFragment : Fragment() { companion object { val applyStyleEvent = Event() @@ -145,6 +142,23 @@ class ChromecastSubtitlesFragment : BaseFragment + binding?.subsEdgeType?.setFocusableInTv() + binding?.subsEdgeType?.setOnClickListener { textView -> val edgeTypes = listOf( Pair( EDGE_TYPE_NONE, @@ -242,15 +254,15 @@ class ChromecastSubtitlesFragment : BaseFragment + binding?.subsFontSize?.setFocusableInTv() + binding?.subsFontSize?.setOnClickListener { textView -> val fontSizes = listOf( Pair(0.75f, "75%"), Pair(0.80f, "80%"), @@ -283,15 +295,17 @@ class ChromecastSubtitlesFragment : BaseFragment + binding?.subsFontSize?.setOnLongClickListener { _ -> state.fontScale = defaultState.fontScale //textView.context.updateState() // font size not changed showToast(R.string.subs_default_reset_toast, Toast.LENGTH_SHORT) return@setOnLongClickListener true } - binding.subsFont.setFocusableInTv() - binding.subsFont.setOnClickListener { textView -> + + + binding?.subsFont?.setFocusableInTv() + binding?.subsFont?.setOnClickListener { textView -> val fontTypes = listOf( null to textView.context.getString(R.string.normal), "Droid Sans" to "Droid Sans", @@ -315,30 +329,24 @@ class ChromecastSubtitlesFragment : BaseFragment + binding?.subsFont?.setOnLongClickListener { _ -> state.fontFamily = defaultState.fontFamily updateState() showToast(activity, R.string.subs_default_reset_toast, Toast.LENGTH_SHORT) return@setOnLongClickListener true } - binding.cancelBtt.setOnClickListener { + binding?.cancelBtt?.setOnClickListener { activity?.popCurrentPage() } - binding.applyBtt.setOnClickListener { + binding?.applyBtt?.setOnClickListener { it.context.saveStyle(state) applyStyleEvent.invoke(state) //it.context.fromSaveToStyle(state) activity?.popCurrentPage() } - - setSubtitleCues(binding) - } - - @OptIn(UnstableApi::class) - private fun setSubtitleCues(binding: ChromecastSubtitleSettingsBinding) { - binding.subtitleText.apply { + binding?.subtitleText?.apply { setCues( listOf( Cue.Builder() diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/subtitles/SubtitlesFragment.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/subtitles/SubtitlesFragment.kt index 5f716cca3..e5671fa80 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/subtitles/SubtitlesFragment.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/subtitles/SubtitlesFragment.kt @@ -12,14 +12,16 @@ import android.text.SpannableString import android.text.style.StyleSpan import android.util.DisplayMetrics import android.util.TypedValue +import android.view.LayoutInflater import android.view.View +import android.view.ViewGroup import android.widget.TextView import android.widget.Toast import androidx.annotation.FontRes import androidx.annotation.OptIn import androidx.annotation.Px -import androidx.core.content.edit import androidx.core.content.res.ResourcesCompat +import androidx.fragment.app.DialogFragment import androidx.media3.common.text.Cue import androidx.media3.common.util.UnstableApi import androidx.media3.ui.CaptionStyleCompat @@ -27,29 +29,23 @@ import androidx.media3.ui.SubtitleView import androidx.preference.PreferenceManager import com.fasterxml.jackson.annotation.JsonProperty import com.jaredrummler.android.colorpicker.ColorPickerDialog -import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey -import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey +import com.lagradost.cloudstream3.AcraApplication.Companion.getKey +import com.lagradost.cloudstream3.AcraApplication.Companion.setKey import com.lagradost.cloudstream3.CommonActivity.onColorSelectedEvent import com.lagradost.cloudstream3.CommonActivity.onDialogDismissedEvent import com.lagradost.cloudstream3.CommonActivity.showToast import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.databinding.SubtitleSettingsBinding -import com.lagradost.cloudstream3.ui.BaseDialogFragment -import com.lagradost.cloudstream3.ui.BaseFragment -import com.lagradost.cloudstream3.ui.player.CustomDecoder -import com.lagradost.cloudstream3.ui.player.CustomDecoder.Companion.setSubtitleAlignment import com.lagradost.cloudstream3.ui.player.OutlineSpan import com.lagradost.cloudstream3.ui.player.RoundedBackgroundColorSpan -import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR 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.DataStore.setKey import com.lagradost.cloudstream3.utils.Event import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showDialog import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showMultiDialog -import com.lagradost.cloudstream3.utils.SubtitleHelper.languages -import com.lagradost.cloudstream3.utils.UIHelper.fixSystemBarsPadding +import com.lagradost.cloudstream3.utils.SubtitleHelper +import com.lagradost.cloudstream3.utils.UIHelper.fixPaddingStatusbar import com.lagradost.cloudstream3.utils.UIHelper.hideSystemUI import com.lagradost.cloudstream3.utils.UIHelper.navigate import com.lagradost.cloudstream3.utils.UIHelper.popCurrentPage @@ -60,11 +56,10 @@ const val SUBTITLE_KEY = "subtitle_settings" const val SUBTITLE_AUTO_SELECT_KEY = "subs_auto_select" const val SUBTITLE_DOWNLOAD_KEY = "subs_auto_download" -data class SaveCaptionStyle( +data class SaveCaptionStyle @OptIn(UnstableApi::class) constructor( @JsonProperty("foregroundColor") var foregroundColor: Int, @JsonProperty("backgroundColor") var backgroundColor: Int, @JsonProperty("windowColor") var windowColor: Int, - @OptIn(UnstableApi::class) @JsonProperty("edgeType") var edgeType: @CaptionStyleCompat.EdgeType Int, @JsonProperty("edgeColor") var edgeColor: Int, @FontRes @@ -86,35 +81,24 @@ data class SaveCaptionStyle( @JsonProperty("italic") var italic: Boolean = false, /** in px, background radius, aka how round the background (backgroundColor) on each row is **/ @JsonProperty("backgroundRadius") var backgroundRadius: Float? = null, - /** The SSA_ALIGNMENT */ - @JsonProperty("alignment") var alignment: Int? = null, ) const val DEF_SUBS_ELEVATION = 20 -@OptIn(UnstableApi::class) -class SubtitlesFragment : BaseDialogFragment( - BaseFragment.BindingCreator.Inflate(SubtitleSettingsBinding::inflate) -) { +@OptIn(androidx.media3.common.util.UnstableApi::class) +class SubtitlesFragment : DialogFragment() { companion object { val applyStyleEvent = Event() private val captionRegex = Regex("""(-\s?|)[\[({][\S\s]*?[])}]\s*""") - fun setSubtitleViewStyle( - view: SubtitleView?, - data: SaveCaptionStyle, - applyElevation: Boolean - ) { + fun setSubtitleViewStyle(view: SubtitleView?, data: SaveCaptionStyle) { if (view == null) return val ctx = view.context ?: return val style = ctx.fromSaveToStyle(data) view.setStyle(style) - - if (applyElevation) { - view.setPadding( - view.paddingLeft, data.elevation.toPx, view.paddingRight, view.paddingBottom - ) - } + view.setPadding( + view.paddingLeft, data.elevation.toPx, view.paddingRight, view.paddingBottom + ) // we default to 25sp, this is needed as RoundedBackgroundColorSpan breaks on override sizes val size = data.fixedTextSize ?: 25.0f @@ -208,8 +192,7 @@ class SubtitlesFragment : BaseDialogFragment( } } - // 6. set alignment - return this.setSubtitleAlignment(style.alignment) + return this } private fun Context.fromSaveToStyle(data: SaveCaptionStyle): CaptionStyleCompat { @@ -293,11 +276,11 @@ class SubtitlesFragment : BaseDialogFragment( return TypedValue.applyDimension(unit, size, metrics).toInt() } - fun getDownloadSubsLanguageTagIETF(): List { + fun getDownloadSubsLanguageISO639_1(): List { return getKey(SUBTITLE_DOWNLOAD_KEY) ?: listOf("en") } - fun getAutoSelectLanguageTagIETF(): String { + fun getAutoSelectLanguageISO639_1(): String { return getKey(SUBTITLE_AUTO_SELECT_KEY) ?: "en" } } @@ -329,7 +312,7 @@ class SubtitlesFragment : BaseDialogFragment( private fun Context.updateState() { val text = getString(R.string.subtitles_example_text) val fixedText = SpannableString.valueOf(if (state.upperCase) text.uppercase() else text) - setSubtitleViewStyle(binding?.subtitleText, state, false) + setSubtitleViewStyle(binding?.subtitleText, state) binding?.subtitleText?.setCues( listOf( @@ -354,6 +337,23 @@ class SubtitlesFragment : BaseDialogFragment( return if (color == Color.TRANSPARENT) Color.BLACK else color } + override fun onDestroyView() { + binding = null + super.onDestroyView() + } + + var binding: SubtitleSettingsBinding? = null + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + val localBinding = SubtitleSettingsBinding.inflate(inflater, container, false) + binding = localBinding + return localBinding.root + //return inflater.inflate(R.layout.subtitle_settings, container, false) + } + private lateinit var state: SaveCaptionStyle private var hide: Boolean = true @@ -364,35 +364,30 @@ class SubtitlesFragment : BaseDialogFragment( override fun onStart() { super.onStart() - dialog?.window?.setWindowAnimations(R.style.DialogFullscreenPlayer) + dialog?.window?.setWindowAnimations(R.style.DialogFullscreen) } override fun getTheme(): Int { - return R.style.DialogFullscreenPlayer + return R.style.DialogFullscreen } - var systemBarsAddPadding = isLayout(TV or EMULATOR) - override fun fixLayout(view: View) { - fixSystemBarsPadding( - view, - padBottom = systemBarsAddPadding || isLandscape(), - padLeft = systemBarsAddPadding - ) - } - - override fun onBindingCreated(binding: SubtitleSettingsBinding) { + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) hide = arguments?.getBoolean("hide") ?: true val popFragment = arguments?.getBoolean("popFragment") ?: false onColorSelectedEvent += ::onColorSelected onDialogDismissedEvent += ::onDialogDismissed - binding.subsImportText.text = getString(R.string.subs_import_text).format( + binding?.subsImportText?.text = getString(R.string.subs_import_text).format( context?.getExternalFilesDir(null)?.absolutePath.toString() + "/Fonts" ) + fixPaddingStatusbar(binding?.subsRoot) + state = getCurrentSavedStyle() context?.updateState() val isTvTrueSettings = isLayout(TV) + fun View.setFocusableInTv() { this.isFocusableInTouchMode = isTvTrueSettings } @@ -416,7 +411,7 @@ class SubtitlesFragment : BaseDialogFragment( return@setOnLongClickListener true } } - binding.apply { + binding?.apply { subsTextColor.setup(0) subsOutlineColor.setup(1) subsBackgroundColor.setup(2) @@ -432,7 +427,7 @@ class SubtitlesFragment : BaseDialogFragment( // tbh this should not be a dialog if it has so many values val elevationTypes = listOf( 0 to textView.context.getString(R.string.none) - ) + (1..40).map { x -> + ) + (1..30).map { x -> val i = x * 10 i to "${i}dp" } @@ -488,33 +483,6 @@ class SubtitlesFragment : BaseDialogFragment( return@setOnLongClickListener true } - subsSubtitleAlignment.setFocusableInTv() - subsSubtitleAlignment.setOnClickListener { textView -> - val alignmentTypes = listOf( - null to R.string.automatic, - CustomDecoder.SSA_ALIGNMENT_BOTTOM_LEFT to R.string.bottom_left, - CustomDecoder.SSA_ALIGNMENT_BOTTOM_CENTER to R.string.bottom_center, - CustomDecoder.SSA_ALIGNMENT_BOTTOM_RIGHT to R.string.bottom_right, - CustomDecoder.SSA_ALIGNMENT_MIDDLE_LEFT to R.string.middle_left, - CustomDecoder.SSA_ALIGNMENT_MIDDLE_CENTER to R.string.middle_center, - CustomDecoder.SSA_ALIGNMENT_MIDDLE_RIGHT to R.string.middle_right, - CustomDecoder.SSA_ALIGNMENT_TOP_LEFT to R.string.top_left, - CustomDecoder.SSA_ALIGNMENT_TOP_CENTER to R.string.top_center, - CustomDecoder.SSA_ALIGNMENT_TOP_RIGHT to R.string.top_right, - ) - - activity?.showDialog( - alignmentTypes.map { textView.context.getString(it.second) }, - alignmentTypes.map { it.first }.indexOf(state.alignment), - (textView as TextView).text.toString(), - false, - dismissCallback - ) { index -> - state.alignment = alignmentTypes.map { it.first }[index] - textView.context.updateState() - } - } - subsEdgeType.setFocusableInTv() subsEdgeType.setOnClickListener { textView -> val edgeTypes = listOf( @@ -638,9 +606,10 @@ class SubtitlesFragment : BaseDialogFragment( subtitlesFilterSubLang.setOnCheckedChangeListener { _, b -> context?.let { ctx -> - PreferenceManager.getDefaultSharedPreferences(ctx).edit { - putBoolean(getString(R.string.filter_sub_lang_key), b) - } + PreferenceManager.getDefaultSharedPreferences(ctx) + .edit() + .putBoolean(getString(R.string.filter_sub_lang_key), b) + .apply() } } @@ -702,29 +671,28 @@ class SubtitlesFragment : BaseDialogFragment( subsAutoSelectLanguage.setFocusableInTv() subsAutoSelectLanguage.setOnClickListener { textView -> - val languagesTagName = - listOf( - Pair( - textView.context.getString(R.string.none), - textView.context.getString(R.string.none) - ) - ) + - languages - .map { Pair(it.IETF_tag, it.nameNextToFlagEmoji()) } - .sortedBy { - it.second.substringAfter("\u00a0").lowercase() - } // name ignoring flag emoji - - val (langTagsIETF, langNames) = languagesTagName.unzip() + val langMap = arrayListOf( + SubtitleHelper.Language639( + textView.context.getString(R.string.none), + textView.context.getString(R.string.none), + "", + "", + "", + "", + "" + ), + ) + langMap.addAll(SubtitleHelper.languages) + val lang639_1 = langMap.map { it.ISO_639_1 } activity?.showDialog( - langNames, - langTagsIETF.indexOf(getAutoSelectLanguageTagIETF()), + langMap.map { it.languageName }, + lang639_1.indexOf(getAutoSelectLanguageISO639_1()), (textView as TextView).text.toString(), true, dismissCallback ) { index -> - setKey(SUBTITLE_AUTO_SELECT_KEY, langTagsIETF[index]) + setKey(SUBTITLE_AUTO_SELECT_KEY, lang639_1[index]) } } @@ -736,26 +704,18 @@ class SubtitlesFragment : BaseDialogFragment( subsDownloadLanguages.setFocusableInTv() subsDownloadLanguages.setOnClickListener { textView -> - val languagesTagName = - languages - .map { Pair(it.IETF_tag, it.nameNextToFlagEmoji()) } - .sortedBy { - it.second.substringAfter("\u00a0").lowercase() - } // name ignoring flag emoji - - val (langTagsIETF, langNames) = languagesTagName.unzip() - - val selectedLanguages = getDownloadSubsLanguageTagIETF() - .map { langTagsIETF.indexOf(it) } - .filter { it >= 0 } + val langMap = SubtitleHelper.languages + val lang639_1 = langMap.map { it.ISO_639_1 } + val keys = getDownloadSubsLanguageISO639_1() + val keyMap = keys.map { lang639_1.indexOf(it) }.filter { it >= 0 } activity?.showMultiDialog( - langNames, - selectedLanguages, + langMap.map { it.languageName }, + keyMap, (textView as TextView).text.toString(), dismissCallback ) { indexList -> - setKey(SUBTITLE_DOWNLOAD_KEY, indexList.map { langTagsIETF[it] }.toList()) + setKey(SUBTITLE_DOWNLOAD_KEY, indexList.map { lang639_1[it] }.toList()) } } 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..a451972f7 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/AppContextUtils.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/AppContextUtils.kt @@ -6,6 +6,7 @@ import android.app.Activity import android.app.Activity.RESULT_CANCELED import android.app.NotificationChannel import android.app.NotificationManager +import android.content.ContentValues import android.content.Context import android.content.DialogInterface import android.content.Intent @@ -18,9 +19,13 @@ import android.media.tv.TvContract.Channels.COLUMN_INTERNAL_PROVIDER_ID import android.net.ConnectivityManager import android.net.Network import android.net.NetworkCapabilities +import android.net.Uri import android.os.Build +import android.os.Environment import android.os.Handler import android.os.Looper +import android.os.ParcelFileDescriptor +import android.provider.MediaStore import android.text.Spanned import android.util.Log import android.view.View @@ -32,7 +37,6 @@ import androidx.activity.result.contract.ActivityResultContracts import androidx.annotation.RequiresApi import androidx.annotation.WorkerThread import androidx.appcompat.app.AlertDialog -import androidx.core.net.toUri import androidx.core.text.HtmlCompat import androidx.core.text.toSpanned import androidx.core.widget.ContentLoadingProgressBar @@ -40,6 +44,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 @@ -54,8 +59,8 @@ import com.google.android.gms.common.GoogleApiAvailability import com.google.android.gms.common.wrappers.Wrappers import com.google.android.material.bottomsheet.BottomSheetDialog import com.lagradost.cloudstream3.APIHolder.apis +import com.lagradost.cloudstream3.AcraApplication.Companion.getActivity import com.lagradost.cloudstream3.AllLanguagesName -import com.lagradost.cloudstream3.CloudStreamApp.Companion.getActivity import com.lagradost.cloudstream3.CommonActivity.activity import com.lagradost.cloudstream3.CommonActivity.showToast import com.lagradost.cloudstream3.DubStatus @@ -85,18 +90,25 @@ 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 import java.io.File +import java.io.FileNotFoundException +import java.io.FileOutputStream +import java.io.InputStream +import java.io.OutputStream import java.net.URL import java.net.URLDecoder import java.util.concurrent.Executor import java.util.concurrent.Executors - object AppContextUtils { + fun RecyclerView.setMaxViewPoolSize(maxViewTypeId: Int, maxPoolSize: Int) { + for (i in 0..maxViewTypeId) + recycledViewPool.setMaxRecycledViews(i, maxPoolSize) + } + fun RecyclerView.isRecyclerScrollable(): Boolean { val layoutManager = this.layoutManager as? LinearLayoutManager? @@ -147,12 +159,12 @@ object AppContextUtils { text.toSpanned() } } - /** Get channel ID by name */ + @SuppressLint("RestrictedApi") 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) { @@ -170,10 +182,10 @@ object AppContextUtils { ) .setWatchNextType(TvContractCompat.WatchNextPrograms.WATCH_NEXT_TYPE_CONTINUE) .setTitle(title) - .setPosterArtUri(card.posterUrl?.toUri()) - .setIntentUri((card.id?.let { + .setPosterArtUri(Uri.parse(card.posterUrl)) + .setIntentUri(Uri.parse(card.id?.let { "$APP_STRING_RESUME_WATCHING://$it" - } ?: card.url).toUri()) + } ?: card.url)) .setInternalProviderId(card.url) .setLastEngagementTimeUtcMillis( resumeWatching?.updateTime ?: System.currentTimeMillis() @@ -319,7 +331,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 +381,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 +461,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 +485,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 { @@ -535,6 +559,45 @@ object AppContextUtils { } } + abstract class DiffAdapter( + open val items: MutableList, + val comparison: (first: T, second: T) -> Boolean = { first, second -> + first.hashCode() == second.hashCode() + } + ) : + RecyclerView.Adapter() { + override fun getItemCount(): Int { + return items.size + } + + fun updateList(newList: List) { + val diffResult = DiffUtil.calculateDiff( + GenericDiffCallback(this.items, newList) + ) + + items.clear() + items.addAll(newList) + + diffResult.dispatchUpdatesTo(this) + } + + inner class GenericDiffCallback( + private val oldList: List, + private val newList: List + ) : + DiffUtil.Callback() { + override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int) = + comparison(oldList[oldItemPosition], newList[newItemPosition]) + + override fun getOldListSize() = oldList.size + + override fun getNewListSize() = newList.size + + override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int) = + oldList[oldItemPosition] == newList[newItemPosition] + } + } + fun Activity.addRepositoryDialog( repositoryName: String, repositoryURL: String, @@ -591,7 +654,7 @@ object AppContextUtils { ) = (this.getActivity() ?: activity)?.runOnUiThread { try { val intent = Intent(Intent.ACTION_VIEW) - intent.data = url.toUri() + intent.data = Uri.parse(url) intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK) // activityResultRegistry is used to fall back to webview if a browser is missing @@ -675,18 +738,6 @@ object AppContextUtils { return "" } - fun Context.getShortSeasonText(episode: Int?, season: Int?): String? { - val rEpisode = if (episode == 0) null else episode - val rSeason = if (season == 0) null else season - val seasonNameShort = getString(R.string.season_short) - val episodeNameShort = getString(R.string.episode_short) - return if (rEpisode != null && rSeason != null) { - "$seasonNameShort${rSeason}:$episodeNameShort${rEpisode}" - } else if (rEpisode != null) { - "$episodeNameShort$rEpisode" - }else null - } - fun Activity?.loadCache() { try { cacheClass("android.net.NetworkCapabilities".load()) 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..c816dcb04 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/BackPressedCallbackHelper.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/BackPressedCallbackHelper.kt @@ -2,46 +2,23 @@ package com.lagradost.cloudstream3.utils import androidx.activity.ComponentActivity import androidx.activity.OnBackPressedCallback -import java.lang.ref.WeakReference import java.util.WeakHashMap object BackPressedCallbackHelper { + private val backPressedCallbacks = WeakHashMap>() - private val backPressedCallbacks = - WeakHashMap>() - - class CallbackHelper( - private val activityRef: WeakReference, - private val callback: OnBackPressedCallback - ) { - fun runDefault() { - val activity = activityRef.get() ?: return - val wasEnabled = callback.isEnabled - callback.isEnabled = false - try { - activity.onBackPressedDispatcher.onBackPressed() - } finally { - callback.isEnabled = wasEnabled - } - } - } - - fun ComponentActivity.attachBackPressedCallback( - id: String, - callback: CallbackHelper.() -> Unit - ) { + fun ComponentActivity.attachBackPressedCallback(id: String, callback: () -> Unit) { val callbackMap = backPressedCallbacks.getOrPut(this) { mutableMapOf() } + if (callbackMap.containsKey(id)) return - // We use WeakReference to protect against potential leaks. - val activityRef = WeakReference(this) val newCallback = object : OnBackPressedCallback(true) { override fun handleOnBackPressed() { - CallbackHelper(activityRef, this).callback() + callback.invoke() } } - callbackMap[id] = newCallback + onBackPressedDispatcher.addCallback(this, newCallback) } @@ -55,8 +32,9 @@ object BackPressedCallbackHelper { fun ComponentActivity.detachBackPressedCallback(id: String) { val callbackMap = backPressedCallbacks[this] ?: return + callbackMap[id]?.let { callback -> - callback.remove() + callback.isEnabled = false callbackMap.remove(id) } @@ -64,4 +42,4 @@ object BackPressedCallbackHelper { backPressedCallbacks.remove(this) } } -} +} \ No newline at end of file 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..3e003c7ea 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/BackupUtils.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/BackupUtils.kt @@ -1,5 +1,6 @@ package com.lagradost.cloudstream3.utils +import android.annotation.SuppressLint import android.content.Context import android.net.Uri import android.widget.Toast @@ -10,7 +11,8 @@ import androidx.core.net.toUri import androidx.fragment.app.FragmentActivity import androidx.preference.PreferenceManager import com.fasterxml.jackson.annotation.JsonProperty -import com.lagradost.cloudstream3.CloudStreamApp.Companion.getActivity +import com.fasterxml.jackson.module.kotlin.readValue +import com.lagradost.cloudstream3.AcraApplication.Companion.getActivity import com.lagradost.cloudstream3.CommonActivity.showToast import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.mvvm.logError @@ -19,21 +21,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 @@ -43,7 +40,6 @@ import java.io.PrintWriter import java.lang.System.currentTimeMillis import java.text.SimpleDateFormat import java.util.Date -import java.util.Locale object BackupUtils { @@ -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() } @@ -186,13 +157,9 @@ object BackupUtils { context.restoreMap(backupFile.datastore.long) context.restoreMap(backupFile.datastore.stringSet) } - - // Make sure the library is fresh - for(api in AccountManager.syncApis) { - api.requireLibraryRefresh = true - } } + @SuppressLint("SimpleDateFormat") fun backup(context: Context?) = ioSafe { if (context == null) return@ioSafe @@ -205,14 +172,14 @@ object BackupUtils { return@ioSafe } - val date = SimpleDateFormat("yyyy_MM_dd_HH_mm", Locale.getDefault()).format(Date(currentTimeMillis())) + val date = SimpleDateFormat("yyyy_MM_dd_HH_mm").format(Date(currentTimeMillis())) val displayName = "CS3_Backup_${date}" val backupFile = getBackup(context) val stream = setupBackupStream(context, displayName) fileStream = stream.openNew() printStream = PrintWriter(fileStream) - printStream.print(backupFile.toJson()) + printStream.print(mapper.writeValueAsString(backupFile)) showToast( R.string.backup_success, @@ -235,7 +202,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 +224,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 +284,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/BiometricAuthenticator.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/BiometricAuthenticator.kt index bce8f09dc..1d9cf5f46 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/BiometricAuthenticator.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/BiometricAuthenticator.kt @@ -1,6 +1,5 @@ package com.lagradost.cloudstream3.utils -import android.annotation.SuppressLint import android.app.Activity import android.app.KeyguardManager import android.content.Context @@ -101,51 +100,31 @@ object BiometricAuthenticator { } private fun isBiometricHardWareAvailable(): Boolean { - // Authentication occurs only when this is true and device is truly capable. + // authentication occurs only when this is true and device is truly capable var result = false - when { - Build.VERSION.SDK_INT >= Build.VERSION_CODES.BAKLAVA -> { - @SuppressLint("RestrictedApi") - when (biometricManager?.canAuthenticate( - DEVICE_CREDENTIAL or BIOMETRIC_STRONG or BIOMETRIC_WEAK - )) { - BiometricManager.BIOMETRIC_SUCCESS -> result = true - BiometricManager.BIOMETRIC_ERROR_NO_HARDWARE -> result = false - BiometricManager.BIOMETRIC_ERROR_HW_UNAVAILABLE -> result = false - BiometricManager.BIOMETRIC_ERROR_NONE_ENROLLED -> result = false - BiometricManager.BIOMETRIC_ERROR_NOT_ENABLED_FOR_APPS -> result = false - BiometricManager.BIOMETRIC_ERROR_SECURITY_UPDATE_REQUIRED -> result = true - BiometricManager.BIOMETRIC_ERROR_UNSUPPORTED -> result = true - BiometricManager.BIOMETRIC_STATUS_UNKNOWN -> result = false - } - } - Build.VERSION.SDK_INT >= Build.VERSION_CODES.R -> { - @Suppress("SwitchIntDef") - when (biometricManager?.canAuthenticate( - DEVICE_CREDENTIAL or BIOMETRIC_STRONG or BIOMETRIC_WEAK - )) { - BiometricManager.BIOMETRIC_SUCCESS -> result = true - BiometricManager.BIOMETRIC_ERROR_NO_HARDWARE -> result = false - BiometricManager.BIOMETRIC_ERROR_HW_UNAVAILABLE -> result = false - BiometricManager.BIOMETRIC_ERROR_NONE_ENROLLED -> result = false - BiometricManager.BIOMETRIC_ERROR_SECURITY_UPDATE_REQUIRED -> result = true - BiometricManager.BIOMETRIC_ERROR_UNSUPPORTED -> result = true - BiometricManager.BIOMETRIC_STATUS_UNKNOWN -> result = false - } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + when (biometricManager?.canAuthenticate( + DEVICE_CREDENTIAL or BIOMETRIC_STRONG or BIOMETRIC_WEAK + )) { + BiometricManager.BIOMETRIC_SUCCESS -> result = true + BiometricManager.BIOMETRIC_ERROR_NO_HARDWARE -> result = false + BiometricManager.BIOMETRIC_ERROR_HW_UNAVAILABLE -> result = false + BiometricManager.BIOMETRIC_ERROR_NONE_ENROLLED -> result = false + BiometricManager.BIOMETRIC_ERROR_SECURITY_UPDATE_REQUIRED -> result = true + BiometricManager.BIOMETRIC_ERROR_UNSUPPORTED -> result = true + BiometricManager.BIOMETRIC_STATUS_UNKNOWN -> result = false } - - else -> { - @Suppress("DEPRECATION", "SwitchIntDef") - when (biometricManager?.canAuthenticate()) { - BiometricManager.BIOMETRIC_SUCCESS -> result = true - BiometricManager.BIOMETRIC_ERROR_NO_HARDWARE -> result = false - BiometricManager.BIOMETRIC_ERROR_HW_UNAVAILABLE -> result = false - BiometricManager.BIOMETRIC_ERROR_NONE_ENROLLED -> result = false - BiometricManager.BIOMETRIC_ERROR_SECURITY_UPDATE_REQUIRED -> result = true - BiometricManager.BIOMETRIC_ERROR_UNSUPPORTED -> result = true - BiometricManager.BIOMETRIC_STATUS_UNKNOWN -> result = false - } + } else { + @Suppress("DEPRECATION") + when (biometricManager?.canAuthenticate()) { + BiometricManager.BIOMETRIC_SUCCESS -> result = true + BiometricManager.BIOMETRIC_ERROR_NO_HARDWARE -> result = false + BiometricManager.BIOMETRIC_ERROR_HW_UNAVAILABLE -> result = false + BiometricManager.BIOMETRIC_ERROR_NONE_ENROLLED -> result = false + BiometricManager.BIOMETRIC_ERROR_SECURITY_UPDATE_REQUIRED -> result = true + BiometricManager.BIOMETRIC_ERROR_UNSUPPORTED -> result = true + BiometricManager.BIOMETRIC_STATUS_UNKNOWN -> result = false } } diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/CastHelper.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/CastHelper.kt index b48c8d40a..d83731658 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/CastHelper.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/CastHelper.kt @@ -1,6 +1,6 @@ package com.lagradost.cloudstream3.utils -import androidx.core.net.toUri +import android.net.Uri import androidx.media3.common.MimeTypes import com.google.android.gms.cast.* import com.google.android.gms.cast.framework.CastSession @@ -41,7 +41,7 @@ object CastHelper { val srcPoster = epData.poster ?: holder.poster if (srcPoster != null) { - movieMetadata.addImage(WebImage(srcPoster.toUri())) + movieMetadata.addImage(WebImage(Uri.parse(srcPoster))) } var subIndex = 0 diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/ConsistentLiveData.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/ConsistentLiveData.kt deleted file mode 100644 index def41d7a0..000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/ConsistentLiveData.kt +++ /dev/null @@ -1,47 +0,0 @@ -package com.lagradost.cloudstream3.utils - -import androidx.annotation.MainThread -import androidx.lifecycle.LiveData -import com.lagradost.cloudstream3.mvvm.Resource - -/** - * This is an atomic LiveData where you can do .value instantly after doing .postValue. - * - * The default behavior is a footgun that will cause race conditions, - * as we do not really care if it is posted as we only want the latest data (even in the binding). - * - * Fuck all that is LiveData, because we want this value to be accessible everywhere instantly. - * */ -open class ConsistentLiveData(initValue : T? = null) : LiveData(initValue) { - @Volatile private var internalValue : T? = initValue - - override fun getValue(): T? { - return internalValue - } - - /** If someone want the old behavior then good for them */ - val postedValue : T? get() = super.getValue() - - public override fun postValue(value : T?) { - super.postValue(value) - internalValue = value - } - - @MainThread - public override fun setValue(value: T?) { - super.setValue(value) - internalValue = value - } -} - -/** Atomic resource livedata, to make it easier to work with resources without local copies */ -class ResourceLiveData(initValue : Resource? = null) : ConsistentLiveData>(initValue) { - var success - get() = when(val output = this.value) { - is Resource.Success -> { - output.value - } - else -> null - } - set(value) = this.postValue(value?.let { Resource.Success(it) } ) -} 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..b5192aae2 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/DataStore.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/DataStore.kt @@ -2,27 +2,25 @@ package com.lagradost.cloudstream3.utils import android.content.Context import android.content.SharedPreferences -import androidx.core.content.edit import androidx.preference.PreferenceManager -import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKeyClass -import com.lagradost.cloudstream3.CloudStreamApp.Companion.removeKey -import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKeyClass +import com.fasterxml.jackson.databind.DeserializationFeature +import com.fasterxml.jackson.databind.json.JsonMapper +import com.fasterxml.jackson.module.kotlin.kotlinModule +import com.lagradost.cloudstream3.AcraApplication.Companion.getKeyClass +import com.lagradost.cloudstream3.AcraApplication.Companion.removeKey +import com.lagradost.cloudstream3.AcraApplication.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 -/** 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" + const val PREFERENCES_NAME = "rebuild_preference" // TODO degelgate by value for get & set @@ -31,7 +29,6 @@ class PreferenceDelegate( val key: String, val default: T //, private val klass: KClass ) { private val klass: KClass = default::class - // simple cache to make it not get the key every time it is accessed, however this requires // that ONLY this changes the key private var cache: T? = null @@ -55,10 +52,10 @@ class PreferenceDelegate( /** When inserting many keys use this function, this is because apply for every key is very expensive on memory */ data class Editor( - val editor: SharedPreferences.Editor + val editor : SharedPreferences.Editor ) { /** Always remember to call apply after */ - fun setKeyRaw(path: String, value: T) { + fun setKeyRaw(path: String, value: T) { @Suppress("UNCHECKED_CAST") if (isStringSet(value)) { editor.putStringSet(path, value as Set) @@ -73,7 +70,7 @@ data class Editor( } } - private fun isStringSet(value: Any?): Boolean { + private fun isStringSet(value: Any?) : Boolean { if (value is Set<*>) { return value.filterIsInstance().size == value.size } @@ -87,18 +84,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) @@ -112,10 +99,9 @@ object DataStore { return "${folder}/${path}" } - fun editor(context: Context, isEditingAppSettings: Boolean = false): Editor { + fun editor(context : Context, isEditingAppSettings: Boolean = false) : Editor { val editor: SharedPreferences.Editor = - if (isEditingAppSettings) context.getDefaultSharedPrefs() - .edit() else context.getSharedPrefs().edit() + if (isEditingAppSettings) context.getDefaultSharedPrefs().edit() else context.getSharedPrefs().edit() return Editor(editor) } @@ -124,9 +110,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) { @@ -146,9 +130,9 @@ object DataStore { try { val prefs = getSharedPrefs() if (prefs.contains(path)) { - prefs.edit { - remove(path) - } + val editor: SharedPreferences.Editor = prefs.edit() + editor.remove(path) + editor.apply() } } catch (e: Exception) { logError(e) @@ -157,33 +141,26 @@ object DataStore { fun Context.removeKeys(folder: String): Int { val keys = getKeys("$folder/") - try { - getSharedPrefs().edit { - keys.forEach { value -> - remove(value) - } - } - return keys.size - } catch (e: Exception) { - logError(e) - return 0 + keys.forEach { value -> + removeKey(value) } + return keys.size } fun Context.setKey(path: String, value: T) { try { - getSharedPrefs().edit { - putString(path, value?.toJsonLiteral()) - } + val editor: SharedPreferences.Editor = getSharedPrefs().edit() + editor.putString(path, mapper.writeValueAsString(value)) + editor.apply() } 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 +171,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 +199,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..bacd416e7 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/DataStoreHelper.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/DataStoreHelper.kt @@ -3,14 +3,13 @@ package com.lagradost.cloudstream3.utils import android.content.Context import com.fasterxml.jackson.annotation.JsonProperty import com.lagradost.cloudstream3.APIHolder.unixTimeMS -import com.lagradost.cloudstream3.CloudStreamApp.Companion.context -import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey -import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKeyClass -import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKeys -import com.lagradost.cloudstream3.CloudStreamApp.Companion.removeKey -import com.lagradost.cloudstream3.CloudStreamApp.Companion.removeKeys -import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey -import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKeyClass +import com.lagradost.cloudstream3.AcraApplication +import com.lagradost.cloudstream3.AcraApplication.Companion.context +import com.lagradost.cloudstream3.AcraApplication.Companion.getKey +import com.lagradost.cloudstream3.AcraApplication.Companion.getKeys +import com.lagradost.cloudstream3.AcraApplication.Companion.removeKey +import com.lagradost.cloudstream3.AcraApplication.Companion.removeKeys +import com.lagradost.cloudstream3.AcraApplication.Companion.setKey import com.lagradost.cloudstream3.CommonActivity.showToast import com.lagradost.cloudstream3.DubStatus import com.lagradost.cloudstream3.EpisodeResponse @@ -24,13 +23,9 @@ import com.lagradost.cloudstream3.syncproviders.AccountManager import com.lagradost.cloudstream3.syncproviders.SyncAPI import com.lagradost.cloudstream3.ui.WatchType import com.lagradost.cloudstream3.ui.library.ListSorting -import com.lagradost.cloudstream3.ui.player.ExtractorUri -import com.lagradost.cloudstream3.ui.player.NEXT_WATCH_EPISODE_PERCENTAGE 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 @@ -58,7 +53,7 @@ class UserPreferenceDelegate( private val klass: KClass = default::class private val realKey get() = "${DataStoreHelper.currentAccount}/$key" operator fun getValue(self: Any?, property: KProperty<*>) = - getKeyClass(realKey, klass.java) ?: default + AcraApplication.getKeyClass(realKey, klass.java) ?: default operator fun setValue( self: Any?, @@ -68,7 +63,7 @@ class UserPreferenceDelegate( if (t == null) { removeKey(realKey) } else { - setKeyClass(realKey, t) + AcraApplication.setKeyClass(realKey, t) } } } @@ -277,7 +272,6 @@ object DataStoreHelper { var rating: Int? = null set(value) { if (value != null) { - @Suppress("DEPRECATION_ERROR") score = Score.fromOld(value) } } @@ -530,7 +524,7 @@ object DataStoreHelper { setKey( "$currentAccount/$RESULT_RESUME_WATCHING", parentId.toString(), - DownloadObjects.ResumeWatching( + VideoDownloadHelper.ResumeWatching( parentId, episodeId, episode, @@ -551,7 +545,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 +553,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", @@ -648,62 +642,6 @@ object DataStoreHelper { setKey("$currentAccount/$VIDEO_POS_DUR", id.toString(), PosDur(pos, dur)) } - /** Sets the position, duration, and resume data of an episode/movie, - * - * if nextEpisode is not specified it will not be able to set the next episode as resumable if progress > NEXT_WATCH_EPISODE_PERCENTAGE - * */ - fun setViewPosAndResume(id: Int?, position: Long, duration: Long, currentEpisode: Any?, nextEpisode: Any?) { - setViewPos(id, position, duration) - if (id != null) { - when (val meta = currentEpisode) { - is ResultEpisode -> { - if (meta.videoWatchState == VideoWatchState.Watched) { - setVideoWatchState(id, VideoWatchState.None) - } - } - } - } - - val percentage = position * 100L / duration - val nextEp = percentage >= NEXT_WATCH_EPISODE_PERCENTAGE - val resumeMeta = if (nextEp) nextEpisode else currentEpisode - if (resumeMeta == null && nextEp) { - // remove last watched as it is the last episode and you have watched too much - when (val newMeta = currentEpisode) { - is ResultEpisode -> { - removeLastWatched(newMeta.parentId) - } - - is ExtractorUri -> { - removeLastWatched(newMeta.parentId) - } - } - } else { - // save resume - when (resumeMeta) { - is ResultEpisode -> { - setLastWatched( - resumeMeta.parentId, - resumeMeta.id, - resumeMeta.episode, - resumeMeta.season, - isFromDownload = false - ) - } - - is ExtractorUri -> { - setLastWatched( - resumeMeta.parentId, - resumeMeta.id, - resumeMeta.episode, - resumeMeta.season, - isFromDownload = true - ) - } - } - } - } - fun getViewPos(id: Int?): PosDur? { if (id == null) return null return getKey("$currentAccount/$VIDEO_POS_DUR", id.toString(), null) @@ -726,7 +664,7 @@ object DataStoreHelper { } fun getDub(id: Int): DubStatus? { - return DubStatus.entries + return DubStatus.values() .getOrNull(getKey("$currentAccount/$RESULT_DUB", id.toString(), -1) ?: -1) } @@ -778,8 +716,7 @@ object DataStoreHelper { getKey("${idPrefix}_sync", id.toString()) } } - - var pinnedProviders: Array + var pinnedProviders:Array get() = getKey(USER_PINNED_PROVIDERS) ?: emptyArray() set(value) = setKey(USER_PINNED_PROVIDERS, value) 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..4eeb4e5da --- /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.AcraApplication.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/Event.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/Event.kt index f66da4e5f..a0dfe734e 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/Event.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/Event.kt @@ -24,28 +24,3 @@ class Event { } } } - -class EmptyEvent { - private val observers = mutableSetOf() - - val size: Int get() = observers.size - - operator fun plusAssign(observer: Runnable) { - synchronized(observers) { - observers.add(observer) - } - } - - operator fun minusAssign(observer: Runnable) { - synchronized(observers) { - observers.remove(observer) - } - } - - operator fun invoke() { - synchronized(observers) { - for (observer in observers) - observer.run() - } - } -} 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..14d1b0556 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) ?: return null + 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..a75d1b437 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,9 +11,7 @@ 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 import coil3.memory.MemoryCache import coil3.network.NetworkHeaders @@ -24,86 +21,76 @@ 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) - this.dispose() - if (imageData == null) return - // setImageResource is better than coil3 on resources due to attr - if (imageData is Int) { - this.setImageResource(imageData); return - } - // headers can be overridden by extensions. + // clear image to avoid loading & flickering issue at fast scrolling (e.g, an image recycler) + this.load(null) + + if(imageData == null) return // Just in case + + // 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 +98,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 +131,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 +166,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/ImageUtil.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/ImageUtil.kt index 6ed4d4afa..1f90b920d 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/ImageUtil.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/ImageUtil.kt @@ -7,9 +7,9 @@ import android.graphics.drawable.BitmapDrawable import android.graphics.drawable.Drawable import androidx.annotation.DrawableRes import androidx.core.content.ContextCompat -import androidx.core.graphics.createBitmap import coil3.Image import coil3.asImage +import coil3.request.ImageRequest /// Type safe any image, because THIS IS NOT PYTHON sealed class UiImage { @@ -30,7 +30,11 @@ fun drawableToBitmap(drawable: Drawable): Bitmap? { return when (drawable) { is BitmapDrawable -> drawable.bitmap else -> { - val bitmap = createBitmap(drawable.intrinsicWidth, drawable.intrinsicHeight) + val bitmap = Bitmap.createBitmap( + drawable.intrinsicWidth, + drawable.intrinsicHeight, + Bitmap.Config.ARGB_8888 + ) val canvas = Canvas(bitmap) drawable.setBounds(0, 0, canvas.width, canvas.height) drawable.draw(canvas) 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..8bce8f639 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/InAppUpdater.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/InAppUpdater.kt @@ -3,363 +3,392 @@ package com.lagradost.cloudstream3.utils import android.app.Activity import android.content.Context import android.content.Intent -import android.content.pm.PackageManager.NameNotFoundException import android.net.Uri import android.util.Log import android.widget.Toast import androidx.appcompat.app.AlertDialog import androidx.core.content.ContextCompat import androidx.core.content.FileProvider -import androidx.core.content.edit import androidx.preference.PreferenceManager import com.fasterxml.jackson.annotation.JsonProperty -import com.lagradost.cloudstream3.BuildConfig +import com.lagradost.cloudstream3.* import com.lagradost.cloudstream3.CommonActivity.showToast -import com.lagradost.cloudstream3.MainActivity.Companion.deleteFileOnExit -import com.lagradost.cloudstream3.R -import com.lagradost.cloudstream3.app import com.lagradost.cloudstream3.mvvm.logError -import com.lagradost.cloudstream3.mvvm.safe -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 import okio.buffer import okio.sink -import java.io.BufferedReader import java.io.File +import android.text.TextUtils +import com.lagradost.cloudstream3.MainActivity.Companion.deleteFileOnExit +import com.lagradost.cloudstream3.services.PackageInstallerService +import com.lagradost.cloudstream3.utils.AppContextUtils.setDefaultFocus +import java.io.BufferedReader import java.io.IOException import java.io.InputStreamReader -object InAppUpdater { - private const val GITHUB_USER_NAME = "recloudstream" - private const val GITHUB_REPO = "cloudstream" - private const val PRERELEASE_PACKAGE_NAME = "com.lagradost.cloudstream3.prerelease" - private const val LOG_TAG = "InAppUpdater" +class InAppUpdater { + companion object { + private const val GITHUB_USER_NAME = "recloudstream" + private const val GITHUB_REPO = "cloudstream" - private data class GithubAsset( - @JsonProperty("name") val name: String, - @JsonProperty("size") val size: Int, // Size in bytes - @JsonProperty("browser_download_url") val browserDownloadUrl: String, - @JsonProperty("content_type") val contentType: String, // application/vnd.android.package-archive - ) + private const val LOG_TAG = "InAppUpdater" - private data class GithubRelease( - @JsonProperty("tag_name") val tagName: String, // Version code - @JsonProperty("body") val body: String, // Description - @JsonProperty("assets") val assets: List, - @JsonProperty("target_commitish") val targetCommitish: String, // Branch - @JsonProperty("prerelease") val prerelease: Boolean, - @JsonProperty("node_id") val nodeId: String, - ) - - private data class GithubObject( - @JsonProperty("sha") val sha: String, // SHA-256 hash - @JsonProperty("type") val type: String, - @JsonProperty("url") val url: String, - ) - - private data class GithubTag( - @JsonProperty("object") val githubObject: GithubObject, - ) - - private data class Update( - @JsonProperty("shouldUpdate") val shouldUpdate: Boolean, - @JsonProperty("updateURL") val updateURL: String?, - @JsonProperty("updateVersion") val updateVersion: String?, - @JsonProperty("changelog") val changelog: String?, - @JsonProperty("updateNodeId") val updateNodeId: String?, - ) - - private suspend fun Activity.getAppUpdate(installPrerelease: Boolean): Update { - return try { - when { - // No updates on debug version - BuildConfig.DEBUG -> Update(false, null, null, null, null) - BuildConfig.FLAVOR == "prerelease" || installPrerelease -> getPreReleaseUpdate() - else -> getReleaseUpdate() - } - } catch (e: Exception) { - Log.e(LOG_TAG, Log.getStackTraceString(e)) - Update(false, null, null, null, null) - } - } - - 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>( - app.get(url, headers = headers).text - ).toList() - - val versionRegex = Regex("""(.*?((\d+)\.(\d+)\.(\d+))\.apk)""") - val versionRegexLocal = Regex("""(.*?((\d+)\.(\d+)\.(\d+)).*)""") - val foundList = response.filter { rel -> - !rel.prerelease - }.sortedWith(compareBy { release -> - release.assets.firstOrNull { it.contentType == "application/vnd.android.package-archive" }?.name?.let { it1 -> - versionRegex.find(it1)?.groupValues?.let { - it[3].toInt() * 100_000_000 + it[4].toInt() * 10_000 + it[5].toInt() - } - } - }).toList() - - val found = foundList.lastOrNull() - val foundAsset = found?.assets?.getOrNull(0) - val foundVersion = foundAsset?.name?.let { versionRegex.find(it) } - - if (foundVersion == null) { - return Update(false, null, null, null, null) - } - - val currentVersion = packageName?.let { - packageManager.getPackageInfo(it, 0) - } - - val shouldUpdate = if (foundAsset.browserDownloadUrl.isBlank()) { - false - } else { - currentVersion?.versionName?.let { versionName -> - versionRegexLocal.find(versionName)?.groupValues?.let { - it[3].toInt() * 100_000_000 + it[4].toInt() * 10_000 + it[5].toInt() - } - }?.compareTo( - foundVersion.groupValues.let { - it[3].toInt() * 100_000_000 + it[4].toInt() * 10_000 + it[5].toInt() - })!! < 0 - } - - return Update( - shouldUpdate, - foundAsset.browserDownloadUrl, - foundVersion.groupValues[2], - found.body, - found.nodeId - ) - } - - private suspend fun Activity.getPreReleaseUpdate(): Update { - val tagUrl = - "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>( - app.get(releaseUrl, headers = headers).text - ).toList() - - val found = response.lastOrNull { rel -> - rel.prerelease || rel.tagName == "pre-release" - } - - val foundAsset = found?.assets?.filter { it -> - it.contentType == "application/vnd.android.package-archive" - }?.getOrNull(0) - - if (foundAsset == null) { - return Update(false, null, null, null, null) - } - - val tagResponse = parseJson(app.get(tagUrl, headers = headers).text) - val updateCommitHash = tagResponse.githubObject.sha.trim().take(7) - Log.d(LOG_TAG, "Fetched GitHub tag: $updateCommitHash") - - return Update( - currentCommitHash() != updateCommitHash, - foundAsset.browserDownloadUrl, - updateCommitHash, - found.body, - found.nodeId - ) - } - - private val updateLock = Mutex() - - private suspend fun Activity.downloadUpdate(url: String): Boolean { - try { - Log.d(LOG_TAG, "Downloading update: $url") - val appUpdateName = "CloudStream" - val appUpdateSuffix = "apk" - - // Delete all old updates - this.cacheDir.listFiles()?.filter { - it.name.startsWith(appUpdateName) && it.extension == appUpdateSuffix - }?.forEach { deleteFileOnExit(it) } - - val downloadedFile = File.createTempFile(appUpdateName, ".$appUpdateSuffix") - val sink: BufferedSink = downloadedFile.sink().buffer() - - updateLock.withLock { - sink.writeAll(app.get(url).body.source()) - sink.close() - openApk(this, Uri.fromFile(downloadedFile)) - } - - return true - } catch (e: Exception) { - logError(e) - return false - } - } - - private fun openApk(context: Context, uri: Uri) = safe { - val path = uri.path ?: return@safe - val contentUri = FileProvider.getUriForFile( - context, BuildConfig.APPLICATION_ID + ".provider", File(path) - ) - val installIntent = Intent(Intent.ACTION_VIEW).apply { - addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) - addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) - putExtra(Intent.EXTRA_NOT_UNKNOWN_SOURCE, true) - data = contentUri - } - context.startActivity(installIntent) - } - - fun Activity.installPreReleaseIfNeeded() = ioSafe { - val isInstalled = try { - packageManager.getPackageInfo(PRERELEASE_PACKAGE_NAME, 0) - true - } catch (_: NameNotFoundException) { - false - } - - if (isInstalled) { - showToast(R.string.prerelease_already_installed) - } else if (!runAutoUpdate(checkAutoUpdate = false, installPrerelease = true)) { - showToast(R.string.prerelease_install_failed) - } - } - - - /** - * @param checkAutoUpdate if the update check was launched automatically - * @param installPrerelease if we want to install the pre-release version - */ - suspend fun Activity.runAutoUpdate( - checkAutoUpdate: Boolean = true, installPrerelease: Boolean = false - ): Boolean { - val settingsManager = PreferenceManager.getDefaultSharedPreferences(this) - val autoUpdateEnabled = - settingsManager.getBoolean(getString(R.string.auto_update_key), true) - if (checkAutoUpdate && !autoUpdateEnabled) { - return false - } - - val update = getAppUpdate(installPrerelease) - if (!update.shouldUpdate || update.updateURL == null) { - return false - } - - // Check if update should be skipped - val updateNodeId = settingsManager.getString( - getString(R.string.skip_update_key), "" + // === IN APP UPDATER === + data class GithubAsset( + @JsonProperty("name") val name: String, + @JsonProperty("size") val size: Int, // Size bytes + @JsonProperty("browser_download_url") val browserDownloadUrl: String, // download link + @JsonProperty("content_type") val contentType: String, // application/vnd.android.package-archive ) - // Skips the update if its an automatic update and the update is skipped - // This allows updating manually - if (update.updateNodeId.equals(updateNodeId) && checkAutoUpdate) { - return false - } + data class GithubRelease( + @JsonProperty("tag_name") val tagName: String, // Version code + @JsonProperty("body") val body: String, // Desc + @JsonProperty("assets") val assets: List, + @JsonProperty("target_commitish") val targetCommitish: String, // branch + @JsonProperty("prerelease") val prerelease: Boolean, + @JsonProperty("node_id") val nodeId: String //Node Id + ) - runOnUiThread { - safe { - val currentVersion = packageName?.let { - packageManager.getPackageInfo(it, 0) - } + data class GithubObject( + @JsonProperty("sha") val sha: String, // sha 256 hash + @JsonProperty("type") val type: String, // object type + @JsonProperty("url") val url: String, + ) - val builder = AlertDialog.Builder(this, R.style.AlertDialogCustom) - builder.setTitle( - getString(R.string.new_update_format).format( - currentVersion?.versionName, update.updateVersion + data class GithubTag( + @JsonProperty("object") val githubObject: GithubObject, + ) + + data class Update( + @JsonProperty("shouldUpdate") val shouldUpdate: Boolean, + @JsonProperty("updateURL") val updateURL: String?, + @JsonProperty("updateVersion") val updateVersion: String?, + @JsonProperty("changelog") val changelog: String?, + @JsonProperty("updateNodeId") val updateNodeId: String? + ) + + private suspend fun Activity.getAppUpdate(): Update { + return try { + val settingsManager = PreferenceManager.getDefaultSharedPreferences(this) + if (settingsManager.getBoolean( + getString(R.string.prerelease_update_key), + resources.getBoolean(R.bool.is_prerelease) ) + ) { + getPreReleaseUpdate() + } else { + getReleaseUpdate() + } + } catch (e: Exception) { + Log.e(LOG_TAG, Log.getStackTraceString(e)) + Update(false, null, null, null, null) + } + } + + 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>( + app.get( + url, + headers = headers + ).text ) - val logRegex = Regex("\\[(.*?)]\\((.*?)\\)") - val sanitizedChangelog = update.changelog?.replace(logRegex) { matchResult -> - matchResult.groupValues[1] - } // Sanitized because it looks cluttered - - builder.setMessage(sanitizedChangelog) - builder.apply { - setPositiveButton(R.string.update) { _, _ -> - // Forcefully start any delayed installations - if (ApkInstaller.delayedInstaller?.startInstallation() == true) return@setPositiveButton - - showToast(R.string.download_started, Toast.LENGTH_LONG) - - // Check if the setting hasn't been changed - if (settingsManager.getInt( - getString(R.string.apk_installer_key), -1 - ) == -1 - ) { - // Set to legacy installer if using MIUI - if (isMiUi()) { - settingsManager.edit { - putInt(getString(R.string.apk_installer_key), 1) - } + val versionRegex = Regex("""(.*?((\d+)\.(\d+)\.(\d+))\.apk)""") + val versionRegexLocal = Regex("""(.*?((\d+)\.(\d+)\.(\d+)).*)""") + /* + val releases = response.map { it.assets }.flatten() + .filter { it.content_type == "application/vnd.android.package-archive" } + val found = + releases.sortedWith(compareBy { + versionRegex.find(it.name)?.groupValues?.get(2) + }).toList().lastOrNull()*/ + val foundList = + response.filter { rel -> + !rel.prerelease + }.sortedWith(compareBy { release -> + release.assets.firstOrNull { it.contentType == "application/vnd.android.package-archive" }?.name?.let { it1 -> + versionRegex.find( + it1 + )?.groupValues?.let { + it[3].toInt() * 100_000_000 + it[4].toInt() * 10_000 + it[5].toInt() } } + }).toList() + val found = foundList.lastOrNull() + val foundAsset = found?.assets?.getOrNull(0) + val currentVersion = packageName?.let { + packageManager.getPackageInfo( + it, + 0 + ) + } - val currentInstaller = settingsManager.getInt( - getString(R.string.apk_installer_key), 1 - ) + foundAsset?.name?.let { assetName -> + val foundVersion = versionRegex.find(assetName) + val shouldUpdate = + if (foundAsset.browserDownloadUrl != "" && foundVersion != null) currentVersion?.versionName?.let { versionName -> + versionRegexLocal.find(versionName)?.groupValues?.let { + it[3].toInt() * 100_000_000 + it[4].toInt() * 10_000 + it[5].toInt() + } + }?.compareTo( + foundVersion.groupValues.let { + it[3].toInt() * 100_000_000 + it[4].toInt() * 10_000 + it[5].toInt() + } + )!! < 0 else false + return if (foundVersion != null) { + Update( + shouldUpdate, + foundAsset.browserDownloadUrl, + foundVersion.groupValues[2], + found.body, + found.nodeId + ) + } else { + Update(false, null, null, null, null) + } + } + return Update(false, null, null, null, null) + } - when (currentInstaller) { - // New method - 0 -> { - val intent = PackageInstallerService.Companion.getIntent( - this@runAutoUpdate, update.updateURL - ) - ContextCompat.startForegroundService( - this@runAutoUpdate, intent + private suspend fun Activity.getPreReleaseUpdate(): Update { + val tagUrl = + "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>(app.get(releaseUrl, headers = headers).text) + + val found = + response.lastOrNull { rel -> + rel.prerelease || rel.tagName == "pre-release" + } + val foundAsset = found?.assets?.filter { it -> + it.contentType == "application/vnd.android.package-archive" + }?.getOrNull(0) + + val tagResponse = + parseJson(app.get(tagUrl, headers = headers).text) + + Log.d(LOG_TAG, "Fetched GitHub tag: ${tagResponse.githubObject.sha.take(7)}") + + val shouldUpdate = + (getString(R.string.commit_hash) + .trim { c -> c.isWhitespace() } + .take(7) + != + tagResponse.githubObject.sha + .trim { c -> c.isWhitespace() } + .take(7)) + + return if (foundAsset != null) { + Update( + shouldUpdate, + foundAsset.browserDownloadUrl, + tagResponse.githubObject.sha.take(10), + found.body, + found.nodeId + ) + } else { + Update(false, null, null, null, null) + } + } + + + private val updateLock = Mutex() + + private suspend fun Activity.downloadUpdate(url: String): Boolean { + try { + Log.d(LOG_TAG, "Downloading update: $url") + val appUpdateName = "CloudStream" + val appUpdateSuffix = "apk" + + // Delete all old updates + this.cacheDir.listFiles()?.filter { + it.name.startsWith(appUpdateName) && it.extension == appUpdateSuffix + }?.forEach { + deleteFileOnExit(it) + } + + val downloadedFile = File.createTempFile(appUpdateName, ".$appUpdateSuffix") + val sink: BufferedSink = downloadedFile.sink().buffer() + + updateLock.withLock { + sink.writeAll(app.get(url).body.source()) + sink.close() + openApk(this, Uri.fromFile(downloadedFile)) + } + return true + } catch (e: Exception) { + return false + } + } + + private fun openApk(context: Context, uri: Uri) { + try { + uri.path?.let { + val contentUri = FileProvider.getUriForFile( + context, + BuildConfig.APPLICATION_ID + ".provider", + File(it) + ) + val installIntent = Intent(Intent.ACTION_VIEW).apply { + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) + putExtra(Intent.EXTRA_NOT_UNKNOWN_SOURCE, true) + data = contentUri + } + context.startActivity(installIntent) + } + } catch (e: Exception) { + logError(e) + } + } + + /** + * @param checkAutoUpdate if the update check was launched automatically + **/ + suspend fun Activity.runAutoUpdate(checkAutoUpdate: Boolean = true): Boolean { + val settingsManager = PreferenceManager.getDefaultSharedPreferences(this) + + if (!checkAutoUpdate || settingsManager.getBoolean( + getString(R.string.auto_update_key), + true + ) + ) { + val update = getAppUpdate() + if ( + update.shouldUpdate && + update.updateURL != null) { + + // Check if update should be skipped + val updateNodeId = + settingsManager.getString(getString(R.string.skip_update_key), "") + + // Skips the update if its an automatic update and the update is skipped + // This allows updating manually + if (update.updateNodeId.equals(updateNodeId) && checkAutoUpdate) { + return false + } + + runOnUiThread { + try { + val currentVersion = packageName?.let { + packageManager.getPackageInfo( + it, + 0 ) } - // Legacy - 1 -> { - ioSafe { - if (!downloadUpdate(update.updateURL)) { - runOnUiThread { - showToast( - R.string.download_failed, Toast.LENGTH_LONG + + val builder: AlertDialog.Builder = AlertDialog.Builder(this) + builder.setTitle( + getString(R.string.new_update_format).format( + currentVersion?.versionName, + update.updateVersion + ) + ) + + val logRegex = Regex("\\[(.*?)\\]\\((.*?)\\)") + val sanitizedChangelog = update.changelog?.replace(logRegex) { matchResult -> + matchResult.groupValues[1] + } // Sanitized because it looks cluttered + + builder.setMessage(sanitizedChangelog) + + val context = this + builder.apply { + setPositiveButton(R.string.update) { _, _ -> + // Forcefully start any delayed installations + if (ApkInstaller.delayedInstaller?.startInstallation() == true) return@setPositiveButton + + showToast(R.string.download_started, Toast.LENGTH_LONG) + + // Check if the setting hasn't been changed + if (settingsManager.getInt( + getString(R.string.apk_installer_key), + -1 + ) == -1 + ) { + if (isMiUi()) // Set to legacy if using miui + settingsManager.edit() + .putInt(getString(R.string.apk_installer_key), 1) + .apply() + } + + val currentInstaller = + settingsManager.getInt( + getString(R.string.apk_installer_key), + 0 + ) + + when (currentInstaller) { + // New method + 0 -> { + val intent = PackageInstallerService.Companion.getIntent( + context, + update.updateURL ) + ContextCompat.startForegroundService(context, intent) + } + // Legacy + 1 -> { + ioSafe { + if (!downloadUpdate(update.updateURL)) + runOnUiThread { + showToast( + R.string.download_failed, + Toast.LENGTH_LONG + ) + } + } } } } + + setNegativeButton(R.string.cancel) { _, _ -> } + + if (checkAutoUpdate) { + setNeutralButton(R.string.skip_update) { _, _ -> + settingsManager.edit().putString( + getString(R.string.skip_update_key), + update.updateNodeId ?: "" + ).apply() + } + } } + builder.show().setDefaultFocus() + } catch (e: Exception) { + logError(e) } } - - setNegativeButton(R.string.cancel) { _, _ -> } - - if (checkAutoUpdate) { - setNeutralButton(R.string.skip_update) { _, _ -> - settingsManager.edit { - putString( - getString(R.string.skip_update_key), update.updateNodeId ?: "" - ) - } - } - } + return true } - builder.show().setDefaultFocus() + return false + } + return false + } + + private fun isMiUi(): Boolean { + return !TextUtils.isEmpty(getSystemProperty("ro.miui.ui.version.name")) + } + + private fun getSystemProperty(propName: String): String? { + return try { + val p = Runtime.getRuntime().exec("getprop $propName") + BufferedReader(InputStreamReader(p.inputStream), 1024).use { + it.readLine() + } + } catch (ex: IOException) { + null } } - return true - } - - private fun isMiUi(): Boolean = !getSystemProperty("ro.miui.ui.version.name").isNullOrEmpty() - - private fun getSystemProperty(propName: String): String? = try { - val p = Runtime.getRuntime().exec("getprop $propName") - BufferedReader(InputStreamReader(p.inputStream), 1024).use { - it.readLine() - } - } catch (_: IOException) { - null } } diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/PackageInstaller.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/PackageInstaller.kt index 67851f629..4be0dd56c 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/PackageInstaller.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/PackageInstaller.kt @@ -11,7 +11,7 @@ import android.content.pm.PackageInstaller import android.os.Build import android.util.Log import android.widget.Toast -import com.lagradost.cloudstream3.CloudStreamApp.Companion.context +import com.lagradost.cloudstream3.AcraApplication.Companion.context import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.services.PackageInstallerService 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..1e572fb7c 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/PercentageCropImageView.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/PercentageCropImageView.kt @@ -4,67 +4,16 @@ 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 - * of an image by adjusting its horizontal and vertical center offset percentages. - * - * ### Key Features: - * - Allows **manual vertical or horizontal cropping** via percentage offsets. - * - Works seamlessly with Coil, Glide, or any image loading library. - * - * ### Usage (XML): - * You can set the crop offset directly in XML using custom attributes: - * ```xml - * - * ``` - * - `app:cropYCenterOffsetPct` → controls how far vertically the image shifts - * `0.0` = top-aligned, `0.5` = centered, `1.0` = bottom-aligned. - * - `app:cropXCenterOffsetPct` → controls how far horizontally the image shifts - * `0.0` = left, `0.5` = center, `1.0` = right. - * - * ### Programmatic Example: - * ```kotlin - * imageView.cropYCenterOffsetPct = 0.15f // Show slightly more (15%) of the top area - * imageView.cropXCenterOffsetPct = 0.5f // Keep image centered horizontally - * imageView.redraw() //Only needed if you changed cropYCenterOffsetPct/cropXCenterOffsetPct at runtime - * ``` - * - * ### Notes: - * - Must use `android:scaleType="matrix"` to enable manual matrix transformations. - * - Reference: https://stackoverflow.com/a/29055283 - * - * @property cropYCenterOffsetPct the vertical crop percentage (0.0–1.0) - * @property cropXCenterOffsetPct the horizontal crop percentage (0.0–1.0) - * - * @see ImageView.ScaleType.MATRIX - */ class PercentageCropImageView : androidx.appcompat.widget.AppCompatImageView { private var mCropYCenterOffsetPct: Float? = null private var mCropXCenterOffsetPct: Float? = null - constructor(context: Context?) : super(context!!) - - constructor(context: Context?, attrs: AttributeSet?) : super(context!!, attrs) { - initAttrs(context, attrs) - } - + constructor(context: Context?, attrs: AttributeSet?) : super(context!!, attrs) constructor( context: Context?, attrs: AttributeSet?, defStyle: Int - ) : super(context!!, attrs, defStyle) { - initAttrs(context, attrs) - } + ) : super(context!!, attrs, defStyle) var cropYCenterOffsetPct: Float get() = mCropYCenterOffsetPct!! @@ -94,12 +43,12 @@ class PercentageCropImageView : androidx.appcompat.widget.AppCompatImageView { var dy = 0f if (dWidth * vHeight > vWidth * dHeight) { val cropXCenterOffsetPct = - if (mCropXCenterOffsetPct != null) mCropXCenterOffsetPct!! else 0.5f + if (mCropXCenterOffsetPct != null) mCropXCenterOffsetPct!!.toFloat() else 0.5f scale = vHeight.toFloat() / dHeight.toFloat() dx = (vWidth - dWidth * scale) * cropXCenterOffsetPct } else { val cropYCenterOffsetPct = - if (mCropYCenterOffsetPct != null) mCropYCenterOffsetPct!! else 0f + if (mCropYCenterOffsetPct != null) mCropYCenterOffsetPct!!.toFloat() else 0f scale = vWidth.toFloat() / dWidth.toFloat() dy = (vHeight - dHeight * scale) * cropYCenterOffsetPct } @@ -131,7 +80,6 @@ class PercentageCropImageView : androidx.appcompat.widget.AppCompatImageView { super.setImageResource(resId) myConfigureBounds() } - // In case you can change the ScaleType in code you have to call redraw() //fullsizeImageView.setScaleType(ScaleType.FIT_CENTER); //fullsizeImageView.redraw(); @@ -143,26 +91,4 @@ class PercentageCropImageView : androidx.appcompat.widget.AppCompatImageView { setImageDrawable(d) } } - - 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) - } - } - } } \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/PowerManagerAPI.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/PowerManagerAPI.kt index e3c7d68df..0d7a8abc4 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/PowerManagerAPI.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/PowerManagerAPI.kt @@ -3,13 +3,12 @@ package com.lagradost.cloudstream3.utils import android.content.ActivityNotFoundException import android.content.Context import android.content.Intent +import android.net.Uri import android.os.Build.VERSION.SDK_INT import android.os.PowerManager import android.provider.Settings import android.util.Log import androidx.appcompat.app.AlertDialog -import androidx.core.content.edit -import androidx.core.net.toUri import androidx.preference.PreferenceManager import com.lagradost.cloudstream3.BuildConfig import com.lagradost.cloudstream3.CommonActivity.showToast @@ -39,6 +38,7 @@ object BatteryOptimizationChecker { fun Context.showBatteryOptimizationDialog() { val settingsManager = PreferenceManager.getDefaultSharedPreferences(this) + try { AlertDialog.Builder(this) .setTitle(R.string.battery_dialog_title) @@ -46,9 +46,9 @@ object BatteryOptimizationChecker { .setMessage(R.string.battery_dialog_message) .setPositiveButton(R.string.ok) { _, _ -> showRequestIgnoreBatteryOptDialog() } .setNegativeButton(R.string.cancel) { _, _ -> - settingsManager.edit { - putBoolean(getString(R.string.battery_optimisation_key), false) - } + settingsManager.edit() + .putBoolean(getString(R.string.battery_optimisation_key), false) + .apply() } .show() } catch (t: Throwable) { @@ -67,7 +67,7 @@ object BatteryOptimizationChecker { try { val intent = Intent().apply { action = Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS - data = "package:$PACKAGE_NAME".toUri() + data = Uri.parse("package:$PACKAGE_NAME") } startActivity(intent) } catch (t: Throwable) { diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/SingleSelectionHelper.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/SingleSelectionHelper.kt index 26c710103..ea75ff62e 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/SingleSelectionHelper.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/SingleSelectionHelper.kt @@ -22,7 +22,6 @@ import com.lagradost.cloudstream3.databinding.BottomSelectionDialogBinding import com.lagradost.cloudstream3.databinding.BottomTextDialogBinding import com.lagradost.cloudstream3.databinding.OptionsPopupTvBinding 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.utils.ImageLoader.loadImage @@ -114,12 +113,8 @@ object SingleSelectionHelper { val textView = binding.text1 val applyButton = binding.applyBtt val cancelButton = binding.cancelBtt - val applyHolder = binding.applyBttHolder - - if (isLayout(PHONE or EMULATOR) && dialog is BottomSheetDialog) { - binding.dragHandle.isVisible = true - listView.isNestedScrollingEnabled = true - } + val applyHolder = + binding.applyBttHolder applyHolder.isVisible = realShowApply if (!realShowApply) { 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..66a6e156c 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,8 @@ 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.getFolder +import com.lagradost.safefile.SafeFile object SubtitleUtils { @@ -13,21 +13,17 @@ object SubtitleUtils { ".ttml", ".sbv", ".dfxp" ) - fun deleteMatchingSubtitles(context: Context, info: DownloadObjects.DownloadedFileInfo) { - val cleanDisplay = cleanDisplayName(info.displayName) + fun deleteMatchingSubtitles(context: Context, info: VideoDownloadManager.DownloadedFileInfo) { + val relative = info.relativePath + val display = info.displayName + val cleanDisplay = cleanDisplayName(display) - val base = basePathToFile(context, info.basePath) - val folder = - base?.gotoDirectory(info.relativePath, createMissingDirectories = false) ?: return - val folderFiles = folder.listFiles() ?: return - - for (file in folderFiles) { - val name = file.name() ?: continue - if (!isMatchingSubtitle(name, info.displayName, cleanDisplay)) { - continue - } - if (file.delete() != true) { - Log.e("SubtitleDeletion", "Failed to delete subtitle file: $name") + getFolder(context, relative, info.basePath)?.forEach { (name, uri) -> + if (isMatchingSubtitle(name, display, cleanDisplay)) { + val subtitleFile = SafeFile.fromUri(context, uri) + if (subtitleFile == null || subtitleFile.delete() != true) { + Log.e("SubtitleDeletion", "Failed to delete subtitle file: ${subtitleFile?.name()}") + } } } } @@ -43,7 +39,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 +53,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..049f92fb4 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 -> { @@ -116,10 +112,10 @@ object TestingUtils { val searchResults = testQueries.firstNotNullOfOrNull { query -> try { logger.log("Searching for: $query") - api.search(query, 1)?.items?.takeIf { it.isNotEmpty() } + api.search(query).takeIf { !it.isNullOrEmpty() } } 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/TvChannelUtils.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/TvChannelUtils.kt deleted file mode 100644 index feecbe312..000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/TvChannelUtils.kt +++ /dev/null @@ -1,164 +0,0 @@ -package com.lagradost.cloudstream3.utils - -import android.annotation.SuppressLint -import android.content.ComponentName -import android.content.ContentUris -import android.content.Context -import android.content.Intent -import android.util.Log -import androidx.core.net.toUri -import androidx.tvprovider.media.tv.Channel -import androidx.tvprovider.media.tv.PreviewProgram -import androidx.tvprovider.media.tv.TvContractCompat -import com.lagradost.cloudstream3.MainActivity -import com.lagradost.cloudstream3.R -import com.lagradost.cloudstream3.SearchResponse -import com.lagradost.cloudstream3.base64Encode -import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.APP_STRING_SHARE -import com.lagradost.cloudstream3.utils.DataStore.getKey -import com.lagradost.cloudstream3.utils.DataStore.setKey -import java.net.URLEncoder - -const val PROGRAM_ID_LIST_KEY = "persistent_program_ids" - -object TvChannelUtils { - fun Context.saveProgramId(programId: Long) { - val existing: List = getKey(PROGRAM_ID_LIST_KEY) ?: emptyList() - val updated = (existing + programId).distinct() - setKey(PROGRAM_ID_LIST_KEY, updated) - } - fun Context.getStoredProgramIds(): List { - return getKey(PROGRAM_ID_LIST_KEY) ?: emptyList() - } - fun Context.removeProgramId(programId: Long) { - val existing: List = getKey(PROGRAM_ID_LIST_KEY) ?: emptyList() - val updated = existing.filter { it != programId } - setKey(PROGRAM_ID_LIST_KEY, updated) - } - - - fun getChannelId(context: Context, channelName: String): Long? { - return try { - context.contentResolver.query( - TvContractCompat.Channels.CONTENT_URI, - arrayOf( - TvContractCompat.Channels._ID, - TvContractCompat.Channels.COLUMN_DISPLAY_NAME - ), - null, - null, - null - )?.use { cursor -> - while (cursor.moveToNext()) { - val id = cursor.getLong( - cursor.getColumnIndexOrThrow(TvContractCompat.Channels._ID) - ) - val name = cursor.getString( - cursor.getColumnIndexOrThrow(TvContractCompat.Channels.COLUMN_DISPLAY_NAME) - ) - if (name == channelName) return id - } - null - } - } catch (e: Exception) { - Log.e("TvChannelUtils", "Query failed: ${e.message}", e) - null - } - } - - /** Insert programs into a channel */ - @SuppressLint("RestrictedApi") - fun addPrograms(context: Context, channelId: Long, items: List) { - for (item in items) { - try { - val nameBase64 = base64Encode(item.apiName.toByteArray(Charsets.UTF_8)) - val urlBase64 = base64Encode(item.url.toByteArray(Charsets.UTF_8)) - val csshareUri = "$APP_STRING_SHARE:$nameBase64?$urlBase64" - val poster=item.posterUrl - val builder = PreviewProgram.Builder() - .setChannelId(channelId) - .setTitle(item.name) - .apply { - val scoreText = item.score?.toStringNull(0.1, 10, 1)?.let { - " - " + txt(R.string.rating_format, it).asString(context) - } ?: "" - setDescription("${item.apiName}$scoreText") - } - .setContentId(item.url) - .setType(TvContractCompat.PreviewPrograms.TYPE_MOVIE) - .setIntentUri(csshareUri.toUri()) - .setPosterArtAspectRatio(TvContractCompat.PreviewPrograms.ASPECT_RATIO_2_3) - - // Validate poster URL before setting - if (!poster.isNullOrBlank() && poster.startsWith("http")) { - builder.setPosterArtUri(poster.toUri()) - - } - val program = builder.build() - - val uri = context.contentResolver.insert( - TvContractCompat.PreviewPrograms.CONTENT_URI, - program.toContentValues() - ) - - if (uri != null) { - val programId = ContentUris.parseId(uri) - context.saveProgramId(programId) - Log.d("TvChannelUtils", "Inserted program ${item.name}, ID=$programId") - } else { - Log.e("TvChannelUtils", "Insert failed for ${item.name}") - } - - } catch (error: Exception) { - Log.e("TvChannelUtils", "Error inserting ${item.name}: $error") - } - } - } - - fun deleteStoredPrograms(context: Context) { - val programIds = context.getStoredProgramIds() - - for (id in programIds) { - val uri = ContentUris.withAppendedId(TvContractCompat.PreviewPrograms.CONTENT_URI, id) - try { - val rowsDeleted = context.contentResolver.delete(uri, null, null) - if (rowsDeleted > 0) { - context.removeProgramId(id) // Remove from persistent list - } else { - Log.w("ProgramDelete", "No program found for ID: $id") - } - } catch (e: Exception) { - Log.e("ProgramDelete", "Failed to delete program ID: $id", e) - } - } - - Log.d("ProgramDelete", "Finished deleting stored programs") - } - - fun createTvChannel(context: Context) { - val componentName = ComponentName(context, MainActivity::class.java) - val iconUri = "android.resource://${context.packageName}/mipmap/ic_launcher".toUri() - val inputId = TvContractCompat.buildInputId(componentName) - val channel = Channel.Builder() - .setType(TvContractCompat.Channels.TYPE_PREVIEW) - .setAppLinkIconUri(iconUri) - .setDisplayName(context.getString(R.string.app_name)) - .setAppLinkIntent(Intent(Intent.ACTION_VIEW).apply { - data = "cloudstreamapp://open".toUri() - }) - .setInputId(inputId) - .build() - - val channelUri = context.contentResolver.insert( - TvContractCompat.Channels.CONTENT_URI, - channel.toContentValues() - ) - - channelUri?.let { - val channelId = ContentUris.parseId(it) - TvContractCompat.requestChannelBrowsable(context, channelId) - Log.d("TvChannelUtils", "Channel Created: $channelId") - } - } - -} \ 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..557bb1ea5 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/UIHelper.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/UIHelper.kt @@ -13,12 +13,7 @@ import android.content.pm.PackageManager import android.content.res.Configuration import android.content.res.Resources import android.graphics.Bitmap -import android.graphics.Canvas import android.graphics.Color -import android.graphics.ColorFilter -import android.graphics.Paint -import android.graphics.PixelFormat -import android.graphics.drawable.Drawable import android.os.Build import android.os.Bundle import android.os.TransactionTooLargeException @@ -28,6 +23,7 @@ import android.view.MenuItem import android.view.View import android.view.ViewGroup import android.view.ViewGroup.MarginLayoutParams +import android.view.WindowInsets import android.view.WindowManager import android.view.inputmethod.InputMethodManager import android.widget.ListAdapter @@ -35,7 +31,6 @@ import android.widget.ListView import android.widget.Toast.LENGTH_LONG import androidx.annotation.AttrRes import androidx.annotation.ColorInt -import androidx.annotation.DimenRes import androidx.annotation.StyleRes import androidx.appcompat.view.ContextThemeWrapper import androidx.appcompat.view.menu.MenuBuilder @@ -43,7 +38,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 @@ -53,11 +47,6 @@ import androidx.core.view.marginLeft import androidx.core.view.marginRight import androidx.core.view.marginTop import androidx.core.view.updateLayoutParams -import androidx.core.view.updatePadding -import androidx.core.view.ViewCompat -import androidx.core.view.WindowCompat -import androidx.core.view.WindowInsetsCompat -import androidx.core.view.WindowInsetsControllerCompat import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentActivity import androidx.navigation.NavOptions @@ -68,17 +57,17 @@ import com.google.android.material.appbar.AppBarLayout import com.google.android.material.chip.Chip import com.google.android.material.chip.ChipDrawable import com.google.android.material.chip.ChipGroup -import com.lagradost.cloudstream3.CloudStreamApp.Companion.context +import com.lagradost.cloudstream3.AcraApplication.Companion.context +import com.lagradost.cloudstream3.CommonActivity import com.lagradost.cloudstream3.CommonActivity.activity import com.lagradost.cloudstream3.CommonActivity.showToast import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.mvvm.logError +import com.lagradost.cloudstream3.ui.settings.Globals 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.utils.AppContextUtils.isRtl import com.lagradost.cloudstream3.utils.Coroutines.main +import com.lagradost.cloudstream3.utils.UIHelper.navigate import kotlinx.coroutines.delay import kotlin.math.roundToInt import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.disableBackPressedCallback @@ -104,8 +93,7 @@ object UIHelper { fun populateChips( view: ChipGroup?, tags: List, - @StyleRes style: Int = R.style.ChipFilled, - @AttrRes textColor: Int? = R.attr.white, + @StyleRes style: Int = R.style.ChipFilled ) { if (view == null) return view.removeAllViews() @@ -126,9 +114,7 @@ object UIHelper { chip.isCheckable = false chip.isFocusable = false chip.isClickable = false - textColor?.let { - chip.setTextColor(context.colorFromAttribute(it)) - } + chip.setTextColor(context.colorFromAttribute(R.attr.white)) view.addView(chip) } } @@ -204,15 +190,17 @@ object UIHelper { listView.requestLayout() } - fun Context.getSpanCount(isHorizontal:Boolean=false): Int { -// val compactView = false - val spanCountLandscape = if (isHorizontal) 3 else 6 - val spanCountPortrait = if (isHorizontal) 2 else 3 - val orientation = resources.configuration.orientation + fun Context?.getSpanCount(): Int? { + val compactView = false + val spanCountLandscape = if (compactView) 2 else 6 + val spanCountPortrait = if (compactView) 1 else 3 + val orientation = this?.resources?.configuration?.orientation ?: return null return if (orientation == Configuration.ORIENTATION_LANDSCAPE) { spanCountLandscape - } else spanCountPortrait + } else { + spanCountPortrait + } } fun Fragment.hideKeyboard() { @@ -223,7 +211,7 @@ object UIHelper { } fun View?.setAppBarNoScrollFlagsOnTV() { - if (isLayout(TV or EMULATOR)) { + if (isLayout(Globals.TV or EMULATOR)) { this?.updateLayoutParams { scrollFlags = AppBarLayout.LayoutParams.SCROLL_FLAG_NO_SCROLL } @@ -259,12 +247,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) } @@ -276,12 +262,12 @@ object UIHelper { } /** If you want to call this from a BackPressedCallback, pass the name of the callback to temporarily disable it */ - fun FragmentActivity.popCurrentPage(fromBackPressedCallback: String? = null) { + fun FragmentActivity.popCurrentPage(fromBackPressedCallback : String? = null) { // Use the main looper handler to post actions on the main thread main { // Post the back press action to the main thread handler to ensure it executes // after any currently pending UI updates or fragment transactions. - if (fromBackPressedCallback != null) { + if(fromBackPressedCallback != null) { disableBackPressedCallback(fromBackPressedCallback) } if (!supportFragmentManager.isStateSaved) { @@ -299,7 +285,7 @@ object UIHelper { onBackPressedDispatcher.onBackPressed() } } - if (fromBackPressedCallback != null) { + if(fromBackPressedCallback != null) { enableBackPressedCallback(fromBackPressedCallback) } } @@ -307,23 +293,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,18 +319,35 @@ 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) { - val controller = WindowCompat.getInsetsController(window, window.decorView) - controller.systemBarsBehavior = - WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE - controller.hide(WindowInsetsCompat.Type.systemBars()) - return - } - // For "lean back" mode, remove SYSTEM_UI_FLAG_IMMERSIVE. // Or for "sticky immersive," replace it with SYSTEM_UI_FLAG_IMMERSIVE_STICKY + /** BUGGED AF **/ + /*if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + + WindowCompat.setDecorFitsSystemWindows(window, false) + WindowInsetsControllerCompat(window, View(this)).let { controller -> + controller.hide(WindowInsetsCompat.Type.systemBars()) + controller.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE + } + }*/ + @Suppress("DEPRECATION") window.decorView.systemUiVisibility = ( View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY @@ -363,25 +359,12 @@ object UIHelper { // Hide the nav bar and status bar or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or View.SYSTEM_UI_FLAG_FULLSCREEN - ) - } - - fun Activity.enableEdgeToEdgeCompat() { - // edge-to-edge is very buggy on earlier versions - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) return - WindowCompat.enableEdgeToEdge(window) - } - - fun Activity.setNavigationBarColorCompat(@AttrRes resourceId: Int) { - // edge-to-edge handles this - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) return - - @Suppress("DEPRECATION") - window?.navigationBarColor = colorFromAttribute(resourceId) + ) // FIXME this should be replaced + //} } fun Context.getStatusBarHeight(): Int { - if (isLayout(TV or EMULATOR)) { + if (isLayout(Globals.TV or EMULATOR)) { return 0 } @@ -393,6 +376,17 @@ object UIHelper { return result } + fun fixPaddingStatusbar(v: View?) { + if (v == null) return + val ctx = v.context ?: return + v.setPadding( + v.paddingLeft, + v.paddingTop + ctx.getStatusBarHeight(), + v.paddingRight, + v.paddingBottom + ) + } + fun fixPaddingStatusbarMargin(v: View?) { if (v == null) return val ctx = v.context ?: return @@ -417,84 +411,6 @@ object UIHelper { v.layoutParams = params } - fun fixSystemBarsPadding( - v: View, - @DimenRes heightResId: Int? = null, - @DimenRes widthResId: Int? = null, - padTop: Boolean = true, - padBottom: Boolean = true, - padLeft: Boolean = true, - padRight: Boolean = true, - overlayCutout: Boolean = true, - fixIme: Boolean = false - ) { - // edge-to-edge is very buggy on earlier versions so we just - // handle the status bar here instead. - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) { - if (padTop) { - val ctx = v.context ?: return - v.updatePadding(top = ctx.getStatusBarHeight()) - } - return - } - - ViewCompat.setOnApplyWindowInsetsListener(v) { view, windowInsets -> - val leftCheck = if (view.isRtl()) padRight else padLeft - val rightCheck = if (view.isRtl()) padLeft else padRight - - val insetTypes = WindowInsetsCompat.Type.systemBars() or - WindowInsetsCompat.Type.displayCutout() or - if (fixIme) WindowInsetsCompat.Type.ime() else 0 - - val insets = windowInsets.getInsets(insetTypes) - - view.updatePadding( - left = if (leftCheck) insets.left else view.paddingLeft, - right = if (rightCheck) insets.right else view.paddingRight, - bottom = if (padBottom) insets.bottom else view.paddingBottom, - top = if (padTop) insets.top else view.paddingTop - ) - - heightResId?.let { - val heightPx = view.resources.getDimensionPixelSize(it) - view.updateLayoutParams { - height = heightPx + insets.bottom - } - } - - widthResId?.let { - val widthPx = view.resources.getDimensionPixelSize(it) - view.updateLayoutParams { - val startInset = if (view.isRtl()) insets.right else insets.left - width = if (startInset > 0) widthPx + startInset else widthPx - } - } - - if (overlayCutout && isLayout(PHONE)) { - // Draw a black overlay over the cutout. We do this so that - // it doesn't use the fragment background. We want it to - // appear as if the screen actually ends at cutout. - val cutout = windowInsets.displayCutout - if (cutout != null) { - val left = if (!leftCheck) 0 else cutout.safeInsetLeft - val right = if (!rightCheck) 0 else cutout.safeInsetRight - view.overlay.clear() - if (left > 0 || right > 0) { - view.overlay.add( - CutoutOverlayDrawable( - view, - leftCutout = left, - rightCutout = right - ) - ) - } - } - } - - WindowInsetsCompat.CONSUMED - } - } - fun Context.getNavigationBarHeight(): Int { var result = 0 val resourceId = resources.getIdentifier("navigation_bar_height", "dimen", "android") @@ -510,12 +426,12 @@ object UIHelper { return settingsManager.getBoolean(getString(R.string.bottom_title_key), true) } - fun Activity.changeStatusBarState(hide: Boolean) { + fun Activity.changeStatusBarState(hide: Boolean): Int { try { if (hide) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - val controller = WindowCompat.getInsetsController(window, window.decorView) - controller.hide(WindowInsetsCompat.Type.statusBars()) + window.insetsController?.hide(WindowInsets.Type.statusBars()) + } else { @Suppress("DEPRECATION") window.setFlags( @@ -523,39 +439,80 @@ object UIHelper { WindowManager.LayoutParams.FLAG_FULLSCREEN ) } + 0 } else { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - val controller = WindowCompat.getInsetsController(window, window.decorView) - controller.show(WindowInsetsCompat.Type.statusBars()) + window.insetsController?.show(WindowInsets.Type.statusBars()) } else { @Suppress("DEPRECATION") window.clearFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN) } + + this.getStatusBarHeight() } } catch (t: Throwable) { logError(t) } + return if (hide) { + 0 + } else { + this.getStatusBarHeight() + } } // Shows the system bars by removing all the flags // except for the ones that make the content appear under the system bars. fun Activity.showSystemUI() { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - val controller = WindowCompat.getInsetsController(window, window.decorView) - if (isLayout(EMULATOR)) { - controller.show(WindowInsetsCompat.Type.navigationBars()) - controller.hide(WindowInsetsCompat.Type.statusBars()) - } else controller.show(WindowInsetsCompat.Type.systemBars()) - return - } + /*if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + + WindowCompat.setDecorFitsSystemWindows(window, true) + WindowInsetsControllerCompat(window, View(this)).show(WindowInsetsCompat.Type.systemBars()) + + } else {*/ + /** WINDOW COMPAT IS BUGGY DUE TO FU*KED UP PLAYER AND TRAILERS **/ @Suppress("DEPRECATION") window.decorView.systemUiVisibility = - (View.SYSTEM_UI_FLAG_LAYOUT_STABLE or View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN) + (View.SYSTEM_UI_FLAG_LAYOUT_STABLE or View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN) // FIXME this should be replaced + //} changeStatusBarState(isLayout(EMULATOR)) } + fun Context.shouldShowPIPMode(isInPlayer: Boolean): Boolean { + return try { + val settingsManager = PreferenceManager.getDefaultSharedPreferences(this) + settingsManager?.getBoolean( + getString(R.string.pip_enabled_key), + true + ) ?: true && isInPlayer + } catch (e: Exception) { + logError(e) + false + } + } + + fun Context.hasPIPPermission(): Boolean { + val appOps = + getSystemService(Context.APP_OPS_SERVICE) as AppOpsManager + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + appOps.unsafeCheckOpNoThrow( + AppOpsManager.OPSTR_PICTURE_IN_PICTURE, + android.os.Process.myUid(), + packageName + ) == AppOpsManager.MODE_ALLOWED + } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + @Suppress("DEPRECATION") + appOps.checkOpNoThrow( + AppOpsManager.OPSTR_PICTURE_IN_PICTURE, + android.os.Process.myUid(), + packageName + ) == AppOpsManager.MODE_ALLOWED + } else { + return true + } + } + fun hideKeyboard(view: View?) { if (view == null) return @@ -642,39 +599,4 @@ object UIHelper { popup.show() return popup } -} - -private class CutoutOverlayDrawable( - private val view: View, - private val leftCutout: Int, - private val rightCutout: Int, -) : Drawable() { - private val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply { - color = Color.BLACK - style = Paint.Style.FILL - } - - override fun draw(canvas: Canvas) { - if (leftCutout > 0) canvas.drawRect( - 0f, - 0f, - leftCutout.toFloat(), - view.height.toFloat(), - paint - ) - if (rightCutout > 0) { - canvas.drawRect( - view.width - rightCutout.toFloat(), - 0f, view.width.toFloat(), - view.height.toFloat(), - paint - ) - } - } - - override fun setAlpha(alpha: Int) {} - override fun setColorFilter(colorFilter: ColorFilter?) {} - - @Suppress("OVERRIDE_DEPRECATION") - override fun getOpacity() = PixelFormat.OPAQUE } \ No newline at end of file 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..966ccfd56 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/VideoDownloadHelper.kt @@ -0,0 +1,54 @@ +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) { + 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..4ca9d0655 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,30 +1,40 @@ -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.AcraApplication.Companion.removeKey +import com.lagradost.cloudstream3.AcraApplication.Companion.setKey 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 import com.lagradost.cloudstream3.MainActivity import com.lagradost.cloudstream3.R @@ -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) @@ -318,7 +388,7 @@ object VideoDownloadManager { val mbFormat = "%.1f MB" if (hlsProgress != null && hlsTotal != null) { - progressPercentage = hlsProgress * 100 / hlsTotal + progressPercentage = hlsProgress.toLong() * 100 / hlsTotal progressMbString = hlsProgress.toString() totalMbString = hlsTotal.toString() suffix = " - $mbFormat".format(progress / 1000000f) @@ -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. + * */ + private 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/color/black_button_ripple.xml b/app/src/main/res/color/black_button_ripple.xml deleted file mode 100644 index d2a6b6c4d..000000000 --- a/app/src/main/res/color/black_button_ripple.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - - - \ No newline at end of file diff --git a/app/src/main/res/color/item_select_color_tv.xml b/app/src/main/res/color/item_select_color_tv.xml deleted file mode 100644 index 3042fd588..000000000 --- a/app/src/main/res/color/item_select_color_tv.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - - - 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_color_both.xml b/app/src/main/res/drawable/bg_color_both.xml deleted file mode 100644 index bb71f8731..000000000 --- a/app/src/main/res/drawable/bg_color_both.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - - - \ No newline at end of file diff --git a/app/src/main/res/drawable/bg_color_bottom.xml b/app/src/main/res/drawable/bg_color_bottom.xml deleted file mode 100644 index 7c744f19f..000000000 --- a/app/src/main/res/drawable/bg_color_bottom.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - - - \ No newline at end of file diff --git a/app/src/main/res/drawable/bg_color_center.xml b/app/src/main/res/drawable/bg_color_center.xml deleted file mode 100644 index 7cb437452..000000000 --- a/app/src/main/res/drawable/bg_color_center.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - - - \ No newline at end of file diff --git a/app/src/main/res/drawable/bg_color_top.xml b/app/src/main/res/drawable/bg_color_top.xml deleted file mode 100644 index 45497d272..000000000 --- a/app/src/main/res/drawable/bg_color_top.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - - - \ No newline at end of file diff --git a/app/src/main/res/drawable/bg_imdb_badge.xml b/app/src/main/res/drawable/bg_imdb_badge.xml deleted file mode 100644 index de7a6704b..000000000 --- a/app/src/main/res/drawable/bg_imdb_badge.xml +++ /dev/null @@ -1,11 +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/go_back_30.xml b/app/src/main/res/drawable/go_back_30.xml index 149990116..e57946b65 100644 --- a/app/src/main/res/drawable/go_back_30.xml +++ b/app/src/main/res/drawable/go_back_30.xml @@ -1,7 +1,6 @@ + + diff --git a/app/src/main/res/drawable/ic_baseline_close_24.xml b/app/src/main/res/drawable/ic_baseline_close_24.xml index 7dea8241e..70db409b3 100644 --- a/app/src/main/res/drawable/ic_baseline_close_24.xml +++ b/app/src/main/res/drawable/ic_baseline_close_24.xml @@ -1,4 +1,4 @@ - diff --git a/app/src/main/res/drawable/ic_baseline_exit_24.xml b/app/src/main/res/drawable/ic_baseline_exit_24.xml index 6aebfabdc..bf421c227 100644 --- a/app/src/main/res/drawable/ic_baseline_exit_24.xml +++ b/app/src/main/res/drawable/ic_baseline_exit_24.xml @@ -1,13 +1,5 @@ - - - - + + + + diff --git a/app/src/main/res/drawable/ic_baseline_folder_open_24.xml b/app/src/main/res/drawable/ic_baseline_folder_open_24.xml index 66afaed2c..6e130c3c9 100644 --- a/app/src/main/res/drawable/ic_baseline_folder_open_24.xml +++ b/app/src/main/res/drawable/ic_baseline_folder_open_24.xml @@ -1,12 +1,5 @@ - - - - + + + + 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/ic_offline_pin_24.xml b/app/src/main/res/drawable/ic_offline_pin_24.xml deleted file mode 100644 index 455006b31..000000000 --- a/app/src/main/res/drawable/ic_offline_pin_24.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_refresh.xml b/app/src/main/res/drawable/ic_refresh.xml deleted file mode 100644 index e61dcf1ce..000000000 --- a/app/src/main/res/drawable/ic_refresh.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/kid_star_24px.xml b/app/src/main/res/drawable/kid_star_24px.xml deleted file mode 100644 index 2efe84195..000000000 --- a/app/src/main/res/drawable/kid_star_24px.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - 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_20_gray.xml b/app/src/main/res/drawable/outline_big_20_gray.xml deleted file mode 100644 index ebcdc0bf4..000000000 --- a/app/src/main/res/drawable/outline_big_20_gray.xml +++ /dev/null @@ -1,10 +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/play_button.xml b/app/src/main/res/drawable/play_button.xml index ee3d47dfe..04886b6e5 100644 --- a/app/src/main/res/drawable/play_button.xml +++ b/app/src/main/res/drawable/play_button.xml @@ -1,19 +1,25 @@ + xmlns:android="http://schemas.android.com/apk/res/android" + android:name="vector" + android:width="842dp" + android:height="842dp" + android:viewportWidth="842" + android:viewportHeight="842"> + android:name="path" + android:pathData="M 421.44 17.5 C 336.15 17.5 253.011 44.513 184.01 94.646 C 115.009 144.778 63.626 215.5 37.27 296.616 C 10.914 377.732 10.914 465.148 37.27 546.264 C 63.626 627.38 115.009 698.102 184.01 748.234 C 253.011 798.367 336.15 825.38 421.44 825.38 C 506.73 825.38 589.869 798.367 658.87 748.234 C 727.871 698.102 779.254 627.38 805.61 546.264 C 831.966 465.148 831.966 377.732 805.61 296.616 C 779.254 215.5 727.871 144.778 658.87 94.646 C 589.869 44.513 506.73 17.5 421.44 17.5 Z" + android:fillColor="#B3000000" + android:strokeWidth="1"/> + android:name="path_1" + android:pathData="M 421.44 17.5 C 336.15 17.5 253.011 44.513 184.01 94.646 C 115.009 144.778 63.626 215.5 37.27 296.616 C 10.914 377.732 10.914 465.148 37.27 546.264 C 63.626 627.38 115.009 698.102 184.01 748.234 C 253.011 798.367 336.15 825.38 421.44 825.38 C 506.73 825.38 589.869 798.367 658.87 748.234 C 727.871 698.102 779.254 627.38 805.61 546.264 C 831.966 465.148 831.966 377.732 805.61 296.616 C 779.254 215.5 727.871 144.778 658.87 94.646 C 589.869 44.513 506.73 17.5 421.44 17.5 Z" + android:strokeColor="#ffffff" + android:strokeWidth="35" + android:strokeMiterLimit="10"/> + diff --git a/app/src/main/res/drawable/play_button_transparent.xml b/app/src/main/res/drawable/play_button_transparent.xml deleted file mode 100644 index caa7041e6..000000000 --- a/app/src/main/res/drawable/play_button_transparent.xml +++ /dev/null @@ -1,15 +0,0 @@ - - - - diff --git a/app/src/main/res/drawable/player_gradient_tv.xml b/app/src/main/res/drawable/player_gradient_tv.xml index 8077b418f..79bb3af5f 100644 --- a/app/src/main/res/drawable/player_gradient_tv.xml +++ b/app/src/main/res/drawable/player_gradient_tv.xml @@ -4,10 +4,10 @@ @@ -15,10 +15,10 @@ diff --git a/app/src/main/res/drawable/rating_bg_color.xml b/app/src/main/res/drawable/rating_bg_color.xml index 4cf33aba0..3ae9b4f84 100644 --- a/app/src/main/res/drawable/rating_bg_color.xml +++ b/app/src/main/res/drawable/rating_bg_color.xml @@ -1,6 +1,6 @@ - + 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/rounded_select_ripple.xml b/app/src/main/res/drawable/rounded_select_ripple.xml deleted file mode 100644 index 5dd7559b3..000000000 --- a/app/src/main/res/drawable/rounded_select_ripple.xml +++ /dev/null @@ -1,14 +0,0 @@ - - - - - - - - - - - - - diff --git a/app/src/main/res/drawable/speedup.xml b/app/src/main/res/drawable/speedup.xml index 879ef852c..79ef428ac 100644 --- a/app/src/main/res/drawable/speedup.xml +++ b/app/src/main/res/drawable/speedup.xml @@ -1,10 +1,12 @@ + - - + + \ No newline at end of file diff --git a/app/src/main/res/drawable/subdl_logo_big.xml b/app/src/main/res/drawable/subdl_logo_big.xml index 12116eabc..a6cbb3115 100644 --- a/app/src/main/res/drawable/subdl_logo_big.xml +++ b/app/src/main/res/drawable/subdl_logo_big.xml @@ -1,12 +1,10 @@ - - - + android:width="20dp"> + + + 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-port/player_select_source_and_subs.xml b/app/src/main/res/layout-port/player_select_source_and_subs.xml deleted file mode 100644 index 4710473d4..000000000 --- a/app/src/main/res/layout-port/player_select_source_and_subs.xml +++ /dev/null @@ -1,171 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/layout-port/player_select_source_priority.xml b/app/src/main/res/layout-port/player_select_source_priority.xml deleted file mode 100644 index 2cba9c869..000000000 --- a/app/src/main/res/layout-port/player_select_source_priority.xml +++ /dev/null @@ -1,126 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/layout-port/subtitle_offset.xml b/app/src/main/res/layout-port/subtitle_offset.xml deleted file mode 100644 index b6c4f61fd..000000000 --- a/app/src/main/res/layout-port/subtitle_offset.xml +++ /dev/null @@ -1,147 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/layout/account_edit_dialog.xml b/app/src/main/res/layout/account_edit_dialog.xml index f52c8ea51..066b94342 100644 --- a/app/src/main/res/layout/account_edit_dialog.xml +++ b/app/src/main/res/layout/account_edit_dialog.xml @@ -40,28 +40,28 @@ - + > - + android:layout_gravity="center" /> + android:background="@color/skipOpTransparent" + /> + android:text="@string/lock_profile" /> + android:layout_marginTop="-60dp"> + style="@style/BlackButton" /> + style="@style/WhiteButton" /> + style="@style/BlackButton" /> \ No newline at end of file diff --git a/app/src/main/res/layout/account_list_item.xml b/app/src/main/res/layout/account_list_item.xml index 3cbfc72fb..f133d6c3f 100644 --- a/app/src/main/res/layout/account_list_item.xml +++ b/app/src/main/res/layout/account_list_item.xml @@ -6,11 +6,11 @@ android:id="@+id/card_view" android:layout_width="110dp" android:layout_height="110dp" - android:layout_margin="10dp" android:animateLayoutChanges="true" - android:backgroundTint="@color/primaryGrayBackground" - android:focusable="true" + android:backgroundTint="?attr/primaryGrayBackground" android:foreground="?attr/selectableItemBackground" + android:layout_margin="10dp" + android:focusable="true" app:cardCornerRadius="@dimen/rounded_image_radius" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintDimensionRatio="1" @@ -42,7 +42,6 @@ android:layout_margin="4dp" android:src="@drawable/video_locked" android:visibility="gone" - app:tint="@color/textColor" tools:visibility="visible" /> + android:textSize="16sp" /> \ No newline at end of file diff --git a/app/src/main/res/layout/account_list_item_edit.xml b/app/src/main/res/layout/account_list_item_edit.xml index 3f41a23c2..0adade19f 100644 --- a/app/src/main/res/layout/account_list_item_edit.xml +++ b/app/src/main/res/layout/account_list_item_edit.xml @@ -6,11 +6,11 @@ android:id="@+id/card_view" android:layout_width="110dp" android:layout_height="110dp" - android:layout_margin="10dp" android:animateLayoutChanges="true" - android:backgroundTint="@color/primaryGrayBackground" - android:focusable="true" + android:backgroundTint="?attr/primaryGrayBackground" android:foreground="?attr/selectableItemBackground" + android:layout_margin="10dp" + android:focusable="true" app:cardCornerRadius="@dimen/rounded_image_radius" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintDimensionRatio="1" @@ -42,7 +42,6 @@ android:layout_margin="4dp" android:src="@drawable/video_locked" android:visibility="gone" - app:tint="@color/textColor" tools:visibility="visible" /> + android:src="@drawable/ic_baseline_edit_24" /> + android:textSize="16sp" /> \ No newline at end of file diff --git a/app/src/main/res/layout/activity_account_select.xml b/app/src/main/res/layout/activity_account_select.xml index 9f62d5601..bd6007dcf 100644 --- a/app/src/main/res/layout/activity_account_select.xml +++ b/app/src/main/res/layout/activity_account_select.xml @@ -6,7 +6,7 @@ android:layout_width="match_parent" android:layout_height="match_parent"> - - + + android:layout_width="100dp" + android:layout_height="100dp" + android:importantForAccessibility="no"> + \ No newline at end of file diff --git a/app/src/main/res/layout/add_account_input.xml b/app/src/main/res/layout/add_account_input.xml index 4f96b109e..ea48a80f0 100644 --- a/app/src/main/res/layout/add_account_input.xml +++ b/app/src/main/res/layout/add_account_input.xml @@ -80,7 +80,6 @@ android:id="@+id/login_server_input" android:layout_width="match_parent" android:layout_height="wrap_content" - android:autofillHints="no" android:hint="@string/example_ip" android:inputType="textUri" android:nextFocusLeft="@id/apply_btt" @@ -97,7 +96,7 @@ android:layout_height="wrap_content" android:autofillHints="password" android:hint="@string/example_password" - android:inputType="textPassword" + android:inputType="textVisiblePassword" android:nextFocusLeft="@id/apply_btt" android:nextFocusRight="@id/cancel_btt" diff --git a/app/src/main/res/layout/add_repo_input.xml b/app/src/main/res/layout/add_repo_input.xml index a8bdf2a38..cb4224d10 100644 --- a/app/src/main/res/layout/add_repo_input.xml +++ b/app/src/main/res/layout/add_repo_input.xml @@ -81,7 +81,6 @@ android:id="@+id/repo_url_input" android:layout_width="match_parent" android:layout_height="wrap_content" - android:autofillHints="no" android:hint="@string/repository_url_hint" android:inputType="textUri" android:nextFocusLeft="@id/apply_btt" diff --git a/app/src/main/res/layout/add_site_input.xml b/app/src/main/res/layout/add_site_input.xml index 519b790da..1c61f8b4d 100644 --- a/app/src/main/res/layout/add_site_input.xml +++ b/app/src/main/res/layout/add_site_input.xml @@ -62,7 +62,6 @@ + xmlns:tools="http://schemas.android.com/tools" + android:nextFocusDown="@id/nginx_text_input" + android:orientation="vertical" + android:layout_width="match_parent" + android:layout_height="match_parent"> + android:id="@+id/nginx_text_input" + android:nextFocusRight="@id/cancel_btt" + android:nextFocusLeft="@id/apply_btt" + android:layout_marginBottom="60dp" + android:layout_marginHorizontal="10dp" + android:paddingTop="10dp" + android:requiresFadingEdge="vertical" + tools:text="nginx.com" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:layout_rowWeight="1" + android:inputType="text" + tools:ignore="LabelFor" /> + android:id="@+id/apply_btt_holder" + android:orientation="horizontal" + android:layout_gravity="bottom" + android:gravity="bottom|end" + android:layout_marginTop="-60dp" + android:layout_width="match_parent" + android:layout_height="60dp"> + style="@style/WhiteButton" + android:layout_gravity="center_vertical|end" + android:text="@string/sort_apply" + android:id="@+id/apply_btt" + android:layout_width="wrap_content" /> + style="@style/BlackButton" + android:layout_gravity="center_vertical|end" + android:text="@string/sort_cancel" + android:id="@+id/cancel_btt" + android:layout_width="wrap_content" /> diff --git a/app/src/main/res/layout/bottom_selection_dialog.xml b/app/src/main/res/layout/bottom_selection_dialog.xml index 55ca6562e..0532f2506 100644 --- a/app/src/main/res/layout/bottom_selection_dialog.xml +++ b/app/src/main/res/layout/bottom_selection_dialog.xml @@ -1,65 +1,58 @@ - - + android:layout_height="match_parent"> - - - - + android:layout_width="match_parent" + android:layout_rowWeight="1" + tools:text="Test" + android:layout_height="wrap_content" /> + android:nextFocusRight="@id/cancel_btt" + android:nextFocusLeft="@id/apply_btt" + + android:id="@+id/listview1" + android:layout_marginBottom="60dp" + android:paddingTop="10dp" + android:requiresFadingEdge="vertical" + tools:listitem="@layout/sort_bottom_single_choice" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:layout_rowWeight="1" /> + android:id="@+id/apply_btt_holder" + android:orientation="horizontal" + android:layout_gravity="bottom" + android:gravity="bottom|end" + android:layout_marginTop="-60dp" + android:layout_width="match_parent" + android:layout_height="60dp"> + style="@style/WhiteButton" + android:layout_gravity="center_vertical|end" + android:text="@string/sort_apply" + android:id="@+id/apply_btt" + android:layout_width="wrap_content" /> + style="@style/BlackButton" + android:layout_gravity="center_vertical|end" + android:text="@string/sort_cancel" + android:id="@+id/cancel_btt" + android:layout_width="wrap_content" /> 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/chromecast_subtitle_settings.xml b/app/src/main/res/layout/chromecast_subtitle_settings.xml index 92d0bd350..4d3b50dfe 100644 --- a/app/src/main/res/layout/chromecast_subtitle_settings.xml +++ b/app/src/main/res/layout/chromecast_subtitle_settings.xml @@ -1,21 +1,16 @@ - - - + android:layout_height="match_parent" + android:id="@+id/subs_root" + android:background="?attr/primaryBlackBackground"> - + android:layout_height="wrap_content"> - - - - - + - - - - - - - - - - - - + + - - - - + + \ No newline at end of file diff --git a/app/src/main/res/layout/confirm_exit_dialog.xml b/app/src/main/res/layout/confirm_exit_dialog.xml index c312e64e3..518aaa477 100644 --- a/app/src/main/res/layout/confirm_exit_dialog.xml +++ b/app/src/main/res/layout/confirm_exit_dialog.xml @@ -5,11 +5,9 @@ android:orientation="vertical" android:paddingHorizontal="16dp" android:paddingVertical="8dp"> - + android:text="@string/dont_show_again" /> diff --git a/app/src/main/res/layout/custom_preference_category_material.xml b/app/src/main/res/layout/custom_preference_category_material.xml index f5d78e835..06db99017 100644 --- a/app/src/main/res/layout/custom_preference_category_material.xml +++ b/app/src/main/res/layout/custom_preference_category_material.xml @@ -19,7 +19,9 @@ xmlns:android="http://schemas.android.com/apk/res/android" android:layout_width="match_parent" android:layout_height="wrap_content" + android:paddingLeft="?android:attr/listPreferredItemPaddingLeft" android:paddingStart="?android:attr/listPreferredItemPaddingStart" + android:paddingRight="?android:attr/listPreferredItemPaddingRight" android:paddingEnd="?android:attr/listPreferredItemPaddingEnd" android:background="@drawable/outline_drawable_less" android:baselineAligned="false" @@ -50,6 +52,7 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_below="@android:id/title" + android:layout_alignLeft="@android:id/title" android:layout_alignStart="@android:id/title" android:layout_gravity="start" android:textAlignment="viewStart" diff --git a/app/src/main/res/layout/custom_preference_material.xml b/app/src/main/res/layout/custom_preference_material.xml index c6685ee29..0ab98c22b 100644 --- a/app/src/main/res/layout/custom_preference_material.xml +++ b/app/src/main/res/layout/custom_preference_material.xml @@ -21,7 +21,9 @@ android:layout_height="wrap_content" android:minHeight="?android:attr/listPreferredItemHeightSmall" android:gravity="center_vertical" + android:paddingLeft="?android:attr/listPreferredItemPaddingLeft" android:paddingStart="?android:attr/listPreferredItemPaddingStart" + android:paddingRight="?android:attr/listPreferredItemPaddingRight" android:paddingEnd="?android:attr/listPreferredItemPaddingEnd" android:background="?attr/focusBackground" android:clipToPadding="false" @@ -49,6 +51,7 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_below="@android:id/title" + android:layout_alignLeft="@android:id/title" android:layout_alignStart="@android:id/title" android:layout_gravity="start" android:textAlignment="viewStart" @@ -64,7 +67,9 @@ android:layout_width="wrap_content" android:layout_height="match_parent" android:gravity="end|center_vertical" + android:paddingLeft="16dp" android:paddingStart="16dp" + android:paddingRight="0dp" android:paddingEnd="0dp" android:orientation="vertical"/> diff --git a/app/src/main/res/layout/custom_preference_widget_seekbar.xml b/app/src/main/res/layout/custom_preference_widget_seekbar.xml index 132091e5f..02c5ec1be 100644 --- a/app/src/main/res/layout/custom_preference_widget_seekbar.xml +++ b/app/src/main/res/layout/custom_preference_widget_seekbar.xml @@ -18,12 +18,13 @@ + android:ellipsize="marquee"/> @@ -96,7 +99,9 @@ android:layout_width="0dp" android:layout_weight="1" android:layout_height="wrap_content" + android:paddingLeft="@dimen/preference_seekbar_padding_horizontal" android:paddingStart="@dimen/preference_seekbar_padding_horizontal" + android:paddingRight="@dimen/preference_seekbar_padding_horizontal" android:paddingEnd="@dimen/preference_seekbar_padding_horizontal" android:paddingTop="@dimen/preference_seekbar_padding_vertical" android:paddingBottom="@dimen/preference_seekbar_padding_vertical" @@ -108,11 +113,13 @@ - + tools:ignore="UseCompoundDrawables"> + + + android:layout_marginEnd="40dp"> - - + + + + + + + + - + + + diff --git a/app/src/main/res/layout/download_child_episode.xml b/app/src/main/res/layout/download_child_episode.xml index cb9c13d53..e53e63d31 100644 --- a/app/src/main/res/layout/download_child_episode.xml +++ b/app/src/main/res/layout/download_child_episode.xml @@ -14,44 +14,30 @@ app:cardCornerRadius="@dimen/rounded_image_radius" app:cardElevation="0dp"> - - - - - - - - - - + - - - - - - - - + - + - - - - - - - - - - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/layout/empty_layout.xml b/app/src/main/res/layout/empty_layout.xml index e128f7cec..388e862b2 100644 --- a/app/src/main/res/layout/empty_layout.xml +++ b/app/src/main/res/layout/empty_layout.xml @@ -1,19 +1,18 @@ - + - - + + \ No newline at end of file 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_child_downloads.xml b/app/src/main/res/layout/fragment_child_downloads.xml index 0a7b42327..c3ab356c2 100644 --- a/app/src/main/res/layout/fragment_child_downloads.xml +++ b/app/src/main/res/layout/fragment_child_downloads.xml @@ -12,14 +12,14 @@ + android:background="@android:color/transparent"> @@ -33,7 +33,7 @@ android:padding="8dp" android:layout_gravity="center_vertical" android:nextFocusLeft="@id/navigation_downloads" - app:tint="?attr/white" /> + app:tint="@android:color/white" />