diff --git a/.github/locales.py b/.github/locales.py index a74d7258834..6127d9d806e 100644 --- a/.github/locales.py +++ b/.github/locales.py @@ -1,14 +1,13 @@ 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-" +XML_NAME = "app/src/main/res/values-b+" ISO_MAP_URL = "https://raw.githubusercontent.com/haliaeetus/iso-639/master/data/iso_639-1.min.json" INDENT = " "*4 @@ -21,29 +20,29 @@ # Load already added langs languages = {} -for lang in re.finditer(r'Triple\("(.*)", "(.*)", "(.*)"\)', rest): - flag, name, iso = lang.groups() - languages[iso] = (flag, name) +for lang in re.finditer(r'Pair\("(.*)", "(.*)"\)', rest): + name, iso = lang.groups() + languages[iso] = name # Add not yet added langs for folder in glob.glob(f"{XML_NAME}*"): - iso = folder[len(XML_NAME):] + iso = folder[len(XML_NAME):].replace("+", "-") if iso not in languages.keys(): - entry = iso_map.get(iso.lower(),{'nativeName':iso}) - languages[iso] = ("", entry['nativeName'].split(',')[0]) + 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 -# Create triples -triples = [] -for iso in sorted(languages.keys()): - flag, name = languages[iso] - triples.append(f'{INDENT}Triple("{flag}", "{name}", "{iso}"),') +# 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}"),') # Update settings file open(SETTINGS_PATH, "w+",encoding='utf-8').write( before_src + START_MARKER + "\n" + - "\n".join(triples) + + "\n".join(pairs) + "\n" + END_MARKER + after_src @@ -62,8 +61,5 @@ 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 f62f1ba055c..b5960d5d942 100644 --- a/.github/workflows/build_to_archive.yml +++ b/.github/workflows/build_to_archive.yml @@ -9,7 +9,10 @@ on: - '**/wcokey.txt' workflow_dispatch: -concurrency: +permissions: + contents: read + +concurrency: group: "Archive-build" cancel-in-progress: true @@ -24,6 +27,7 @@ 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 @@ -31,14 +35,18 @@ jobs: app_id: ${{ secrets.GH_APP_ID }} private_key: ${{ secrets.GH_APP_KEY }} repository: "recloudstream/cloudstream-archive" - - uses: actions/checkout@v4 + + - uses: actions/checkout@v6 + - name: Set up JDK 17 - uses: actions/setup-java@v4 + uses: actions/setup-java@v5 with: - java-version: '17' - distribution: 'adopt' + distribution: temurin + java-version: 17 + - name: Grant execute permission for gradlew run: chmod +x gradlew + - name: Fetch keystore id: fetch_keystore run: | @@ -49,25 +57,31 @@ 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 assemblePrerelease + run: ./gradlew assemblePrereleaseRelease 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 }} - - uses: actions/checkout@v4 + MDL_API_KEY: ${{ secrets.MDL_API_KEY }} + + - uses: actions/checkout@v6 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 @@ -75,4 +89,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 \ No newline at end of file + git push --force diff --git a/.github/workflows/generate_dokka.yml b/.github/workflows/generate_dokka.yml index 666e2ba1078..d67b8a519d7 100644 --- a/.github/workflows/generate_dokka.yml +++ b/.github/workflows/generate_dokka.yml @@ -1,19 +1,18 @@ 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: - # choose your default branch - - master - - main + branches: [ master ] paths-ignore: - '*.md' +permissions: + contents: read + +concurrency: + group: "dokka" + cancel-in-progress: true + jobs: build: runs-on: ubuntu-latest @@ -25,32 +24,35 @@ jobs: app_id: ${{ secrets.GH_APP_ID }} private_key: ${{ secrets.GH_APP_KEY }} repository: "recloudstream/dokka" + - name: Checkout - uses: actions/checkout@master + uses: actions/checkout@v6 with: path: "src" - name: Checkout dokka - uses: actions/checkout@master + uses: actions/checkout@v6 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: Setup JDK 17 - uses: actions/setup-java@v4 + - name: Set up JDK 17 + uses: actions/setup-java@v5 with: + distribution: temurin java-version: 17 - distribution: 'adopt' - - name: Setup Android SDK - uses: android-actions/setup-android@v3 + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v5 + with: + cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} - name: Generate Dokka run: | @@ -59,8 +61,7 @@ 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 index 88ab3656ce9..e354d657d50 100644 --- a/.github/workflows/issue_action.yml +++ b/.github/workflows/issue_action.yml @@ -4,6 +4,10 @@ on: issues: types: [opened] +permissions: + contents: read + issues: write + jobs: issue-moderator: runs-on: ubuntu-latest @@ -14,6 +18,7 @@ jobs: 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 @@ -25,9 +30,10 @@ jobs: ### 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 + uses: actions/github-script@v9 with: github-token: ${{ steps.generate_token.outputs.token }} script: | @@ -37,7 +43,9 @@ jobs: repo: context.repo.repo, labels: ["possible duplicate"] }) - - uses: actions/checkout@v4 + + - uses: actions/checkout@v6 + - name: Automatically close issues that dont follow the issue template uses: lucasbento/auto-close-issues@v1.0.2 with: @@ -46,6 +54,7 @@ jobs: @${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: @@ -55,6 +64,7 @@ jobs: 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 @@ -66,9 +76,10 @@ jobs: 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 + uses: actions/github-script@v9 with: github-token: ${{ steps.generate_token.outputs.token }} script: | @@ -78,11 +89,10 @@ jobs: 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 dd608b321e3..d9a20a04b2b 100644 --- a/.github/workflows/prerelease.yml +++ b/.github/workflows/prerelease.yml @@ -8,10 +8,13 @@ on: - '*.json' - '**/wcokey.txt' -concurrency: +concurrency: group: "pre-release" cancel-in-progress: true +permissions: + contents: write + jobs: build: runs-on: ubuntu-latest @@ -23,14 +26,18 @@ jobs: app_id: ${{ secrets.GH_APP_ID }} private_key: ${{ secrets.GH_APP_KEY }} repository: "recloudstream/secrets" - - uses: actions/checkout@v4 + + - uses: actions/checkout@v6 + - name: Set up JDK 17 - uses: actions/setup-java@v4 + uses: actions/setup-java@v5 with: - java-version: '17' - distribution: 'adopt' + distribution: temurin + java-version: 17 + - name: Grant execute permission for gradlew run: chmod +x gradlew + - name: Fetch keystore id: fetch_keystore run: | @@ -41,10 +48,14 @@ 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 assemblePrerelease build androidSourcesJar - ./gradlew makeJar # for classes.jar, has to be done after assemblePrerelease + run: ./gradlew assemblePrereleaseRelease androidSourcesJar makeJar env: SIGNING_KEY_ALIAS: "key0" SIGNING_KEY_PASSWORD: ${{ steps.fetch_keystore.outputs.key_pwd }} @@ -52,8 +63,9 @@ jobs: SIMKL_CLIENT_ID: ${{ secrets.SIMKL_CLIENT_ID }} SIMKL_CLIENT_SECRET: ${{ secrets.SIMKL_CLIENT_SECRET }} MDL_API_KEY: ${{ secrets.MDL_API_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 7f6dd412356..675ce3b2f77 100644 --- a/.github/workflows/pull_request.yml +++ b/.github/workflows/pull_request.yml @@ -2,22 +2,35 @@ name: Artifact Build on: [pull_request] +permissions: + contents: read + jobs: build: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 + - name: Set up JDK 17 - uses: actions/setup-java@v4 + uses: actions/setup-java@v5 with: - java-version: '17' - distribution: 'adopt' + distribution: temurin + java-version: 17 + - 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 + run: ./gradlew assemblePrereleaseDebug lint + - name: Upload Artifact - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 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 ce140e55993..0a538d5d4da 100644 --- a/.github/workflows/update_locales.yml +++ b/.github/workflows/update_locales.yml @@ -1,17 +1,19 @@ name: Fix locale issues on: - workflow_dispatch: push: + branches: [ master ] paths: - '**.xml' - branches: - - master + workflow_dispatch: -concurrency: +concurrency: group: "locale" cancel-in-progress: true +permissions: + contents: read + jobs: create: runs-on: ubuntu-latest @@ -23,15 +25,17 @@ jobs: app_id: ${{ secrets.GH_APP_ID }} private_key: ${{ secrets.GH_APP_KEY }} repository: "recloudstream/cloudstream" - - uses: actions/checkout@v4 + + - uses: actions/checkout@v6 with: token: ${{ steps.generate_token.outputs.token }} + - name: Install dependencies - run: | - pip3 install lxml + run: pip3 install lxml requests + - 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/.gitignore b/.gitignore index 2ac6c9695ca..5fc9f0870b6 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,3 @@ -*.iml -.gradle /local.properties /.idea/caches /.idea/misc.xml @@ -11,6 +9,220 @@ .DS_Store /build /captures -.externalNativeBuild .cxx +.kotlin/* + +# Created by https://www.toptal.com/developers/gitignore/api/kotlin,java,android,androidstudio,visualstudiocode +# Edit at https://www.toptal.com/developers/gitignore?templates=kotlin,java,android,androidstudio,visualstudiocode + +### Android ### +# Gradle files +.gradle/ +build/ + +# Local configuration file (sdk path, etc) local.properties + +# Log/OS Files +*.log + +# Android Studio generated files and folders +captures/ +.externalNativeBuild/ +.cxx/ +*.apk +output.json + +# IntelliJ +*.iml +.idea/ +misc.xml +deploymentTargetDropDown.xml +render.experimental.xml + +# Keystore files +*.jks +*.keystore + +# Google Services (e.g. APIs or Firebase) +google-services.json + +# Android Profiling +*.hprof + +### Android Patch ### +gen-external-apklibs + +# Replacement of .externalNativeBuild directories introduced +# with Android Studio 3.5. + +### Java ### +# Compiled class file +*.class + +# Log file + +# BlueJ files +*.ctxt + +# Mobile Tools for Java (J2ME) +.mtj.tmp/ + +# Package Files # +*.jar +*.war +*.nar +*.ear +*.zip +*.tar.gz +*.rar + +# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml +hs_err_pid* +replay_pid* + +### Kotlin ### +# Compiled class file + +# Log file + +# BlueJ files + +# Mobile Tools for Java (J2ME) + +# Package Files # + +# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml + +### VisualStudioCode ### +.vscode/* + +# Local History for Visual Studio Code +.history/ + +# Built Visual Studio Code Extensions +*.vsix + +### VisualStudioCode Patch ### +# Ignore all local history of files +.history +.ionide + +### AndroidStudio ### +# Covers files to be ignored for android development using Android Studio. + +# Built application files +*.ap_ +*.aab + +# Files for the ART/Dalvik VM +*.dex + +# Java class files + +# Generated files +bin/ +gen/ +out/ + +# Gradle files +.gradle + +# Signing files +.signing/ + +# Local configuration file (sdk path, etc) + +# Proguard folder generated by Eclipse +proguard/ + +# Log Files + +# Android Studio +/*/build/ +/*/local.properties +/*/out +/*/*/build +/*/*/production +.navigation/ +*.ipr +*~ +*.swp + +# Keystore files + +# Google Services (e.g. APIs or Firebase) +# google-services.json + +# Android Patch + +# External native build folder generated in Android Studio 2.2 and later +.externalNativeBuild + +# NDK +obj/ + +# IntelliJ IDEA +*.iws +/out/ + +# User-specific configurations +.idea/caches/ +.idea/libraries/ +.idea/shelf/ +.idea/workspace.xml +.idea/tasks.xml +.idea/.name +.idea/compiler.xml +.idea/copyright/profiles_settings.xml +.idea/encodings.xml +.idea/misc.xml +.idea/modules.xml +.idea/scopes/scope_settings.xml +.idea/dictionaries +.idea/vcs.xml +.idea/jsLibraryMappings.xml +.idea/datasources.xml +.idea/dataSources.ids +.idea/sqlDataSources.xml +.idea/dynamic.xml +.idea/uiDesigner.xml +.idea/assetWizardSettings.xml +.idea/gradle.xml +.idea/jarRepositories.xml +.idea/navEditor.xml + +# Legacy Eclipse project files +.classpath +.project +.cproject +.settings/ + +# Mobile Tools for Java (J2ME) + +# Package Files # + +# virtual machine crash logs (Reference: http://www.java.com/en/download/help/error_hotspot.xml) + +## Plugin-specific files: + +# mpeltonen/sbt-idea plugin +.idea_modules/ + +# JIRA plugin +atlassian-ide-plugin.xml + +# Mongo Explorer plugin +.idea/mongoSettings.xml + +# Crashlytics plugin (for Android Studio and IntelliJ) +com_crashlytics_export_strings.xml +crashlytics.properties +crashlytics-build.properties +fabric.properties + +### AndroidStudio Patch ### + +!/gradle/wrapper/gradle-wrapper.jar + +# End of https://www.toptal.com/developers/gitignore/api/kotlin,java,android,androidstudio,visualstudiocode diff --git a/.idea/.name b/.idea/.name deleted file mode 100644 index 1eb497a9359..00000000000 --- a/.idea/.name +++ /dev/null @@ -1 +0,0 @@ -CloudStream \ No newline at end of file diff --git a/.idea/codeStyles/Project.xml b/.idea/codeStyles/Project.xml deleted file mode 100644 index 7643783a82f..00000000000 --- a/.idea/codeStyles/Project.xml +++ /dev/null @@ -1,123 +0,0 @@ - - - - - - - - - - \ No newline at end of file diff --git a/.idea/codeStyles/codeStyleConfig.xml b/.idea/codeStyles/codeStyleConfig.xml deleted file mode 100644 index 79ee123c2b2..00000000000 --- a/.idea/codeStyles/codeStyleConfig.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - - \ No newline at end of file diff --git a/.idea/compiler.xml b/.idea/compiler.xml deleted file mode 100644 index b589d56e9f2..00000000000 --- a/.idea/compiler.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/.idea/discord.xml b/.idea/discord.xml deleted file mode 100644 index d8e95616687..00000000000 --- a/.idea/discord.xml +++ /dev/null @@ -1,7 +0,0 @@ - - - - - \ No newline at end of file diff --git a/.idea/gradle.xml b/.idea/gradle.xml deleted file mode 100644 index d06216153db..00000000000 --- a/.idea/gradle.xml +++ /dev/null @@ -1,22 +0,0 @@ - - - - - - - \ No newline at end of file diff --git a/.idea/jarRepositories.xml b/.idea/jarRepositories.xml deleted file mode 100644 index 333d49373bb..00000000000 --- a/.idea/jarRepositories.xml +++ /dev/null @@ -1,40 +0,0 @@ - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/.idea/studiobot.xml b/.idea/studiobot.xml deleted file mode 100644 index 9298202cbef..00000000000 --- a/.idea/studiobot.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml deleted file mode 100644 index 35eb1ddfbbc..00000000000 --- a/.idea/vcs.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json deleted file mode 100644 index 7282979ad72..00000000000 --- a/.vscode/settings.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "githubPullRequests.ignoredPullRequestBranches": [ - "master" - ], - "java.configuration.updateBuildConfiguration": "interactive" -} \ No newline at end of file diff --git a/AI-POLICY.md b/AI-POLICY.md new file mode 100644 index 00000000000..5409393fb18 --- /dev/null +++ b/AI-POLICY.md @@ -0,0 +1,11 @@ +# 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 64fd1f873c9..ae530192998 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,53 +1,95 @@ 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 { - id("com.android.application") - id("kotlin-android") - id("org.jetbrains.dokka") + alias(libs.plugins.android.application) + alias(libs.plugins.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() - -fun getGitCommitHash(): String { - return try { - val headFile = file("${project.rootDir}/.git/HEAD") - - // 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 + +abstract class GenerateGitHashTask : DefaultTask() { + + @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) } } +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 } - viewBinding { - enable = 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 + ) + } } signingConfigs { - if (prereleaseStoreFile != null) { + // 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) { create("prerelease") { - storeFile = file(prereleaseStoreFile) + val tmpFilePath = System.getProperty("user.home") + "/work/_temp/keystore/" + val prereleaseStoreFile: File? = File(tmpFilePath).listFiles()?.first() + + storeFile = prereleaseStoreFile?.let { file(it) } storePassword = System.getenv("SIGNING_STORE_PASSWORD") keyAlias = System.getenv("SIGNING_KEY_ALIAS") keyPassword = System.getenv("SIGNING_KEY_PASSWORD") @@ -61,12 +103,10 @@ android { applicationId = "com.lagradost.cloudstream3" minSdk = libs.versions.minSdk.get().toInt() targetSdk = libs.versions.targetSdk.get().toInt() - versionCode = 66 - versionName = "4.5.2" + versionCode = 68 + versionName = "4.7.0" - resValue("string", "app_version", "${defaultConfig.versionName}${versionNameSuffix ?: ""}") - resValue("string", "commit_hash", getGitCommitHash()) - resValue("bool", "is_prerelease", "false") + manifestPlaceholders["target_sdk_version"] = libs.versions.targetSdk.get() // Reads local.properties val localProperties = gradleLocalProperties(rootDir, project.providers) @@ -113,12 +153,9 @@ 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") @@ -136,13 +173,29 @@ 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" @@ -159,37 +212,37 @@ dependencies { // Android Core & Lifecycle implementation(libs.core.ktx) + implementation(libs.activity.ktx) + implementation(libs.annotation) implementation(libs.appcompat) - implementation(libs.bundles.navigationKtx) - implementation(libs.lifecycle.livedata.ktx) - implementation(libs.lifecycle.viewmodel.ktx) + implementation(libs.fragment.ktx) + implementation(libs.bundles.lifecycle) + implementation(libs.bundles.navigation) + implementation(libs.kotlinx.collections.immutable) // Design & UI implementation(libs.preference.ktx) implementation(libs.material) implementation(libs.constraintlayout) - implementation(libs.swiperefreshlayout) // Coil Image Loading - implementation(libs.coil) - implementation(libs.coil.network.okhttp) + implementation(libs.bundles.coil) // 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 @@ -200,50 +253,32 @@ 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) { - 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 + implementation(libs.conscrypt.android) // To Fix SSL Fu*kery on Android 9 + implementation(libs.jackson.module.kotlin) // JSON Parser + implementation(libs.zipline) // Torrent Support implementation(libs.torrentserver) // Downloading & Networking - implementation(libs.work.runtime) implementation(libs.work.runtime.ktx) implementation(libs.nicehttp) // HTTP Lib - 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) - }) + implementation(project(":library")) } tasks.register("androidSourcesJar") { archiveClassifier.set("sources") - from(android.sourceSets.getByName("main").java.srcDirs) // Full Sources + from(android.sourceSets.getByName("main").java.directories) // Full Sources } tasks.register("copyJar") { + dependsOn("build", ":library:jvmJar") from( "build/intermediates/compile_app_classes_jar/prereleaseDebug/bundlePrereleaseDebugClassesToCompileJar", "../library/build/libs" @@ -270,15 +305,22 @@ tasks.register("makeJar") { tasks.withType { compilerOptions { jvmTarget.set(javaTarget) - freeCompilerArgs.add("-Xjvm-default=all-compatibility") + jvmDefault.set(JvmDefaultMode.ENABLE) + freeCompilerArgs.add("-Xannotation-default-target=param-property") + optIn.addAll( + "com.lagradost.cloudstream3.InternalAPI", + "com.lagradost.cloudstream3.Prerelease", + ) } } dokka { moduleName = "App" dokkaSourceSets { - main { + configureEach { + suppress = name != "prereleaseDebug" analysisPlatform = KotlinPlatform.JVM + displayName = "JVM" documentedVisibilities( VisibilityModifier.Public, VisibilityModifier.Protected diff --git a/app/lint.xml b/app/lint.xml new file mode 100644 index 00000000000..b2f5e8f2bc3 --- /dev/null +++ b/app/lint.xml @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/app/src/androidTest/java/com/lagradost/cloudstream3/ExampleInstrumentedTest.kt b/app/src/androidTest/java/com/lagradost/cloudstream3/ExampleInstrumentedTest.kt index 0adfc1faedf..4c5cdea5bee 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 isoNames = SubtitleHelper.languages.map { it.ISO_639_1 } - Assert.assertFalse("ISO does not contain any languages", isoNames.isNullOrEmpty()) + val langTagsIETF = SubtitleHelper.languages.map { it.IETF_tag } + Assert.assertFalse("IETFTagNames does not contain any languages", langTagsIETF.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", - isoNames.contains(api.lang) + langTagsIETF.contains(api.lang) ) Assert.assertTrue( "Api ${api.name} does not contain any supported types", diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index d960d910c6a..ee4c978f2be 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -2,8 +2,6 @@ - - @@ -18,12 +16,53 @@ - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + tools:targetApi="${target_sdk_version}"> + android:supportsPictureInPicture="true" /> + + + + + + + + + + + + @@ -144,7 +200,14 @@ + + + + + + + @@ -168,7 +231,7 @@ - + @@ -181,21 +244,6 @@ - - - - - - - - - - - - + + -#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 9f493fbbc75..bbe7d97debc 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/AcraApplication.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/AcraApplication.kt @@ -1,233 +1,78 @@ 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.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 - -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() - ) - - thread { // to not run it on main thread - runBlocking { - safeAsync { - app.post(url, data = data) - //println("Report response: $post") - } - } - } - - 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) - } - +/** + * 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 { + + @Deprecated( + message = "AcraApplication is deprecated, use CloudStreamApp instead", + replaceWith = ReplaceWith("com.lagradost.cloudstream3.CloudStreamApp.context"), + level = DeprecationLevel.WARNING + ) + val context get() = CloudStreamApp.context + + @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) + + @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) + } } - -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 new file mode 100644 index 00000000000..a9cd9c01edd --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/CloudStreamApp.kt @@ -0,0 +1,181 @@ +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 9a8e274f5ab..ed0aaf9b761 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/CommonActivity.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/CommonActivity.kt @@ -1,13 +1,16 @@ package com.lagradost.cloudstream3 -import android.Manifest +import android.annotation.SuppressLint 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 @@ -24,34 +27,41 @@ 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.AcraApplication.Companion.getKey -import com.lagradost.cloudstream3.AcraApplication.Companion.removeKey +import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey +import com.lagradost.cloudstream3.CloudStreamApp.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.ui.player.PlayerEventType +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.Torrent -import com.lagradost.cloudstream3.ui.settings.Globals.TV +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.utils.UiText +import com.lagradost.cloudstream3.ui.settings.Globals.TV 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 -import com.lagradost.cloudstream3.utils.UIHelper.hasPIPPermission -import com.lagradost.cloudstream3.utils.UIHelper.shouldShowPIPMode +import com.lagradost.cloudstream3.utils.UIHelper.showInputMethod import com.lagradost.cloudstream3.utils.UIHelper.toPx -import org.schabi.newpipe.extractor.NewPipe +import com.lagradost.cloudstream3.utils.UiText 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, @@ -91,17 +101,24 @@ object CommonActivity { get() { return min(displayMetrics.widthPixels, displayMetrics.heightPixels) } + val screenWidthWithOrientation: Int + get() { + return displayMetrics.widthPixels + } + val screenHeightWithOrientation: Int + get() { + return displayMetrics.heightPixels + } - - var canEnterPipMode: Boolean = false - var canShowPipMode: Boolean = false + var isPipDesired: 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 @@ -174,23 +191,35 @@ 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) } } /** - * 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) + * 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) val resources: Resources = context.resources val config = resources.configuration Locale.setDefault(locale) @@ -198,6 +227,7 @@ object CommonActivity { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) context.createConfigurationContext(config) + @Suppress("DEPRECATION") resources.updateConfiguration( config, @@ -214,18 +244,11 @@ 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() NewPipe.init(DownloaderTestImpl.getInstance()) MainActivity.activityResultLauncher = @@ -238,7 +261,7 @@ object CommonActivity { ?: return@registerForActivityResult action.onResultSafe(act, result.data) removeKey("last_click_action") - removeKey("last_opened_id") + removeKey("last_opened") } } @@ -260,13 +283,15 @@ object CommonActivity { } } + /** Enters pip mode if it is both possible and desired to do so*/ private fun Activity.enterPIPMode() { - if (!shouldShowPIPMode(canEnterPipMode) || !canShowPipMode) return + if (!isPipDesired || !this.isPIPPossible()) return + try { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { try { enterPictureInPictureMode(PictureInPictureParams.Builder().build()) - } catch (e: Exception) { + } catch (_: Exception) { // Use fallback just in case @Suppress("DEPRECATION") enterPictureInPictureMode() @@ -282,10 +307,10 @@ object CommonActivity { } } - fun onUserLeaveHint(act: Activity?) { - if (canEnterPipMode && canShowPipMode) { - act?.enterPIPMode() - } + 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 updateTheme(act: Activity) { @@ -325,6 +350,10 @@ 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 } @@ -360,6 +389,8 @@ 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( @@ -392,8 +423,7 @@ object CommonActivity { private fun View.hasContent(): Boolean { return isShown && when (this) { - //is RecyclerView -> this.childCount > 0 - is ViewGroup -> this.childCount > 0 + is ViewGroup -> this.isNotEmpty() else -> true } } @@ -423,7 +453,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.childCount > 0 + parent.descendantFocusability == ViewGroup.FOCUS_AFTER_DESCENDANTS && parent.isNotEmpty() } ?: false if (!next.isFocusable && shown && !hasChildrenThatWantsFocus) return null @@ -502,87 +532,7 @@ object CommonActivity { fun onKeyDown(act: Activity?, keyCode: Int, event: KeyEvent?): Boolean? { - - // 149 keycode_numpad 5 - val playerEvent = when (keyCode) { - KeyEvent.KEYCODE_FORWARD, KeyEvent.KEYCODE_D, KeyEvent.KEYCODE_MEDIA_SKIP_FORWARD, KeyEvent.KEYCODE_MEDIA_FAST_FORWARD -> { - PlayerEventType.SeekForward - } - - KeyEvent.KEYCODE_A, KeyEvent.KEYCODE_MEDIA_SKIP_BACKWARD, KeyEvent.KEYCODE_MEDIA_REWIND -> { - PlayerEventType.SeekBack - } - - KeyEvent.KEYCODE_MEDIA_NEXT, KeyEvent.KEYCODE_BUTTON_R1, KeyEvent.KEYCODE_N, KeyEvent.KEYCODE_NUMPAD_2, KeyEvent.KEYCODE_CHANNEL_UP -> { - PlayerEventType.NextEpisode - } - - KeyEvent.KEYCODE_MEDIA_PREVIOUS, KeyEvent.KEYCODE_BUTTON_L1, KeyEvent.KEYCODE_B, KeyEvent.KEYCODE_NUMPAD_1, KeyEvent.KEYCODE_CHANNEL_DOWN -> { - PlayerEventType.PrevEpisode - } - - KeyEvent.KEYCODE_MEDIA_PAUSE -> { - PlayerEventType.Pause - } - - KeyEvent.KEYCODE_MEDIA_PLAY, KeyEvent.KEYCODE_BUTTON_START -> { - PlayerEventType.Play - } - - KeyEvent.KEYCODE_L, KeyEvent.KEYCODE_NUMPAD_7, KeyEvent.KEYCODE_7 -> { - PlayerEventType.Lock - } - - KeyEvent.KEYCODE_H, KeyEvent.KEYCODE_MENU -> { - PlayerEventType.ToggleHide - } - - KeyEvent.KEYCODE_M, KeyEvent.KEYCODE_VOLUME_MUTE -> { - PlayerEventType.ToggleMute - } - - KeyEvent.KEYCODE_S, KeyEvent.KEYCODE_NUMPAD_9, KeyEvent.KEYCODE_9 -> { - PlayerEventType.ShowMirrors - } - // OpenSubtitles shortcut - KeyEvent.KEYCODE_O, KeyEvent.KEYCODE_NUMPAD_8, KeyEvent.KEYCODE_8 -> { - PlayerEventType.SearchSubtitlesOnline - } - - KeyEvent.KEYCODE_E, KeyEvent.KEYCODE_NUMPAD_3, KeyEvent.KEYCODE_3 -> { - PlayerEventType.ShowSpeed - } - - KeyEvent.KEYCODE_R, KeyEvent.KEYCODE_NUMPAD_0, KeyEvent.KEYCODE_0 -> { - PlayerEventType.Resize - } - - KeyEvent.KEYCODE_C, KeyEvent.KEYCODE_NUMPAD_4, KeyEvent.KEYCODE_4 -> { - PlayerEventType.SkipOp - } - - KeyEvent.KEYCODE_V, KeyEvent.KEYCODE_NUMPAD_5, KeyEvent.KEYCODE_5 -> { - PlayerEventType.SkipCurrentChapter - } - - KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE, KeyEvent.KEYCODE_P, KeyEvent.KEYCODE_SPACE, KeyEvent.KEYCODE_NUMPAD_ENTER, KeyEvent.KEYCODE_ENTER -> { // space is not captured due to navigation - PlayerEventType.PlayPauseToggle - } - - else -> return null - } - val listener = playerEventListener - if (listener != null) { - listener.invoke(playerEvent) - return true - } return null - - //when (keyCode) { - // KeyEvent.KEYCODE_DPAD_CENTER -> { - // println("DPAD PRESSED") - // } - //} } /** overrides focus and custom key events */ @@ -619,6 +569,7 @@ object CommonActivity { else -> null } + // println("NEXT FOCUS : $nextView") if (nextView != null) { nextView.requestFocus() @@ -626,10 +577,13 @@ 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. + @SuppressLint("RestrictedApi") if (keyCode == KeyEvent.KEYCODE_DPAD_CENTER && (act.currentFocus is SearchView || act.currentFocus is SearchView.SearchAutoComplete) ) { - UIHelper.showInputMethod(act.currentFocus?.findFocus()) + showInputMethod(act.currentFocus?.findFocus()) } //println("Keycode: $keyCode") @@ -638,7 +592,6 @@ 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 diff --git a/app/src/main/java/com/lagradost/cloudstream3/HeaderDecorationBindingAdapter.kt b/app/src/main/java/com/lagradost/cloudstream3/HeaderDecorationBindingAdapter.kt deleted file mode 100644 index 045a7963ad0..00000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/HeaderDecorationBindingAdapter.kt +++ /dev/null @@ -1,11 +0,0 @@ -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 7f5c499ffdc..8a98bd2972e 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt @@ -9,7 +9,6 @@ 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 @@ -24,15 +23,16 @@ 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.constraintlayout.widget.ConstraintLayout +import androidx.core.content.edit +import androidx.core.net.toUri import androidx.core.view.children +import androidx.core.view.get import androidx.core.view.isGone import androidx.core.view.isInvisible import androidx.core.view.isVisible @@ -50,6 +50,7 @@ import androidx.preference.PreferenceManager import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearSnapHelper import androidx.recyclerview.widget.RecyclerView +import androidx.viewpager2.widget.ViewPager2 import com.google.android.gms.cast.framework.CastContext import com.google.android.gms.cast.framework.Session import com.google.android.gms.cast.framework.SessionManager @@ -63,9 +64,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.AcraApplication.Companion.getKey -import com.lagradost.cloudstream3.AcraApplication.Companion.removeKey -import com.lagradost.cloudstream3.AcraApplication.Companion.setKey +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.loadThemes import com.lagradost.cloudstream3.CommonActivity.onColorSelectedEvent import com.lagradost.cloudstream3.CommonActivity.onDialogDismissedEvent @@ -90,14 +91,13 @@ import com.lagradost.cloudstream3.plugins.PluginManager.___DO_NOT_CALL_FROM_A_PL import com.lagradost.cloudstream3.plugins.PluginManager.loadSinglePlugin import com.lagradost.cloudstream3.receivers.VideoDownloadRestartReceiver import com.lagradost.cloudstream3.services.SubscriptionWorkManager +import com.lagradost.cloudstream3.syncproviders.AccountManager import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.APP_STRING import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.APP_STRING_PLAYER 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.OAuth2Apis -import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.accountManagers -import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.inAppAuths +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,6 +119,7 @@ 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 @@ -156,17 +157,20 @@ 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.Companion.runAutoUpdate +import com.lagradost.cloudstream3.utils.InAppUpdater.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 @@ -184,7 +188,9 @@ 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 { @@ -194,6 +200,21 @@ 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" @@ -255,7 +276,6 @@ 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?, @@ -275,28 +295,29 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa loadRepository(realUrl) return true } else if (str.contains(APP_STRING)) { - for (api in OAuth2Apis) { - if (str.contains("/${api.redirectUrl}")) { + for (api in AccountManager.allApis) { + if (api.isValidRedirectUrl(str)) { ioSafe { Log.i(TAG, "handleAppIntent $str") - val isSuccessful = api.handleRedirect(str) - - if (isSuccessful) { - Log.i(TAG, "authenticated ${api.name}") - } else { - Log.i(TAG, "failed to authenticate ${api.name}") - } - - this@with.runOnUiThread { - try { - showToast( - getString(if (isSuccessful) R.string.authenticated_user else R.string.authenticated_user_fail).format( - api.name - ) - ) - } catch (e: Exception) { - logError(e) // format might fail + try { + val isSuccessful = api.login(str) + if (isSuccessful) { + Log.i(TAG, "authenticated ${api.name}") + } else { + Log.i(TAG, "failed to authenticate ${api.name}") } + showToast( + if (isSuccessful) { + txt(R.string.authenticated_user, api.name) + } else { + txt(R.string.authenticated_user_fail, api.name) + } + ) + } catch (t: Throwable) { + logError(t) + showToast( + txt(R.string.authenticated_user_fail, api.name) + ) } } return true @@ -331,7 +352,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 = Uri.parse(str) + val uri = str.toUri() val name = uri.getQueryParameter("name") val url = URLDecoder.decode(uri.authority, "UTF-8") @@ -341,7 +362,8 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa LinkGenerator( listOf(BasicLink(url, name)), extract = true, - ) + id = url.hashCode() + ), 0 ) ) } else if (safeURI(str)?.scheme == APP_STRING_RESUME_WATCHING) { @@ -357,6 +379,20 @@ 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) @@ -385,9 +421,29 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa } 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) @@ -403,7 +459,8 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa syncViewModel.clear() } - if (load) { + lastPopupJob?.cancel() + lastPopupJob = if (load) { viewModel.load( this, result.url, result.apiName, false, if (getApiDubstatusSettings() .contains(DubStatus.Dubbed) @@ -450,6 +507,7 @@ 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, @@ -464,7 +522,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, @@ -495,25 +553,19 @@ 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 && landscape - navView.isVisible = isNavVisible && !landscape + 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 + } + } /** * We need to make sure if we return to a sub-fragment, @@ -521,7 +573,11 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa * highlight the wrong one in UI. */ when (destination.id) { - in listOf(R.id.navigation_downloads, R.id.navigation_download_child) -> { + in listOf( + R.id.navigation_downloads, + R.id.navigation_download_child, + R.id.navigation_download_queue + ) -> { navRailView.menu.findItem(R.id.navigation_downloads).isChecked = true navView.menu.findItem(R.id.navigation_downloads).isChecked = true } @@ -643,7 +699,9 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa .setNegativeButton(R.string.no) { _, _ -> /*NO-OP*/ } .setPositiveButton(R.string.yes) { _, _ -> if (dontShowAgainCheck.isChecked) { - settingsManager.edit().putInt(getString(R.string.confirm_exit_key), 1).commit() + settingsManager.edit(commit = true) { + putInt(getString(R.string.confirm_exit_key), 1) + } } // finish() causes a bug on some TVs where player // may keep playing after closing the app. @@ -668,10 +726,11 @@ 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) } @@ -680,6 +739,7 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa if (intent == null) return val str = intent.dataString loadCache() + handleAppIntentUrl(this, str, false, intent.extras) } @@ -698,6 +758,36 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa // Check if we are already at the selected destination if (navController.currentDestination?.id == destinationId) return false + // Make all nav buttons focus on this specific view when nextFocusRightId + val targetView = when (destinationId) { + // Please note that if R.id.navigation_home is readded, then it will only take affect when + // navigation to home for the second time as onNavDestinationSelected will not get called + // when first loading up the app + + // R.id.navigation_home -> R.id.home_preview_change_api + R.id.navigation_search -> R.id.main_search + R.id.navigation_library -> R.id.main_search + R.id.navigation_downloads -> R.id.download_appbar + else -> null + } + if (targetView != null && isLayout(TV or EMULATOR)) { + val fromView = binding?.navRailView + if (fromView != null) { + fromView.nextFocusRightId = targetView + + for (focusView in arrayOf( + R.id.navigation_downloads, + R.id.navigation_home, + R.id.navigation_search, + R.id.navigation_library, + R.id.navigation_settings, + )) { + fromView.findViewById(focusView)?.nextFocusRightId = targetView + } + } + } + + val builder = NavOptions.Builder().setLaunchSingleTop(true).setRestoreState(true) .setEnterAnim(R.anim.enter_anim) .setExitAnim(R.anim.exit_anim) @@ -719,6 +809,7 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa } } + private val pluginsLock = Mutex() private fun onAllPluginsLoaded(success: Boolean = false) { ioSafe { @@ -769,6 +860,8 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa private fun hidePreviewPopupDialog() { bottomPreviewPopup.dismissSafe(this) + lastPopupJob?.cancel() + lastPopupJob = null bottomPreviewPopup = null bottomPreviewBinding = null } @@ -1088,35 +1181,14 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa } } - 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) + app.initClient(this, ignoreSSL = false) + @OptIn(UnsafeSSL::class) + insecureApp.initClient(this, ignoreSSL = true) + val settingsManager = PreferenceManager.getDefaultSharedPreferences(this) - val errorFile = filesDir.resolve("last_error") - if (errorFile.exists() && errorFile.isFile) { - lastError = errorFile.readText(Charset.defaultCharset()) - errorFile.delete() - } else { - lastError = null - } + setLastError(this) val settingsForProvider = SettingsJson() settingsForProvider.enableAdult = @@ -1125,6 +1197,8 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa MainAPI.settingsForProvider = settingsForProvider loadThemes(this) + enableEdgeToEdgeCompat() + setNavigationBarColorCompat(R.attr.primaryGrayBackground) updateLocale() super.onCreate(savedInstanceState) try { @@ -1145,6 +1219,8 @@ 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) } @@ -1176,7 +1252,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, @@ -1208,6 +1284,22 @@ 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) @@ -1298,6 +1390,9 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa false ) } + +// Add your channel creation here + } } else { val builder: AlertDialog.Builder = AlertDialog.Builder(this) @@ -1535,18 +1630,6 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa // init accounts ioSafe { - for (api in accountManagers) { - api.init() - } - - inAppAuths.amap { api -> - try { - api.initialize() - } catch (e: Exception) { - logError(e) - } - } - // we need to run this after we init all apis, otherwise currentSyncApi will fuck itself this@MainActivity.runOnUiThread { // Change library icon with logo of current api in sync @@ -1599,10 +1682,6 @@ 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") } @@ -1630,17 +1709,27 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa navController ) } + } binding?.navRailView?.apply { - itemRippleColor = rippleColor - itemActiveIndicatorColor = rippleColor + 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 + } setupWithNavController(navController) - if (isLayout(TV or EMULATOR)) { + /*if (isLayout(TV or EMULATOR)) { background?.alpha = 200 } else { background?.alpha = 255 - } + }*/ setOnItemSelectedListener { item -> onNavDestinationSelected( @@ -1649,6 +1738,7 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa ) } + fun noFocus(view: View) { view.tag = view.context.getString(R.string.tv_no_focus_tag) (view as? ViewGroup)?.let { @@ -1687,6 +1777,104 @@ 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 + for (id in arrayOf( + R.id.navigation_home, + R.id.navigation_search, + R.id.navigation_library, + 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() + } + } + } + } + }*/ + } + } + + // Navigation button long click functionality to scroll to top + for (view in listOf(binding?.navView, binding?.navRailView)) { + view?.findViewById(R.id.navigation_home)?.setOnLongClickListener { + val recycler = binding?.root?.findViewById(R.id.home_master_recycler) + recycler?.smoothScrollToPosition(0) + return@setOnLongClickListener recycler != null + } + + view?.findViewById(R.id.navigation_library)?.setOnLongClickListener { + val viewPager = binding?.root?.findViewById(R.id.viewpager) + ?: return@setOnLongClickListener false + try { + val children = (viewPager[0] as? RecyclerView)?.children + ?: return@setOnLongClickListener false + for (child in children) { + child.findViewById(R.id.page_recyclerview) + ?.smoothScrollToPosition(0) + } + } catch (_: IndexOutOfBoundsException) { + } catch (t: Throwable) { + logError(t) + } + return@setOnLongClickListener true + } + + view?.findViewById(R.id.navigation_search)?.setOnLongClickListener { + for (recyclerId in arrayOf( + R.id.search_master_recycler, + R.id.search_autofit_results, + R.id.search_history_recycler + )) { + val recycler = binding?.root?.findViewById(recyclerId) + ?: return@setOnLongClickListener false + recycler.smoothScrollToPosition(0) + } + return@setOnLongClickListener true + } + + view?.findViewById(R.id.navigation_downloads)?.setOnLongClickListener { + val recycler: RecyclerView? = binding?.root?.findViewById(R.id.download_list) + ?: binding?.root?.findViewById(R.id.download_child_list) + recycler?.smoothScrollToPosition(0) + return@setOnLongClickListener recycler != null + } + } + loadCache() updateHasTrailers() /*nav_view.setOnNavigationItemSelectedListener { item -> @@ -1753,7 +1941,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(Uri.parse(video).toString()) + val mediaInfo = MediaInfo.Builder(video.toUri().toString()) .setStreamType(MediaInfo.STREAM_TYPE_NONE) .setContentType(MimeTypes.IMAGE_JPEG) // .setMetadata(movieMetadata).build() @@ -1815,6 +2003,17 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa migrateResumeWatching() } + main { + val channelId = + TvChannelUtils.getChannelId(this@MainActivity, getString(R.string.app_name)) + if (channelId == null) { + Log.d("TvChannel", "Channel not found, creating") + TvChannelUtils.createTvChannel(this@MainActivity) + } else { + Log.d("TvChannel", "Channel ID: $channelId") + } + } + getKey(USER_SELECTED_HOMEPAGE_API)?.let { homepage -> DataStoreHelper.currentHomePage = homepage removeKey(USER_SELECTED_HOMEPAGE_API) @@ -1846,23 +2045,14 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa // } // } - onBackPressedDispatcher.addCallback( - this, - object : OnBackPressedCallback(true) { - override fun handleOnBackPressed() { - @Suppress("DEPRECATION") - window?.navigationBarColor = colorFromAttribute(R.attr.primaryGrayBackground) - updateLocale() - - // 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 - } - } - ) + attachBackPressedCallback("MainActivityDefault") { + setNavigationBarColorCompat(R.attr.primaryGrayBackground) + updateLocale() + runDefault() + } + + // Start the download queue + DownloadQueueManager.init(this) } /** Biometric stuff **/ @@ -1885,4 +2075,4 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa false } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/actions/OpenInAppAction.kt b/app/src/main/java/com/lagradost/cloudstream3/actions/OpenInAppAction.kt index cc64a6d394d..ac912cbeb41 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.AcraApplication.Companion.getKey -import com.lagradost.cloudstream3.AcraApplication.Companion.setKey +import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey +import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.ui.result.LinkLoadingResult @@ -21,7 +21,8 @@ import java.io.File fun updateDurationAndPosition(position: Long, duration: Long) { if (position <= 0 || duration <= 0) return - DataStoreHelper.setViewPos(getKey("last_opened_id"), position, duration) + val episode = getKey("last_opened") ?: return + DataStoreHelper.setViewPosAndResume(episode.id, position, duration, episode, null) ResultFragment.updateUI() } @@ -98,7 +99,7 @@ abstract class OpenInAppAction( intent.component = ComponentName(packageName, intentClass) } putExtra(context, intent, video, result, index) - setKey("last_opened_id", video.id) + setKey("last_opened", video) 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 8407fa7a42a..4843b7617a2 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/actions/VideoClickAction.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/actions/VideoClickAction.kt @@ -16,12 +16,14 @@ 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.MpvYTDLPackage import com.lagradost.cloudstream3.actions.temp.NextPlayerPackage 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 @@ -46,9 +48,11 @@ object VideoClickActionHolder { PlayInBrowserAction(), CopyClipboardAction(), ViewM3U8Action(), + PlayMirrorAction(), // main support external apps VlcPackage(), MpvPackage(), + MpvExPackage(), NextPlayerPackage(), JustPlayerPackage(), FcastAction(), 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 d7f69db2c88..d414b611783 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.BuildConfig import com.lagradost.cloudstream3.actions.OpenInAppAction +import com.lagradost.cloudstream3.BuildConfig import com.lagradost.cloudstream3.ui.player.ExtractorUri import com.lagradost.cloudstream3.ui.player.SubtitleData import com.lagradost.cloudstream3.ui.player.SubtitleOrigin @@ -18,8 +18,10 @@ 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.Qualities 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.txt /** @@ -122,7 +124,9 @@ class CloudStreamPackage : OpenInAppAction( originalName = name ?: "Unknown", headers = headers, origin = SubtitleOrigin.URL, - languageCode = null, + languageCode = fromCodeToLangTagIETF(name) ?: + fromLanguageToTagIETF(name, true) ?: + name, ) } 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 102f0ac8bb3..faae3921240 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,7 +3,6 @@ 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 @@ -45,7 +44,7 @@ open class MpvKtPackage( intent.apply { putExtra("subs", result.subs.map { it.url.toUri() }.toTypedArray()) - setDataAndType(Uri.parse(link.url), "video/*") + setDataAndType(link.url.toUri(), "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 68e619c92c8..cd49eb994e0 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,7 +3,6 @@ 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 @@ -18,6 +17,9 @@ 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, @@ -26,10 +28,10 @@ class MpvYTDLPackage : MpvPackage("MPV YTDL", "is.xyz.mpv.ytdl") { ) } -open class MpvPackage(appName: String = "MPV", packageName: String = "is.xyz.mpv"): OpenInAppAction( +open class MpvPackage(appName: String = "MPV", packageName: String = "is.xyz.mpv",intentClass:String = "is.xyz.mpv.MPVActivity"): OpenInAppAction( txt(appName), packageName, - "is.xyz.mpv.MPVActivity" + intentClass ) { override val oneSource = true // mpv has poor playlist support on TV override suspend fun putExtra( @@ -44,7 +46,7 @@ open class MpvPackage(appName: String = "MPV", packageName: String = "is.xyz.mpv putExtra("title", video.name) if (index != null) { - setDataAndType(Uri.parse(result.links.getOrNull(index)?.url ?: return), "video/*") + setDataAndType((result.links.getOrNull(index)?.url ?: return).toUri(), "video/*") } else { makeTempM3U8Intent(context, this, result) } 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 7c1b68c054e..bfd2926bf1c 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 android.net.Uri +import androidx.core.net.toUri 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 = Uri.parse(link.url) + i.data = link.url.toUri() 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 new file mode 100644 index 00000000000..56512377bae --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/actions/temp/PlayMirrorAction.kt @@ -0,0 +1,65 @@ +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 e1fc22d3c56..46b46a2c2fe 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.AcraApplication.Companion.getKey +import com.lagradost.cloudstream3.CloudStreamApp.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 9f7eee7b820..963221bb343 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,7 +3,6 @@ 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 @@ -38,7 +37,7 @@ class WebVideoCastPackage: OpenInAppAction( val link = result.links[index ?: 0] intent.apply { - setDataAndType(Uri.parse(link.url), "video/*") + setDataAndType(link.url.toUri(), "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 e3916df01c6..1036a70557c 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.AcraApplication.Companion.getActivity +import com.lagradost.cloudstream3.CloudStreamApp.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 282ef834eb2..e2cf4f002f6 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,6 +7,7 @@ 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 { @@ -72,52 +73,66 @@ class FcastManager { } override fun onServiceFound(serviceInfo: NsdServiceInfo?) { - if (serviceInfo == null) return + // Safe here as, java.lang.NoClassDefFoundError: Failed resolution of: Landroid/net/nsd/NsdManager$ServiceInfoCallback + safe { + if (serviceInfo == null) return@safe + + 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") + } - 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 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 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 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 + override fun onServiceResolved(serviceInfo: NsdServiceInfo?) { + if (serviceInfo == null) return - synchronized(_currentDevices) { - _currentDevices.add(PublicDeviceInfo(serviceInfo)) - } + synchronized(_currentDevices) { + _currentDevices.add(PublicDeviceInfo(serviceInfo)) + } - Log.d( - tag, - "Service found: ${serviceInfo.serviceName}, Net: ${serviceInfo.host.hostAddress}" - ) - } - }) + Log.d( + tag, + "Service found: ${serviceInfo.serviceName}, Net: ${serviceInfo.host.hostAddress}" + ) + } + }) + } } } @@ -168,8 +183,9 @@ 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 3df5197cd00..482ec05fc1b 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/mvvm/Lifecycle.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/mvvm/Lifecycle.kt @@ -1,16 +1,68 @@ package com.lagradost.cloudstream3.mvvm +import android.view.View +import androidx.activity.ComponentActivity +import androidx.core.view.doOnAttach import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LiveData +import androidx.lifecycle.findViewTreeLifecycleOwner +import androidx.viewbinding.ViewBinding +import com.lagradost.cloudstream3.ui.BaseFragment /** NOTE: Only one observer at a time per value */ -fun LifecycleOwner.observe(liveData: LiveData, action: (t: T) -> Unit) { - liveData.removeObservers(this) - liveData.observe(this) { it?.let { t -> action(t) } } +fun ComponentActivity.observe(liveData: LiveData, action: (T) -> Unit) { + observeNullable(liveData) { t -> t?.run(action) } } /** NOTE: Only one observer at a time per value */ -fun LifecycleOwner.observeNullable(liveData: LiveData, action: (t: T) -> Unit) { +fun ComponentActivity.observeNullable(liveData: LiveData, action: (T?) -> Unit) { liveData.removeObservers(this) - liveData.observe(this) { action(it) } + liveData.observe(this, action) +} + +/** NOTE: Only one observer at a time per value */ +fun BaseFragment.observe(liveData: LiveData, action: (T) -> Unit) { + observeNullable(liveData) { t -> t?.run(action) } +} + +/** + * Attaches an observable to the root binding, instead of the fragment. This is more efficient as + * it will not call observe if the view is in the background. + * + * NOTE: Only one observer at a time per value + * */ +fun BaseFragment.observeNullable( + liveData: LiveData, action: (T?) -> Unit +) { + val root = this.binding?.root + if (root == null) { + liveData.removeObservers(this) + liveData.observe(this, action) + } else { + root.doOnAttach { view -> + // On attach should make findViewTreeLifecycleOwner non-null, but use "this" just in case + val owner: LifecycleOwner = view.findViewTreeLifecycleOwner() ?: this@observeNullable + liveData.removeObservers(owner) + liveData.observe(owner, action) + } + } } + +/** NOTE: Only one observer at a time per value */ +fun View.observe(liveData: LiveData, action: (T) -> Unit) { + observeNullable(liveData) { t -> t?.run(action) } +} + +/** NOTE: Only one observer at a time per value */ +fun View.observeNullable(liveData: LiveData, action: (T?) -> Unit) { + doOnAttach { view -> + // On attach should make findViewTreeLifecycleOwner non-null + val owner: LifecycleOwner? = view.findViewTreeLifecycleOwner() + if(owner == null) { + debugException { "Expected non-null findViewTreeLifecycleOwner" } + return@doOnAttach + } + liveData.removeObservers(owner) + liveData.observe(owner, action) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/network/RequestsHelper.kt b/app/src/main/java/com/lagradost/cloudstream3/network/RequestsHelper.kt index ec486d61db6..6234297d080 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/network/RequestsHelper.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/network/RequestsHelper.kt @@ -2,6 +2,7 @@ package com.lagradost.cloudstream3.network import android.content.Context import androidx.preference.PreferenceManager +import com.lagradost.cloudstream3.Prerelease import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.USER_AGENT import com.lagradost.cloudstream3.mvvm.safe @@ -15,11 +16,26 @@ import org.conscrypt.Conscrypt import java.io.File import java.security.Security +// Backwards compatible constructor, mark as deprecated later fun Requests.initClient(context: Context) { this.baseClient = buildDefaultClient(context) } +/** Only use ignoreSSL if you know what you are doing*/ +@Prerelease +fun Requests.initClient(context: Context, ignoreSSL: Boolean = false) { + this.baseClient = buildDefaultClient(context, ignoreSSL) +} + + +// Backwards compatible constructor, mark as deprecated later fun buildDefaultClient(context: Context): OkHttpClient { + return buildDefaultClient(context, false) +} + +/** Only use ignoreSSL if you know what you are doing*/ +@Prerelease +fun buildDefaultClient(context: Context, ignoreSSL: Boolean = false): OkHttpClient { safe { Security.insertProviderAt(Conscrypt.newProvider(), 1) } val settingsManager = PreferenceManager.getDefaultSharedPreferences(context) @@ -27,7 +43,11 @@ fun buildDefaultClient(context: Context): OkHttpClient { val baseClient = OkHttpClient.Builder() .followRedirects(true) .followSslRedirects(true) - .ignoreAllSSLErrors() + .apply { + if (ignoreSSL) { + ignoreAllSSLErrors() + } + } .cache( // Note that you need to add a ResponseInterceptor to make this 100% active. // The server response dictates if and when stuff should be cached. @@ -52,11 +72,6 @@ fun buildDefaultClient(context: Context): OkHttpClient { return baseClient } -//val Request.cookies: Map -// get() { -// return this.headers.getCookies("Cookie") -// } - private val DEFAULT_HEADERS = mapOf("user-agent" to USER_AGENT) /** diff --git a/app/src/main/java/com/lagradost/cloudstream3/plugins/PluginManager.kt b/app/src/main/java/com/lagradost/cloudstream3/plugins/PluginManager.kt index 1cffa7c1bfb..eae14a6c0c3 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/plugins/PluginManager.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/plugins/PluginManager.kt @@ -13,6 +13,7 @@ import android.os.Build import android.os.Environment import android.util.Log import android.widget.Toast +import androidx.annotation.WorkerThread import androidx.core.app.ActivityCompat import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat @@ -20,15 +21,17 @@ 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 @@ -43,6 +46,7 @@ import com.lagradost.cloudstream3.plugins.RepositoryManager.ONLINE_PLUGINS_FOLDE import com.lagradost.cloudstream3.plugins.RepositoryManager.PREBUILT_REPOSITORIES import com.lagradost.cloudstream3.plugins.RepositoryManager.downloadPluginToFile import com.lagradost.cloudstream3.plugins.RepositoryManager.getRepoPlugins +import com.lagradost.cloudstream3.plugins.RepositoryManager.sha256 import com.lagradost.cloudstream3.ui.settings.extensions.REPOSITORIES_KEY import com.lagradost.cloudstream3.ui.settings.extensions.RepositoryData import com.lagradost.cloudstream3.utils.AppContextUtils.getApiProviderLangSettings @@ -51,7 +55,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.VideoDownloadManager.sanitizeFilename +import com.lagradost.cloudstream3.utils.downloader.DownloadFileManagement.sanitizeFilename import com.lagradost.cloudstream3.utils.extractorApis import com.lagradost.cloudstream3.utils.txt import dalvik.system.PathClassLoader @@ -76,6 +80,7 @@ data class PluginData( @JsonProperty("filePath") val filePath: String, @JsonProperty("version") val version: Int, ) { + @WorkerThread fun toSitePlugin(): SitePlugin { return SitePlugin( this.filePath, @@ -90,7 +95,9 @@ data class PluginData( null, null, null, - File(this.filePath).length() + File(this.filePath).length(), + // No file hash for local plugins. Local plugins have no use for the hash, and it's expensive to compute. + null ) } } @@ -258,12 +265,8 @@ 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", "DEPRECATION_ERROR") - @Deprecated( - "Calling this function from a plugin will lead to crashes, use loadPlugin and unloadPlugin", - replaceWith = ReplaceWith("loadPlugin"), - level = DeprecationLevel.ERROR - ) + @Suppress("FunctionName") + @InternalAPI @Throws suspend fun ___DO_NOT_CALL_FROM_A_PLUGIN_updateAllOnlinePluginsAndLoadThem(activity: Activity) { assertNonRecursiveCallstack() @@ -304,6 +307,7 @@ object PluginManager { downloadPlugin( activity, pluginData.onlineData.second.url, + pluginData.onlineData.second.fileHash, pluginData.savedData.internalName, File(pluginData.savedData.filePath), true @@ -339,12 +343,8 @@ 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", "DEPRECATION_ERROR") - @Deprecated( - "Calling this function from a plugin will lead to crashes, use loadPlugin and unloadPlugin", - replaceWith = ReplaceWith("loadPlugin"), - level = DeprecationLevel.ERROR - ) + @Suppress("FunctionName") + @InternalAPI @Throws suspend fun ___DO_NOT_CALL_FROM_A_PLUGIN_downloadNotExistingPluginsAndLoad( activity: Activity, @@ -419,6 +419,7 @@ object PluginManager { downloadPlugin( activity, pluginData.onlineData.second.url, + pluginData.onlineData.second.fileHash, pluginData.savedData.internalName, pluginData.onlineData.first, !pluginData.isDisabled @@ -453,12 +454,8 @@ 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", "DEPRECATION_ERROR") - @Deprecated( - "Calling this function from a plugin will lead to crashes, use loadPlugin and unloadPlugin", - replaceWith = ReplaceWith("loadPlugin"), - level = DeprecationLevel.ERROR - ) + @Suppress("FunctionName") + @InternalAPI @Throws suspend fun ___DO_NOT_CALL_FROM_A_PLUGIN_loadAllOnlinePlugins(context: Context) { assertNonRecursiveCallstack() @@ -479,13 +476,9 @@ 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", "DEPRECATION_ERROR") + @Suppress("FunctionName") + @InternalAPI @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() @@ -504,12 +497,8 @@ 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", "DEPRECATION_ERROR") - @Deprecated( - "Calling this function from a plugin will lead to crashes, use loadPlugin and unloadPlugin", - replaceWith = ReplaceWith("loadPlugin"), - level = DeprecationLevel.ERROR - ) + @Suppress("FunctionName") + @InternalAPI @Throws suspend fun ___DO_NOT_CALL_FROM_A_PLUGIN_loadAllLocalPlugins(context: Context, forceReload: Boolean) { assertNonRecursiveCallstack() @@ -572,6 +561,11 @@ 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 @@ -704,16 +698,25 @@ object PluginManager { APIHolder.allProviders.removeIf { provider: MainAPI -> provider.sourcePlugin == plugin.filename } } - extractorApis.removeIf { provider: ExtractorApi -> provider.sourcePlugin == plugin.filename } + synchronized(extractorApis) { + extractorApis.removeIf { provider: ExtractorApi -> provider.sourcePlugin == plugin.filename } + } synchronized(VideoClickActionHolder.allVideoClickActions) { VideoClickActionHolder.allVideoClickActions.removeIf { action: VideoClickAction -> action.sourcePlugin == plugin.filename } } - classLoaders.values.removeIf { v -> v == plugin } + synchronized(classLoaders) { + classLoaders.values.removeIf { v -> v == plugin } + } - plugins.remove(absolutePath) - urlPlugins.values.removeIf { v -> v == plugin } + synchronized(plugins) { + plugins.remove(absolutePath) + } + + synchronized(urlPlugins) { + urlPlugins.values.removeIf { v -> v == plugin } + } } /** @@ -743,25 +746,27 @@ object PluginManager { suspend fun downloadPlugin( activity: Activity, pluginUrl: String, + pluginHash: String?, internalName: String, repositoryUrl: String, loadPlugin: Boolean ): Boolean { val file = getPluginPath(activity, internalName, repositoryUrl) - return downloadPlugin(activity, pluginUrl, internalName, file, loadPlugin) + return downloadPlugin(activity, pluginUrl, pluginHash, internalName, file, loadPlugin) } suspend fun downloadPlugin( activity: Activity, pluginUrl: String, + pluginHash: String?, internalName: String, file: File, - loadPlugin: Boolean + loadPlugin: Boolean, ): Boolean { try { Log.d(TAG, "Downloading plugin: $pluginUrl to ${file.absolutePath}") // The plugin file needs to be salted with the repository url hash as to allow multiple repositories with the same internal plugin names - val newFile = downloadPluginToFile(pluginUrl, file) ?: return false + val newFile = downloadPluginToFile(activity, pluginUrl, file, pluginHash) ?: return false val data = PluginData( internalName, @@ -808,13 +813,9 @@ 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", "DEPRECATION_ERROR") + @Suppress("FunctionName") + @InternalAPI @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() @@ -853,6 +854,7 @@ object PluginManager { if (downloadPlugin( activity, pluginData.onlineData.second.url, + pluginData.onlineData.second.fileHash, pluginData.savedData.internalName, existingFile, true @@ -951,4 +953,4 @@ object PluginManager { return null } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/plugins/RepositoryManager.kt b/app/src/main/java/com/lagradost/cloudstream3/plugins/RepositoryManager.kt index d92e81acdee..07d6aaa37bc 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/plugins/RepositoryManager.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/plugins/RepositoryManager.kt @@ -1,10 +1,11 @@ package com.lagradost.cloudstream3.plugins import android.content.Context +import androidx.annotation.WorkerThread import com.fasterxml.jackson.annotation.JsonProperty -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.CloudStreamApp.Companion.context +import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey +import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.amap import com.lagradost.cloudstream3.app @@ -18,16 +19,19 @@ import com.lagradost.cloudstream3.ui.settings.extensions.RepositoryData import com.lagradost.cloudstream3.utils.AppUtils.tryParseJson import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock -import java.io.BufferedInputStream import java.io.File -import java.io.InputStream -import java.io.OutputStream +import java.nio.file.AtomicMoveNotSupportedException +import java.nio.file.Files +import java.nio.file.StandardCopyOption +import java.security.MessageDigest +import java.util.concurrent.atomic.AtomicInteger /** * Comes with the app, always available in the app, non removable. * */ data class Repository( + @JsonProperty("iconUrl") val iconUrl: String?, @JsonProperty("name") val name: String, @JsonProperty("description") val description: String?, @JsonProperty("manifestVersion") val manifestVersion: Int, @@ -61,10 +65,12 @@ 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?, ) @@ -73,7 +79,26 @@ object RepositoryManager { val PREBUILT_REPOSITORIES: Array by lazy { getKey("PREBUILT_REPOSITORIES") ?: emptyArray() } - private val GH_REGEX = Regex("^https://raw.githubusercontent.com/([A-Za-z0-9-]+)/([A-Za-z0-9_.-]+)/(.*)$") + private val GH_REGEX = + Regex("^https://raw.githubusercontent.com/([A-Za-z0-9-]+)/([A-Za-z0-9_.-]+)/(.*)$") + + + /** Returns a SHA-256 string of the file content. + * Example: "sha256-b70462c264cb7f90fc2860a8e58d7544ce747ff347d1d11fa093623901853573" **/ + @WorkerThread + fun sha256(file: File): String { + val digest = MessageDigest.getInstance("SHA-256") + + file.inputStream().use { fis -> + val buffer = ByteArray(8192) + var read = fis.read(buffer) + while (read != -1) { + digest.update(buffer, 0, read) + read = fis.read(buffer) + } + } + return "sha256-" + digest.digest().joinToString("") { "%02x".format(it) } + } /* Convert raw.githubusercontent.com urls to cdn.jsdelivr.net if enabled in settings */ fun convertRawGitUrl(url: String): String { @@ -138,21 +163,52 @@ object RepositoryManager { }.flatten() } + suspend fun downloadPluginToFile( + context: Context, pluginUrl: String, - file: File + file: File, + expectedFileHash: String? ): File? { return safeAsync { - file.mkdirs() + val parentDir = file.parentFile ?: return@safeAsync null + parentDir.mkdirs() - // Overwrite if exists - if (file.exists()) { - file.delete() - } - file.createNewFile() + // Prevent corrupting the plugin file if the operation fails + val tempFile = File.createTempFile(file.name, ".tmp", context.cacheDir) val body = app.get(convertRawGitUrl(pluginUrl)).okhttpResponse.body - write(body.byteStream(), file.outputStream()) + + body.byteStream().use { body -> + tempFile.outputStream().use { fileSteam -> + body.copyTo(fileSteam) + } + } + + if (expectedFileHash != null) { + val downloadHash = sha256(tempFile) + if (expectedFileHash != downloadHash) { + tempFile.delete() + throw IllegalStateException("Extension hash mismatch when validating '${file.name}'! Expected: '$expectedFileHash', got: '$downloadHash'.") + } + } + + // We prefer the operation to be atomic + try { + Files.move( + tempFile.toPath(), + file.toPath(), + StandardCopyOption.REPLACE_EXISTING, + StandardCopyOption.ATOMIC_MOVE + ) + } catch (_: AtomicMoveNotSupportedException) { + Files.move( + tempFile.toPath(), + file.toPath(), + StandardCopyOption.REPLACE_EXISTING + ) + } + file } } @@ -200,13 +256,4 @@ object RepositoryManager { PluginManager.deleteRepositoryData(file.absolutePath) } - - private fun write(stream: InputStream, output: OutputStream) { - val input = BufferedInputStream(stream) - val dataBuffer = ByteArray(512) - var readBytes: Int - while (input.read(dataBuffer).also { readBytes = it } != -1) { - output.write(dataBuffer, 0, readBytes) - } - } } diff --git a/app/src/main/java/com/lagradost/cloudstream3/plugins/VotingApi.kt b/app/src/main/java/com/lagradost/cloudstream3/plugins/VotingApi.kt index d1b702f4ce3..85a806f0b12 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.AcraApplication.Companion.context -import com.lagradost.cloudstream3.AcraApplication.Companion.getKey -import com.lagradost.cloudstream3.AcraApplication.Companion.setKey +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.R import java.security.MessageDigest import com.lagradost.cloudstream3.app @@ -12,87 +12,76 @@ import com.lagradost.cloudstream3.utils.Coroutines.main import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock -object VotingApi { // please do not cheat the votes lol - private const val LOGKEY = "VotingApi" +object VotingApi { - private const val API_DOMAIN = "https://counterapi.com/api" + private const val LOGKEY = "VotingApi" + private const val API_DOMAIN = "https://api.countify.xyz" - private fun transformUrl(url: String): String = // dont touch or all votes get reset + private fun transformUrl(url: String): String = MessageDigest .getInstance("SHA-256") .digest("${url}#funny-salt".toByteArray()) .fold("") { str, it -> str + "%02x".format(it) } - suspend fun SitePlugin.getVotes(): Int { - return getVotes(url) - } - - fun SitePlugin.hasVoted(): Boolean { - return hasVoted(url) - } - - suspend fun SitePlugin.vote(): Int { - return vote(url) - } + 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) - 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 url = "${API_DOMAIN}/cs-${getRepository(pluginUrl)}/vote/${transformUrl(pluginUrl)}?readOnly=true" - Log.d(LOGKEY, "Requesting: $url") - return app.get(url).parsedSafe()?.value ?: 0 + val id = transformUrl(pluginUrl) + val url = "$API_DOMAIN/get-total/$id" + Log.d(LOGKEY, "Requesting GET: $url") + return app.get(url).parsedSafe()?.count ?: 0 } private suspend fun writeVote(pluginUrl: String): Boolean { - val url = "${API_DOMAIN}/cs-${getRepository(pluginUrl)}/vote/${transformUrl(pluginUrl)}" - Log.d(LOGKEY, "Requesting: $url") - return app.get(url).parsedSafe()?.value != null + val id = transformUrl(pluginUrl) + val url = "$API_DOMAIN/increment/$id" + Log.d(LOGKEY, "Requesting POST: $url") + return app.post(url, emptyMap()) + .parsedSafe()?.count != 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 { - return PluginManager.urlPlugins.contains(pluginUrl) - } + fun canVote(pluginUrl: String): Boolean = + 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 @@ -102,7 +91,8 @@ object VotingApi { // please do not cheat the votes lol } } - private data class Result( - val value: Int? + private data class CountifyResult( + val id: String? = null, + val count: Int? = null ) -} \ 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 new file mode 100644 index 00000000000..e07747a860c --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/services/DownloadQueueService.kt @@ -0,0 +1,279 @@ +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 fc31c1f3e0d..7134650ed4e 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.VideoDownloadManager.getImageBitmapFromUrl +import com.lagradost.cloudstream3.utils.downloader.DownloadUtils.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 6151a0edd20..d63b18cdc97 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/services/VideoDownloadService.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/services/VideoDownloadService.kt @@ -2,12 +2,13 @@ package com.lagradost.cloudstream3.services import android.app.Service import android.content.Intent import android.os.IBinder -import com.lagradost.cloudstream3.utils.VideoDownloadManager +import com.lagradost.cloudstream3.utils.downloader.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) @@ -42,19 +43,3 @@ 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/subtitles/AbstractSubProvider.kt b/app/src/main/java/com/lagradost/cloudstream3/subtitles/AbstractSubProvider.kt index bfc6dacb65e..9e6f241fb95 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/subtitles/AbstractSubProvider.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/subtitles/AbstractSubProvider.kt @@ -1,18 +1,9 @@ package com.lagradost.cloudstream3.subtitles -import androidx.annotation.WorkerThread import androidx.core.net.toUri -import com.lagradost.cloudstream3.APIHolder.unixTime -import com.lagradost.cloudstream3.ErrorLoadingException import com.lagradost.cloudstream3.MainActivity.Companion.deleteFileOnExit import com.lagradost.cloudstream3.app -import com.lagradost.cloudstream3.mvvm.Resource -import com.lagradost.cloudstream3.mvvm.safeApiCall -import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities.SubtitleEntity -import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities.SubtitleSearch -import com.lagradost.cloudstream3.syncproviders.AuthAPI import com.lagradost.cloudstream3.ui.player.SubtitleOrigin -import com.lagradost.cloudstream3.utils.Coroutines.threadSafeListOf import okio.BufferedSource import okio.buffer import okio.sink @@ -20,116 +11,6 @@ import okio.source import java.io.File import java.util.zip.ZipInputStream -interface AbstractSubProvider { - val idPrefix: String - - @WorkerThread - @Throws - suspend fun search(query: SubtitleSearch): List? { - throw NotImplementedError() - } - - @WorkerThread - @Throws - suspend fun load(data: SubtitleEntity): String? { - throw NotImplementedError() - } - - @WorkerThread - @Throws - suspend fun SubtitleResource.getResources(data: SubtitleEntity) { - this.addUrl(load(data)) - } - - @WorkerThread - @Throws - suspend fun getResource(data: SubtitleEntity): SubtitleResource { - return SubtitleResource().apply { - this.getResources(data) - } - } -} - -class SubRepository(val api: AbstractSubProvider) { - companion object { - data class SavedSearchResponse( - val unixTime: Long, - val response: List, - val query: SubtitleSearch - ) - - data class SavedResourceResponse( - val unixTime: Long, - val response: SubtitleResource, - val query: SubtitleEntity - ) - - // maybe make this a generic struct? right now there is a lot of boilerplate - private val searchCache = threadSafeListOf() - private var searchCacheIndex: Int = 0 - private val resourceCache = threadSafeListOf() - private var resourceCacheIndex: Int = 0 - const val CACHE_SIZE = 20 - } - - val idPrefix: String get() = api.idPrefix - - @WorkerThread - suspend fun getResource(data: SubtitleEntity): Resource = safeApiCall { - synchronized(resourceCache) { - for (item in resourceCache) { - // 20 min save - if (item.query == data && (unixTime - item.unixTime) < 60 * 20) { - return@safeApiCall item.response - } - } - } - - val returnValue = api.getResource(data) - synchronized(resourceCache) { - val add = SavedResourceResponse(unixTime, returnValue, data) - if (resourceCache.size > CACHE_SIZE) { - resourceCache[resourceCacheIndex] = add // rolling cache - resourceCacheIndex = (resourceCacheIndex + 1) % CACHE_SIZE - } else { - resourceCache.add(add) - } - } - returnValue - } - - @WorkerThread - suspend fun search(query: SubtitleSearch): Resource> { - return safeApiCall { - synchronized(searchCache) { - for (item in searchCache) { - // 120 min save - if (item.query == query && (unixTime - item.unixTime) < 60 * 120) { - return@safeApiCall item.response - } - } - } - - val returnValue = api.search(query) ?: throw ErrorLoadingException("Null subtitles") - - // only cache valid return values - if (returnValue.isNotEmpty()) { - val add = SavedSearchResponse(unixTime, returnValue, query) - synchronized(searchCache) { - if (searchCache.size > CACHE_SIZE) { - searchCache[searchCacheIndex] = add // rolling cache - searchCacheIndex = (searchCacheIndex + 1) % CACHE_SIZE - } else { - searchCache.add(add) - } - } - } - returnValue - } - } - -} - /** * A builder for subtitle files. * @see addUrl @@ -210,4 +91,3 @@ class SubtitleResource { } } -interface AbstractSubApi : AbstractSubProvider, AuthAPI \ No newline at end of file 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 2e14c3c46fd..3bc5f273397 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/AccountManager.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/AccountManager.kt @@ -1,149 +1,165 @@ -package com.lagradost.cloudstream3.syncproviders - -import com.lagradost.cloudstream3.AcraApplication.Companion.getKey -import com.lagradost.cloudstream3.AcraApplication.Companion.removeKeys -import com.lagradost.cloudstream3.AcraApplication.Companion.setKey -import com.lagradost.cloudstream3.LoadResponse -import com.lagradost.cloudstream3.syncproviders.providers.* -import java.util.concurrent.TimeUnit - -abstract class AccountManager(private val defIndex: Int) : AuthAPI { - companion object { - val malApi = MALApi(0).also { api -> - LoadResponse.Companion.malIdPrefix = api.idPrefix - } - val aniListApi = AniListApi(0).also { api -> - LoadResponse.Companion.aniListIdPrefix = api.idPrefix - } - val simklApi = SimklApi(0).also { api -> - LoadResponse.Companion.simklIdPrefix = api.idPrefix - } - val openSubtitlesApi = OpenSubtitlesApi(0) - val addic7ed = Addic7ed() - val subDlApi = SubDlApi(0) - val localListApi = LocalList() - val subSourceApi = SubSourceApi() - - // used to login via app intent - val OAuth2Apis - get() = listOf( - malApi, aniListApi, simklApi - ) - - // this needs init with context and can be accessed in settings - val accountManagers - get() = listOf( - malApi, aniListApi, openSubtitlesApi, subDlApi, simklApi //nginxApi - ) - - // used for active syncing - val SyncApis - get() = listOf( - SyncRepo(malApi), SyncRepo(aniListApi), SyncRepo(localListApi), SyncRepo(simklApi) - ) - - val inAppAuths - get() = listOf( - openSubtitlesApi, - subDlApi - )//, nginxApi) - - val subtitleProviders - get() = listOf( - openSubtitlesApi, - addic7ed, - subDlApi, - subSourceApi - ) - - const val APP_STRING = "cloudstreamapp" - const val APP_STRING_REPO = "cloudstreamrepo" - const val APP_STRING_PLAYER = "cloudstreamplayer" - - // Instantly start the search given a query - const val APP_STRING_SEARCH = "cloudstreamsearch" - - // Instantly resume watching a show - const val APP_STRING_RESUME_WATCHING = "cloudstreamcontinuewatching" - - val unixTime: Long - get() = System.currentTimeMillis() / 1000L - val unixTimeMs: Long - get() = System.currentTimeMillis() - - const val MAX_STALE = 60 * 10 - - fun secondsToReadable(seconds: Int, completedValue: String): String { - var secondsLong = seconds.toLong() - val days = TimeUnit.SECONDS - .toDays(secondsLong) - secondsLong -= TimeUnit.DAYS.toSeconds(days) - - val hours = TimeUnit.SECONDS - .toHours(secondsLong) - secondsLong -= TimeUnit.HOURS.toSeconds(hours) - - val minutes = TimeUnit.SECONDS - .toMinutes(secondsLong) - secondsLong -= TimeUnit.MINUTES.toSeconds(minutes) - if (minutes < 0) { - return completedValue - } - //println("$days $hours $minutes") - return "${if (days != 0L) "$days" + "d " else ""}${if (hours != 0L) "$hours" + "h " else ""}${minutes}m" - } - } - - var accountIndex = defIndex - private var lastAccountIndex = defIndex - protected val accountId get() = "${idPrefix}_account_$accountIndex" - private val accountActiveKey get() = "${idPrefix}_active" - - // int array of all accounts indexes - private val accountsKey get() = "${idPrefix}_accounts" - - protected fun removeAccountKeys() { - removeKeys(accountId) - val accounts = getAccounts()?.toMutableList() ?: mutableListOf() - accounts.remove(accountIndex) - setKey(accountsKey, accounts.toIntArray()) - - init() - } - - fun getAccounts(): IntArray? { - return getKey(accountsKey, intArrayOf()) - } - - fun init() { - accountIndex = getKey(accountActiveKey, defIndex)!! - val accounts = getAccounts() - if (accounts?.isNotEmpty() == true && this.loginInfo() == null) { - accountIndex = accounts.first() - } - } - - protected fun switchToNewAccount() { - val accounts = getAccounts() - lastAccountIndex = accountIndex - accountIndex = (accounts?.maxOrNull() ?: 0) + 1 - } - protected fun switchToOldAccount() { - accountIndex = lastAccountIndex - } - - protected fun registerAccount() { - setKey(accountActiveKey, accountIndex) - val accounts = getAccounts()?.toMutableList() ?: mutableListOf() - if (!accounts.contains(accountIndex)) { - accounts.add(accountIndex) - } - - setKey(accountsKey, accounts.toIntArray()) - } - - fun changeAccount(index: Int) { - accountIndex = index - setKey(accountActiveKey, index) - } -} +package com.lagradost.cloudstream3.syncproviders + +import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey +import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey +import com.lagradost.cloudstream3.LoadResponse +import com.lagradost.cloudstream3.syncproviders.providers.Addic7ed +import com.lagradost.cloudstream3.syncproviders.providers.AniListApi +import com.lagradost.cloudstream3.syncproviders.providers.KitsuApi +import com.lagradost.cloudstream3.syncproviders.providers.LocalList +import com.lagradost.cloudstream3.syncproviders.providers.MALApi +import com.lagradost.cloudstream3.syncproviders.providers.OpenSubtitlesApi +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() + + val openSubtitlesApi = OpenSubtitlesApi() + val addic7ed = Addic7ed() + val subDlApi = SubDlApi() + val subSourceApi = SubSourceApi() + val animeSkipApi = AnimeSkipAuth() + + var cachedAccounts: MutableMap> + var cachedAccountIds: MutableMap + + const val ACCOUNT_TOKEN = "auth_tokens" + const val ACCOUNT_IDS = "auth_ids" + + fun accounts(prefix: String): Array { + require(prefix != "NONE") + return getKey>( + ACCOUNT_TOKEN, + "${prefix}/${DataStoreHelper.currentAccount}" + ) ?: arrayOf() + } + + fun updateAccounts(prefix: String, array: Array) { + require(prefix != "NONE") + setKey(ACCOUNT_TOKEN, "${prefix}/${DataStoreHelper.currentAccount}", array) + synchronized(cachedAccounts) { + cachedAccounts[prefix] = array + } + } + + fun updateAccountsId(prefix: String, id: Int) { + require(prefix != "NONE") + setKey(ACCOUNT_IDS, "${prefix}/${DataStoreHelper.currentAccount}", id) + synchronized(cachedAccountIds) { + cachedAccountIds[prefix] = id + } + } + + val allApis = arrayOf( + SyncRepo(malApi), + SyncRepo(kitsuApi), + SyncRepo(aniListApi), + SyncRepo(simklApi), + SyncRepo(localListApi), + SubtitleRepo(openSubtitlesApi), + SubtitleRepo(addic7ed), + SubtitleRepo(subDlApi), + PlainAuthRepo(animeSkipApi) + ) + + fun updateAccountIds() { + val ids = mutableMapOf() + for (api in allApis) { + ids.put( + api.idPrefix, + getKey( + ACCOUNT_IDS, + "${api.idPrefix}/${DataStoreHelper.currentAccount}", + NONE_ID + ) ?: NONE_ID + ) + } + synchronized(cachedAccountIds) { + cachedAccountIds = ids + } + } + + init { + val data = mutableMapOf>() + val ids = mutableMapOf() + for (api in allApis) { + data.put(api.idPrefix, accounts(api.idPrefix)) + ids.put( + api.idPrefix, + getKey( + ACCOUNT_IDS, + "${api.idPrefix}/${DataStoreHelper.currentAccount}", + NONE_ID + ) ?: NONE_ID + ) + } + cachedAccounts = data + cachedAccountIds = ids + } + + // I do not want to place this in the init block as JVM initialization order is weird, and it may cause exceptions + // accessing other classes + fun initMainAPI() { + LoadResponse.malIdPrefix = malApi.idPrefix + LoadResponse.kitsuIdPrefix = kitsuApi.idPrefix + LoadResponse.aniListIdPrefix = aniListApi.idPrefix + LoadResponse.simklIdPrefix = simklApi.idPrefix + } + + val subtitleProviders = arrayOf( + SubtitleRepo(openSubtitlesApi), + SubtitleRepo(addic7ed), + SubtitleRepo(subDlApi) + ) + val syncApis = arrayOf( + SyncRepo(malApi), + SyncRepo(kitsuApi), + SyncRepo(aniListApi), + SyncRepo(simklApi), + SyncRepo(localListApi) + ) + + const val APP_STRING = "cloudstreamapp" + const val APP_STRING_REPO = "cloudstreamrepo" + const val APP_STRING_PLAYER = "cloudstreamplayer" + + // Instantly start the search given a query + const val APP_STRING_SEARCH = "cloudstreamsearch" + + // 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 + .toDays(secondsLong) + secondsLong -= TimeUnit.DAYS.toSeconds(days) + + val hours = TimeUnit.SECONDS + .toHours(secondsLong) + secondsLong -= TimeUnit.HOURS.toSeconds(hours) + + val minutes = TimeUnit.SECONDS + .toMinutes(secondsLong) + secondsLong -= TimeUnit.MINUTES.toSeconds(minutes) + if (minutes < 0) { + return completedValue + } + //println("$days $hours $minutes") + return "${if (days != 0L) "$days" + "d " else ""}${if (hours != 0L) "$hours" + "h " else ""}${minutes}m" + } + } +} \ No newline at end of file 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 8b085bc0b83..83a7a09847c 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/AuthAPI.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/AuthAPI.kt @@ -1,23 +1,282 @@ package com.lagradost.cloudstream3.syncproviders -interface AuthAPI { - val name: String - val icon: Int? +import android.util.Base64 +import androidx.annotation.WorkerThread +import com.fasterxml.jackson.annotation.JsonProperty +import com.lagradost.cloudstream3.APIHolder.unixTime +import com.lagradost.cloudstream3.ActorData +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.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.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.KitsuApi +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 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 - val requiresLogin: Boolean +data class AuthLoginPage( + /** The website to open to authenticate */ + val url: String, + /** + * State/control code to verify against the redirectUrl to make sure the request is valid. + * This parameter will be saved, and then used in AuthAPI::login. + * */ + val payload: String? = null, +) - val createAccountUrl : String? +data class AuthToken( + /** + * This is the general access tokens/api token representing a logged in user. + * + * `Access tokens are the thing that applications use to make API requests on behalf of a user.` + * */ + @JsonProperty("accessToken") + val accessToken: String? = null, + /** + * For OAuth a special refresh token is issues to refresh the access token. + * */ + @JsonProperty("refreshToken") + val refreshToken: String? = null, + /** In UnixTime (sec) when it expires */ + @JsonProperty("accessTokenLifetime") + val accessTokenLifetime: Long? = null, + /** In UnixTime (sec) when it expires */ + @JsonProperty("refreshTokenLifetime") + val refreshTokenLifetime: Long? = null, + /** Sometimes AuthToken needs to be customized to store e.g. username/password, + * this acts as a catch all to store text or JSON data. */ + @JsonProperty("payload") + val payload: String? = null, +) { + fun isAccessTokenExpired(marginSec: Long = 10L) = + accessTokenLifetime != null && (System.currentTimeMillis() / 1000) + marginSec >= accessTokenLifetime - // don't change this as all keys depend on it - val idPrefix: String + fun isRefreshTokenExpired(marginSec: Long = 10L) = + refreshTokenLifetime != null && (System.currentTimeMillis() / 1000) + marginSec >= refreshTokenLifetime +} - // if this returns null then you are not logged in - fun loginInfo(): LoginInfo? - fun logOut() +data class AuthUser( + /** Account display-name, can also be email if name does not exist */ + @JsonProperty("name") + val name: String?, + /** Unique account identifier, + * if a subsequent login is done then it will be refused if another account with the same id exists*/ + @JsonProperty("id") + val id: Int, + /** Profile picture URL */ + @JsonProperty("profilePicture") + val profilePicture: String? = null, + /** Profile picture Headers of the URL */ + @JsonProperty("profilePictureHeader") + val profilePictureHeaders: Map? = null +) +/** + * Stores all information that should be used to authorize access. + * Be aware that token and user may change independently when a refresh is needed, + * and as such there should be no strong pairing between the two. + * + * Any local set/get key should use user.id.toString(), + * as token.accessToken (even hashed) is unsecure, and will rotate. + * */ +data class AuthData( + @JsonProperty("user") + val user: AuthUser, + @JsonProperty("token") + val token: AuthToken, +) + +data class AuthPinData( + val deviceCode: String, + val userCode: String, + /** QR Code url */ + val verificationUrl: String, + /** In seconds */ + val expiresIn: Int, + /** Check if the code has been verified interval */ + val interval: Int, +) + +/** The login field requirements to display to the user */ +data class AuthLoginRequirement( + val password: Boolean = false, + val username: Boolean = false, + val email: Boolean = false, + val server: Boolean = false, +) + +/** What the user responds to the AuthLoginRequirement */ +data class AuthLoginResponse( + @JsonProperty("password") + val password: String?, + @JsonProperty("username") + val username: String?, + @JsonProperty("email") + val email: String?, + @JsonProperty("server") + val server: String?, +) + +/** Stateless Authentication class used for all personalized content */ +abstract class AuthAPI { + open val name: String = "NONE" + open val idPrefix: String = "NONE" + + /** Drawable icon of the service */ + open val icon: Int? = null + + /** If this service requires an account to use */ + open val requiresLogin: Boolean = true + + /** Link to a website for creating a new account */ + open val createAccountUrl: String? = null + + /** The sensitive redirect URL from OAuth should contain "/redirectUrlIdentifier" to trigger the login */ + open val redirectUrlIdentifier: String? = null + + /** Has OAuth2 login support, including login, loginRequest and refreshToken */ + open val hasOAuth2: Boolean = false + + /** Has on device pin support, aka login with a QR code */ + open val hasPin: Boolean = false + + /** Has in app login support, aka login with a dialog */ + open val hasInApp: Boolean = false + + /** The requirements to login in app */ + open val inAppLoginRequirement: AuthLoginRequirement? = null + + companion object { + val unixTime: Long + get() = System.currentTimeMillis() / 1000L + val unixTimeMs: Long + get() = System.currentTimeMillis() + + fun splitRedirectUrl(redirectUrl: String): Map { + return splitQuery( + URL( + redirectUrl.replace(APP_STRING, "https").replace("/#", "?") + ) + ) + } + + fun generateCodeVerifier(): String { + // It is recommended to use a URL-safe string as code_verifier. + // See section 4 of RFC 7636 for more details. + val secureRandom = SecureRandom() + val codeVerifierBytes = ByteArray(96) // base64 has 6bit per char; (8/6)*96 = 128 + secureRandom.nextBytes(codeVerifierBytes) + return Base64.encodeToString(codeVerifierBytes, Base64.DEFAULT).trimEnd('=') + .replace("+", "-") + .replace("/", "_").replace("\n", "") + } + } + + /** Is this url a valid redirect url for this service? */ + @Throws + open fun isValidRedirectUrl(url: String): Boolean = + redirectUrlIdentifier != null && url.contains("/$redirectUrlIdentifier") + + /** OAuth2 login from a valid redirectUrl, and payload given in loginRequest */ + @Throws + open suspend fun login(redirectUrl: String, payload: String?): AuthToken? = + throw NotImplementedError() + + /** OAuth2 login request, asking the service to provide a url to open in the browser */ + @Throws + open fun loginRequest(): AuthLoginPage? = throw NotImplementedError() + + /** Pin login request, asking the service to provide an verificationUrl to display with a QR code */ + @Throws + open suspend fun pinRequest(): AuthPinData? = throw NotImplementedError() + + /** OAuth2 token refresh, this ensures that all token passed to other functions will be valid */ + @Throws + open suspend fun refreshToken(token: AuthToken): AuthToken? = throw NotImplementedError() + + /** Pin login, this will be called periodically while logging in to check if the pin has been verified by the user */ + @Throws + open suspend fun login(payload: AuthPinData): AuthToken? = throw NotImplementedError() + + /** In app login */ + @Throws + open suspend fun login(form: AuthLoginResponse): AuthToken? = throw NotImplementedError() + + /** Get the visible user account */ + @Throws + open suspend fun user(token: AuthToken?): AuthUser? = throw NotImplementedError() + + /** + * An optional security measure to make sure that even if an attacker gets ahold of the token, it will be invalid. + * + * Note that this will currently only be called *once* on logout, + * and as such any network issues it will fail silently, and the token will not be revoked. + **/ + @Throws + open suspend fun invalidateToken(token: AuthToken): Nothing = throw NotImplementedError() + + @Throws + @Deprecated("Please use the new API for AuthAPI", level = DeprecationLevel.ERROR) + 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) + fun loginInfo(): LoginInfo? { + return this.toRepo().authUser()?.let { user -> + LoginInfo( + profilePicture = user.profilePicture, + name = user.name, + accountIndex = -1, + ) + } + } + + @Deprecated("Please use the new API for AuthAPI", level = DeprecationLevel.ERROR) + 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) class LoginInfo( val profilePicture: String? = null, val name: String?, val accountIndex: Int, ) -} \ No newline at end of file +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/AuthRepo.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/AuthRepo.kt new file mode 100644 index 00000000000..645a19e3a60 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/AuthRepo.kt @@ -0,0 +1,168 @@ +package com.lagradost.cloudstream3.syncproviders + +import com.lagradost.cloudstream3.CloudStreamApp.Companion.openBrowser +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.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 + val idPrefix get() = api.idPrefix + val name get() = api.name + val icon get() = api.icon + val requiresLogin get() = api.requiresLogin + val createAccountUrl get() = api.createAccountUrl + val hasOAuth2 get() = api.hasOAuth2 + val hasPin get() = api.hasPin + val hasInApp get() = api.hasInApp + val inAppLoginRequirement get() = api.inAppLoginRequirement + val isAvailable get() = !api.requiresLogin || authUser() != null + + companion object { + private val oauthPayload: MutableMap = mutableMapOf() + } + + @Throws + protected suspend fun freshAuth(): AuthData? { + val data = authData() ?: return null + if (data.token.isAccessTokenExpired()) { + val newToken = api.refreshToken(data.token) ?: return null + val newAuth = AuthData(user = data.user, token = newToken) + refreshUser(newAuth) + return newAuth + } + return data + } + + @Throws + fun openOAuth2Page(): Boolean { + val page = api.loginRequest() ?: return false + synchronized(oauthPayload) { + oauthPayload.put(idPrefix, page.payload) + } + openBrowser(page.url) + return true + } + + fun openOAuth2PageWithToast() { + try { + if (!openOAuth2Page()) { + showToast(txt(R.string.authenticated_user_fail, api.name)) + } + } catch (t: Throwable) { + logError(t) + if (t is ErrorLoadingException && t.message != null) { + showToast(t.message) + return + } + showToast(txt(R.string.authenticated_user_fail, api.name)) + } + } + + suspend fun logout(from: AuthUser) { + val currentAccounts = AccountManager.accounts(idPrefix) + val (newAccounts, oldAccounts) = currentAccounts.partition { it.user.id != from.id } + if (newAccounts.size < currentAccounts.size) { + AccountManager.updateAccounts(idPrefix, newAccounts.toTypedArray()) + AccountManager.updateAccountsId(idPrefix, 0) + } + + for (oldAccount in oldAccounts) { + try { + api.invalidateToken(oldAccount.token) + } catch (_: NotImplementedError) { + // no-op + } catch (t: Throwable) { + logError(t) + } + } + } + + fun refreshUser(newAuth: AuthData) { + val currentAccounts = AccountManager.accounts(idPrefix) + val newAccounts = currentAccounts.map { + if (it.user.id == newAuth.user.id) { + newAuth + } else { + it + } + }.toTypedArray() + AccountManager.updateAccounts(idPrefix, newAccounts) + } + + fun authData(): AuthData? = synchronized(AccountManager.cachedAccountIds) { + AccountManager.cachedAccountIds[idPrefix]?.let { id -> + AccountManager.cachedAccounts[idPrefix]?.firstOrNull { data -> data.user.id == id } + } + } + + fun authToken(): AuthToken? = authData()?.token + + fun authUser(): AuthUser? = authData()?.user + + val accounts + get() = synchronized(AccountManager.cachedAccounts) { + AccountManager.cachedAccounts[idPrefix] ?: emptyArray() + } + var accountId + get() = synchronized(AccountManager.cachedAccountIds) { + AccountManager.cachedAccountIds[idPrefix] ?: NONE_ID + } + set(value) { + AccountManager.updateAccountsId(idPrefix, value) + } + + @Throws + suspend fun pinRequest() = + api.pinRequest() + + @Throws + private suspend fun setupLogin(token: AuthToken): Boolean { + val user = api.user(token) ?: return false + + val newAccount = AuthData( + token = token, + user = user, + ) + + val currentAccounts = AccountManager.accounts(idPrefix) + if (currentAccounts.any { it.user.id == newAccount.user.id }) { + throw ErrorLoadingException("Already logged into this account") + } + + val newAccounts = currentAccounts + newAccount + AccountManager.updateAccounts(idPrefix, newAccounts) + AccountManager.updateAccountsId(idPrefix, user.id) + if (this is SyncRepo) { + requireLibraryRefresh = true + } + return true + } + + @Throws + suspend fun login(form: AuthLoginResponse): Boolean { + return setupLogin(api.login(form) ?: return false) + } + + @Throws + suspend fun login(payload: AuthPinData): Boolean { + return setupLogin(api.login(payload) ?: return false) + } + + @Throws + suspend fun login(redirectUrl: String): Boolean { + return setupLogin( + api.login( + redirectUrl, + synchronized(oauthPayload) { oauthPayload[api.idPrefix] }) ?: return false + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/BackupAPI.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/BackupAPI.kt new file mode 100644 index 00000000000..5efb88e5b74 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/BackupAPI.kt @@ -0,0 +1,14 @@ +package com.lagradost.cloudstream3.syncproviders + +/** Work in progress */ +abstract class BackupAPI : AuthAPI() { + open val filename : String = "cloudstream-backup.json" + + /** Get the backup file as a JSON string from the remote storage. Return null if not found/empty */ + @Throws + open suspend fun downloadFile(auth: AuthData?) : String? = throw NotImplementedError() + + /** Get the backup file as a JSON string from the remote storage. */ + @Throws + open suspend fun uploadFile(auth: AuthData?, data : String) : String? = throw NotImplementedError() +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/InAppAuthAPI.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/InAppAuthAPI.kt deleted file mode 100644 index 8b6fdf463cf..00000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/InAppAuthAPI.kt +++ /dev/null @@ -1,66 +0,0 @@ -package com.lagradost.cloudstream3.syncproviders - -import androidx.annotation.WorkerThread - -interface InAppAuthAPI : AuthAPI { - data class LoginData( - val username: String? = null, - val password: String? = null, - val server: String? = null, - val email: String? = null, - ) - - // this is for displaying the UI - val requiresPassword: Boolean - val requiresUsername: Boolean - val requiresServer: Boolean - val requiresEmail: Boolean - - // if this is false we can assume that getLatestLoginData returns null and wont be called - // this is used in case for some reason it is not preferred to store any login data besides the "token" or encrypted data - val storesPasswordInPlainText: Boolean - - // return true if logged in successfully - suspend fun login(data: LoginData): Boolean - - // used to fill the UI if you want to edit any data about your login info - fun getLatestLoginData(): LoginData? -} - -abstract class InAppAuthAPIManager(defIndex: Int) : AccountManager(defIndex), InAppAuthAPI { - override val requiresPassword = false - override val requiresUsername = false - override val requiresEmail = false - override val requiresServer = false - override val storesPasswordInPlainText = true - override val requiresLogin = true - - // runs on startup - @WorkerThread - open suspend fun initialize() { - } - - override fun logOut() { - throw NotImplementedError() - } - - override val idPrefix: String - get() = throw NotImplementedError() - - override val name: String - get() = throw NotImplementedError() - - override val icon: Int? = null - - override suspend fun login(data: InAppAuthAPI.LoginData): Boolean { - throw NotImplementedError() - } - - override fun getLatestLoginData(): InAppAuthAPI.LoginData? { - throw NotImplementedError() - } - - override fun loginInfo(): AuthAPI.LoginInfo? { - throw NotImplementedError() - } -} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/OAuth2API.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/OAuth2API.kt deleted file mode 100644 index 3d0bb9402cc..00000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/OAuth2API.kt +++ /dev/null @@ -1,27 +0,0 @@ -package com.lagradost.cloudstream3.syncproviders - -import androidx.fragment.app.FragmentActivity - -interface OAuth2API : AuthAPI { - val key: String - val redirectUrl: String - val supportDeviceAuth: Boolean - - suspend fun handleRedirect(url: String) : Boolean - fun authenticate(activity: FragmentActivity?) - suspend fun getDevicePin() : PinAuthData? { - return null - } - - suspend fun handleDeviceAuth(pinAuthData: PinAuthData) : Boolean { - return false - } - - data class PinAuthData( - val deviceCode: String, - val userCode: String, - val verificationUrl: String, - val expiresIn: Int, - val interval: Int, - ) -} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/SubtitleAPI.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/SubtitleAPI.kt new file mode 100644 index 00000000000..a1149b5f8f8 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/SubtitleAPI.kt @@ -0,0 +1,37 @@ +package com.lagradost.cloudstream3.syncproviders + +import androidx.annotation.WorkerThread +import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities.SubtitleEntity +import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities.SubtitleSearch +import com.lagradost.cloudstream3.subtitles.SubtitleResource + +/** + * Stateless subtitle class for external subtitles. + * + * All non-null `AuthToken` will be non-expired when each function is called. + */ +abstract class SubtitleAPI : AuthAPI() { + @WorkerThread + @Throws + open suspend fun search(auth: AuthData?, query: SubtitleSearch): List? = + throw NotImplementedError() + + @WorkerThread + @Throws + open suspend fun load(auth: AuthData?, subtitle: SubtitleEntity): String? = + throw NotImplementedError() + + @WorkerThread + @Throws + open suspend fun SubtitleResource.getResources(auth: AuthData?, subtitle: SubtitleEntity) { + this.addUrl(load(auth, subtitle)) + } + + @WorkerThread + @Throws + suspend fun resource(auth: AuthData?, subtitle: SubtitleEntity): SubtitleResource { + return SubtitleResource().apply { + this.getResources(auth, subtitle) + } + } +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/SubtitleRepo.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/SubtitleRepo.kt new file mode 100644 index 00000000000..7a93f96f697 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/SubtitleRepo.kt @@ -0,0 +1,89 @@ +package com.lagradost.cloudstream3.syncproviders + +import androidx.annotation.WorkerThread +import com.lagradost.cloudstream3.APIHolder.unixTime +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.threadSafeListOf + +/** Stateless safe abstraction of SubtitleAPI */ +class SubtitleRepo(override val api: SubtitleAPI) : AuthRepo(api) { + companion object { + data class SavedSearchResponse( + val unixTime: Long, + val response: List, + val query: SubtitleSearch + ) + + data class SavedResourceResponse( + val unixTime: Long, + val response: SubtitleResource, + val query: SubtitleEntity + ) + + // maybe make this a generic struct? right now there is a lot of boilerplate + private val searchCache = threadSafeListOf() + private var searchCacheIndex: Int = 0 + private val resourceCache = threadSafeListOf() + private var resourceCacheIndex: Int = 0 + const val CACHE_SIZE = 20 + } + + @WorkerThread + suspend fun resource(data: SubtitleEntity): Result = runCatching { + synchronized(resourceCache) { + for (item in resourceCache) { + // 20 min save + if (item.query == data && (unixTime - item.unixTime) < 60 * 20) { + return@runCatching item.response + } + } + } + + val returnValue = api.resource(freshAuth(), data) + synchronized(resourceCache) { + val add = SavedResourceResponse(unixTime, returnValue, data) + if (resourceCache.size > CACHE_SIZE) { + resourceCache[resourceCacheIndex] = add // rolling cache + resourceCacheIndex = (resourceCacheIndex + 1) % CACHE_SIZE + } else { + resourceCache.add(add) + } + } + returnValue + } + + @WorkerThread + suspend fun search(query: SubtitleSearch): Result> { + return runCatching { + synchronized(searchCache) { + for (item in searchCache) { + // 120 min save + if (item.query == query && (unixTime - item.unixTime) < 60 * 120) { + return@runCatching item.response + } + } + } + + val returnValue = + api.search(freshAuth(), query) ?: emptyList() + + // only cache valid return values + if (returnValue.isNotEmpty()) { + val add = SavedSearchResponse(unixTime, returnValue, query) + synchronized(searchCache) { + if (searchCache.size > CACHE_SIZE) { + searchCache[searchCacheIndex] = add // rolling cache + searchCacheIndex = (searchCacheIndex + 1) % CACHE_SIZE + } else { + searchCache.add(add) + } + } + } + returnValue + } + } +} + diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/SyncApi.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/SyncAPI.kt similarity index 64% rename from app/src/main/java/com/lagradost/cloudstream3/syncproviders/SyncApi.kt rename to app/src/main/java/com/lagradost/cloudstream3/syncproviders/SyncAPI.kt index 9d43685c830..e5f9aca8493 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/SyncApi.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/SyncAPI.kt @@ -1,170 +1,194 @@ -package com.lagradost.cloudstream3.syncproviders - -import com.lagradost.cloudstream3.* -import com.lagradost.cloudstream3.ui.SyncWatchType -import com.lagradost.cloudstream3.ui.library.ListSorting -import com.lagradost.cloudstream3.utils.UiText -import me.xdrop.fuzzywuzzy.FuzzySearch -import java.util.Date - -interface SyncAPI : OAuth2API { - /** - * Set this to true if the user updates something on the list like watch status or score - **/ - var requireLibraryRefresh: Boolean - val mainUrl: String - - /** - * Allows certain providers to open pages from - * library links. - **/ - val syncIdName: SyncIdName - - /** - -1 -> None - 0 -> Watching - 1 -> Completed - 2 -> OnHold - 3 -> Dropped - 4 -> PlanToWatch - 5 -> ReWatching - */ - suspend fun score(id: String, status: AbstractSyncStatus): Boolean - - suspend fun getStatus(id: String): AbstractSyncStatus? - - suspend fun getResult(id: String): SyncResult? - - suspend fun search(name: String): List? - - suspend fun getPersonalLibrary(): LibraryMetadata? - - fun getIdFromUrl(url: String): String - - data class SyncSearchResult( - override val name: String, - override val apiName: String, - var syncId: String, - override val url: String, - override var posterUrl: String?, - override var type: TvType? = null, - override var quality: SearchQuality? = null, - override var posterHeaders: Map? = null, - override var id: Int? = null, - ) : SearchResponse - - abstract class AbstractSyncStatus { - abstract var status: SyncWatchType - - /** 1-10 */ - abstract var score: Int? - abstract var watchedEpisodes: Int? - abstract var isFavorite: Boolean? - abstract var maxEpisodes: Int? - } - - - data class SyncStatus( - override var status: SyncWatchType, - /** 1-10 */ - override var score: Int?, - override var watchedEpisodes: Int?, - override var isFavorite: Boolean? = null, - override var maxEpisodes: Int? = null, - ) : AbstractSyncStatus() - - data class SyncResult( - /**Used to verify*/ - var id: String, - - var totalEpisodes: Int? = null, - - var title: String? = null, - /**1-1000*/ - var publicScore: Int? = null, - /**In minutes*/ - var duration: Int? = null, - var synopsis: String? = null, - var airStatus: ShowStatus? = null, - var nextAiring: NextAiring? = null, - var studio: List? = null, - var genres: List? = null, - var synonyms: List? = null, - var trailers: List? = null, - var isAdult: Boolean? = null, - var posterUrl: String? = null, - var backgroundPosterUrl: String? = null, - - /** In unixtime */ - var startDate: Long? = null, - /** In unixtime */ - var endDate: Long? = null, - var recommendations: List? = null, - var nextSeason: SyncSearchResult? = null, - var prevSeason: SyncSearchResult? = null, - var actors: List? = null, - ) - - - data class Page( - val title: UiText, var items: List - ) { - fun sort(method: ListSorting?, query: String? = null) { - items = when (method) { - ListSorting.Query -> - if (query != null) { - items.sortedBy { - -FuzzySearch.partialRatio( - query.lowercase(), it.name.lowercase() - ) - } - } else items - ListSorting.RatingHigh -> items.sortedBy { -(it.personalRating ?: 0) } - ListSorting.RatingLow -> items.sortedBy { (it.personalRating ?: 0) } - ListSorting.AlphabeticalA -> items.sortedBy { it.name } - ListSorting.AlphabeticalZ -> items.sortedBy { it.name }.reversed() - ListSorting.UpdatedNew -> items.sortedBy { it.lastUpdatedUnixTime?.times(-1) } - ListSorting.UpdatedOld -> items.sortedBy { it.lastUpdatedUnixTime } - ListSorting.ReleaseDateNew -> items.sortedByDescending { it.releaseDate } - ListSorting.ReleaseDateOld -> items.sortedBy { it.releaseDate } - else -> items - } - } - } - - data class LibraryMetadata( - val allLibraryLists: List, - val supportedListSorting: Set - ) - - data class LibraryList( - val name: UiText, - val items: List - ) - - data class LibraryItem( - override val name: String, - override val url: String, - /** - * Unique unchanging string used for data storage. - * This should be the actual id when you change scores and status - * since score changes from library might get added in the future. - **/ - val syncId: String, - val episodesCompleted: Int?, - val episodesTotal: Int?, - /** Out of 100 */ - val personalRating: Int?, - val lastUpdatedUnixTime: Long?, - override val apiName: String, - override var type: TvType?, - override var posterUrl: String?, - override var posterHeaders: Map?, - override var quality: SearchQuality?, - val releaseDate: Date?, - override var id: Int? = null, - val plot : String? = null, - val rating: Int? = null, - val tags: List? = null - ) : SearchResponse +package com.lagradost.cloudstream3.syncproviders + +import androidx.annotation.WorkerThread +import com.lagradost.cloudstream3.ActorData +import com.lagradost.cloudstream3.NextAiring +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.ui.SyncWatchType +import com.lagradost.cloudstream3.ui.library.ListSorting +import com.lagradost.cloudstream3.utils.UiText +import me.xdrop.fuzzywuzzy.FuzzySearch +import java.util.Date + +/** + * Stateless synchronization class, used for syncing status about a specific movie/show. + * + * All non-null `AuthToken` will be non-expired when each function is called. + */ +abstract class SyncAPI : AuthAPI() { + /** + * Set this to true if the user updates something on the list like watch status or score + **/ + open var requireLibraryRefresh: Boolean = true + open val mainUrl: String = "NONE" + + /** Currently unused, but will be used to correctly render the UI. + * This should specify what sync watch types can be used with this service. */ + open val supportedWatchTypes: Set = SyncWatchType.entries.toSet() + /** + * Allows certain providers to open pages from + * library links. + **/ + open val syncIdName: SyncIdName? = null + + /** Modify the current status of an item */ + @Throws + @WorkerThread + open suspend fun updateStatus( + auth: AuthData?, + id: String, + newStatus: AbstractSyncStatus + ): Boolean = throw NotImplementedError() + + /** Get the current status of an item */ + @Throws + @WorkerThread + open suspend fun status(auth: AuthData?, id: String): AbstractSyncStatus? = + throw NotImplementedError() + + /** Get metadata about an item */ + @Throws + @WorkerThread + open suspend fun load(auth: AuthData?, id: String): SyncResult? = throw NotImplementedError() + + /** Search this service for any results for a given query */ + @Throws + @WorkerThread + open suspend fun search(auth: AuthData?, query: String): List? = + throw NotImplementedError() + + /** Get the current library/bookmarks of this service */ + @Throws + @WorkerThread + open suspend fun library(auth: AuthData?): LibraryMetadata? = throw NotImplementedError() + + /** Helper function, may be used in the future */ + @Throws + open fun urlToId(url: String): String? = null + + data class SyncSearchResult( + override val name: String, + override val apiName: String, + var syncId: String, + override val url: String, + override var posterUrl: String?, + override var type: TvType? = null, + override var quality: SearchQuality? = null, + override var posterHeaders: Map? = null, + override var id: Int? = null, + override var score: Score? = null, + ) : SearchResponse + + abstract class AbstractSyncStatus { + abstract var status: SyncWatchType + abstract var score: Score? + abstract var watchedEpisodes: Int? + abstract var isFavorite: Boolean? + abstract var maxEpisodes: Int? + } + + data class SyncStatus( + override var status: SyncWatchType, + override var score: Score?, + override var watchedEpisodes: Int?, + override var isFavorite: Boolean? = null, + override var maxEpisodes: Int? = null, + ) : AbstractSyncStatus() + + data class SyncResult( + /**Used to verify*/ + var id: String, + + var totalEpisodes: Int? = null, + + var title: String? = null, + var publicScore: Score? = null, + /**In minutes*/ + var duration: Int? = null, + var synopsis: String? = null, + var airStatus: ShowStatus? = null, + var nextAiring: NextAiring? = null, + var studio: List? = null, + var genres: List? = null, + var synonyms: List? = null, + var trailers: List? = null, + var isAdult: Boolean? = null, + var posterUrl: String? = null, + var backgroundPosterUrl: String? = null, + + /** In unixtime */ + var startDate: Long? = null, + /** In unixtime */ + var endDate: Long? = null, + var recommendations: List? = null, + var nextSeason: SyncSearchResult? = null, + var prevSeason: SyncSearchResult? = null, + var actors: List? = null, + ) + + data class Page( + val title: UiText, var items: List + ) { + fun sort(method: ListSorting?, query: String? = null) { + items = when (method) { + ListSorting.Query -> + if (query != null) { + items.sortedBy { + -FuzzySearch.partialRatio( + query.lowercase(), it.name.lowercase() + ) + } + } else items + + ListSorting.RatingHigh -> items.sortedBy { -(it.personalRating?.toInt(100) ?: 0) } + ListSorting.RatingLow -> items.sortedBy { (it.personalRating?.toInt(100) ?: 0) } + ListSorting.AlphabeticalA -> items.sortedBy { it.name } + ListSorting.AlphabeticalZ -> items.sortedBy { it.name }.reversed() + ListSorting.UpdatedNew -> items.sortedBy { it.lastUpdatedUnixTime?.times(-1) } + ListSorting.UpdatedOld -> items.sortedBy { it.lastUpdatedUnixTime } + ListSorting.ReleaseDateNew -> items.sortedByDescending { it.releaseDate } + ListSorting.ReleaseDateOld -> items.sortedBy { it.releaseDate } + else -> items + } + } + } + + data class LibraryMetadata( + val allLibraryLists: List, + val supportedListSorting: Set + ) + + data class LibraryList( + val name: UiText, + val items: List + ) + + data class LibraryItem( + override val name: String, + override val url: String, + /** + * Unique unchanging string used for data storage. + * This should be the actual id when you change scores and status + * since score changes from library might get added in the future. + **/ + val syncId: String, + val episodesCompleted: Int?, + val episodesTotal: Int?, + val personalRating: Score?, + val lastUpdatedUnixTime: Long?, + override val apiName: String, + override var type: TvType?, + override var posterUrl: String?, + override var posterHeaders: Map?, + override var quality: SearchQuality?, + val releaseDate: Date?, + override var id: Int? = null, + val plot: String? = null, + 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/SyncRepo.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/SyncRepo.kt index df88eeb71ef..de82624fc7e 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/SyncRepo.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/SyncRepo.kt @@ -1,48 +1,30 @@ -package com.lagradost.cloudstream3.syncproviders - -import com.lagradost.cloudstream3.ErrorLoadingException -import com.lagradost.cloudstream3.mvvm.Resource -import com.lagradost.cloudstream3.mvvm.safe -import com.lagradost.cloudstream3.mvvm.safeApiCall - -class SyncRepo(private val repo: SyncAPI) { - val idPrefix = repo.idPrefix - val name = repo.name - val icon = repo.icon - val mainUrl = repo.mainUrl - val requiresLogin = repo.requiresLogin - val syncIdName = repo.syncIdName - var requireLibraryRefresh: Boolean - get() = repo.requireLibraryRefresh - set(value) { - repo.requireLibraryRefresh = value - } - - suspend fun score(id: String, status: SyncAPI.AbstractSyncStatus): Resource { - return safeApiCall { repo.score(id, status) } - } - - suspend fun getStatus(id: String): Resource { - return safeApiCall { repo.getStatus(id) ?: throw ErrorLoadingException("No data") } - } - - suspend fun getResult(id: String): Resource { - return safeApiCall { repo.getResult(id) ?: throw ErrorLoadingException("No data") } - } - - suspend fun search(query: String): Resource> { - return safeApiCall { repo.search(query) ?: throw ErrorLoadingException() } - } - - suspend fun getPersonalLibrary(): Resource { - return safeApiCall { repo.getPersonalLibrary() ?: throw ErrorLoadingException() } - } - - fun hasAccount(): Boolean { - return safe { repo.loginInfo() != null } ?: false - } - - fun getIdFromUrl(url: String): String? = safe { - repo.getIdFromUrl(url) - } -} \ No newline at end of file +package com.lagradost.cloudstream3.syncproviders + +/** Stateless safe abstraction of SyncAPI */ +class SyncRepo(override val api: SyncAPI) : AuthRepo(api) { + val syncIdName = api.syncIdName + var requireLibraryRefresh: Boolean + get() = api.requireLibraryRefresh + set(value) { + api.requireLibraryRefresh = value + } + + suspend fun updateStatus(id: String, newStatus: SyncAPI.AbstractSyncStatus): Result = + runCatching { + val status = api.updateStatus(freshAuth() ?: return@runCatching false, id, newStatus) + requireLibraryRefresh = true + status + } + + suspend fun status(id: String): Result = runCatching { + api.status(freshAuth(), id) + } + + suspend fun load(id: String): Result = runCatching { + api.load(freshAuth(), id) + } + + suspend fun library(): Result = runCatching { + api.library(freshAuth()) + } +} 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 db4676393ab..144efff99ce 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,108 +1,205 @@ package com.lagradost.cloudstream3.syncproviders.providers -import com.lagradost.cloudstream3.TvType +import com.lagradost.cloudstream3.AllLanguagesName import com.lagradost.cloudstream3.app -import com.lagradost.cloudstream3.subtitles.AbstractSubApi -import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities -import com.lagradost.cloudstream3.utils.SubtitleHelper +import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities.SubtitleEntity +import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities.SubtitleSearch +import com.lagradost.cloudstream3.syncproviders.AuthData +import com.lagradost.cloudstream3.syncproviders.SubtitleAPI +import com.lagradost.cloudstream3.TvType +import com.lagradost.cloudstream3.utils.SubtitleHelper.fromTagToEnglishLanguageName -class Addic7ed : AbstractSubApi { +class Addic7ed : SubtitleAPI() { override val name = "Addic7ed" override val idPrefix = "addic7ed" override val requiresLogin = false - override val icon: Nothing? = null - override val createAccountUrl: Nothing? = null - - override fun loginInfo(): Nothing? = null - - override fun logOut() {} companion object { const val HOST = "https://www.addic7ed.com" const val TAG = "ADDIC7ED" } - private fun fixUrl(url: String): String { + private fun String.fixUrl(): String { + val url = this return if (url.startsWith("/")) HOST + url else if (!url.startsWith("http")) "$HOST/$url" else url - } - override suspend fun search(query: AbstractSubtitleEntities.SubtitleSearch): List { - val lang = query.lang - val queryLang = SubtitleHelper.fromTwoLettersToLanguage(lang.toString()) - val queryText = query.query.trim() + 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() 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 cleanResources( - results: MutableList, - name: String, - link: String, - headers: Map, + fun newSubtitleEntity ( + displayName: String?, + link: String?, isHearingImpaired: Boolean - ) { - 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 - ) + ): 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 ) } - 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)").isNullOrEmpty()) searchResult = url - else if (!hostDocument.select("table.tabel") - .isNullOrEmpty() - ) searchResult = hostDocument.select("a:contains($title)").attr("href").toString() - else { - val show = - hostDocument.selectFirst("#sl button")?.attr("onmouseup")?.substringAfter("(") - ?.substringBefore(",") + 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 doc = app.get( - "$HOST/ajax_loadShow.php?show=$show&season=$seasonNum&langs=&hd=undefined&hi=undefined", + "$HOST/ajax_loadShow.php?show=$showId&season=$seasonNum&langs=|$langNumAddic7ed|&hd=0&hi=0", referer = "$HOST/" ).document - 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")) + + // 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 } + // 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 - 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")) + // 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() val isHearingImpaired = - !node.parent()!!.select("tr:last-child [title=\"Hearing Impaired\"]").isNullOrEmpty() - cleanResources(results, name, link, mapOf("referer" to "$HOST/"), isHearingImpaired) + node.parent()!!.select("tr:last-child [title=\"Hearing Impaired\"]").isNotEmpty() + + newSubtitleEntity(displayName, link, isHearingImpaired) } - return results } - override suspend fun load(data: AbstractSubtitleEntities.SubtitleEntity): String { - return data.data + override suspend fun load( + auth: AuthData?, + subtitle: 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 f8e82409522..7a46b411376 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 @@ -1,92 +1,89 @@ package com.lagradost.cloudstream3.syncproviders.providers import androidx.annotation.StringRes -import androidx.fragment.app.FragmentActivity import com.fasterxml.jackson.annotation.JsonProperty -import com.lagradost.cloudstream3.* -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.Actor +import com.lagradost.cloudstream3.ActorData +import com.lagradost.cloudstream3.ActorRole +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 +import com.lagradost.cloudstream3.Score +import com.lagradost.cloudstream3.TvType +import com.lagradost.cloudstream3.app import com.lagradost.cloudstream3.mvvm.logError -import com.lagradost.cloudstream3.mvvm.safeAsync -import com.lagradost.cloudstream3.syncproviders.AccountManager -import com.lagradost.cloudstream3.syncproviders.AuthAPI +import com.lagradost.cloudstream3.syncproviders.AuthData +import com.lagradost.cloudstream3.syncproviders.AuthLoginPage +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.txt -import com.lagradost.cloudstream3.utils.AppContextUtils.splitQuery import com.lagradost.cloudstream3.utils.AppUtils.parseJson import com.lagradost.cloudstream3.utils.AppUtils.toJson import com.lagradost.cloudstream3.utils.AppUtils.tryParseJson -import com.lagradost.cloudstream3.utils.Coroutines.ioSafe import com.lagradost.cloudstream3.utils.DataStore.toKotlinObject import com.lagradost.cloudstream3.utils.DataStoreHelper.toYear -import java.net.URL +import com.lagradost.cloudstream3.utils.txt import java.net.URLEncoder import java.util.Locale -class AniListApi(index: Int) : AccountManager(index), SyncAPI { +class AniListApi : SyncAPI() { override var name = "AniList" - override val key = "6871" - override val redirectUrl = "anilistlogin" override val idPrefix = "anilist" + + val key = "6871" + override val redirectUrlIdentifier = "anilistlogin" override var requireLibraryRefresh = true - override val supportDeviceAuth = false + override val hasOAuth2 = true override var mainUrl = "https://anilist.co" override val icon = R.drawable.ic_anilist_icon - override val requiresLogin = false override val createAccountUrl = "$mainUrl/signup" override val syncIdName = SyncIdName.Anilist - override fun loginInfo(): AuthAPI.LoginInfo? { - // context.getUser(true)?. - getKey(accountId, ANILIST_USER_KEY)?.let { user -> - return AuthAPI.LoginInfo( - profilePicture = user.picture, - name = user.name, - accountIndex = accountIndex - ) - } - return null - } + override fun loginRequest(): AuthLoginPage? = + AuthLoginPage("https://anilist.co/api/v2/oauth/authorize?client_id=$key&response_type=token") - override fun logOut() { - requireLibraryRefresh = true - removeAccountKeys() + 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"), + //refreshToken = sanitizer["refresh_token"], + accessTokenLifetime = unixTime + sanitizer["expires_in"]!!.toLong(), + ) + return token } - override fun authenticate(activity: FragmentActivity?) { - val request = "https://anilist.co/api/v2/oauth/authorize?client_id=$key&response_type=token" - openBrowser(request, activity) + // https://docs.anilist.co/guide/auth/ + override suspend fun refreshToken(token: AuthToken): AuthToken? { + // AniList access tokens are long-lived. They will remain valid for 1 year from the time they are issued. + // Refresh tokens are not currently supported. Once a token expires, you will need to re-authenticate your users. + return super.refreshToken(token) } - override suspend fun handleRedirect(url: String): Boolean { - val sanitizer = - splitQuery(URL(url.replace(APP_STRING, "https").replace("/#", "?"))) // FIX ERROR - val token = sanitizer["access_token"]!! - val expiresIn = sanitizer["expires_in"]!! - - val endTime = unixTime + expiresIn.toLong() + override suspend fun user(token: AuthToken?): AuthUser? { + val user = getUser(token ?: return null) + ?: throw ErrorLoadingException("Unable to fetch user data") - switchToNewAccount() - setKey(accountId, ANILIST_UNIXTIME_KEY, endTime) - setKey(accountId, ANILIST_TOKEN_KEY, token) - val user = getUser() - requireLibraryRefresh = true - return user != null + return AuthUser( + id = user.id, + name = user.name, + profilePicture = user.picture, + ) } - override fun getIdFromUrl(url: String): String { - return url.removePrefix("$mainUrl/anime/").removeSuffix("/") - } + override fun urlToId(url: String): String? = + url.removePrefix("$mainUrl/anime/").removeSuffix("/") + private fun getUrlFromId(id: Int): String { return "$mainUrl/anime/$id" } - override suspend fun search(name: String): List? { + override suspend fun search(auth : AuthData?, query: String): List? { val data = searchShows(name) ?: return null return data.data?.page?.media?.map { SyncAPI.SyncSearchResult( @@ -99,7 +96,7 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI { } } - override suspend fun getResult(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 @@ -141,7 +138,7 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI { } ) }, - publicScore = season.averageScore?.times(100), + publicScore = Score.from100(season.averageScore), recommendations = season.recommendations?.edges?.mapNotNull { rec -> val recMedia = rec.node.mediaRecommendation SyncAPI.SyncSearchResult( @@ -161,12 +158,12 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI { ) } - override suspend fun getStatus(id: String): SyncAPI.AbstractSyncStatus? { + override suspend fun status(auth : AuthData?, id: String): SyncAPI.AbstractSyncStatus? { val internalId = id.toIntOrNull() ?: return null - val data = getDataAboutId(internalId) ?: return null + val data = getDataAboutId(auth ?: return null, internalId) ?: return null return SyncAPI.SyncStatus( - score = data.score, + score = Score.from100(data.score), watchedEpisodes = data.progress, status = SyncWatchType.fromInternalId(data.type?.value ?: return null), isFavorite = data.isFavourite, @@ -174,24 +171,25 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI { ) } - override suspend fun score(id: String, status: SyncAPI.AbstractSyncStatus): Boolean { + override suspend fun updateStatus( + auth: AuthData?, + id: String, + newStatus: AbstractSyncStatus + ): Boolean { return postDataAboutId( + auth ?: return false, id.toIntOrNull() ?: return false, - fromIntToAnimeStatus(status.status.internalId), - status.score, - status.watchedEpisodes - ).also { - requireLibraryRefresh = requireLibraryRefresh || it - } + fromIntToAnimeStatus(newStatus.status.internalId), + newStatus.score, + newStatus.watchedEpisodes + ) } companion object { + const val MAX_STALE = 60 * 10 private val aniListStatusString = arrayOf("CURRENT", "COMPLETED", "PAUSED", "DROPPED", "PLANNING", "REPEATING") - const val ANILIST_UNIXTIME_KEY: String = "anilist_unixtime" // When token expires - const val ANILIST_TOKEN_KEY: String = "anilist_token" // anilist token for api - const val ANILIST_USER_KEY: String = "anilist_user" // user data like profile const val ANILIST_CACHED_LIST: String = "anilist_cached_list" private fun fixName(name: String): String { @@ -461,21 +459,7 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI { } } - fun initGetUser() { - if (getAuth() == null) return - ioSafe { - getUser() - } - } - - private fun checkToken(): Boolean { - return unixTime > getKey( - accountId, - ANILIST_UNIXTIME_KEY, 0L - )!! - } - - private suspend fun getDataAboutId(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) @@ -485,7 +469,7 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI { mediaListEntry { progress status - score (format: POINT_10) + score (format: POINT_100) } title { english @@ -494,7 +478,7 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI { } }""" - val data = postApi(q, true) + val data = postApi(auth.token, q, true) val d = parseJson(data ?: return null) val main = d.data?.media @@ -522,37 +506,24 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI { } - private fun getAuth(): String? { - return getKey( - accountId, - ANILIST_TOKEN_KEY - ) + private suspend fun postApi(token : AuthToken, q: String, cache: Boolean = false): String? { + return app.post( + "https://graphql.anilist.co/", + headers = mapOf( + "Authorization" to "Bearer ${token.accessToken ?: return null}", + if (cache) "Cache-Control" to "max-stale=$MAX_STALE" else "Cache-Control" to "no-cache" + ), + cacheTime = 0, + data = mapOf( + "query" to URLEncoder.encode( + q, + "UTF-8" + ) + ), //(if (vars == null) mapOf("query" to q) else mapOf("query" to q, "variables" to vars)) + timeout = 5 // REASONABLE TIMEOUT + ).text.replace("\\/", "/") } - private suspend fun postApi(q: String, cache: Boolean = false): String? { - return safeAsync { - if (!checkToken()) { - app.post( - "https://graphql.anilist.co/", - headers = mapOf( - "Authorization" to "Bearer " + (getAuth() - ?: return@safeAsync null), - if (cache) "Cache-Control" to "max-stale=$MAX_STALE" else "Cache-Control" to "no-cache" - ), - cacheTime = 0, - data = mapOf( - "query" to URLEncoder.encode( - q, - "UTF-8" - ) - ), //(if (vars == null) mapOf("query" to q) else mapOf("query" to q, "variables" to vars)) - timeout = 5 // REASONABLE TIMEOUT - ).text.replace("\\/", "/") - } else { - null - } - } - } data class MediaRecommendation( @JsonProperty("id") val id: Int, @@ -624,7 +595,7 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI { this.media.id.toString(), this.progress, this.media.episodes, - this.score, + Score.from100(this.score), this.updatedAt.toLong(), "AniList", TvType.Anime, @@ -652,27 +623,23 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI { @JsonProperty("MediaListCollection") val mediaListCollection: MediaListCollection ) - private fun getAniListListCached(): Array? { - return getKey(ANILIST_CACHED_LIST) as? Array - } - - private suspend fun getAniListAnimeListSmart(): Array? { - if (getAuth() == null) return null - - if (checkToken()) return null + private suspend fun getAniListAnimeListSmart(auth: AuthData): Array? { return if (requireLibraryRefresh) { - val list = getFullAniListList()?.data?.mediaListCollection?.lists?.toTypedArray() + val list = getFullAniListList(auth)?.data?.mediaListCollection?.lists?.toTypedArray() if (list != null) { - setKey(ANILIST_CACHED_LIST, list) + setKey(ANILIST_CACHED_LIST, auth.user.id.toString(), list) } list } else { - getAniListListCached() + getKey>( + ANILIST_CACHED_LIST, + auth.user.id.toString() + ) as? Array } } - override suspend fun getPersonalLibrary(): SyncAPI.LibraryMetadata { - val list = getAniListAnimeListSmart()?.groupBy { + override suspend fun library(auth : AuthData?): SyncAPI.LibraryMetadata? { + val list = getAniListAnimeListSmart(auth ?: return null)?.groupBy { convertAniListStringToStatus(it.status ?: "").stringRes }?.mapValues { group -> group.value.map { it.entries.map { entry -> entry.toLibraryItem() } }.flatten() @@ -699,10 +666,8 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI { ) } - private suspend fun getFullAniListList(): FullAnilistList? { - /** WARNING ASSUMES ONE USER! **/ - - val userID = getKey(accountId, ANILIST_USER_KEY)?.id ?: return null + private suspend fun getFullAniListList(auth : AuthData): FullAnilistList? { + val userID = auth.user.id val mediaType = "ANIME" val query = """ @@ -745,11 +710,11 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI { } } """ - val text = postApi(query) + val text = postApi(auth.token, query) return text?.toKotlinObject() } - suspend fun toggleLike(id: Int): Boolean { + suspend fun toggleLike(auth : AuthData, id: Int): Boolean { val q = """mutation (${'$'}animeId: Int = $id) { ToggleFavourite (animeId: ${'$'}animeId) { anime { @@ -762,7 +727,7 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI { } } }""" - val data = postApi(q) + val data = postApi(auth.token, q) return data != "" } @@ -772,15 +737,17 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI { data class MediaListId(@JsonProperty("id") val id: Long? = null) private suspend fun postDataAboutId( + auth : AuthData, id: Int, type: AniListStatusType, - score: Int?, + score: Score?, progress: Int? ): Boolean { + val userID = auth.user.id + val q = // Delete item if status type is None if (type == AniListStatusType.None) { - val userID = getKey(accountId, ANILIST_USER_KEY)?.id ?: return false // Get list ID for deletion val idQuery = """ query MediaList(${'$'}userId: Int = $userID, ${'$'}mediaId: Int = $id) { @@ -789,7 +756,7 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI { } } """ - val response = postApi(idQuery) + val response = postApi(auth.token, idQuery) val listId = tryParseJson(response)?.data?.mediaList?.id ?: return false """ @@ -805,7 +772,7 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI { 0, type.value )] - }, ${if (score != null) "${'$'}scoreRaw: Int = ${score * 10}" else ""} , ${if (progress != null) "${'$'}progress: Int = $progress" else ""}) { + }, ${if (score != null) "${'$'}scoreRaw: Int = ${score.toInt(100)}" else ""} , ${if (progress != null) "${'$'}progress: Int = $progress" else ""}) { SaveMediaListEntry (mediaId: ${'$'}id, status: ${'$'}status, scoreRaw: ${'$'}scoreRaw, progress: ${'$'}progress) { id status @@ -815,11 +782,11 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI { }""" } - val data = postApi(q) + val data = postApi(auth.token, q) return data != "" } - private suspend fun getUser(setSettings: Boolean = true): AniListUser? { + private suspend fun getUser(token : AuthToken): AniListUser? { val q = """ { Viewer { @@ -837,23 +804,15 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI { } } }""" - val data = postApi(q) + val data = postApi(token, q) if (data.isNullOrBlank()) return null val userData = parseJson(data) - val u = userData.data?.viewer + val u = userData.data?.viewer ?: return null val user = AniListUser( - u?.id, - u?.name, - u?.avatar?.large, + u.id, + u.name, + u.avatar?.large, ) - if (setSettings) { - setKey(accountId, ANILIST_USER_KEY, user) - registerAccount() - } - /* // TODO FIX FAVS - for(i in u.favourites.anime.nodes) { - println("FFAV:" + i.id) - }*/ return user } @@ -1048,8 +1007,8 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI { ) data class AniListViewer( - @JsonProperty("id") val id: Int?, - @JsonProperty("name") val name: String?, + @JsonProperty("id") val id: Int, + @JsonProperty("name") val name: String, @JsonProperty("avatar") val avatar: AniListAvatar?, @JsonProperty("favourites") val favourites: AniListFavourites?, ) @@ -1063,8 +1022,8 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI { ) data class AniListUser( - @JsonProperty("id") val id: Int?, - @JsonProperty("name") val name: String?, + @JsonProperty("id") val id: Int, + @JsonProperty("name") val name: String, @JsonProperty("picture") val picture: String?, ) diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/DropboxApi.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/DropboxApi.kt deleted file mode 100644 index 94537ea3367..00000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/DropboxApi.kt +++ /dev/null @@ -1,35 +0,0 @@ -package com.lagradost.cloudstream3.syncproviders.providers - -import androidx.fragment.app.FragmentActivity -import com.lagradost.cloudstream3.syncproviders.AuthAPI -import com.lagradost.cloudstream3.syncproviders.OAuth2API - -//TODO dropbox sync -class Dropbox : OAuth2API { - override val idPrefix = "dropbox" - override var name = "Dropbox" - override val key = "zlqsamadlwydvb2" - override val redirectUrl = "dropboxlogin" - override val requiresLogin = true - override val supportDeviceAuth = false - override val createAccountUrl: String? = null - - override val icon: Int - get() = TODO("Not yet implemented") - - override fun authenticate(activity: FragmentActivity?) { - TODO("Not yet implemented") - } - - override suspend fun handleRedirect(url: String): Boolean { - TODO("Not yet implemented") - } - - override fun logOut() { - TODO("Not yet implemented") - } - - override fun loginInfo(): AuthAPI.LoginInfo? { - TODO("Not yet implemented") - } -} \ No newline at end of file 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 724d72163b0..29c3c0c1793 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,8 +1,680 @@ package com.lagradost.cloudstream3.syncproviders.providers + +import androidx.annotation.StringRes import com.fasterxml.jackson.annotation.JsonProperty +import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey +import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.Score +import com.lagradost.cloudstream3.ShowStatus +import com.lagradost.cloudstream3.TvType import com.lagradost.cloudstream3.app import com.lagradost.cloudstream3.mvvm.logError +import com.lagradost.cloudstream3.syncproviders.AuthData +import com.lagradost.cloudstream3.syncproviders.AuthLoginRequirement +import com.lagradost.cloudstream3.syncproviders.AuthLoginResponse +import com.lagradost.cloudstream3.syncproviders.AuthToken +import com.lagradost.cloudstream3.syncproviders.AuthUser +import com.lagradost.cloudstream3.syncproviders.SyncAPI +import com.lagradost.cloudstream3.syncproviders.SyncIdName +import com.lagradost.cloudstream3.ui.SyncWatchType +import com.lagradost.cloudstream3.ui.library.ListSorting +import com.lagradost.cloudstream3.utils.AppUtils.toJson +import com.lagradost.cloudstream3.utils.txt +import okhttp3.Interceptor +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import okhttp3.Response +import java.text.SimpleDateFormat +import java.time.Instant +import java.time.LocalDate +import java.time.format.DateTimeFormatter +import java.util.Date +import java.util.Locale + +const val KITSU_MAX_SEARCH_LIMIT = 20 + +class KitsuApi: SyncAPI() { + override var name = "Kitsu" + override val idPrefix = "kitsu" + + private val apiUrl = "https://kitsu.io/api/edge" + private val fallbackApiUrl = "https://kitsu.app/api/edge" + private val oauthUrl = "https://kitsu.io/api/oauth" + private val fallbackOauthUrl = "https://kitsu.app/api/oauth" + override val hasInApp = true + override val mainUrl = "https://kitsu.app" + override val icon = R.drawable.kitsu_icon + override val syncIdName = SyncIdName.Kitsu + override val createAccountUrl = mainUrl + + override val supportedWatchTypes = setOf( + SyncWatchType.WATCHING, + SyncWatchType.COMPLETED, + SyncWatchType.PLANTOWATCH, + SyncWatchType.DROPPED, + SyncWatchType.ONHOLD, + SyncWatchType.NONE + ) + + override val inAppLoginRequirement = AuthLoginRequirement( + password = true, + email = true + ) + + private class FallbackInterceptor(private val apiUrl: String, private val fallbackApiUrl: String) : Interceptor { + override fun intercept(chain: Interceptor.Chain): Response { + val request: Request = chain.request() + + try { + + val response = chain.proceed(request); + + if (response.isSuccessful) return response + + response.close() + + } catch (_: Exception) { + } + + val fallbackRequest: Request = request.newBuilder() + .url(request.url.toString().replaceFirst(apiUrl, fallbackApiUrl)) + .build() + + return chain.proceed(fallbackRequest) + + } + } + + private val apiFallbackInterceptor = FallbackInterceptor(apiUrl, fallbackApiUrl) + private val oauthFallbackInterceptor = FallbackInterceptor(oauthUrl, fallbackOauthUrl) + + override suspend fun login(form: AuthLoginResponse): AuthToken? { + val username = form.email ?: return null + val password = form.password ?: return null + + val grantType = "password" + + val token = app.post( + "$oauthUrl/token", + data = mapOf( + "grant_type" to grantType, + "username" to username, + "password" to password + ), + interceptor = oauthFallbackInterceptor + ).parsed() + + return AuthToken( + accessTokenLifetime = unixTime + token.expiresIn.toLong(), + refreshToken = token.refreshToken, + accessToken = token.accessToken, + ) + } + + override suspend fun refreshToken(token: AuthToken): AuthToken { + val res = app.post( + "$oauthUrl/token", + data = mapOf( + "grant_type" to "refresh_token", + "refresh_token" to token.refreshToken!! + ), + interceptor = oauthFallbackInterceptor + ).parsed() + + return AuthToken( + accessToken = res.accessToken, + refreshToken = res.refreshToken, + accessTokenLifetime = unixTime + res.expiresIn.toLong() + ) + } + + override suspend fun user(token: AuthToken?): AuthUser? { + val user = app.get( + "$apiUrl/users?filter[self]=true", + headers = mapOf( + "Authorization" to "Bearer ${token?.accessToken ?: return null}" + ), cacheTime = 0, + interceptor = apiFallbackInterceptor + ).parsed() + + if (user.data.isEmpty()) { + return null + } + + return AuthUser( + id = user.data[0].id.toInt(), + name = user.data[0].attributes.name, + profilePicture = user.data[0].attributes.avatar?.original + ) + } + + override suspend fun search(auth: AuthData?, query: String): List? { + val auth = auth?.token?.accessToken ?: return null + val animeSelectedFields = arrayOf("titles","canonicalTitle","posterImage","episodeCount") + val url = "$apiUrl/anime?filter[text]=$query&page[limit]=$KITSU_MAX_SEARCH_LIMIT&fields[anime]=${animeSelectedFields.joinToString(",")}" + + val res = app.get( + url, headers = mapOf( + "Authorization" to "Bearer $auth", + ), cacheTime = 0, + interceptor = apiFallbackInterceptor + ).parsed() + + return res.data.map { + val attributes = it.attributes + + val title = attributes.canonicalTitle ?: attributes.titles?.enJp ?: attributes.titles?.jaJp ?: "No title" + + SyncSearchResult( + title, + this.name, + it.id, + "$mainUrl/anime/${it.id}/", + attributes.posterImage?.large ?: attributes.posterImage?.medium + ) + } + } + + override suspend fun load(auth : AuthData?, id: String): SyncResult? { + val auth = auth?.token?.accessToken ?: return null + if (id.toIntOrNull() == null) { + return null + } + + data class KitsuResponse( + @field:JsonProperty(value = "data") + val data: KitsuNode, + ) + + val url = + "$apiUrl/anime/$id" + + val anime = app.get( + url, headers = mapOf( + "Authorization" to "Bearer $auth" + ), + interceptor = apiFallbackInterceptor + ).parsed().data.attributes + + return SyncResult( + id = id, + totalEpisodes = anime.episodeCount, + title = anime.canonicalTitle ?: anime.titles?.enJp ?: anime.titles?.jaJp.orEmpty(), + publicScore = Score.from(anime.ratingTwenty.toString(), 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.toString(), 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","episodeCount") + val libraryEntriesSelectedFields = arrayOf("progress","rating","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.toString(), 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( + Instant.from( + DateTimeFormatter.ofPattern(if (startDate.length == 4) "yyyy" else if (startDate.length == 7) "yyyy-MM" else "yyyy-MM-dd") + .parse(startDate) + ) + ) + } 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: Float?, + @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:ssZ", 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 @@ -142,4 +814,4 @@ query { val canonical: String? = null ) } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/LocalList.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/LocalList.kt index 2b51f7efdf6..8f0d7ca6dac 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/LocalList.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/LocalList.kt @@ -1,13 +1,11 @@ package com.lagradost.cloudstream3.syncproviders.providers -import androidx.fragment.app.FragmentActivity import com.lagradost.cloudstream3.R -import com.lagradost.cloudstream3.syncproviders.AuthAPI +import com.lagradost.cloudstream3.syncproviders.AuthData import com.lagradost.cloudstream3.syncproviders.SyncAPI import com.lagradost.cloudstream3.syncproviders.SyncIdName import com.lagradost.cloudstream3.ui.WatchType import com.lagradost.cloudstream3.ui.library.ListSorting -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.Coroutines.ioWork @@ -16,56 +14,19 @@ import com.lagradost.cloudstream3.utils.DataStoreHelper.getAllSubscriptions import com.lagradost.cloudstream3.utils.DataStoreHelper.getAllWatchStateIds import com.lagradost.cloudstream3.utils.DataStoreHelper.getBookmarkedData import com.lagradost.cloudstream3.utils.DataStoreHelper.getResultWatchState +import com.lagradost.cloudstream3.utils.txt -class LocalList : SyncAPI { +class LocalList : SyncAPI() { override val name = "Local" + override val idPrefix = "local" + override val icon: Int = R.drawable.ic_baseline_storage_24 override val requiresLogin = false - override val supportDeviceAuth = false - override val createAccountUrl: Nothing? = null - override val idPrefix = "local" + override val createAccountUrl = null override var requireLibraryRefresh = true - - override fun loginInfo(): AuthAPI.LoginInfo { - return AuthAPI.LoginInfo( - null, - null, - 0 - ) - } - - override fun logOut() { - - } - - override val key: String = "" - override val redirectUrl = "" - override suspend fun handleRedirect(url: String): Boolean { - return true - } - - override fun authenticate(activity: FragmentActivity?) { - } - - override val mainUrl = "" override val syncIdName = SyncIdName.LocalList - override suspend fun score(id: String, status: SyncAPI.AbstractSyncStatus): Boolean { - return true - } - - override suspend fun getStatus(id: String): SyncAPI.AbstractSyncStatus? { - return null - } - override suspend fun getResult(id: String): SyncAPI.SyncResult? { - return null - } - - override suspend fun search(name: String): List? { - return null - } - - override suspend fun getPersonalLibrary(): SyncAPI.LibraryMetadata? { + override suspend fun library(auth : AuthData?): SyncAPI.LibraryMetadata? { val watchStatusIds = ioWork { getAllWatchStateIds()?.map { id -> Pair(id, getResultWatchState(id)) @@ -102,9 +63,10 @@ class LocalList : SyncAPI { val result = if (isTrueTv) { baseMap + watchStatusMap + favoritesMap } else { - val subscriptionsMap = mapOf(R.string.subscription_list_name to getAllSubscriptions().mapNotNull { - it.toLibraryItem() - }) + val subscriptionsMap = + mapOf(R.string.subscription_list_name to getAllSubscriptions().mapNotNull { + it.toLibraryItem() + }) baseMap + watchStatusMap + subscriptionsMap + favoritesMap } @@ -112,8 +74,8 @@ class LocalList : SyncAPI { result } - return SyncAPI.LibraryMetadata( - list.map { SyncAPI.LibraryList(txt(it.key), it.value) }, + return LibraryMetadata( + list.map { LibraryList(txt(it.key), it.value) }, setOf( ListSorting.AlphabeticalA, ListSorting.AlphabeticalZ, @@ -127,8 +89,4 @@ class LocalList : SyncAPI { ) ) } - - override fun getIdFromUrl(url: String): String { - return url - } } \ 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 4836eca131d..ba0195be6b8 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 @@ -1,87 +1,112 @@ package com.lagradost.cloudstream3.syncproviders.providers -import android.util.Base64 import androidx.annotation.StringRes -import androidx.fragment.app.FragmentActivity import com.fasterxml.jackson.annotation.JsonProperty -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.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.AccountManager -import com.lagradost.cloudstream3.syncproviders.AuthAPI +import com.lagradost.cloudstream3.syncproviders.AuthData +import com.lagradost.cloudstream3.syncproviders.AuthLoginPage +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.txt -import com.lagradost.cloudstream3.utils.AppContextUtils.splitQuery import com.lagradost.cloudstream3.utils.AppUtils.parseJson +import com.lagradost.cloudstream3.utils.AppUtils.toJson import com.lagradost.cloudstream3.utils.DataStore.toKotlinObject -import java.net.URL -import java.security.SecureRandom -import java.text.ParseException +import com.lagradost.cloudstream3.utils.txt import java.text.SimpleDateFormat import java.time.Instant import java.time.format.DateTimeFormatter -import java.util.Calendar import java.util.Date import java.util.Locale -import java.util.TimeZone /** max 100 via https://myanimelist.net/apiconfig/references/api/v2#tag/anime */ const val MAL_MAX_SEARCH_LIMIT = 25 -class MALApi(index: Int) : AccountManager(index), SyncAPI { +class MALApi : SyncAPI() { override var name = "MAL" - override val key = "1714d6f2f4f7cc19644384f8c4629910" - override val redirectUrl = "mallogin" override val idPrefix = "mal" - override var mainUrl = "https://myanimelist.net" + + val key = "1714d6f2f4f7cc19644384f8c4629910" private val apiUrl = "https://api.myanimelist.net" + override val hasOAuth2 = true + override val redirectUrlIdentifier: String? = "mallogin" + override val mainUrl = "https://myanimelist.net" override val icon = R.drawable.mal_logo - override val requiresLogin = false - override val supportDeviceAuth = false override val syncIdName = SyncIdName.MyAnimeList - override var requireLibraryRefresh = true override val createAccountUrl = "$mainUrl/register.php" - override fun logOut() { - requireLibraryRefresh = true - removeAccountKeys() - } + override val supportedWatchTypes = setOf( + SyncWatchType.WATCHING, + SyncWatchType.COMPLETED, + SyncWatchType.PLANTOWATCH, + SyncWatchType.DROPPED, + SyncWatchType.ONHOLD, + SyncWatchType.NONE + ) - override fun loginInfo(): AuthAPI.LoginInfo? { - getKey(accountId, MAL_USER_KEY)?.let { user -> - return AuthAPI.LoginInfo( - profilePicture = user.picture, - name = user.name, - accountIndex = accountIndex - ) + data class PayLoad( + val requestId: Int, + val codeVerifier: String + ) + + override suspend fun login(redirectUrl: String, payload: String?): AuthToken? { + val payloadData = parseJson(payload!!) + val sanitizer = splitRedirectUrl(redirectUrl) + val state = sanitizer["state"]!! + + if (state != "RequestID${payloadData.requestId}") { + return null } - return null + + val currentCode = sanitizer["code"]!! + + val token = app.post( + "$mainUrl/v1/oauth2/token", + data = mapOf( + "client_id" to key, + "code" to currentCode, + "code_verifier" to payloadData.codeVerifier, + "grant_type" to "authorization_code" + ) + ).parsed() + return AuthToken( + accessTokenLifetime = unixTime + token.expiresIn.toLong(), + refreshToken = token.refreshToken, + accessToken = token.accessToken + ) } - private fun getAuth(): String? { - return getKey( - accountId, - MAL_TOKEN_KEY + override suspend fun user(token: AuthToken?): AuthUser? { + val user = app.get( + "$apiUrl/v2/users/@me", + headers = mapOf( + "Authorization" to "Bearer ${token?.accessToken ?: return null}" + ), cacheTime = 0 + ).parsed() + return AuthUser( + id = user.id, + name = user.name, + profilePicture = user.picture ) } - override suspend fun search(name: String): List { + override suspend fun search(auth : AuthData?, query: String): List? { + val auth = auth?.token?.accessToken ?: return null val url = "$apiUrl/v2/anime?q=$name&limit=$MAL_MAX_SEARCH_LIMIT" - val auth = getAuth() ?: return emptyList() val res = app.get( url, headers = mapOf( "Authorization" to "Bearer $auth", ), cacheTime = 0 - ).text - return parseJson(res).data.map { + ).parsed() + return res.data.map { val node = it.node SyncAPI.SyncSearchResult( node.title, @@ -93,19 +118,21 @@ class MALApi(index: Int) : AccountManager(index), SyncAPI { } } - override fun getIdFromUrl(url: String): String { - return Regex("""/anime/((.*)/|(.*))""").find(url)!!.groupValues.first() - } + override fun urlToId(url: String): String? = + Regex("""/anime/((.*)/|(.*))""").find(url)!!.groupValues.first() - override suspend fun score(id: String, status: SyncAPI.AbstractSyncStatus): Boolean { + override suspend fun updateStatus( + auth : AuthData?, + id: String, + newStatus: SyncAPI.AbstractSyncStatus + ): Boolean { return setScoreRequest( + auth?.token ?: return false, id.toIntOrNull() ?: return false, - fromIntToAnimeStatus(status.status.internalId), - status.score, - status.watchedEpisodes - ).also { - requireLibraryRefresh = requireLibraryRefresh || it - } + fromIntToAnimeStatus(newStatus.status), + newStatus.score?.toInt(10), + newStatus.watchedEpisodes + ) } data class MalAnime( @@ -198,14 +225,14 @@ class MALApi(index: Int) : AccountManager(index), SyncAPI { ) } - override suspend fun getResult(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 = "$apiUrl/v2/anime/$internalId?fields=id,title,main_picture,alternative_titles,start_date,end_date,synopsis,mean,rank,popularity,num_list_users,num_scoring_users,nsfw,created_at,updated_at,media_type,status,genres,my_list_status,num_episodes,start_season,broadcast,source,average_episode_duration,rating,pictures,background,related_anime,related_manga,recommendations,studios,statistics" - val auth = getAuth() val res = app.get( - url, headers = if (auth == null) emptyMap() else mapOf( + url, headers = mapOf( "Authorization" to "Bearer $auth" ) ).text @@ -214,7 +241,7 @@ class MALApi(index: Int) : AccountManager(index), SyncAPI { id = internalId.toString(), totalEpisodes = malAnime.numEpisodes, title = malAnime.title, - publicScore = malAnime.mean?.toFloat()?.times(1000)?.toInt(), + publicScore = Score.from10(malAnime.mean), duration = malAnime.averageEpisodeDuration, synopsis = malAnime.synopsis, airStatus = when (malAnime.status) { @@ -244,13 +271,20 @@ class MALApi(index: Int) : AccountManager(index), SyncAPI { } } - override suspend fun getStatus(id: String): SyncAPI.SyncStatus? { - val internalId = id.toIntOrNull() ?: return null + 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 + val url = + "$apiUrl/v2/anime/$id?fields=id,title,num_episodes,my_list_status" + val data = app.get( + url, headers = mapOf( + "Authorization" to "Bearer $auth" + ), cacheTime = 0 + ).parsed().myListStatus - val data = - getDataAboutMalId(internalId)?.myListStatus //?: throw ErrorLoadingException("No my_list_status") return SyncAPI.SyncStatus( - score = data?.score, + score = Score.from10(data?.score), status = SyncWatchType.fromInternalId(malStatusAsString.indexOf(data?.status)), isFavorite = null, watchedEpisodes = data?.numEpisodesWatched, @@ -261,14 +295,17 @@ class MALApi(index: Int) : AccountManager(index), SyncAPI { private val malStatusAsString = arrayOf("watching", "completed", "on_hold", "dropped", "plan_to_watch") - const val MAL_USER_KEY: String = "mal_user" // user data like profile const val MAL_CACHED_LIST: String = "mal_cached_list" - const val MAL_UNIXTIME_KEY: String = "mal_unixtime" // When token expires - const val MAL_REFRESH_TOKEN_KEY: String = "mal_refresh_token" // refresh token - const val MAL_TOKEN_KEY: String = "mal_token" // anilist token for api fun convertToStatus(string: String): MalStatusType { - return fromIntToAnimeStatus(malStatusAsString.indexOf(string)) + return when (string) { + "watching" -> MalStatusType.Watching + "completed" -> MalStatusType.Completed + "on_hold" -> MalStatusType.OnHold + "dropped" -> MalStatusType.Dropped + "plan_to_watch" -> MalStatusType.PlanToWatch + else -> MalStatusType.None + } } enum class MalStatusType(var value: Int, @StringRes val stringRes: Int) { @@ -280,16 +317,15 @@ class MALApi(index: Int) : AccountManager(index), SyncAPI { None(-1, R.string.type_none) } - private fun fromIntToAnimeStatus(inp: Int): MalStatusType {//= AniListStatusType.values().first { it.value == inp } + private fun fromIntToAnimeStatus(inp: SyncWatchType): MalStatusType {//= AniListStatusType.values().first { it.value == inp } return when (inp) { - -1 -> MalStatusType.None - 0 -> MalStatusType.Watching - 1 -> MalStatusType.Completed - 2 -> MalStatusType.OnHold - 3 -> MalStatusType.Dropped - 4 -> MalStatusType.PlanToWatch - 5 -> MalStatusType.Watching - else -> MalStatusType.None + SyncWatchType.NONE -> MalStatusType.None + SyncWatchType.WATCHING -> MalStatusType.Watching + SyncWatchType.COMPLETED -> MalStatusType.Completed + SyncWatchType.ONHOLD -> MalStatusType.OnHold + SyncWatchType.DROPPED -> MalStatusType.Dropped + SyncWatchType.PLANTOWATCH -> MalStatusType.PlanToWatch + SyncWatchType.REWATCHING -> MalStatusType.Watching } } @@ -304,86 +340,39 @@ class MALApi(index: Int) : AccountManager(index), SyncAPI { } } - override suspend fun handleRedirect(url: String): Boolean { - val sanitizer = - splitQuery(URL(url.replace(APP_STRING, "https").replace("/#", "?"))) // FIX ERROR - val state = sanitizer["state"]!! - if (state == "RequestID$requestId") { - val currentCode = sanitizer["code"]!! - - val res = app.post( - "$mainUrl/v1/oauth2/token", - data = mapOf( - "client_id" to key, - "code" to currentCode, - "code_verifier" to codeVerifier, - "grant_type" to "authorization_code" - ) - ).text - - if (res.isNotBlank()) { - switchToNewAccount() - storeToken(res) - val user = getMalUser() - requireLibraryRefresh = true - return user != null - } - } - return false - } - - override fun authenticate(activity: FragmentActivity?) { - // It is recommended to use a URL-safe string as code_verifier. - // See section 4 of RFC 7636 for more details. - - val secureRandom = SecureRandom() - val codeVerifierBytes = ByteArray(96) // base64 has 6bit per char; (8/6)*96 = 128 - secureRandom.nextBytes(codeVerifierBytes) - codeVerifier = - Base64.encodeToString(codeVerifierBytes, Base64.DEFAULT).trimEnd('=').replace("+", "-") - .replace("/", "_").replace("\n", "") + override fun loginRequest(): AuthLoginPage? { + val codeVerifier = generateCodeVerifier() + val requestId = ++requestIdCounter val codeChallenge = codeVerifier val request = "$mainUrl/v1/oauth2/authorize?response_type=code&client_id=$key&code_challenge=$codeChallenge&state=RequestID$requestId" - openBrowser(request, activity) - } - private var requestId = 0 - private var codeVerifier = "" - - private fun storeToken(response: String) { - try { - if (response != "") { - val token = parseJson(response) - setKey(accountId, MAL_UNIXTIME_KEY, (token.expiresIn + unixTime)) - setKey(accountId, MAL_REFRESH_TOKEN_KEY, token.refreshToken) - setKey(accountId, MAL_TOKEN_KEY, token.accessToken) - requireLibraryRefresh = true - } - } catch (e: Exception) { - logError(e) - } + return AuthLoginPage( + url = request, + payload = PayLoad(requestId, codeVerifier).toJson() + ) } - private suspend fun refreshToken() { - try { - val res = app.post( - "$mainUrl/v1/oauth2/token", - data = mapOf( - "client_id" to key, - "grant_type" to "refresh_token", - "refresh_token" to getKey( - accountId, - MAL_REFRESH_TOKEN_KEY - )!! - ) - ).text - storeToken(res) - } catch (e: Exception) { - logError(e) - } + override suspend fun refreshToken(token: AuthToken): AuthToken? { + val res = app.post( + "$mainUrl/v1/oauth2/token", + data = mapOf( + "client_id" to key, + "grant_type" to "refresh_token", + "refresh_token" to token.refreshToken!! + ) + ).parsed() + + return AuthToken( + accessToken = res.accessToken, + refreshToken = res.refreshToken, + accessTokenLifetime = unixTime + res.expiresIn.toLong() + ) } + private var requestIdCounter = 0 + + private val allTitles = hashMapOf() data class MalList( @@ -441,7 +430,7 @@ class MALApi(index: Int) : AccountManager(index), SyncAPI { this.node.id.toString(), this.listStatus?.numEpisodesWatched, this.node.numEpisodes, - this.listStatus?.score?.times(10), + Score.from10(this.listStatus?.score), parseDateLong(this.listStatus?.updatedAt), "MAL", TvType.Anime, @@ -449,12 +438,16 @@ class MALApi(index: Int) : AccountManager(index), SyncAPI { null, null, plot = this.node.synopsis, - releaseDate = if (this.node.startDate == null) null else try {Date.from( - Instant.from( - DateTimeFormatter.ofPattern(if (this.node.startDate.length == 4) "yyyy" else if (this.node.startDate.length == 7) "yyyy-MM" else "yyyy-MM-dd") - .parse(this.node.startDate) + releaseDate = if (this.node.startDate == null) null else try { + Date.from( + Instant.from( + DateTimeFormatter.ofPattern(if (this.node.startDate.length == 4) "yyyy" else if (this.node.startDate.length == 7) "yyyy-MM" else "yyyy-MM-dd") + .parse(this.node.startDate) + ) ) - )} catch (_: RuntimeException) {null} + } catch (_: RuntimeException) { + null + } ) } } @@ -484,23 +477,8 @@ class MALApi(index: Int) : AccountManager(index), SyncAPI { @JsonProperty("start_time") val startTime: String? ) - private fun getMalAnimeListCached(): Array? { - return getKey(MAL_CACHED_LIST) as? Array - } - - private suspend fun getMalAnimeListSmart(): Array? { - if (getAuth() == null) return null - return if (requireLibraryRefresh) { - val list = getMalAnimeList() - setKey(MAL_CACHED_LIST, list) - list - } else { - getMalAnimeListCached() - } - } - - override suspend fun getPersonalLibrary(): SyncAPI.LibraryMetadata { - val list = getMalAnimeListSmart()?.groupBy { + override suspend fun library(auth : AuthData?): LibraryMetadata? { + val list = getMalAnimeListSmart(auth ?: return null)?.groupBy { convertToStatus(it.listStatus?.status ?: "").stringRes }?.mapValues { group -> group.value.map { it.toLibraryItem() } @@ -527,13 +505,22 @@ class MALApi(index: Int) : AccountManager(index), SyncAPI { ) } - private suspend fun getMalAnimeList(): Array { - checkMalToken() + private suspend fun getMalAnimeListSmart(auth : AuthData): Array? { + return if (requireLibraryRefresh) { + val list = getMalAnimeList(auth.token) + setKey(MAL_CACHED_LIST, auth.user.id.toString(), list) + list + } else { + getKey>(MAL_CACHED_LIST, auth.user.id.toString()) as? Array + } + } + + private suspend fun getMalAnimeList(token: AuthToken): Array { var offset = 0 val fullList = mutableListOf() val offsetRegex = Regex("""offset=(\d+)""") while (true) { - val data: MalList = getMalAnimeListSlice(offset) ?: break + val data: MalList = getMalAnimeListSlice(token, offset) ?: break fullList.addAll(data.data) offset = data.paging.next?.let { offsetRegex.find(it)?.groupValues?.get(1)?.toInt() } @@ -542,128 +529,29 @@ class MALApi(index: Int) : AccountManager(index), SyncAPI { return fullList.toTypedArray() } - private suspend fun getMalAnimeListSlice(offset: Int = 0): MalList? { + private suspend fun getMalAnimeListSlice(token: AuthToken, offset: Int = 0): MalList? { val user = "@me" - val auth = getAuth() ?: return null // Very lackluster docs // https://myanimelist.net/apiconfig/references/api/v2#operation/users_user_id_animelist_get val url = "$apiUrl/v2/users/$user/animelist?fields=list_status,num_episodes,media_type,status,start_date,end_date,synopsis,alternative_titles,mean,genres,rank,num_list_users,nsfw,average_episode_duration,num_favorites,popularity,num_scoring_users,start_season,favorites_info,broadcast,created_at,updated_at&nsfw=1&limit=100&offset=$offset" val res = app.get( url, headers = mapOf( - "Authorization" to "Bearer $auth", + "Authorization" to "Bearer ${token.accessToken}", ), cacheTime = 0 ).text return res.toKotlinObject() } - private suspend fun getDataAboutMalId(id: Int): SmallMalAnime? { - // https://myanimelist.net/apiconfig/references/api/v2#operation/anime_anime_id_get - val url = - "$apiUrl/v2/anime/$id?fields=id,title,num_episodes,my_list_status" - val res = app.get( - url, headers = mapOf( - "Authorization" to "Bearer " + (getAuth() ?: return null) - ), cacheTime = 0 - ).text - - return parseJson(res) - } - - suspend fun setAllMalData() { - val user = "@me" - var isDone = false - var index = 0 - allTitles.clear() - checkMalToken() - while (!isDone) { - val res = app.get( - "$apiUrl/v2/users/$user/animelist?fields=list_status&limit=1000&offset=${index * 1000}", - headers = mapOf( - "Authorization" to "Bearer " + (getAuth() ?: return) - ), cacheTime = 0 - ).text - val values = parseJson(res) - val titles = - values.data.map { MalTitleHolder(it.listStatus, it.node.id, it.node.title) } - for (t in titles) { - allTitles[t.id] = t - } - isDone = titles.size < 1000 - index++ - } - } - - private fun convertJapanTimeToTimeRemaining(date: String, endDate: String? = null): String? { - // No time remaining if the show has already ended - try { - endDate?.let { - if (SimpleDateFormat("yyyy-MM-dd", Locale.getDefault()).parse(it) - ?.before(Date.from(Instant.now())) != false - ) return@convertJapanTimeToTimeRemaining null - } - } catch (e: ParseException) { - logError(e) - } - - // Unparseable date: "2021 7 4 other null" - // Weekday: other, date: null - if (date.contains("null") || date.contains("other")) { - return null - } - - val currentDate = Calendar.getInstance() - val currentMonth = currentDate.get(Calendar.MONTH) + 1 - val currentWeek = currentDate.get(Calendar.WEEK_OF_MONTH) - val currentYear = currentDate.get(Calendar.YEAR) - - val dateFormat = SimpleDateFormat("yyyy MM W EEEE HH:mm", Locale.getDefault()) - dateFormat.timeZone = TimeZone.getTimeZone("Japan") - val parsedDate = - dateFormat.parse("$currentYear $currentMonth $currentWeek $date") ?: return null - val timeDiff = (parsedDate.time - System.currentTimeMillis()) / 1000 - - // if it has already aired this week add a week to the timer - val updatedTimeDiff = - if (timeDiff > -60 * 60 * 24 * 7 && timeDiff < 0) timeDiff + 60 * 60 * 24 * 7 else timeDiff - return secondsToReadable(updatedTimeDiff.toInt(), "Now") - - } - - private suspend fun checkMalToken() { - if (unixTime > (getKey( - accountId, - MAL_UNIXTIME_KEY - ) ?: 0L) - ) { - refreshToken() - } - } - - private suspend fun getMalUser(setSettings: Boolean = true): MalUser? { - checkMalToken() - val res = app.get( - "$apiUrl/v2/users/@me", - headers = mapOf( - "Authorization" to "Bearer " + (getAuth() ?: return null) - ), cacheTime = 0 - ).text - - val user = parseJson(res) - if (setSettings) { - setKey(accountId, MAL_USER_KEY, user) - registerAccount() - } - return user - } - private suspend fun setScoreRequest( + token: AuthToken, id: Int, status: MalStatusType? = null, score: Int? = null, numWatchedEpisodes: Int? = null, ): Boolean { val res = setScoreRequest( + token, id, if (status == null) null else malStatusAsString[maxOf(0, status.value)], score, @@ -686,6 +574,7 @@ class MALApi(index: Int) : AccountManager(index), SyncAPI { @Suppress("UNCHECKED_CAST") private suspend fun setScoreRequest( + token: AuthToken, id: Int, status: String? = null, score: Int? = null, @@ -700,7 +589,7 @@ class MALApi(index: Int) : AccountManager(index), SyncAPI { return app.put( "$apiUrl/v2/anime/$id/my_list_status", headers = mapOf( - "Authorization" to "Bearer " + (getAuth() ?: return null) + "Authorization" to "Bearer ${token.accessToken}" ), data = data ).text 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 2e28f19455b..4b17fdb2920 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,56 +2,44 @@ package com.lagradost.cloudstream3.syncproviders.providers import android.util.Log import com.fasterxml.jackson.annotation.JsonProperty -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.app import com.lagradost.cloudstream3.ErrorLoadingException import com.lagradost.cloudstream3.R -import com.lagradost.cloudstream3.TvType -import com.lagradost.cloudstream3.app -import com.lagradost.cloudstream3.mvvm.logError -import com.lagradost.cloudstream3.subtitles.AbstractSubApi import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities -import com.lagradost.cloudstream3.syncproviders.AuthAPI -import com.lagradost.cloudstream3.syncproviders.InAppAuthAPI -import com.lagradost.cloudstream3.syncproviders.InAppAuthAPIManager +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.SubtitleAPI +import com.lagradost.cloudstream3.TvType import com.lagradost.cloudstream3.utils.AppUtils -import okhttp3.Interceptor -import okhttp3.Response +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(index: Int) : InAppAuthAPIManager(index), AbstractSubApi { - override val idPrefix = "opensubtitles" +class OpenSubtitlesApi : SubtitleAPI() { override val name = "OpenSubtitles" + override val idPrefix = "opensubtitles" + override val icon = R.drawable.open_subtitles_icon - override val requiresPassword = true - override val requiresUsername = true + override val hasInApp = true + override val inAppLoginRequirement = AuthLoginRequirement( + password = true, + username = true, + ) + override val createAccountUrl = "https://www.opensubtitles.com/en/users/sign_up" companion object { - const val OPEN_SUBTITLES_USER_KEY: String = "open_subtitles_user" // user data like profile const val API_KEY = "uyBLgFD17MgrYmA0gSXoKllMJBelOYj2" const val HOST = "https://api.opensubtitles.com/api/v1" const val TAG = "OPENSUBS" const val COOLDOWN_DURATION: Long = 1000L * 30L // CoolDown if 429 error code in ms var currentCoolDown: Long = 0L - var currentSession: SubtitleOAuthEntity? = null - } - - private val headerInterceptor = OpenSubtitleInterceptor() - - /** Automatically adds required api headers */ - private class OpenSubtitleInterceptor : Interceptor { - /** Required user agent! */ - private val userAgent = "Cloudstream3 v0.2" - override fun intercept(chain: Interceptor.Chain): Response { - return chain.proceed( - chain.request().newBuilder() - .removeHeader("user-agent") - .addHeader("user-agent", userAgent) - .addHeader("Api-Key", API_KEY) - .build() - ) - } + const val userAgent = "Cloudstream3 v0.2" + val headers = mapOf("user-agent" to userAgent, "Api-Key" to API_KEY) } private fun canDoRequest(): Boolean { @@ -69,121 +57,53 @@ class OpenSubtitlesApi(index: Int) : InAppAuthAPIManager(index), AbstractSubApi throw ErrorLoadingException("Too many requests") } - private fun getAuthKey(): SubtitleOAuthEntity? { - return getKey(accountId, OPEN_SUBTITLES_USER_KEY) - } - - private fun setAuthKey(data: SubtitleOAuthEntity?) { - if (data == null) removeKey(accountId, OPEN_SUBTITLES_USER_KEY) - currentSession = data - setKey(accountId, OPEN_SUBTITLES_USER_KEY, data) + override suspend fun refreshToken(token: AuthToken): AuthToken? { + return login(parseJson(token.payload ?: return null)) } - override fun loginInfo(): AuthAPI.LoginInfo? { - getAuthKey()?.let { user -> - return AuthAPI.LoginInfo( - profilePicture = null, - name = user.user, - accountIndex = accountIndex - ) - } - return null - } - - override fun getLatestLoginData(): InAppAuthAPI.LoginData? { - val current = getAuthKey() ?: return null - return InAppAuthAPI.LoginData(username = current.user, current.pass) + override suspend fun user(token: AuthToken?): AuthUser? { + val user = parseJson(token?.payload ?: return null) + val username = user.username ?: return null + return AuthUser( + id = username.hashCode(), + name = username + ) } - /* - Authorize app to connect to API, using username/password. - Required to run at startup. - Returns OAuth entity with valid access token. - */ - override suspend fun initialize() { - currentSession = getAuthKey() ?: return // just in case the following fails - initLogin(currentSession?.user ?: return, currentSession?.pass ?: return) - } + override suspend fun login(form: AuthLoginResponse): AuthToken? { + val username = form.username ?: return null + val password = form.password ?: return null - override fun logOut() { - setAuthKey(null) - removeAccountKeys() - currentSession = getAuthKey() - } - - private suspend fun initLogin(username: String, password: String): Boolean { - //Log.i(TAG, "DATA = [$username] [$password]") val response = app.post( url = "$HOST/login", headers = mapOf( "Content-Type" to "application/json", - ), + ) + headers, json = mapOf( "username" to username, "password" to password ), - interceptor = headerInterceptor + ).parsed() + + return AuthToken( + accessToken = response.token + ?: throw ErrorLoadingException("Invalid password or username"), + /// JWT token is valid 24 hours after successfully authentication of user + accessTokenLifetime = unixTime + 60 * 60 * 24, + payload = form.toJson() ) - //Log.i(TAG, "Responsecode = ${response.code}") - //Log.i(TAG, "Result => ${response.text}") - - if (response.isSuccessful) { - AppUtils.tryParseJson(response.text)?.let { token -> - setAuthKey( - SubtitleOAuthEntity( - user = username, - pass = password, - accessToken = token.token ?: run { - return false - }) - ) - } - return true - } - return false - } - - override suspend fun login(data: InAppAuthAPI.LoginData): Boolean { - val username = data.username ?: throw ErrorLoadingException("Requires Username") - val password = data.password ?: throw ErrorLoadingException("Requires Password") - switchToNewAccount() - try { - if (initLogin(username, password)) { - registerAccount() - return true - } - } catch (e: Exception) { - logError(e) - switchToOldAccount() - } - switchToOldAccount() - return false - } - - /** - * 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). * */ - override suspend fun search(query: AbstractSubtitleEntities.SubtitleSearch): List? { + override suspend fun search( + auth : AuthData?, + query: AbstractSubtitleEntities.SubtitleSearch + ): List? { throwIfCantDoRequest() - val fixedLang = fixLanguage(query.lang) + val langOpenSubTag = fromCodeToOpenSubtitlesTag(query.lang) ?: query.lang ?: "" val imdbId = query.imdbId?.replace("tt", "")?.toInt() ?: 0 val queryText = query.query @@ -196,17 +116,17 @@ class OpenSubtitlesApi(index: Int) : InAppAuthAPIManager(index), AbstractSubApi val searchQueryUrl = when (imdbId > 0) { //Use imdb_id to search if its valid - true -> "$HOST/subtitles?imdb_id=$imdbId&languages=${fixedLang}$yearQuery$epQuery$seasonQuery" - false -> "$HOST/subtitles?query=${queryText}&languages=${fixedLang}$yearQuery$epQuery$seasonQuery" + true -> "$HOST/subtitles?imdb_id=$imdbId&languages=${langOpenSubTag}$yearQuery$epQuery$seasonQuery" + false -> "$HOST/subtitles?query=${queryText}&languages=${langOpenSubTag}$yearQuery$epQuery$seasonQuery" } val req = app.get( url = searchQueryUrl, headers = mapOf( Pair("Content-Type", "application/json") - ), - interceptor = headerInterceptor + ) + headers, ) + Log.i(TAG, "searchQueryUrl => ${searchQueryUrl}") Log.i(TAG, "Search Req => ${req.text}") if (!req.isSuccessful) { if (req.code == 429) @@ -227,7 +147,7 @@ class OpenSubtitlesApi(index: Int) : InAppAuthAPIManager(index), AbstractSubApi //Use any valid name/title in hierarchy val name = filename ?: featureDetails?.movieName ?: featureDetails?.title ?: featureDetails?.parentTitle ?: attr.release ?: query.query - val lang = fixLanguageReverse(attr.language) ?: "" + val langTagIETF = fromCodeToLangTagIETF(attr.language) ?: "" val resEpNum = featureDetails?.episodeNumber ?: query.epNumber val resSeasonNum = featureDetails?.seasonNumber ?: query.seasonNumber val year = featureDetails?.year ?: query.year @@ -241,7 +161,7 @@ class OpenSubtitlesApi(index: Int) : InAppAuthAPIManager(index), AbstractSubApi AbstractSubtitleEntities.SubtitleEntity( idPrefix = this.idPrefix, name = name, - lang = lang, + lang = langTagIETF, data = resultData, type = type, source = this.name, @@ -261,7 +181,12 @@ class OpenSubtitlesApi(index: Int) : InAppAuthAPIManager(index), AbstractSubApi Process data returned from search. Returns string url for the subtitle file. */ - override suspend fun load(data: AbstractSubtitleEntities.SubtitleEntity): String? { + + override suspend fun load( + auth : AuthData?, + subtitle: AbstractSubtitleEntities.SubtitleEntity + ): String? { + if(auth == null) return null throwIfCantDoRequest() val req = app.post( @@ -269,15 +194,14 @@ class OpenSubtitlesApi(index: Int) : InAppAuthAPIManager(index), AbstractSubApi headers = mapOf( Pair( "Authorization", - "Bearer ${currentSession?.accessToken ?: throw ErrorLoadingException("No access token active in current session")}" + "Bearer ${auth.token.accessToken ?: throw ErrorLoadingException("No access token active in current session")}" ), Pair("Content-Type", "application/json"), Pair("Accept", "*/*") - ), + ) + headers, data = mapOf( - Pair("file_id", data.data) - ), - interceptor = headerInterceptor + Pair("file_id", subtitle.data) + ) ) Log.i(TAG, "Request result => (${req.code}) ${req.text}") //Log.i(TAG, "Request headers => ${req.headers}") @@ -294,13 +218,6 @@ class OpenSubtitlesApi(index: Int) : InAppAuthAPIManager(index), AbstractSubApi return null } - - data class SubtitleOAuthEntity( - var user: String, - var pass: String, - var accessToken: String, - ) - data class OAuthToken( @JsonProperty("token") var token: String? = null, @JsonProperty("status") var status: Int? = null 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 519fb4c3a28..c4095e2d881 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 @@ -2,38 +2,36 @@ package com.lagradost.cloudstream3.syncproviders.providers import androidx.annotation.StringRes import androidx.core.net.toUri -import androidx.fragment.app.FragmentActivity import com.fasterxml.jackson.annotation.JsonInclude import com.fasterxml.jackson.annotation.JsonProperty -import com.lagradost.cloudstream3.AcraApplication -import com.lagradost.cloudstream3.AcraApplication.Companion.getKey -import com.lagradost.cloudstream3.AcraApplication.Companion.getKeys -import com.lagradost.cloudstream3.AcraApplication.Companion.openBrowser -import com.lagradost.cloudstream3.AcraApplication.Companion.removeKey -import com.lagradost.cloudstream3.AcraApplication.Companion.setKey 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.debugAssert import com.lagradost.cloudstream3.mvvm.debugPrint import com.lagradost.cloudstream3.mvvm.logError -import com.lagradost.cloudstream3.mvvm.safeAsync -import com.lagradost.cloudstream3.syncproviders.AccountManager -import com.lagradost.cloudstream3.syncproviders.AuthAPI -import com.lagradost.cloudstream3.syncproviders.OAuth2API +import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.APP_STRING +import com.lagradost.cloudstream3.syncproviders.AuthData +import com.lagradost.cloudstream3.syncproviders.AuthLoginPage +import com.lagradost.cloudstream3.syncproviders.AuthPinData +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.txt import com.lagradost.cloudstream3.utils.AppUtils.toJson import com.lagradost.cloudstream3.utils.DataStoreHelper.toYear -import okhttp3.Interceptor -import okhttp3.Response +import com.lagradost.cloudstream3.utils.txt import java.math.BigInteger import java.security.SecureRandom import java.text.SimpleDateFormat @@ -45,25 +43,22 @@ import kotlin.time.Duration import kotlin.time.DurationUnit import kotlin.time.toDuration -class SimklApi(index: Int) : AccountManager(index), SyncAPI { +class SimklApi : SyncAPI() { override var name = "Simkl" - override val key = "simkl-key" - override val redirectUrl = "simkl" - override val supportDeviceAuth = true override val idPrefix = "simkl" + + val key = "simkl-key" + override val redirectUrlIdentifier = "simkl" + override val hasOAuth2 = true + override val hasPin = true override var requireLibraryRefresh = true override var mainUrl = "https://api.simkl.com" override val icon = R.drawable.simkl_logo - override val requiresLogin = false override val createAccountUrl = "$mainUrl/signup" override val syncIdName = SyncIdName.Simkl - private val token: String? - get() = getKey(accountId, SIMKL_TOKEN_KEY).also { - debugAssert({ it == null }) { "No ${this.name} token!" } - } /** Automatically adds simkl auth headers */ - private val interceptor = HeaderInterceptor() + // private val interceptor = HeaderInterceptor() /** * This is required to override the reported last activity as simkl activites @@ -101,7 +96,7 @@ class SimklApi(index: Int) : AccountManager(index), SyncAPI { fun cleanOldCache() { getKeys(SIMKL_CACHE_KEY)?.forEach { - val isOld = AcraApplication.getKey>(it)?.isFresh() == false + val isOld = CloudStreamApp.getKey>(it)?.isFresh() == false if (isOld) { removeKey(it) } @@ -148,10 +143,6 @@ class SimklApi(index: Int) : AccountManager(index), SyncAPI { companion object { private const val CLIENT_ID: String = BuildConfig.SIMKL_CLIENT_ID private const val CLIENT_SECRET: String = BuildConfig.SIMKL_CLIENT_SECRET - private var lastLoginState = "" - - const val SIMKL_TOKEN_KEY: String = "simkl_token" - const val SIMKL_USER_KEY: String = "simkl_user" const val SIMKL_CACHED_LIST: String = "simkl_cached_list" const val SIMKL_CACHED_LIST_TIME: String = "simkl_cached_time" @@ -237,13 +228,23 @@ class SimklApi(index: Int) : AccountManager(index), SyncAPI { /** https://simkl.docs.apiary.io/#reference/users/settings/receive-settings */ data class SettingsResponse( - val user: User + @JsonProperty("user") + val user: User, + @JsonProperty("account") + val account: Account, ) { data class User( + @JsonProperty("name") val name: String, /** Url */ + @JsonProperty("avatar") val avatar: String ) + + data class Account( + @JsonProperty("id") + val id: Int, + ) } data class PinAuthResponse( @@ -365,7 +366,7 @@ class SimklApi(index: Int) : AccountManager(index), SyncAPI { class SimklScoreBuilder private constructor() { data class Builder( private var url: String? = null, - private var interceptor: Interceptor? = null, + private var headers: Map? = null, private var ids: MediaObject.Ids? = null, private var score: Int? = null, private var status: Int? = null, @@ -374,7 +375,7 @@ class SimklApi(index: Int) : AccountManager(index), SyncAPI { // Required for knowing if the status should be overwritten private var onList: Boolean = false ) { - fun interceptor(interceptor: Interceptor) = apply { this.interceptor = interceptor } + fun token(token: AuthToken) = apply { this.headers = getHeaders(token) } fun apiUrl(url: String) = apply { this.url = url } fun ids(ids: MediaObject.Ids) = apply { this.ids = ids } fun score(score: Int?, oldScore: Int?) = apply { @@ -423,7 +424,7 @@ class SimklApi(index: Int) : AccountManager(index), SyncAPI { suspend fun execute(): Boolean { val time = getDateTime(unixTime) - + val headers = this.headers ?: emptyMap() return if (this.status == SimklListStatusType.None.value) { app.post( "$url/sync/history/remove", @@ -431,7 +432,7 @@ class SimklApi(index: Int) : AccountManager(index), SyncAPI { shows = listOf(HistoryMediaObject(ids = ids)), movies = emptyList() ), - interceptor = interceptor + headers = headers ).isSuccessful } else { val statusResponse = this.status?.let { setStatus -> @@ -452,7 +453,7 @@ class SimklApi(index: Int) : AccountManager(index), SyncAPI { ) ), movies = emptyList() ), - interceptor = interceptor + headers = headers ).isSuccessful } ?: true @@ -469,7 +470,7 @@ class SimklApi(index: Int) : AccountManager(index), SyncAPI { ), movies = emptyList() ), - interceptor = interceptor + headers = headers ).isSuccessful } ?: true @@ -496,7 +497,7 @@ class SimklApi(index: Int) : AccountManager(index), SyncAPI { ) ), movies = emptyList() ), - interceptor = interceptor + headers = headers ).isSuccessful } else { true @@ -508,6 +509,9 @@ class SimklApi(index: Int) : AccountManager(index), SyncAPI { } } + fun getHeaders(token: AuthToken): Map = + mapOf("Authorization" to "Bearer ${token.accessToken}", "simkl-api-key" to CLIENT_ID) + suspend fun getEpisodes( simklId: Int?, type: String?, @@ -664,7 +668,7 @@ class SimklApi(index: Int) : AccountManager(index), SyncAPI { movie.ids.simkl.toString(), this.watchedEpisodesCount, this.totalEpisodesCount, - this.userRating?.times(10), + Score.from10(this.userRating), getUnixTime(lastWatchedAt) ?: 0, "Simkl", TvType.Movie, @@ -697,7 +701,7 @@ class SimklApi(index: Int) : AccountManager(index), SyncAPI { show.ids.simkl.toString(), this.watchedEpisodesCount, this.totalEpisodesCount, - this.userRating?.times(10), + Score.from10(this.userRating), getUnixTime(lastWatchedAt) ?: 0, "Simkl", TvType.Anime, @@ -746,7 +750,7 @@ class SimklApi(index: Int) : AccountManager(index), SyncAPI { /** * Appends api keys to the requests **/ - private inner class HeaderInterceptor : Interceptor { + /*private inner class HeaderInterceptor : Interceptor { override fun intercept(chain: Interceptor.Chain): Response { debugPrint { "${this@SimklApi.name} made request to ${chain.request().url}" } return chain.proceed( @@ -757,14 +761,12 @@ class SimklApi(index: Int) : AccountManager(index), SyncAPI { .build() ) } - } + }*/ + + private suspend fun getUser(token: AuthToken): SettingsResponse = + app.post("$mainUrl/users/settings", headers = getHeaders(token)) + .parsed() - private suspend fun getUser(): SettingsResponse.User? { - return safeAsync { - app.post("$mainUrl/users/settings", interceptor = interceptor) - .parsedSafe()?.user - } - } /** * Useful to get episodes on demand to prevent unnecessary requests. @@ -782,7 +784,7 @@ class SimklApi(index: Int) : AccountManager(index), SyncAPI { class SimklSyncStatus( override var status: SyncWatchType, - override var score: Int?, + override var score: Score?, val oldScore: Int?, override var watchedEpisodes: Int?, val episodeConstructor: SimklEpisodeConstructor, @@ -794,7 +796,8 @@ class SimklApi(index: Int) : AccountManager(index), SyncAPI { val oldStatus: String? ) : SyncAPI.AbstractSyncStatus() - override suspend fun getStatus(id: String): SyncAPI.AbstractSyncStatus? { + override suspend fun status(auth: AuthData?, id: String): SyncAPI.AbstractSyncStatus? { + if (auth == null) return null val realIds = readIdFromString(id) // Key which assumes all ids are the same each time :/ @@ -818,7 +821,7 @@ class SimklApi(index: Int) : AccountManager(index), SyncAPI { searchResult.hasEnded() ) - val foundItem = getSyncListSmart()?.let { list -> + val foundItem = getSyncListSmart(auth)?.let { list -> listOf(list.shows, list.anime, list.movies).flatten().firstOrNull { show -> realIds.any { (database, id) -> show.getIds().matchesId(database, id) @@ -836,7 +839,7 @@ class SimklApi(index: Int) : AccountManager(index), SyncAPI { ) } ?: return null, - score = foundItem.userRating, + score = Score.from10(foundItem.userRating), watchedEpisodes = foundItem.watchedEpisodesCount, maxEpisodes = searchResult.totalEpisodes, episodeConstructor = episodeConstructor, @@ -847,7 +850,7 @@ class SimklApi(index: Int) : AccountManager(index), SyncAPI { } else { return SimklSyncStatus( status = SyncWatchType.fromInternalId(SimklListStatusType.None.value), - score = 0, + score = null, watchedEpisodes = 0, maxEpisodes = if (searchResult.type == "movie") 0 else searchResult.totalEpisodes, episodeConstructor = episodeConstructor, @@ -858,22 +861,26 @@ class SimklApi(index: Int) : AccountManager(index), SyncAPI { } } - override suspend fun score(id: String, status: SyncAPI.AbstractSyncStatus): Boolean { + override suspend fun updateStatus( + auth: AuthData?, + id: String, + newStatus: AbstractSyncStatus + ): Boolean { val parsedId = readIdFromString(id) lastScoreTime = unixTime - val simklStatus = status as? SimklSyncStatus + val simklStatus = newStatus as? SimklSyncStatus val builder = SimklScoreBuilder.Builder() .apiUrl(this.mainUrl) - .score(status.score, simklStatus?.oldScore) + .score(newStatus.score?.toInt(10), simklStatus?.oldScore) .status( - status.status.internalId, - (status as? SimklSyncStatus)?.oldStatus?.let { oldStatus -> + newStatus.status.internalId, + (newStatus as? SimklSyncStatus)?.oldStatus?.let { oldStatus -> SimklListStatusType.entries.firstOrNull { it.originalName == oldStatus }?.value }) - .interceptor(interceptor) + .token(auth?.token ?: return false) .ids(MediaObject.Ids.fromMap(parsedId)) @@ -881,11 +888,12 @@ class SimklApi(index: Int) : AccountManager(index), SyncAPI { val episodes = simklStatus?.episodeConstructor?.getEpisodes() // All episodes if marked as completed - val watchedEpisodes = if (status.status.internalId == SimklListStatusType.Completed.value) { - episodes?.size - } else { - status.watchedEpisodes - } + val watchedEpisodes = + if (newStatus.status.internalId == SimklListStatusType.Completed.value) { + episodes?.size + } else { + newStatus.watchedEpisodes + } builder.episodes(episodes?.toList(), watchedEpisodes, simklStatus?.oldEpisodes) @@ -906,39 +914,26 @@ class SimklApi(index: Int) : AccountManager(index), SyncAPI { ).parsedSafe() } - override suspend fun search(name: String): List? { + override suspend fun search(auth: AuthData?, query: String): List? { return app.get( "$mainUrl/search/", params = mapOf("client_id" to CLIENT_ID, "q" to name) ).parsedSafe>()?.mapNotNull { it.toSyncSearchResult() } } - override fun authenticate(activity: FragmentActivity?) { - lastLoginState = BigInteger(130, SecureRandom()).toString(32) + override fun loginRequest(): AuthLoginPage? { + val lastLoginState = BigInteger(130, SecureRandom()).toString(32) val url = - "https://simkl.com/oauth/authorize?response_type=code&client_id=$CLIENT_ID&redirect_uri=$APP_STRING://${redirectUrl}&state=$lastLoginState" - openBrowser(url, activity) - } - - override fun loginInfo(): AuthAPI.LoginInfo? { - return getKey(accountId, SIMKL_USER_KEY)?.let { user -> - AuthAPI.LoginInfo( - name = user.name, - profilePicture = user.avatar, - accountIndex = accountIndex - ) - } - } + "https://simkl.com/oauth/authorize?response_type=code&client_id=$CLIENT_ID&redirect_uri=$APP_STRING://${redirectUrlIdentifier}&state=$lastLoginState" - override fun logOut() { - requireLibraryRefresh = true - removeAccountKeys() + return AuthLoginPage( + url = url, + payload = lastLoginState + ) } - override suspend fun getResult(id: String): SyncAPI.SyncResult? { - return null - } + override suspend fun load(auth: AuthData?, id: String): SyncResult? = null - private suspend fun getSyncListSince(since: Long?): AllItemsResponse? { + private suspend fun getSyncListSince(auth: AuthData, since: Long?): AllItemsResponse? { val params = getDateTime(since)?.let { mapOf("date_from" to it) } ?: emptyMap() @@ -947,23 +942,22 @@ class SimklApi(index: Int) : AccountManager(index), SyncAPI { return app.get( "$mainUrl/sync/all-items/", params = params, - interceptor = interceptor + headers = getHeaders(auth.token) ).parsedSafe() } - private suspend fun getActivities(): ActivitiesResponse? { - return app.post("$mainUrl/sync/activities", interceptor = interceptor).parsedSafe() + private suspend fun getActivities(token: AuthToken): ActivitiesResponse? { + return app.post("$mainUrl/sync/activities", headers = getHeaders(token)).parsedSafe() } - private fun getSyncListCached(): AllItemsResponse? { - return getKey(accountId, SIMKL_CACHED_LIST) + private fun getSyncListCached(auth: AuthData): AllItemsResponse? { + return getKey(SIMKL_CACHED_LIST, auth.user.id.toString()) } - private suspend fun getSyncListSmart(): AllItemsResponse? { - if (token == null) return null - - val activities = getActivities() - val lastCacheUpdate = getKey(accountId, SIMKL_CACHED_LIST_TIME) + private suspend fun getSyncListSmart(auth: AuthData): AllItemsResponse? { + val activities = getActivities(auth.token) + val userId = auth.user.id.toString() + val lastCacheUpdate = getKey(SIMKL_CACHED_LIST_TIME, auth.user.id.toString()) val lastRemoval = listOf( activities?.tvShows?.removedFromList, activities?.anime?.removedFromList, @@ -983,26 +977,28 @@ class SimklApi(index: Int) : AccountManager(index), SyncAPI { debugPrint { "Cache times: lastCacheUpdate=$lastCacheUpdate, lastRemoval=$lastRemoval, lastRealUpdate=$lastRealUpdate" } val list = if (lastCacheUpdate == null || lastCacheUpdate < lastRemoval) { debugPrint { "Full list update in ${this.name}." } - setKey(accountId, SIMKL_CACHED_LIST_TIME, lastRemoval) - getSyncListSince(null) + setKey(SIMKL_CACHED_LIST_TIME, userId, lastRemoval) + getSyncListSince(auth, null) } else if (lastCacheUpdate < lastRealUpdate || lastCacheUpdate < lastScoreTime) { debugPrint { "Partial list update in ${this.name}." } - setKey(accountId, SIMKL_CACHED_LIST_TIME, lastCacheUpdate) - AllItemsResponse.merge(getSyncListCached(), getSyncListSince(lastCacheUpdate)) + setKey(SIMKL_CACHED_LIST_TIME, userId, lastCacheUpdate) + AllItemsResponse.merge( + getSyncListCached(auth), + getSyncListSince(auth, lastCacheUpdate) + ) } else { debugPrint { "Cached list update in ${this.name}." } - getSyncListCached() + getSyncListCached(auth) } debugPrint { "List sizes: movies=${list?.movies?.size}, shows=${list?.shows?.size}, anime=${list?.anime?.size}" } - setKey(accountId, SIMKL_CACHED_LIST, list) + setKey(SIMKL_CACHED_LIST, userId, list) return list } - - override suspend fun getPersonalLibrary(): SyncAPI.LibraryMetadata? { - val list = getSyncListSmart() ?: return null + override suspend fun library(auth: AuthData?): SyncAPI.LibraryMetadata? { + val list = getSyncListSmart(auth ?: return null) ?: return null val baseMap = SimklListStatusType.entries @@ -1038,17 +1034,17 @@ class SimklApi(index: Int) : AccountManager(index), SyncAPI { ) } - override fun getIdFromUrl(url: String): String { + override fun urlToId(url: String): String? { val simklUrlRegex = Regex("""https://simkl\.com/[^/]*/(\d+).*""") return simklUrlRegex.find(url)?.groupValues?.get(1) ?: "" } - override suspend fun getDevicePin(): OAuth2API.PinAuthData? { + override suspend fun pinRequest(): AuthPinData? { val pinAuthResp = app.get( - "$mainUrl/oauth/pin?client_id=$CLIENT_ID&redirect_uri=$APP_STRING://${redirectUrl}" + "$mainUrl/oauth/pin?client_id=$CLIENT_ID&redirect_uri=$APP_STRING://${redirectUrlIdentifier}" ).parsedSafe() ?: return null - return OAuth2API.PinAuthData( + return AuthPinData( deviceCode = pinAuthResp.deviceCode, userCode = pinAuthResp.userCode, verificationUrl = pinAuthResp.verificationUrl, @@ -1057,56 +1053,38 @@ class SimklApi(index: Int) : AccountManager(index), SyncAPI { ) } - override suspend fun handleDeviceAuth(pinAuthData: OAuth2API.PinAuthData): Boolean { + override suspend fun login(payload: AuthPinData): AuthToken? { val pinAuthResp = app.get( - "$mainUrl/oauth/pin/${pinAuthData.userCode}?client_id=$CLIENT_ID" - ).parsedSafe() ?: return false - - if (pinAuthResp.accessToken != null) { - switchToNewAccount() - setKey(accountId, SIMKL_TOKEN_KEY, pinAuthResp.accessToken) - - val user = getUser() - if (user == null) { - removeKey(accountId, SIMKL_TOKEN_KEY) - switchToOldAccount() - return false - } + "$mainUrl/oauth/pin/${payload.userCode}?client_id=$CLIENT_ID" + ).parsedSafe() ?: return null - setKey(accountId, SIMKL_USER_KEY, user) - registerAccount() - requireLibraryRefresh = true - return true - } - return false + return AuthToken( + accessToken = pinAuthResp.accessToken ?: return null, + ) } - override suspend fun handleRedirect(url: String): Boolean { - val uri = url.toUri() + override suspend fun login(redirectUrl: String, payload: String?): AuthToken? { + val uri = redirectUrl.toUri() val state = uri.getQueryParameter("state") // Ensure consistent state - if (state != lastLoginState) return false - lastLoginState = "" + if (state != payload) return null - val code = uri.getQueryParameter("code") ?: return false - val token = app.post( + val code = uri.getQueryParameter("code") ?: return null + val tokenResponse = app.post( "$mainUrl/oauth/token", json = TokenRequest(code) - ).parsedSafe() ?: return false - - switchToNewAccount() - setKey(accountId, SIMKL_TOKEN_KEY, token.accessToken) - - val user = getUser() - if (user == null) { - removeKey(accountId, SIMKL_TOKEN_KEY) - switchToOldAccount() - return false - } + ).parsedSafe() ?: return null - setKey(accountId, SIMKL_USER_KEY, user) - registerAccount() - requireLibraryRefresh = true + return AuthToken( + accessToken = tokenResponse.accessToken, + ) + } - return true + override suspend fun user(token: AuthToken?): AuthUser? { + val user = getUser(token ?: return null) + return AuthUser( + id = user.account.id, + name = user.user.name, + profilePicture = user.user.avatar + ) } } 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 8dad1f88cfe..19122768e23 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 @@ -3,27 +3,33 @@ package com.lagradost.cloudstream3.syncproviders.providers import com.fasterxml.jackson.annotation.JsonProperty import com.lagradost.cloudstream3.TvType import com.lagradost.cloudstream3.app -import com.lagradost.cloudstream3.subtitles.AbstractSubProvider import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities import com.lagradost.cloudstream3.subtitles.SubtitleResource +import com.lagradost.cloudstream3.syncproviders.AuthData +import com.lagradost.cloudstream3.syncproviders.SubtitleAPI import com.lagradost.cloudstream3.utils.AppUtils.parseJson import com.lagradost.cloudstream3.utils.AppUtils.toJson import com.lagradost.cloudstream3.utils.SubtitleHelper -class SubSourceApi : AbstractSubProvider { +class SubSourceApi : SubtitleAPI() { + override val name = "SubSource" override val idPrefix = "subsource" - val name = "SubSource" + + override val requiresLogin = false companion object { const val APIURL = "https://api.subsource.net/api" const val DOWNLOADENDPOINT = "https://api.subsource.net/api/downloadSub" } - override suspend fun search(query: AbstractSubtitleEntities.SubtitleSearch): List? { + override suspend fun search( + auth: AuthData?, + query: AbstractSubtitleEntities.SubtitleSearch + ): List? { //Only supports Imdb Id search for now if (query.imdbId == null) return null - val queryLang = SubtitleHelper.fromTwoLettersToLanguage(query.lang!!) + val queryLang = SubtitleHelper.fromTagToEnglishLanguageName(query.lang) val type = if ((query.seasonNumber ?: 0) > 0) TvType.TvSeries else TvType.Movie val searchRes = app.post( @@ -87,15 +93,17 @@ class SubSourceApi : AbstractSubProvider { } } - override suspend fun SubtitleResource.getResources(data: AbstractSubtitleEntities.SubtitleEntity) { - - val parsedSub = parseJson(data.data) + override suspend fun SubtitleResource.getResources( + auth: AuthData?, + subtitle: AbstractSubtitleEntities.SubtitleEntity + ) { + val parsedSub = parseJson(subtitle.data) val subRes = app.post( url = "$APIURL/getSub", data = mapOf( "movie" to parsedSub.movie, - "lang" to data.lang, + "lang" to subtitle.lang, "id" to parsedSub.id ) ).parsedSafe() ?: return 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 9cec1e1cac4..1f1e6de4450 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,89 +1,71 @@ package com.lagradost.cloudstream3.syncproviders.providers import com.fasterxml.jackson.annotation.JsonProperty -import com.lagradost.cloudstream3.AcraApplication.Companion.getKey -import com.lagradost.cloudstream3.AcraApplication.Companion.removeKey -import com.lagradost.cloudstream3.AcraApplication.Companion.setKey -import com.lagradost.cloudstream3.ErrorLoadingException -import com.lagradost.cloudstream3.R -import com.lagradost.cloudstream3.TvType import com.lagradost.cloudstream3.app -import com.lagradost.cloudstream3.mvvm.logError -import com.lagradost.cloudstream3.subtitles.AbstractSubApi +import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities import com.lagradost.cloudstream3.subtitles.SubtitleResource -import com.lagradost.cloudstream3.syncproviders.AuthAPI.LoginInfo -import com.lagradost.cloudstream3.syncproviders.InAppAuthAPI -import com.lagradost.cloudstream3.syncproviders.InAppAuthAPIManager +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.SubtitleAPI +import com.lagradost.cloudstream3.TvType -class SubDlApi(index: Int) : InAppAuthAPIManager(index), AbstractSubApi { - override val idPrefix = "subdl" +class SubDlApi : SubtitleAPI() { override val name = "SubDL" + override val idPrefix = "subdl" + override val icon = R.drawable.subdl_logo_big - override val requiresPassword = true - override val requiresEmail = true + override val hasInApp = true + override val inAppLoginRequirement = AuthLoginRequirement(password = true, email = true) + override val requiresLogin = true override val createAccountUrl = "https://subdl.com/panel/register" companion object { const val APIURL = "https://apiold.subdl.com" const val APIENDPOINT = "$APIURL/api/v1/subtitles" const val DOWNLOADENDPOINT = "https://dl.subdl.com" - const val SUBDL_SUBTITLES_USER_KEY: String = "subdl_user" - var currentSession: SubtitleOAuthEntity? = null } - override suspend fun initialize() { - currentSession = getAuthKey() - } - - override fun logOut() { - setAuthKey(null) - removeAccountKeys() - currentSession = getAuthKey() - } + override suspend fun login(form: AuthLoginResponse): AuthToken? { + val email = form.email ?: return null + val password = form.password ?: return null + val tokenResponse = app.post( + url = "$APIURL/login", + json = mapOf( + "email" to email, + "password" to password + ) + ).parsed() - override suspend fun login(data: InAppAuthAPI.LoginData): Boolean { - val email = data.email ?: throw ErrorLoadingException("Requires Email") - val password = data.password ?: throw ErrorLoadingException("Requires Password") - switchToNewAccount() - try { - if (initLogin(email, password)) { - registerAccount() - return true - } - } catch (e: Exception) { - logError(e) - switchToOldAccount() - } - switchToOldAccount() - return false - } + val apiResponse = app.get( + url = "$APIURL/user/userApi", + headers = mapOf( + "Authorization" to "Bearer ${tokenResponse.token}" + ) + ).parsed() - override fun getLatestLoginData(): InAppAuthAPI.LoginData? { - val current = getAuthKey() ?: return null - return InAppAuthAPI.LoginData( - email = current.userEmail, - password = current.pass - ) + return AuthToken(accessToken = apiResponse.apiKey, payload = email) } - override fun loginInfo(): LoginInfo? { - getAuthKey()?.let { user -> - return LoginInfo( - profilePicture = null, - name = user.name ?: user.userEmail, - accountIndex = accountIndex - ) - } - return null + override suspend fun user(token: AuthToken?): AuthUser? { + val name = token?.payload ?: return null + return AuthUser(id = name.hashCode(), name = name) } - override suspend fun search(query: AbstractSubtitleEntities.SubtitleSearch): List? { - + override suspend fun search( + auth : AuthData?, + query: AbstractSubtitleEntities.SubtitleSearch + ): List? { + if (auth == null) return null + val apiKey = auth.token.accessToken ?: return null val queryText = query.query 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}" @@ -97,8 +79,8 @@ class SubDlApi(index: Int) : InAppAuthAPIManager(index), AbstractSubApi { val searchQueryUrl = when (idQuery) { //Use imdb/tmdb id to search if its valid - null -> "$APIENDPOINT?api_key=${currentSession?.apiKey}&film_name=$queryText&languages=${query.lang}$epQuery$seasonQuery$yearQuery" - else -> "$APIENDPOINT?api_key=${currentSession?.apiKey}$idQuery&languages=${query.lang}$epQuery$seasonQuery$yearQuery" + null -> "$APIENDPOINT?api_key=${apiKey}&film_name=$queryText&languages=$langSubdlCode$epQuery$seasonQuery$yearQuery" + else -> "$APIENDPOINT?api_key=${apiKey}$idQuery&languages=$langSubdlCode$epQuery$seasonQuery$yearQuery" } val req = app.get( @@ -110,7 +92,9 @@ class SubDlApi(index: Int) : InAppAuthAPIManager(index), AbstractSubApi { return req.parsedSafe()?.subtitles?.map { subtitle -> - val lang = subtitle.lang.replaceFirstChar { it.uppercase() } + val langTagIETF = + langTagIETF2subdl.entries.find { it.value == subtitle.lang }?.key ?: + subtitle.lang val resEpNum = subtitle.episode ?: query.epNumber val resSeasonNum = subtitle.season ?: query.seasonNumber val type = if ((resSeasonNum ?: 0) > 0) TvType.TvSeries else TvType.Movie @@ -118,7 +102,7 @@ class SubDlApi(index: Int) : InAppAuthAPIManager(index), AbstractSubApi { AbstractSubtitleEntities.SubtitleEntity( idPrefix = this.idPrefix, name = subtitle.releaseName, - lang = lang, + lang = langTagIETF, data = "${DOWNLOADENDPOINT}${subtitle.url}", type = type, source = this.name, @@ -129,58 +113,15 @@ class SubDlApi(index: Int) : InAppAuthAPIManager(index), AbstractSubApi { } } - override suspend fun SubtitleResource.getResources(data: AbstractSubtitleEntities.SubtitleEntity) { - this.addZipUrl(data.data) { name, _ -> + override suspend fun SubtitleResource.getResources( + auth: AuthData?, + subtitle: AbstractSubtitleEntities.SubtitleEntity + ) { + this.addZipUrl(subtitle.data) { name, _ -> name } } - private suspend fun initLogin(useremail: String, password: String): Boolean { - - val tokenResponse = app.post( - url = "$APIURL/login", - json = mapOf( - "email" to useremail, - "password" to password - ) - ).parsedSafe() - - if (tokenResponse?.token == null) return false - - val apiResponse = app.get( - url = "$APIURL/user/userApi", - headers = mapOf( - "Authorization" to "Bearer ${tokenResponse.token}" - ) - ).parsedSafe() - - if (apiResponse?.ok == false) return false - - setAuthKey( - SubtitleOAuthEntity( - userEmail = useremail, - pass = password, - name = tokenResponse.userData?.username ?: tokenResponse.userData?.name, - accessToken = tokenResponse.token, - apiKey = apiResponse?.apiKey - ) - ) - return true - } - - private fun getAuthKey(): SubtitleOAuthEntity? { - return getKey(accountId, SUBDL_SUBTITLES_USER_KEY) - } - - private fun setAuthKey(data: SubtitleOAuthEntity?) { - if (data == null) removeKey( - accountId, - SUBDL_SUBTITLES_USER_KEY - ) - currentSession = data - setKey(accountId, SUBDL_SUBTITLES_USER_KEY, data) - } - data class SubtitleOAuthEntity( @JsonProperty("userEmail") var userEmail: String, @JsonProperty("pass") var pass: String, @@ -190,7 +131,7 @@ class SubDlApi(index: Int) : InAppAuthAPIManager(index), AbstractSubApi { ) data class OAuthTokenResponse( - @JsonProperty("token") val token: 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, @@ -208,7 +149,7 @@ class SubDlApi(index: Int) : InAppAuthAPIManager(index), AbstractSubApi { data class ApiKeyResponse( @JsonProperty("ok") val ok: Boolean? = false, - @JsonProperty("api_key") val apiKey: String? = null, + @JsonProperty("api_key") val apiKey: String, @JsonProperty("usage") val usage: Usage? = null, ) @@ -236,13 +177,83 @@ class SubDlApi(index: Int) : InAppAuthAPIManager(index), AbstractSubApi { data class Subtitle( @JsonProperty("release_name") val releaseName: String, @JsonProperty("name") val name: String, - @JsonProperty("lang") val lang: String, + @JsonProperty("lang") val lang: String, // subdl language code @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("language") val language: String? = null, // full language name @JsonProperty("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" + ) } 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 492efacecae..93a79689e50 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/APIRepository.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/APIRepository.kt @@ -9,13 +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.SearchResponse +import com.lagradost.cloudstream3.SearchResponseList 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.threadSafeListOf import com.lagradost.cloudstream3.utils.ExtractorLink import kotlinx.coroutines.CoroutineScope @@ -28,7 +29,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() @@ -58,8 +59,8 @@ class APIRepository(val api: MainAPI) { 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) } } @@ -117,27 +118,29 @@ class APIRepository(val api: MainAPI) { } } - suspend fun search(query: String): Resource> { + suspend fun search(query: String, page: Int): Resource { if (query.isEmpty()) - return Resource.Success(emptyList()) + return Resource.Success(newSearchResponseList(emptyList())) return safeApiCall { withTimeout(getTimeout(api.searchTimeoutMs)) { - (api.search(query) + (api.search(query, page) ?: throw ErrorLoadingException()) - // .filter { typesActive.contains(it.type) } - .toList() + // .filter { typesActive.contains(it.type) } } } } - suspend fun quickSearch(query: String): Resource> { + suspend fun quickSearch(query: String): Resource { if (query.isEmpty()) - return Resource.Success(emptyList()) + return Resource.Success(newSearchResponseList(emptyList())) return safeApiCall { withTimeout(getTimeout(api.quickSearchTimeoutMs)) { - api.quickSearch(query) ?: throw ErrorLoadingException() + newSearchResponseList( + api.quickSearch(query) ?: throw ErrorLoadingException(), + false + ) } } } 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 e930961c550..4ebb7564ca3 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/BaseAdapter.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/BaseAdapter.kt @@ -1,34 +1,55 @@ 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) -// 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>() +/** 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) + }) + } } -abstract class NoStateAdapter(fragment: Fragment) : BaseAdapter(fragment, 0) +/** Clears the shared pool of views */ +fun Pair, RecyclerView.RecycledViewPool.() -> Unit>.clear() { + synchronized(this.first) { + for (pool in this.first.values) { + pool?.clear() + } + } +} /** * BaseAdapter is a persistent state stored adapter that supports headers and footers. @@ -49,13 +70,14 @@ abstract class NoStateAdapter(fragment: Fragment) : BaseAdapter 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] } @@ -85,9 +107,33 @@ abstract class BaseAdapter< AsyncDifferConfig.Builder(diffCallback).build() ) - open fun submitList(list: List?) { + /** + * 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) { // deep copy at least the top list, because otherwise adapter can go crazy - mDiffer.submitList(list?.let { CopyOnWriteArrayList(it) }) + if (list.isNullOrEmpty()) { + mDiffer.submitList(null, commitCallback) // It is "faster" to submit null than emptyList() + } else { + mDiffer.submitList(CopyOnWriteArrayList(list), commitCallback) + } } override fun getItemCount(): Int { @@ -101,16 +147,25 @@ 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 onCreateHeader(parent: ViewGroup): ViewHolderState = throw NotImplementedError() + open fun onCreateCustomFooter( + parent: ViewGroup, + viewType: Int + ) = onCreateFooter(parent) - override fun onViewAttachedToWindow(holder: ViewHolderState) { - holder.onViewAttachedToWindow() - } + open fun onCreateHeader(parent: ViewGroup): ViewHolderState = throw NotImplementedError() + open fun onCreateCustomHeader( + parent: ViewGroup, + viewType: Int + ) = onCreateHeader(parent) - override fun onViewDetachedFromWindow(holder: ViewHolderState) { - holder.onViewDetachedFromWindow() - } + override fun onViewAttachedToWindow(holder: ViewHolderState) {} + override fun onViewDetachedFromWindow(holder: ViewHolderState) {} @Suppress("UNCHECKED_CAST") fun save(recyclerView: RecyclerView) { @@ -121,21 +176,20 @@ abstract class BaseAdapter< } } - fun clear() { - stateViewModel.layoutManagerStates[id]?.clear() + fun clearState() { + layoutManagerStates[id]?.clear() } @Suppress("UNCHECKED_CAST") private fun getState(holder: ViewHolderState): S? = - stateViewModel.layoutManagerStates[id]?.get(holder.absoluteAdapterPosition) as? S + layoutManagerStates[id]?.get(holder.absoluteAdapterPosition) as? S private fun setState(holder: ViewHolderState) { - if(id == 0) return - - if (!stateViewModel.layoutManagerStates.contains(id)) { - stateViewModel.layoutManagerStates[id] = HashMap() + if (id == 0) return + if (!layoutManagerStates.contains(id)) { + layoutManagerStates[id] = HashMap() } - stateViewModel.layoutManagerStates[id]?.let { map -> + layoutManagerStates[id]?.let { map -> map[holder.absoluteAdapterPosition] = holder.save() } } @@ -158,30 +212,40 @@ 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 + return HEADER or customHeaderViewType() } - if (position - headers >= mDiffer.currentList.size) { - return FOOTER + val realPosition = position - headers + if (realPosition >= mDiffer.currentList.size) { + return FOOTER or customFooterViewType() } - - return CONTENT + return CONTENT or customContentViewType(getItem(realPosition)) } - private val stateViewModel: StateViewModel by fragment.viewModels() - final override fun onViewRecycled(holder: ViewHolderState) { setState(holder) - holder.onViewRecycled() + onClearView(holder) 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) { - CONTENT -> onCreateContent(parent) - HEADER -> onCreateHeader(parent) - FOOTER -> onCreateFooter(parent) + 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) else -> throw NotImplementedError() } } @@ -196,7 +260,7 @@ abstract class BaseAdapter< super.onBindViewHolder(holder, position, payloads) return } - when (getItemViewType(position)) { + when (getItemViewType(position) and TYPE_MASK) { CONTENT -> { val realPosition = position - headers val item = getItem(realPosition) @@ -214,7 +278,7 @@ abstract class BaseAdapter< } final override fun onBindViewHolder(holder: ViewHolderState, position: Int) { - when (getItemViewType(position)) { + when (getItemViewType(position) and TYPE_MASK) { CONTENT -> { val realPosition = position - headers val item = getItem(realPosition) @@ -236,9 +300,20 @@ abstract class BaseAdapter< } companion object { - private const val HEADER: Int = 1 - private const val FOOTER: Int = 2 - private const val CONTENT: Int = 0 + 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 } } @@ -248,5 +323,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 new file mode 100644 index 00000000000..72955e7cf8c --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/BaseFragment.kt @@ -0,0 +1,278 @@ +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 e66b57ab1fc..f91d40f28e0 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/ControllerActivity.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/ControllerActivity.kt @@ -245,7 +245,12 @@ 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) } } @@ -299,7 +304,13 @@ class SelectSourceController(val view: ImageView, val activity: ControllerActivi val currentDuration = remoteMediaClient?.streamDuration val currentPosition = remoteMediaClient?.approximateStreamPosition if (currentDuration != null && currentPosition != null) - DataStoreHelper.setViewPos(epData.id, currentPosition, currentDuration) + DataStoreHelper.setViewPosAndResume( + epData.id, + currentPosition, + currentDuration, + epData, + meta.episodes.getOrNull(index + 1) + ) } catch (t: Throwable) { logError(t) } @@ -315,7 +326,7 @@ class SelectSourceController(val view: ImageView, val activity: ControllerActivi val isSuccessful = safeApiCall { generator.generateLinks( clearCache = false, - allowedTypes = LOADTYPE_CHROMECAST, + sourceTypes = LOADTYPE_CHROMECAST, callback = { it.first?.let { link -> currentLinks.add(link) @@ -323,7 +334,9 @@ class SelectSourceController(val view: ImageView, val activity: ControllerActivi }, subtitleCallback = { currentSubs.add(it) }, - isCasting = true) + offset = 0, + isCasting = true + ) } val sortedLinks = sortUrls(currentLinks) 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 78ad2a6bfcb..30235853887 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/CustomRecyclerViews.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/CustomRecyclerViews.kt @@ -3,6 +3,7 @@ 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 @@ -154,10 +155,9 @@ class AutofitRecyclerView @JvmOverloads constructor(context: Context, attrs: Att init { if (attrs != null) { - val attrsArray = intArrayOf(android.R.attr.columnWidth) - val array = context.obtainStyledAttributes(attrs, attrsArray) - columnWidth = array.getDimensionPixelSize(0, -1) - array.recycle() + context.withStyledAttributes(attrs, intArrayOf(android.R.attr.columnWidth)) { + columnWidth = getDimensionPixelSize(0, -1) + } } 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 bf7f6b8fc42..9be86207759 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/EasterEggMonkeFragment.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/EasterEggMonkeFragment.kt @@ -4,17 +4,13 @@ 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 @@ -26,10 +22,9 @@ import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlin.random.Random -class EasterEggMonkeFragment : Fragment() { - - private var _binding: FragmentEasterEggMonkeBinding? = null - private val binding get() = _binding!! +class EasterEggMonkeFragment : BaseFragment( + BaseFragment.BindingCreator.Inflate(FragmentEasterEggMonkeBinding::inflate) +) { // planet of monks private val monkeys: List = listOf( @@ -51,27 +46,20 @@ class EasterEggMonkeFragment : Fragment() { private val activeMonkeys = mutableListOf() private var spawningJob: Job? = null - 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 fixLayout(view: View) = Unit + override fun onBindingCreated(binding: FragmentEasterEggMonkeBinding) { activity?.hideSystemUI() spawningJob = lifecycleScope.launch { delay(1000) while (isActive) { - spawnMonkey() + spawnMonkey(binding) delay(500) } } } - private fun spawnMonkey() { + private fun spawnMonkey(binding: FragmentEasterEggMonkeBinding) { val newMonkey = ImageView(context ?: return).apply { setImageResource(monkeys.random()) isVisible = true @@ -102,12 +90,12 @@ class EasterEggMonkeFragment : Fragment() { } @SuppressLint("ClickableViewAccessibility") - newMonkey.setOnTouchListener { view, event -> handleTouch(view, event) } + newMonkey.setOnTouchListener { view, event -> handleTouch(view, event, binding) } - startFloatingAnimation(newMonkey) + startFloatingAnimation(newMonkey, binding) } - private fun startFloatingAnimation(monkey: ImageView) { + private fun startFloatingAnimation(monkey: ImageView, binding: FragmentEasterEggMonkeBinding) { val floatUpAnimator = ObjectAnimator.ofFloat( monkey, View.TRANSLATION_Y, monkey.y, -monkey.height.toFloat() ).apply { @@ -117,11 +105,8 @@ class EasterEggMonkeFragment : Fragment() { floatUpAnimator.addListener(object : AnimatorListenerAdapter() { override fun onAnimationEnd(animation: Animator) { - // necessary check because binding becomes null but monkes are still moving until onDestroy() - if (_binding != null) { - binding.frame.removeView(monkey) - activeMonkeys.remove(monkey) - } + binding.frame.removeView(monkey) + activeMonkeys.remove(monkey) } }) @@ -129,7 +114,11 @@ class EasterEggMonkeFragment : Fragment() { monkey.tag = floatUpAnimator } - private fun handleTouch(view: View, event: MotionEvent): Boolean { + private fun handleTouch( + view: View, + event: MotionEvent, + binding: FragmentEasterEggMonkeBinding + ): Boolean { val monkey = view as ImageView when (event.action) { MotionEvent.ACTION_DOWN -> { @@ -143,17 +132,17 @@ class EasterEggMonkeFragment : Fragment() { monkey.y = event.rawY - monkey.height / 2 // Check if monkey touches the screen edge - if (isTouchingEdge(monkey)) { - removeMonkey(monkey) + if (isTouchingEdge(monkey, binding)) { + removeMonkey(monkey, binding) } return true } MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> { - if (isTouchingEdge(monkey)) { - removeMonkey(monkey) + if (isTouchingEdge(monkey, binding)) { + removeMonkey(monkey, binding) } else { - startFloatingAnimation(monkey) + startFloatingAnimation(monkey, binding) } return true } @@ -161,12 +150,12 @@ class EasterEggMonkeFragment : Fragment() { return false } - private fun isTouchingEdge(monkey: ImageView): Boolean { + private fun isTouchingEdge(monkey: ImageView, binding: FragmentEasterEggMonkeBinding): 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) { + private fun removeMonkey(monkey: ImageView, binding: FragmentEasterEggMonkeBinding) { // Fade out and remove the monkey ObjectAnimator.ofFloat(monkey, View.ALPHA, 1f, 0f).apply { duration = 300 @@ -184,6 +173,5 @@ class EasterEggMonkeFragment : Fragment() { 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 deleted file mode 100644 index 40c03012a07..00000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/HeaderViewDecoration.kt +++ /dev/null @@ -1,42 +0,0 @@ -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 b6326eb366b..bd8541e6b26 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,26 +25,15 @@ class MyMiniControllerFragment : MiniControllerFragment() { // I KNOW, KINDA SPAGHETTI SOLUTION, BUT IT WORKS override fun onInflate(context: Context, attributeSet: AttributeSet, bundle: Bundle?) { - 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 + context.withStyledAttributes(attributeSet, R.styleable.CustomCast, 0, 0) { + if (hasValue(R.styleable.CustomCast_customCastBackgroundColor)) { + currentColor = getColor(R.styleable.CustomCast_customCastBackgroundColor, 0) } - get()?.recycle() - }.clear() + } } + + super.onInflate(context, attributeSet, bundle) } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/WatchType.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/WatchType.kt index b778ba5a754..ec0ef5c6bfb 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/WatchType.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/WatchType.kt @@ -18,15 +18,6 @@ enum class WatchType(val internalId: Int, @StringRes val stringRes: Int, @Drawab } enum class SyncWatchType(val internalId: Int, @StringRes val stringRes: Int, @DrawableRes val iconRes: Int) { - /* - -1 -> None - 0 -> Watching - 1 -> Completed - 2 -> OnHold - 3 -> Dropped - 4 -> PlanToWatch - 5 -> ReWatching - */ NONE(-1, R.string.type_none, R.drawable.ic_baseline_add_24), WATCHING(0, R.string.type_watching, R.drawable.ic_baseline_bookmark_24), COMPLETED(1, R.string.type_completed, R.drawable.ic_baseline_bookmark_24), 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 5e2b97e57d5..0d951bf6a7a 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/WebviewFragment.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/WebviewFragment.kt @@ -1,17 +1,12 @@ 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 @@ -19,19 +14,18 @@ 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) +) { -class WebviewFragment : Fragment() { + override fun fixLayout(view: View) = Unit - var binding: FragmentWebviewBinding? = null - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) + override fun onBindingCreated(binding: FragmentWebviewBinding) { val url = arguments?.getString(WEBVIEW_URL) ?: "".also { findNavController().popBackStack() } - binding?.webView?.webViewClient = object : WebViewClient() { - @OptIn(UnstableApi::class) + binding.webView.webViewClient = object : WebViewClient() { override fun shouldOverrideUrlLoading( view: WebView?, request: WebResourceRequest? @@ -46,28 +40,17 @@ class WebviewFragment : Fragment() { 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 { @@ -84,4 +67,4 @@ class WebviewFragment : Fragment() { 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 1e7e0f11285..92d33d0f349 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,16 +1,17 @@ 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 @@ -19,137 +20,174 @@ 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 -) : RecyclerView.Adapter() { +) : NoStateAdapter() { 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) { - fun bind(account: DataStoreHelper.Account?) { - when (binding) { - is AccountListItemBinding -> binding.apply { - if (account == null) return@apply + override val footers: Int = 1 + var viewType = VIEW_TYPE_SELECT_ACCOUNT - val isTv = isLayout(TV or EMULATOR) || !root.isInTouchMode - - val isLastUsedAccount = account.keyIndex == DataStoreHelper.selectedKeyIndex - - accountName.text = account.name - accountImage.loadImage(account.image) - lockIcon.isVisible = account.lockPin != null - outline.isVisible = !isTv && isLastUsedAccount + override fun customContentViewType(item: DataStoreHelper.Account): Int { + return viewType + } - if (isTv) { - // For emulator but this is fine on TV also - root.isFocusableInTouchMode = true - if (isLastUsedAccount) { - root.requestFocus() - } + 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 = item.keyIndex == DataStoreHelper.selectedKeyIndex + + 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 (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { 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 - } } + } else { + root.setOnLongClickListener { + showAccountEditDialog( + context = root.context, + account = item, + isNewAccount = false, + accountEditCallback = { account -> + accountEditCallback.invoke( + account + ) + }, + accountDeleteCallback = { account -> + accountDeleteCallback.invoke( + account + ) + } + ) - root.setOnClickListener { - accountSelectCallback.invoke(account) + true } } - is AccountListItemEditBinding -> binding.apply { - if (account == null) return@apply + root.setOnClickListener { + accountSelectCallback.invoke(item) + } + } - val isTv = isLayout(TV or EMULATOR) || !root.isInTouchMode + is AccountListItemEditBinding -> 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) { - RoundedCornersTransformation(10f) + 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() } - 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() - } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { root.foreground = ContextCompat.getDrawable( root.context, R.drawable.outline_drawable ) } - - root.setOnClickListener { - showAccountEditDialog( - context = root.context, - account = account, - isNewAccount = false, - accountEditCallback = { account -> accountEditCallback.invoke(account) }, - accountDeleteCallback = { account -> accountDeleteCallback.invoke(account) } - ) - } } - is AccountListItemAddBinding -> binding.apply { - root.setOnClickListener { - 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 = {} - ) - } + root.setOnClickListener { + showAccountEditDialog( + context = root.context, + account = item, + isNewAccount = false, + accountEditCallback = { account -> accountEditCallback.invoke(account) }, + accountDeleteCallback = { account -> + accountDeleteCallback.invoke( + account + ) + } + ) } } } } - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): AccountViewHolder = - AccountViewHolder( - binding = when (viewType) { + 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) { VIEW_TYPE_SELECT_ACCOUNT -> { AccountListItemBinding.inflate( LayoutInflater.from(parent.context), @@ -157,13 +195,7 @@ 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), @@ -171,28 +203,9 @@ 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 ce8d53609de..1d6b41e5baf 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 @@ -8,6 +8,7 @@ import android.text.Editable import android.view.LayoutInflater import android.view.inputmethod.EditorInfo import android.widget.TextView +import android.widget.Toast import androidx.annotation.StringRes import androidx.appcompat.app.AlertDialog import androidx.core.view.isGone @@ -16,12 +17,17 @@ import androidx.core.widget.doOnTextChanged import androidx.lifecycle.ViewModelProvider import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView +import coil3.ImageLoader +import coil3.request.ImageRequest +import coil3.request.allowHardware import com.google.android.material.bottomsheet.BottomSheetDialog -import com.lagradost.cloudstream3.AcraApplication.Companion.getActivity +import com.lagradost.cloudstream3.CloudStreamApp.Companion.getActivity +import com.lagradost.cloudstream3.CommonActivity.showToast import com.lagradost.cloudstream3.MainActivity import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.databinding.AccountEditDialogBinding import com.lagradost.cloudstream3.databinding.AccountSelectLinearBinding +import com.lagradost.cloudstream3.databinding.BottomInputDialogBinding import com.lagradost.cloudstream3.databinding.LockPinDialogBinding import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.mvvm.observe @@ -94,6 +100,7 @@ object AccountHelper { binding.accountImage.loadImage(account.image) binding.accountImage.setOnClickListener { // Roll the image forwards once + currentEditAccount = currentEditAccount.copy(customImage = null) currentEditAccount = currentEditAccount.copy(defaultImageIndex = (currentEditAccount.defaultImageIndex + 1) % DataStoreHelper.profileImages.size) binding.accountImage.loadImage(currentEditAccount.image) @@ -156,6 +163,53 @@ object AccountHelper { } canSetPin = true + + binding.editProfilePhotoButton.setOnClickListener({ + val bottomSheetDialog = BottomSheetDialog(context) + val sheetBinding = BottomInputDialogBinding.inflate(LayoutInflater.from(context)) + bottomSheetDialog.setContentView(sheetBinding.root) + bottomSheetDialog.show() + + sheetBinding.apply { + text1.text = context.getString(R.string.edit_profile_image_title) + nginxTextInput.hint = context.getString(R.string.edit_profile_image_hint) + + applyBtt.setOnClickListener({ + val url = sheetBinding.nginxTextInput.text.toString() + if (url.isNotEmpty()) { + val imageLoader = ImageLoader(context) + val request = ImageRequest.Builder(context) + .data(url) + .allowHardware(false) + .listener( + onSuccess = { _, _ -> + currentEditAccount = currentEditAccount.copy(customImage = url) + binding.accountImage.loadImage(url) + showToast( + R.string.edit_profile_image_success, + Toast.LENGTH_SHORT + ) + bottomSheetDialog.dismiss() + }, + onError = { _, _ -> + showToast( + R.string.edit_profile_image_error_invalid, + Toast.LENGTH_SHORT + ) + } + ) + .build() + imageLoader.enqueue(request) + } else { + showToast(R.string.edit_profile_image_error_empty, Toast.LENGTH_SHORT) + } + bottomSheetDialog.dismissSafe() + }) + sheetBinding.cancelBtt.setOnClickListener({ + bottomSheetDialog.dismissSafe() + }) + } + }) } fun showPinInputDialog( @@ -338,7 +392,6 @@ object AccountHelper { activity.observe(viewModel.accounts) { liveAccounts -> recyclerView.adapter = AccountAdapter( - liveAccounts, accountSelectCallback = { account -> viewModel.handleAccountSelect(account, activity) builder.dismissSafe() @@ -346,7 +399,9 @@ 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 a0647219e57..ad323c7d124 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/account/AccountSelectActivity.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/account/AccountSelectActivity.kt @@ -31,20 +31,22 @@ 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.colorFromAttribute +import com.lagradost.cloudstream3.utils.UIHelper.enableEdgeToEdgeCompat +import com.lagradost.cloudstream3.utils.UIHelper.fixSystemBarsPadding 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( @@ -52,8 +54,22 @@ 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() { @@ -89,10 +105,12 @@ 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() @@ -105,12 +123,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) @@ -118,7 +136,6 @@ 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) { @@ -126,8 +143,10 @@ class AccountSelectActivity : FragmentActivity(), BiometricCallback { navigateToMainActivity() } }, - accountDeleteCallback = { accountViewModel.handleAccountDelete(it,this) } - ) + accountDeleteCallback = { accountViewModel.handleAccountDelete(it, this) } + ).apply { + submitList(liveAccounts) + } recyclerView.adapter = adapter @@ -182,16 +201,19 @@ class AccountSelectActivity : FragmentActivity(), BiometricCallback { askBiometricAuth() } + @SuppressLint("UnsafeIntentLaunch") private fun navigateToMainActivity() { - openActivity(MainActivity::class.java) + 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) 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 af62a2b0826..96eaf52a773 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.AcraApplication.Companion.context -import com.lagradost.cloudstream3.AcraApplication.Companion.removeKeys +import com.lagradost.cloudstream3.CloudStreamApp.Companion.context +import com.lagradost.cloudstream3.CloudStreamApp.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 a0e5cabc46a..1b48143a635 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,5 +1,6 @@ package com.lagradost.cloudstream3.ui.download +import android.annotation.SuppressLint import android.text.format.Formatter.formatShortFileSize import android.view.LayoutInflater import android.view.ViewGroup @@ -7,19 +8,18 @@ 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.VideoDownloadHelper +import com.lagradost.cloudstream3.utils.downloader.DownloadObjects const val DOWNLOAD_ACTION_PLAY_FILE = 0 const val DOWNLOAD_ACTION_DELETE_FILE = 1 @@ -27,6 +27,7 @@ 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 @@ -34,22 +35,22 @@ const val DOWNLOAD_ACTION_LOAD_RESULT = 1 sealed class VisualDownloadCached { abstract val currentBytes: Long abstract val totalBytes: Long - abstract val data: VideoDownloadHelper.DownloadCached + abstract val data: DownloadObjects.DownloadCached abstract var isSelected: Boolean data class Child( override val currentBytes: Long, override val totalBytes: Long, - override val data: VideoDownloadHelper.DownloadEpisodeCached, + override val data: DownloadObjects.DownloadEpisodeCached, override var isSelected: Boolean, ) : VisualDownloadCached() data class Header( override val currentBytes: Long, override val totalBytes: Long, - override val data: VideoDownloadHelper.DownloadHeaderCached, + override val data: DownloadObjects.DownloadHeaderCached, override var isSelected: Boolean, - val child: VideoDownloadHelper.DownloadEpisodeCached?, + val child: DownloadObjects.DownloadEpisodeCached?, val currentOngoingDownloads: Int, val totalDownloads: Int, ) : VisualDownloadCached() @@ -57,19 +58,19 @@ sealed class VisualDownloadCached { data class DownloadClickEvent( val action: Int, - val data: VideoDownloadHelper.DownloadEpisodeCached + val data: DownloadObjects.DownloadEpisodeCached ) data class DownloadHeaderClickEvent( val action: Int, - val data: VideoDownloadHelper.DownloadHeaderCached + val data: DownloadObjects.DownloadHeaderCached ) class DownloadAdapter( private val onHeaderClickEvent: (DownloadHeaderClickEvent) -> Unit, private val onItemClickEvent: (DownloadClickEvent) -> Unit, private val onItemSelectionChanged: (Int, Boolean) -> Unit, -) : ListAdapter(DiffCallback()) { +) : NoStateAdapter(DiffCallback()) { private var isMultiDeleteState: Boolean = false @@ -78,112 +79,224 @@ class DownloadAdapter( private const val VIEW_TYPE_CHILD = 1 } - inner class DownloadViewHolder( - private val binding: ViewBinding - ) : RecyclerView.ViewHolder(binding.root) { - fun bind(card: VisualDownloadCached?) { - when (binding) { - is DownloadHeaderEpisodeBinding -> bindHeader(card as? VisualDownloadCached.Header) - is DownloadChildEpisodeBinding -> bindChild(card as? VisualDownloadCached.Child) - } - } + private fun bindHeader(binding: ViewBinding, card: VisualDownloadCached.Header?) { + if (binding !is DownloadHeaderEpisodeBinding || card == null) return - 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) - } + 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 - } + setOnLongClickListener { + toggleIsChecked(deleteCheckbox, data.id) + true } - downloadHeaderTitle.text = data.name - val formattedSize = formatShortFileSize(itemView.context, card.totalBytes) + } + 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 + 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 } } } - private fun DownloadHeaderEpisodeBinding.handleChildDownload( - card: VisualDownloadCached.Header, - formattedSize: String - ) { - card.child ?: return - downloadHeaderGotoChild.isVisible = false + 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 + ) + } + + 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 posDur = getViewPos(card.data.id) - downloadHeaderEpisodeProgress.apply { + 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() + 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 status = downloadButton.getStatus(card.child.id, card.currentBytes, card.totalBytes) + downloadButton.resetView() + val status = downloadButton.getStatus(data.id, card.currentBytes, card.totalBytes) if (status == DownloadStatusTell.IsDone) { // We do this here instead if we are finished downloading // 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) + 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) } - downloadHeaderInfo.text = formattedSize + 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) } @@ -195,199 +308,105 @@ class DownloadAdapter( ) } - downloadButton.setDefaultClickListener(card.child, downloadHeaderInfo, onItemClickEvent) + downloadButton.setDefaultClickListener( + data, + downloadChildEpisodeTextExtra, + onItemClickEvent + ) downloadButton.isVisible = !isMultiDeleteState - if (!isMultiDeleteState) { - episodeHolder.setOnClickListener { - onItemClickEvent.invoke( - DownloadClickEvent( - DOWNLOAD_ACTION_PLAY_FILE, - card.child - ) - ) - } + downloadChildEpisodeText.apply { + text = context.getNameFull(data.name, data.episode, data.season) + isSelected = true // Needed for text repeating } - } - 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 - ) - ) - } - } - } - - 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() - } - } - - 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) - } + downloadChildEpisodeHolder.apply { + when { + isMultiDeleteState -> { + setOnClickListener { + toggleIsChecked(deleteCheckbox, data.id) } + setOnLongClickListener { + toggleIsChecked(deleteCheckbox, data.id) + true + } + } - else -> { - setOnClickListener { - onItemClickEvent.invoke( - DownloadClickEvent( - DOWNLOAD_ACTION_PLAY_FILE, - data - ) + else -> { + setOnClickListener { + onItemClickEvent.invoke( + DownloadClickEvent( + DOWNLOAD_ACTION_PLAY_FILE, + data ) - } + ) } - } - setOnLongClickListener { - toggleIsChecked(deleteCheckbox, data.id) - true + setOnLongClickListener { + onItemSelectionChanged.invoke(data.id, true) + true + } } } + } - if (isMultiDeleteState) { - deleteCheckbox.setOnCheckedChangeListener { _, isChecked -> - onItemSelectionChanged.invoke(data.id, isChecked) - } - } 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 onCreateViewHolder(parent: ViewGroup, viewType: Int): DownloadViewHolder { + override fun onCreateCustomContent(parent: ViewGroup, viewType: Int): ViewHolderState { 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 DownloadViewHolder(binding) + return ViewHolderState(binding) } - override fun onBindViewHolder(holder: DownloadViewHolder, position: Int) { - holder.bind(getItem(position)) + 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 getItemViewType(position: Int): Int { - return when (getItem(position)) { + override fun customContentViewType(item: VisualDownloadCached): Int { + return when (item) { 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 - 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) - } - } + notifyDataSetChanged() // This is shit, but what can you do? } 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 83e0d016788..dae70ebd7ad 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadButtonSetup.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadButtonSetup.kt @@ -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.AcraApplication.Companion.getKey -import com.lagradost.cloudstream3.AcraApplication.Companion.getKeys +import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey +import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKeys import com.lagradost.cloudstream3.CommonActivity.activity import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.mvvm.logError @@ -18,8 +18,9 @@ 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.VideoDownloadHelper -import com.lagradost.cloudstream3.utils.VideoDownloadManager +import com.lagradost.cloudstream3.utils.downloader.DownloadObjects +import com.lagradost.cloudstream3.utils.downloader.DownloadQueueManager +import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager import kotlinx.coroutines.MainScope object DownloadButtonSetup { @@ -82,7 +83,7 @@ object DownloadButtonSetup { } else { val pkg = VideoDownloadManager.getDownloadResumePackage(ctx, id) if (pkg != null) { - VideoDownloadManager.downloadFromResumeUsingWorker(ctx, pkg) + DownloadQueueManager.addToQueue(pkg.toWrapper()) } else { VideoDownloadManager.downloadEvent.invoke( Pair(click.data.id, VideoDownloadManager.DownloadActionType.Resume) @@ -95,7 +96,7 @@ object DownloadButtonSetup { DOWNLOAD_ACTION_LONG_CLICK -> { activity?.let { act -> val length = - VideoDownloadManager.getDownloadFileInfoAndUpdateSettings( + VideoDownloadManager.getDownloadFileInfo( act, click.data.id )?.fileLength @@ -110,24 +111,31 @@ 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 @@ -141,7 +149,7 @@ object DownloadButtonSetup { uri = Uri.EMPTY, id = it.id, parentId = it.parentId, - name = act.getString(R.string.downloaded_file), + name = it.name ?: act.getString(R.string.downloaded_file), season = it.season, episode = it.episode, headerName = parent.name, @@ -154,7 +162,8 @@ object DownloadButtonSetup { } act.navigate( R.id.global_to_navigation_player, GeneratorPlayer.newInstance( - DownloadFileGenerator(items).apply { goto(items.indexOfFirst { it.id == click.data.id }) } + DownloadFileGenerator(items), + items.indexOfFirst { it.id == click.data.id } ) ) } diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadChildFragment.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadChildFragment.kt index 1f5b9e337fa..d44ea0020b5 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,32 +1,35 @@ 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 android.view.ViewGroup +import androidx.core.view.isGone import androidx.core.view.isVisible -import androidx.fragment.app.Fragment -import androidx.lifecycle.ViewModelProvider +import androidx.fragment.app.activityViewModels 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.fixPaddingStatusbar +import com.lagradost.cloudstream3.utils.UIHelper.fixSystemBarsPadding import com.lagradost.cloudstream3.utils.UIHelper.setAppBarNoScrollFlagsOnTV -class DownloadChildFragment : Fragment() { - private lateinit var downloadsViewModel: DownloadViewModel - private var binding: FragmentChildDownloadsBinding? = null +class DownloadChildFragment : BaseFragment( + BaseFragment.BindingCreator.Inflate(FragmentChildDownloadsBinding::inflate) +) { + + private val downloadViewModel: DownloadViewModel by activityViewModels() companion object { fun newInstance(headerName: String, folder: String): Bundle { @@ -39,99 +42,104 @@ class DownloadChildFragment : Fragment() { override fun onDestroyView() { activity?.detachBackPressedCallback("Downloads") - binding = null + downloadViewModel.clearChildren() super.onDestroyView() } - 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 fixLayout(view: View) { + fixSystemBarsPadding( + view, + padBottom = isLandscape(), + padLeft = isLayout(TV or EMULATOR) + ) } - 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() - + override fun onBindingCreated(binding: FragmentChildDownloadsBinding) { val folder = arguments?.getString("folder") val name = arguments?.getString("name") if (folder == null) { - activity?.onBackPressedDispatcher?.onBackPressed() + dispatchBackPressed() return } - binding?.downloadChildToolbar?.apply { + context?.let { downloadViewModel.updateChildList(it, folder) } + + binding.downloadChildToolbar.apply { title = name if (isLayout(PHONE or EMULATOR)) { setNavigationIcon(R.drawable.ic_baseline_arrow_back_24) setNavigationOnClickListener { - activity?.onBackPressedDispatcher?.onBackPressed() + dispatchBackPressed() } } setAppBarNoScrollFlagsOnTV() } - binding?.downloadDeleteAppbar?.setAppBarNoScrollFlagsOnTV() + binding.downloadDeleteAppbar.setAppBarNoScrollFlagsOnTV() - observe(downloadsViewModel.childCards) { - if (it.isEmpty()) { - activity?.onBackPressedDispatcher?.onBackPressed() - return@observe + 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) + } } + } - (binding?.downloadChildList?.adapter as? DownloadAdapter)?.submitList(it) + observe(downloadViewModel.selectedBytes) { + updateDeleteButton(downloadViewModel.selectedItemIds.value?.count() ?: 0, it) } - observe(downloadsViewModel.isMultiDeleteState) { isMultiDeleteState -> - val adapter = binding?.downloadChildList?.adapter as? DownloadAdapter + + + 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 adapter?.setIsMultiDeleteState(isMultiDeleteState) - binding?.downloadDeleteAppbar?.isVisible = isMultiDeleteState - if (!isMultiDeleteState) { + binding.downloadDeleteAppbar.isVisible = isMultiDeleteState + binding.downloadChildToolbar.isGone = isMultiDeleteState + + if (selection == null) { activity?.detachBackPressedCallback("Downloads") - downloadsViewModel.clearSelectedItems() - binding?.downloadChildToolbar?.isVisible = true + return@observeNullable } - } - observe(downloadsViewModel.selectedBytes) { - updateDeleteButton(downloadsViewModel.selectedItemIds.value?.count() ?: 0, it) - } - observe(downloadsViewModel.selectedItemIds) { - handleSelectedChange(it) - updateDeleteButton(it.count(), downloadsViewModel.selectedBytes.value ?: 0L) + activity?.attachBackPressedCallback("Downloads") { + downloadViewModel.cancelSelection() + } + + 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 = downloadsViewModel.isAllSelected() + val allSelected = downloadViewModel.isAllChildrenSelected() 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( @@ -139,18 +147,18 @@ class DownloadChildFragment : Fragment() { { click -> if (click.action == DOWNLOAD_ACTION_DELETE_FILE) { context?.let { ctx -> - downloadsViewModel.handleSingleDelete(ctx, click.data.id) + downloadViewModel.handleSingleDelete(ctx, click.data.id) } } else handleDownloadClick(click) }, { itemId, isChecked -> if (isChecked) { - downloadsViewModel.addSelected(itemId) - } else downloadsViewModel.removeSelected(itemId) + downloadViewModel.addSelected(itemId) + } else downloadViewModel.removeSelected(itemId) } ) - binding?.downloadChildList?.apply { + binding.downloadChildList.apply { setHasFixedSize(true) setItemViewCacheSize(20) this.adapter = adapter @@ -160,43 +168,6 @@ class DownloadChildFragment : Fragment() { 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 7c1c5b76965..abc432ef959 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadFragment.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadFragment.kt @@ -7,13 +7,8 @@ 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 @@ -22,23 +17,28 @@ import androidx.annotation.StringRes import androidx.core.view.isGone import androidx.core.view.isVisible import androidx.core.widget.doOnTextChanged -import androidx.fragment.app.Fragment -import androidx.lifecycle.ViewModelProvider +import androidx.fragment.app.activityViewModels 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.fixPaddingStatusbar +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.setAppBarNoScrollFlagsOnTV @@ -54,9 +54,12 @@ import java.net.URI const val DOWNLOAD_NAVIGATE_TO = "downloadpage" -class DownloadFragment : Fragment() { - private lateinit var downloadsViewModel: DownloadViewModel - private var binding: FragmentDownloadsBinding? = null +class DownloadFragment : BaseFragment( + BaseFragment.BindingCreator.Inflate(FragmentDownloadsBinding::inflate) +) { + + private val downloadViewModel: DownloadViewModel by activityViewModels() + private val downloadQueueViewModel: DownloadQueueViewModel by activityViewModels() private fun View.setLayoutWidth(weight: Long) { val param = LinearLayout.LayoutParams( @@ -69,117 +72,135 @@ class DownloadFragment : Fragment() { override fun onDestroyView() { activity?.detachBackPressedCallback("Downloads") - binding = null super.onDestroyView() } - 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 fixLayout(view: View) { + fixSystemBarsPadding( + view, + padBottom = isLandscape(), + padLeft = isLayout(TV or EMULATOR) + ) } - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) + override fun onBindingCreated(binding: FragmentDownloadsBinding) { hideKeyboard() - binding?.downloadStorageAppbar?.setAppBarNoScrollFlagsOnTV() - binding?.downloadDeleteAppbar?.setAppBarNoScrollFlagsOnTV() - - /** - * 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) - } + 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 + } - /** - * 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() + is Resource.Loading -> { + binding.downloadList.isVisible = false + binding.downloadLoading.isVisible = true + } + + is Resource.Failure -> { + binding.downloadList.isVisible = true + binding.downloadLoading.isVisible = false + } + } } - observe(downloadsViewModel.availableBytes) { + + observe(downloadViewModel.availableBytes) { updateStorageInfo( - view.context, + binding.root.context, it, R.string.free_storage, - binding?.downloadFreeTxt, - binding?.downloadFree + binding.downloadFreeTxt, + binding.downloadFree ) } - observe(downloadsViewModel.usedBytes) { + observe(downloadViewModel.usedBytes) { updateStorageInfo( - view.context, + binding.root.context, it, R.string.used_storage, - binding?.downloadUsedTxt, - binding?.downloadUsed + binding.downloadUsedTxt, + binding.downloadUsed ) - // Prevent race condition and make sure - // we don't display it early - if ( - downloadsViewModel.isMultiDeleteState.value == null || - downloadsViewModel.isMultiDeleteState.value == false - ) binding?.downloadStorageAppbar?.isVisible = it > 0 + val hasBytes = it > 0 + if (hasBytes) { + binding.downloadLoadingBytes.stopShimmer() + } else binding.downloadLoadingBytes.startShimmer() + + binding.downloadBytesBar.isVisible = hasBytes + binding.downloadLoadingBytes.isGone = hasBytes } - observe(downloadsViewModel.downloadBytes) { + observe(downloadViewModel.downloadBytes) { updateStorageInfo( - view.context, + binding.root.context, it, R.string.app_storage, - binding?.downloadAppTxt, - binding?.downloadApp + binding.downloadAppTxt, + binding.downloadApp ) } - observe(downloadsViewModel.selectedBytes) { - updateDeleteButton(downloadsViewModel.selectedItemIds.value?.count() ?: 0, it) + 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.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?.downloadStorageAppbar?.isVisible = true + + 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.selectedItemIds) { - handleSelectedChange(it) - updateDeleteButton(it.count(), downloadsViewModel.selectedBytes.value ?: 0L) - binding?.btnDelete?.isVisible = it.isNotEmpty() - binding?.selectItemsText?.isVisible = it.isEmpty() + 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 + + if (selection == null) { + activity?.detachBackPressedCallback("Downloads") + return@observeNullable + } + activity?.attachBackPressedCallback("Downloads") { + downloadViewModel.cancelSelection() + } + updateDeleteButton(selection.count(), downloadViewModel.selectedBytes.value ?: 0L) - val allSelected = downloadsViewModel.isAllSelected() + binding.btnDelete.isVisible = selection.isNotEmpty() + binding.selectItemsText.isVisible = selection.isEmpty() + + val allSelected = downloadViewModel.isAllHeadersSelected() 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( @@ -187,29 +208,29 @@ class DownloadFragment : Fragment() { { click -> if (click.action == DOWNLOAD_ACTION_DELETE_FILE) { context?.let { ctx -> - downloadsViewModel.handleSingleDelete(ctx, click.data.id) + downloadViewModel.handleSingleDelete(ctx, click.data.id) } } else handleDownloadClick(click) }, { itemId, isChecked -> if (isChecked) { - downloadsViewModel.addSelected(itemId) - } else downloadsViewModel.removeSelected(itemId) + downloadViewModel.addSelected(itemId) + } else downloadViewModel.removeSelected(itemId) } ) - binding?.downloadList?.apply { + binding.downloadList.apply { setHasFixedSize(true) setItemViewCacheSize(20) this.adapter = adapter setLinearListLayout( isHorizontal = false, nextRight = FOCUS_SELF, - nextDown = FOCUS_SELF, + nextDown = R.id.download_queue_button, ) } - binding?.apply { + binding.apply { openLocalVideoButton.apply { isGone = isLayout(TV) setOnClickListener { openLocalVideo() } @@ -218,16 +239,25 @@ class DownloadFragment : Fragment() { isGone = isLayout(TV) 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) + + downloadStreamButtonTv.setOnClickListener { showStreamInputDialog(it.context) } + steamImageviewHolder.isVisible = isLayout(TV) } if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - binding?.downloadList?.setOnScrollChangeListener { _, _, scrollY, _, oldScrollY -> + binding.downloadList.setOnScrollChangeListener { _, _, scrollY, _, oldScrollY -> handleScroll(scrollY - oldScrollY) } } - context?.let { downloadsViewModel.updateHeaderList(it) } - fixPaddingStatusbar(binding?.downloadRoot) + context?.let { downloadViewModel.updateHeaderList(it) } } private fun handleItemClick(click: DownloadHeaderClickEvent) { @@ -249,40 +279,6 @@ class DownloadFragment : Fragment() { } } - private fun handleSelectedChange(selected: MutableSet) { - if (selected.isNotEmpty()) { - binding?.downloadDeleteAppbar?.isVisible = true - binding?.downloadStorageAppbar?.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 = @@ -353,7 +349,8 @@ class DownloadFragment : Fragment() { listOf(BasicLink(url)), extract = true, refererUrl = referer, - ) + id = url.hashCode() + ), 0 ) ) dialog.dismissSafe(activity) @@ -384,7 +381,7 @@ class DownloadFragment : Fragment() { 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 137f1355e25..0d35d5670f5 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,91 +5,119 @@ 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.VideoDownloadHelper -import com.lagradost.cloudstream3.utils.VideoDownloadManager.deleteFilesAndUpdateSettings -import com.lagradost.cloudstream3.utils.VideoDownloadManager.getDownloadFileInfoAndUpdateSettings +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 kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext class DownloadViewModel : ViewModel() { + companion object { + const val TAG = "DownloadViewModel" + } - private val _headerCards = MutableLiveData>() - val headerCards: LiveData> = _headerCards + private val _headerCards = + ResourceLiveData>(Resource.Loading()) + val headerCards: LiveData>> = _headerCards - private val _childCards = MutableLiveData>() - val childCards: LiveData> = _childCards + private val _childCards = ResourceLiveData>(Resource.Loading()) + val childCards: LiveData>> = _childCards - private val _usedBytes = MutableLiveData() + private val _usedBytes = ConsistentLiveData() val usedBytes: LiveData = _usedBytes - private val _availableBytes = MutableLiveData() + private val _availableBytes = ConsistentLiveData() val availableBytes: LiveData = _availableBytes - private val _downloadBytes = MutableLiveData() + private val _downloadBytes = ConsistentLiveData() val downloadBytes: LiveData = _downloadBytes - private val _selectedBytes = MutableLiveData(0) + private val _selectedBytes = ConsistentLiveData(0) val selectedBytes: LiveData = _selectedBytes - private val _isMultiDeleteState = MutableLiveData(false) - val isMultiDeleteState: LiveData = _isMultiDeleteState - - private val _selectedItemIds = MutableLiveData>(mutableSetOf()) - val selectedItemIds: LiveData> = _selectedItemIds + private val _selectedItemIds = ConsistentLiveData?>(null) + val selectedItemIds: LiveData?> = _selectedItemIds - private var previousVisual: List? = null - fun setIsMultiDeleteState(value: Boolean) { - _isMultiDeleteState.postValue(value) + fun cancelSelection() { + updateSelectedItems { null } } fun addSelected(itemId: Int) { - updateSelectedItems { it.add(itemId) } + updateSelectedItems { it?.plus(itemId) ?: setOf(itemId) } } fun removeSelected(itemId: Int) { - updateSelectedItems { it.remove(itemId) } + updateSelectedItems { it?.minus(itemId) ?: emptySet() } } - fun selectAllItems() { - val items = headerCards.value.orEmpty() + childCards.value.orEmpty() - updateSelectedItems { it.addAll(items.map { item -> item.data.id }) } + 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 clearSelectedItems() { // We need this to be done immediately // so we can't use postValue - _selectedItemIds.value = mutableSetOf() - updateSelectedItems { it.clear() } + updateSelectedItems { emptySet() } + } + + fun isAllChildrenSelected(): Boolean { + val currentSelected = selectedItemIds.value ?: return false + val children = _childCards.success.orEmpty() + return currentSelected.size == children.size && children.all { it.data.id in currentSelected } } - fun isAllSelected(): Boolean { + fun isAllHeadersSelected(): Boolean { val currentSelected = selectedItemIds.value ?: return false - val items = headerCards.value.orEmpty() + childCards.value.orEmpty() - return items.count() == currentSelected.count() && items.all { it.data.id in currentSelected } + val headers = _headerCards.success.orEmpty() + return currentSelected.size == headers.size && headers.all { it.data.id in currentSelected } } - private fun updateSelectedItems(action: (MutableSet) -> Unit) { - val currentSelected = selectedItemIds.value ?: mutableSetOf() - action(currentSelected) + private fun updateSelectedItems(action: (Set?) -> Set?) { + val currentSelected = action(selectedItemIds.value) _selectedItemIds.postValue(currentSelected) + postHeaders() + postChildren() updateSelectedBytes() - updateSelectedCards() } private fun updateSelectedBytes() = viewModelScope.launchSafe { @@ -98,61 +126,173 @@ class DownloadViewModel : ViewModel() { _selectedBytes.postValue(totalSelectedBytes) } - private fun updateSelectedCards() = viewModelScope.launchSafe { - val currentSelected = selectedItemIds.value ?: return@launchSafe - headerCards.value?.let { headers -> - headers.forEach { header -> - header.isSelected = header.data.id in currentSelected + 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.postValue(headers) } + } - childCards.value?.let { children -> - children.forEach { child -> - child.isSelected = child.data.id in currentSelected + 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.postValue(children) } } fun updateHeaderList(context: Context) = viewModelScope.launchSafe { - val visual = withContext(Dispatchers.IO) { + // Do not push loading as it interrupts the UI + //_headerCards.postValue(Resource.Loading()) + + val visual = ioWork { val children = context.getKeys(DOWNLOAD_EPISODE_CACHE) - .mapNotNull { context.getKey(it) } + .mapNotNull { context.getKey(it) } .distinctBy { it.id } // Remove duplicates - val (totalBytesUsedByChild, currentBytesUsedByChild, totalDownloads) = + val isCurrentlyDownloading = + DownloadQueueService.isRunning || downloadInstances.value.isNotEmpty() || DownloadQueueManager.queue.value.isNotEmpty() + + val downloadStats = calculateDownloadStats(context, children) val cached = context.getKeys(DOWNLOAD_HEADER_CACHE) - .mapNotNull { context.getKey(it) } + .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) createVisualDownloadList( - context, cached, totalBytesUsedByChild, currentBytesUsedByChild, totalDownloads + context, + cached, + downloadStats.totalBytesUsedByChild, + downloadStats.currentBytesUsedByChild, + downloadStats.totalDownloads ) } - if (visual != previousVisual) { - previousVisual = visual - updateStorageStats(visual) - _headerCards.postValue(visual) - } + updateStorageStats(visual) + postHeaders(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 - ): Triple, Map, Map> { + children: List + ): DownloadStats { // parentId : bytes val totalBytesUsedByChild = mutableMapOf() // parentId : bytes val currentBytesUsedByChild = mutableMapOf() // parentId : downloadsCount val totalDownloads = mutableMapOf() + val redundantDownloads = mutableListOf>() children.forEach { child -> - val childFile = getDownloadFileInfoAndUpdateSettings(context, child.id) ?: return@forEach + 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 + } if (childFile.fileLength <= 1) return@forEach val len = childFile.totalBytes @@ -162,12 +302,17 @@ class DownloadViewModel : ViewModel() { currentBytesUsedByChild.merge(child.parentId, flen, Long::plus) totalDownloads.merge(child.parentId, 1, Int::plus) } - return Triple(totalBytesUsedByChild, currentBytesUsedByChild, totalDownloads) + return DownloadStats( + totalBytesUsedByChild, + currentBytesUsedByChild, + totalDownloads, + redundantDownloads + ) } private fun createVisualDownloadList( context: Context, - cached: List, + cached: List, totalBytesUsedByChild: Map, currentBytesUsedByChild: Map, totalDownloads: Map @@ -176,13 +321,17 @@ 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, @@ -208,12 +357,14 @@ 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 = getDownloadFileInfoAndUpdateSettings(context, it.id) ?: return@mapNotNull null + val info = getDownloadFileInfo(context, it.id) ?: return@mapNotNull null VisualDownloadCached.Child( currentBytes = info.fileLength, totalBytes = info.totalBytes, @@ -221,24 +372,21 @@ 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 } - )) - - if (previousVisual != visual) { - previousVisual = visual - _childCards.postValue(visual) - } + }.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) } private fun removeItems(idsToRemove: Set) = viewModelScope.launchSafe { - 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) + _selectedItemIds.postValue(null) + postHeaders(_headerCards.success?.filter { it.data.id !in idsToRemove }) + postChildren(_childCards.success?.filter { it.data.id !in idsToRemove }) } private fun updateStorageStats(visual: List) { @@ -292,7 +440,7 @@ class DownloadViewModel : ViewModel() { if (item.data.type.isEpisodeBased()) { val episodes = context.getKeys(DOWNLOAD_EPISODE_CACHE) .mapNotNull { - context.getKey( + context.getKey( it ) } @@ -316,7 +464,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() ) @@ -345,16 +493,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) @@ -383,7 +531,6 @@ 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 @@ -414,8 +561,8 @@ class DownloadViewModel : ViewModel() { } private fun getSelectedItemsData(): List? { - val headers = headerCards.value.orEmpty() - val children = childCards.value.orEmpty() + val headers = _headerCards.success.orEmpty() + val children = _childCards.success.orEmpty() return selectedItemIds.value?.mapNotNull { id -> headers.find { it.data.id == id } ?: children.find { it.data.id == id } @@ -423,10 +570,11 @@ class DownloadViewModel : ViewModel() { } private fun getItemDataFromId(itemId: Int): List { - val headers = headerCards.value.orEmpty() - val children = childCards.value.orEmpty() + return (_headerCards.success.orEmpty() + _childCards.success.orEmpty()).filter { it.data.id == itemId } + } - return (headers + children).filter { it.data.id == itemId } + fun clearChildren() { + _childCards.postValue(Resource.Loading()) } 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 908e3a80abc..382a770cd28 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/download/button/BaseFetchButton.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/download/button/BaseFetchButton.kt @@ -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.VideoDownloadManager +import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager typealias DownloadStatusTell = VideoDownloadManager.DownloadType @@ -62,6 +62,7 @@ abstract class BaseFetchButton(context: Context, attributeSet: AttributeSet) : open fun resetViewData() { // lastRequest = null + progressText = null isZeroBytes = true doSetProgress = true persistentId = null @@ -75,10 +76,10 @@ abstract class BaseFetchButton(context: Context, attributeSet: AttributeSet) : currentMetaData.id = id if (!doSetProgress) return + val appContext = context.applicationContext ioSafe { - val savedData = VideoDownloadManager.getDownloadFileInfoAndUpdateSettings(context, id) - + val savedData = VideoDownloadManager.getDownloadFileInfo(appContext, id) mainWork { if (savedData != null) { val downloadedBytes = savedData.fileLength @@ -86,7 +87,7 @@ abstract class BaseFetchButton(context: Context, attributeSet: AttributeSet) : setProgress(downloadedBytes, totalBytes) applyMetaData(id, downloadedBytes, totalBytes) - } else run { resetView() } + } } } } @@ -215,4 +216,4 @@ abstract class BaseFetchButton(context: Context, attributeSet: AttributeSet) : * Get a clean slate again, might be useful in recyclerview? * */ abstract fun resetView() -} \ No newline at end of file +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/download/button/DownloadButton.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/download/button/DownloadButton.kt index 20a4446114b..91c5dd72ce3 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.VideoDownloadHelper +import com.lagradost.cloudstream3.utils.downloader.DownloadObjects class DownloadButton(context: Context, attributeSet: AttributeSet) : PieFetchButton(context, attributeSet) { @@ -18,6 +18,7 @@ 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?) { @@ -35,7 +36,7 @@ class DownloadButton(context: Context, attributeSet: AttributeSet) : } override fun setDefaultClickListener( - card: VideoDownloadHelper.DownloadEpisodeCached, + card: DownloadObjects.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 29c2daa2cf4..f6f8a5ff846 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/download/button/PieFetchButton.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/download/button/PieFetchButton.kt @@ -10,11 +10,14 @@ 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.AcraApplication.Companion.removeKey +import com.lagradost.cloudstream3.CloudStreamApp.Companion.removeKey import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.mvvm.logError +import com.lagradost.cloudstream3.services.DownloadQueueService.Companion.downloadInstances +import com.lagradost.cloudstream3.ui.download.DOWNLOAD_ACTION_CANCEL_PENDING import com.lagradost.cloudstream3.ui.download.DOWNLOAD_ACTION_DELETE_FILE import com.lagradost.cloudstream3.ui.download.DOWNLOAD_ACTION_DOWNLOAD import com.lagradost.cloudstream3.ui.download.DOWNLOAD_ACTION_LONG_CLICK @@ -23,9 +26,10 @@ 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.VideoDownloadHelper -import com.lagradost.cloudstream3.utils.VideoDownloadManager -import com.lagradost.cloudstream3.utils.VideoDownloadManager.KEY_RESUME_PACKAGES +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 open class PieFetchButton(context: Context, attributeSet: AttributeSet) : BaseFetchButton(context, attributeSet) { @@ -63,7 +67,7 @@ open class PieFetchButton(context: Context, attributeSet: AttributeSet) : open fun onInflate() {} init { - context.obtainStyledAttributes(attributeSet, R.styleable.PieFetchButton, 0, 0).apply { + context.withStyledAttributes(attributeSet, R.styleable.PieFetchButton, 0, 0) { try { inflate( overrideLayout ?: getResourceId( @@ -72,6 +76,7 @@ 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" @@ -79,11 +84,6 @@ 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,16 +92,13 @@ 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 @@ -129,19 +126,29 @@ 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() } - resetView() + + 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() 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 @@ -162,16 +169,31 @@ open class PieFetchButton(context: Context, attributeSet: AttributeSet) : }*/ protected fun setDefaultClickListener( - view: View, textView: TextView?, card: VideoDownloadHelper.DownloadEpisodeCached, + view: View, textView: TextView?, card: DownloadObjects.DownloadEpisodeCached, callback: (DownloadClickEvent) -> Unit ) { this.progressText = textView this.setPersistentId(card.id) view.setOnClickListener { if (isZeroBytes) { - removeKey(KEY_RESUME_PACKAGES, card.id.toString()) - callback(DownloadClickEvent(DOWNLOAD_ACTION_DOWNLOAD, card)) - // callback.invoke(DownloadClickEvent(DOWNLOAD_ACTION_DOWNLOAD, data)) + 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)) + } } else { val list = arrayListOf( Pair(DOWNLOAD_ACTION_PLAY_FILE, R.string.popup_play_file), @@ -212,7 +234,7 @@ open class PieFetchButton(context: Context, attributeSet: AttributeSet) : } open fun setDefaultClickListener( - card: VideoDownloadHelper.DownloadEpisodeCached, + card: DownloadObjects.DownloadEpisodeCached, textView: TextView?, callback: (DownloadClickEvent) -> Unit ) { @@ -282,8 +304,8 @@ open class PieFetchButton(context: Context, attributeSet: AttributeSet) : override fun setStatus(status: DownloadStatusTell?) { currentStatus = status - // Runs on the main thread, but also instant if it already is - if (Looper.myLooper() == Looper.getMainLooper()) { + // Runs on the main thread, but also instant if it already is. + if (Looper.getMainLooper().isCurrentThread) { try { setStatusInternal(status) } catch (t: Throwable) { diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/download/queue/DownloadQueueAdapter.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/download/queue/DownloadQueueAdapter.kt new file mode 100644 index 00000000000..877fcfea857 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/download/queue/DownloadQueueAdapter.kt @@ -0,0 +1,274 @@ +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 new file mode 100644 index 00000000000..071d8913d2a --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/download/queue/DownloadQueueFragment.kt @@ -0,0 +1,79 @@ +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 new file mode 100644 index 00000000000..fc384cb4ebf --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/download/queue/DownloadQueueViewModel.kt @@ -0,0 +1,43 @@ +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 ae22afdb223..43f6d19ff6a 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,9 +1,10 @@ package com.lagradost.cloudstream3.ui.home +import android.content.Context import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import androidx.fragment.app.Fragment +import android.widget.FrameLayout import androidx.preference.PreferenceManager import androidx.viewbinding.ViewBinding import com.lagradost.cloudstream3.R @@ -13,7 +14,9 @@ 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 @@ -41,13 +44,11 @@ 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, @@ -67,20 +68,32 @@ 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 } - - if (nextFocusUp != null) { - nextFocusUpId = nextFocusUp + nextFocusUp?.let { + nextFocusUpId = it } - - if (nextFocusDown != null) { - nextFocusDownId = nextFocusDown + nextFocusDown?.let { + nextFocusDownId = it } setOnClickListener { v -> @@ -90,16 +103,49 @@ 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, - protected val nextFocusUp: Int? = null, - protected val nextFocusDown: Int? = null, - private val clickCallback: (SearchClickCallback) -> Unit, + var nextFocusUp: Int? = null, + var nextFocusDown: Int? = null, + var clickCallback: (SearchClickCallback) -> Unit, ) : - BaseAdapter(fragment, id) { - var isHorizontal: Boolean = false + BaseAdapter( + id, diffCallback = BaseDiffCallback( + itemSame = { a, b -> + a.url == b.url && a.name == b.name + }, + contentSame = { a, b -> + a == b + }) + ) { var hasNext: Boolean = false + 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 override fun onCreateContent(parent: ViewGroup): ViewHolderState { val expanded = parent.context.isBottomLayout() @@ -112,52 +158,43 @@ open class HomeChildItemAdapter( return HomeScrollViewHolderState(binding) } - 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() + 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) { when (val binding = holder.view) { is HomeResultGridBinding -> { - binding.backgroundCard.apply { - - layoutParams = - layoutParams.apply { - width = if (!isHorizontal) { - min - } else { - max - } - height = if (!isHorizontal) { - max - } else { - min - } - } - } + updateLayoutParms(binding.backgroundCard, setWidth, setHeight) } is HomeResultGridExpandedBinding -> { - binding.backgroundCard.apply { - - layoutParams = - layoutParams.apply { - width = if (!isHorizontal) { - min - } else { - max - } - height = if (!isHorizontal) { - max - } else { - min - } - } - } + updateLayoutParms(binding.backgroundCard, setWidth, setHeight) 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 08c4359e138..b68ef59625c 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeFragment.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeFragment.kt @@ -5,26 +5,36 @@ 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.* +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 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.cloudstream3.* +import com.lagradost.api.Log 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 @@ -35,13 +45,18 @@ 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.utils.txt -import com.lagradost.cloudstream3.ui.search.* +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.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 @@ -51,22 +66,30 @@ 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.Event +import com.lagradost.cloudstream3.utils.EmptyEvent 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 java.util.* +import com.lagradost.cloudstream3.utils.UIHelper.toPx +private const val TAG = "HomeFragment" -class HomeFragment : Fragment() { +class HomeFragment : BaseFragment( + BindingCreator.Bind(FragmentHomeBinding::bind) +) { companion object { - val configEvent = Event() + // Used for configuration changed events to fix any popups that are not attached to a fragment + val configEvent = EmptyEvent() var currentSpan = 1 - val listHomepageItems = mutableListOf() private val errorProfilePics = listOf( R.drawable.monke_benene, @@ -95,6 +118,7 @@ class HomeFragment : Fragment() { //} // 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, @@ -176,16 +200,17 @@ class HomeFragment : Fragment() { // Span settings - binding.homeExpandedRecycler.spanCount = currentSpan - + binding.homeExpandedRecycler.spanCount = context.getSpanCount(item.isHorizontalImages) + binding.homeExpandedRecycler.setRecycledViewPool(SearchAdapter.sharedPool) binding.homeExpandedRecycler.adapter = - SearchAdapter(item.list.toMutableList(), binding.homeExpandedRecycler) { callback -> + SearchAdapter(binding.homeExpandedRecycler,item.isHorizontalImages) { 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 } @@ -209,7 +234,7 @@ class HomeFragment : Fragment() { expandCallback?.invoke(name)?.let { newExpand -> (recyclerView.adapter as? SearchAdapter?)?.apply { hasNext = newExpand.hasNext - updateList(newExpand.list.list) + submitList(newExpand.list.list) } } } @@ -217,9 +242,12 @@ class HomeFragment : Fragment() { } }) - val spanListener = { span: Int -> - binding.homeExpandedRecycler.spanCount = span - //(recycle.adapter as SearchAdapter).notifyDataSetChanged() + 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() } configEvent += spanListener @@ -289,7 +317,7 @@ class HomeFragment : Fragment() { val pairList = getPairList(header) for ((button, types) in pairList) { button?.isChecked = - button?.isVisible == true && selectedTypes.any { types.contains(it) } + button.isVisible && selectedTypes.any { types.contains(it) } } } @@ -379,8 +407,31 @@ class HomeFragment : Fragment() { dialog.dismissSafe() } + var pinnedphashset = DataStoreHelper.pinnedProviders.toHashSet() + val listView = dialog.findViewById(R.id.listview1) - val arrayAdapter = ArrayAdapter(this, R.layout.sort_bottom_single_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) + 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) + pinIcon.visibility = if (isPinned) View.VISIBLE else View.GONE + return view + } + } listView?.adapter = arrayAdapter listView?.choiceMode = AbsListView.CHOICE_MODE_SINGLE @@ -388,21 +439,39 @@ class HomeFragment : Fragment() { if (currentValidApis.isNotEmpty()) { currentApiName = currentValidApis[i].name //to switch to apply simply remove this - currentApiName?.let(callback) + currentApiName.let(callback) dialog.dismissSafe() } } fun updateList() { DataStoreHelper.homePreference = preSelectedTypes - + val pinnedp = DataStoreHelper.pinnedProviders.toList() + pinnedphashset = pinnedp.toHashSet() arrayAdapter.clear() - currentValidApis = validAPIs.filter { api -> - api.hasMainPage && api.supportedTypes.any { - preSelectedTypes.contains(it) + val sortedApis = validAPIs + .filter { + it.hasMainPage && (pinnedphashset.contains(it.name) || it.supportedTypes.any( + preSelectedTypes::contains + )) } - }.sortedBy { it.name.lowercase() }.toMutableList() - currentValidApis.addAll(0, validAPIs.subList(0, 2)) + .sortedBy { it.name.lowercase() } + + val sortedApiMap = LinkedHashMap().apply { + sortedApis.forEach { put(it.name, it) } + } + + val pinnedApis = pinnedp.asReversed().mapNotNull { name -> + sortedApiMap[name] + } + + val remainingApis = sortedApis.filterNot { pinnedphashset.contains(it.name) } + + currentValidApis = mutableListOf().apply { + addAll(validAPIs.take(2)) + addAll(pinnedApis) + addAll(remainingApis) + } val names = currentValidApis.map { if (isMultiLang) "${getFlagFromIso(it.lang)?.plus(" ") ?: ""}${it.name}" else it.name } @@ -411,6 +480,21 @@ class HomeFragment : Fragment() { arrayAdapter.addAll(names) arrayAdapter.notifyDataSetChanged() } + // pin provider on hold + listView?.setOnItemLongClickListener { _, _, i, _ -> + if (currentValidApis.isNotEmpty() && i > 1) { + val pinnedp = DataStoreHelper.pinnedProviders.toMutableList() + val thisapi = currentValidApis[i].name + if (pinnedp.contains(thisapi)) { + pinnedp.remove(thisapi) + } else { + pinnedp.add(thisapi) + } + DataStoreHelper.pinnedProviders = pinnedp.toTypedArray() + updateList() + } + true + } bindChips( binding.tvtypesChipsScroll.tvtypesChips, @@ -427,47 +511,71 @@ class HomeFragment : Fragment() { } 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 + } + + try { + val existingId = TvChannelUtils.getChannelId(ctx, getString(R.string.app_name)) + if (existingId != null) { + Log.d(TAG, "Channel ID: $existingId") - var binding: FragmentHomeBinding? = null + 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() - 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 + return super.onCreateView(inflater, container, savedInstanceState) } 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) @@ -481,55 +589,129 @@ class HomeFragment : Fragment() { }*/ } - 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 - @SuppressLint("SetTextI18n") - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - fixGrid() + 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) + } + } - binding?.apply { + 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 { //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( - fragment = this@HomeFragment, - homeViewModel, + homeViewModel, accountViewModel ) + 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 (dy > 0) { //check for scroll down - homeApiFab.shrink() // hide - homeRandom.shrink() - } else if (dy < -5) { - if (isLayout(PHONE)) { - homeApiFab.extend() // show - homeRandom.extend() + 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 } } super.onScrolled(recyclerView, dx, dy) @@ -538,7 +720,6 @@ class HomeFragment : Fragment() { } - //Load value for toggling Random button. Hide at startup context?.let { val settingsManager = PreferenceManager.getDefaultSharedPreferences(it) @@ -546,46 +727,56 @@ class HomeFragment : Fragment() { settingsManager.getBoolean( getString(R.string.random_button_key), false - ) && isLayout(PHONE) - binding?.homeRandom?.visibility = View.GONE + ) + binding.homeRandom.visibility = View.GONE + binding.homeRandomButtonTv.visibility = View.GONE } observe(homeViewModel.apiName) { apiName -> currentApiName = apiName - binding?.homeApiFab?.text = apiName - binding?.homeChangeApi?.text = apiName + binding.apply { + homeApiFab.text = apiName + homeChangeApi.text = apiName + homePreviewReloadProvider.isGone = (apiName == noneApi.name) + homePreviewSearchButton.isGone = (apiName == noneApi.name) + } } 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()) ) - }.toMutableList()) + }) + + saveHomepageToTV(d) homeLoading.isVisible = false homeLoadingError.isVisible = false homeMasterRecycler.isVisible = true + homeLoadingShimmer.stopShimmer() //home_loaded?.isVisible = true if (toggleRandomButton) { - //Flatten list - d.values.forEach { dlist -> - mutableListOfResponse.addAll(dlist.list.list) + 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) } } - listHomepageItems.addAll(mutableListOfResponse.distinctBy { it.url }) - homeRandom.isVisible = listHomepageItems.isNotEmpty() + homeRandom.isVisible = isPhone && hasItems + homeRandom.setOnClickListener(randomClickListener) + homeRandomButtonTv.isVisible = !isPhone && hasItems + homeRandomButtonTv.setOnClickListener(randomClickListener) } else { homeRandom.isGone = true + homeRandomButtonTv.isGone = true } } @@ -603,7 +794,7 @@ class HomeFragment : Fragment() { }) { try { val i = Intent(Intent.ACTION_VIEW) - i.data = Uri.parse(validAPIs[itemId].mainUrl) + i.data = validAPIs[itemId].mainUrl.toUri() startActivity(i) } catch (e: Exception) { logError(e) @@ -613,7 +804,7 @@ class HomeFragment : Fragment() { homeLoading.isVisible = false homeLoadingError.isVisible = true - homeMasterRecycler.isVisible = false + homeMasterRecycler.isInvisible = true // Based on https://github.com/recloudstream/cloudstream/pull/1438 val hasNoNetworkConnection = context?.isNetworkAvailable() == false @@ -635,24 +826,28 @@ class HomeFragment : Fragment() { 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.isVisible = false + homeMasterRecycler.isInvisible = true + (homeMasterRecycler.adapter as? ParentItemAdapter)?.apply { + submitList(null) + clearState() + } //home_loaded?.isVisible = false } } } } - - //context?.fixPaddingStatusbarView(home_statusbar) - //context?.fixPaddingStatusbar(home_padding) - observeNullable(homeViewModel.popup) { item -> if (item == null) { bottomSheetDialog?.dismissSafe() @@ -685,7 +880,7 @@ class HomeFragment : Fragment() { //TODO READD THIS /*for (syncApi in OAuth2Apis) { - val login = syncApi.loginInfo() + val login = SyncAPI2.loginInfo() val pic = login?.profilePicture if (home_profile_picture?.setImage( pic, @@ -697,4 +892,44 @@ class HomeFragment : Fragment() { } }*/ } + + 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 8bc0aa287ea..6bdd1bf492f 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,10 +6,8 @@ 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 @@ -17,9 +15,11 @@ 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,13 +34,11 @@ 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 }, @@ -48,6 +46,11 @@ 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 @@ -60,13 +63,16 @@ open class ParentItemAdapter( override fun restore(state: Bundle) { (binding as? HomepageParentBinding)?.homeChildRecyclerview?.layoutManager?.onRestoreInstanceState( - state.getSafeParcelable("value") + state.getSafeParcelable("value") ) } } - override fun submitList(list: List?) { - super.submitList(list?.sortedBy { it.list.list.isEmpty() }) + override fun submitList( + list: Collection?, + commitCallback: Runnable? + ) { + super.submitList(list?.sortedBy { it.list.list.isEmpty() }, commitCallback) } override fun onUpdateContent( @@ -90,17 +96,30 @@ open class ParentItemAdapter( if (binding !is HomepageParentBinding) return val info = item.list binding.apply { - 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) + 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.setLinearListLayout( isHorizontal = true, nextLeft = startFocus, @@ -166,11 +185,6 @@ 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 0ce7ca8f28b..959806e566c 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeParentItemAdapterPreview.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeParentItemAdapterPreview.kt @@ -1,16 +1,18 @@ 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.fragment.app.Fragment +import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.findViewTreeLifecycleOwner import androidx.recyclerview.widget.RecyclerView import androidx.viewbinding.ViewBinding @@ -18,9 +20,8 @@ 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.AcraApplication.Companion.getActivity +import com.lagradost.cloudstream3.CloudStreamApp.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 @@ -34,9 +35,11 @@ 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.home.HomeFragment.Companion.selectHomepage +import com.lagradost.cloudstream3.ui.account.AccountViewModel 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 @@ -47,19 +50,23 @@ 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( - fragment, id = "HomeParentItemAdapterPreview".hashCode(), + id = "HomeParentItemAdapterPreview".hashCode(), clickCallback = { viewModel.click(it) }, moreInfoClickCallback = { @@ -97,15 +104,33 @@ class HomeParentItemAdapterPreview( ) } - return HeaderViewHolder(binding, viewModel, fragment = fragment) + return HeaderViewHolder(binding, viewModel, accountViewModel) } 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, fragment: Fragment, + val binding: ViewBinding, + val viewModel: HomeViewModel, + accountViewModel: AccountViewModel, ) : ViewHolderState(binding) { @@ -131,9 +156,13 @@ class HomeParentItemAdapterPreview( } } - val previewAdapter = HomeScrollAdapter(fragment = fragment) + val previewAdapter = HomeScrollAdapter { view, position, item -> + viewModel.click( + LoadClickCallback(0, view, position, item) + ) + } + private val resumeAdapter = ResumeItemAdapter( - fragment, nextFocusUp = itemView.nextFocusUpId, nextFocusDown = itemView.nextFocusDownId, removeCallback = { v -> @@ -216,7 +245,6 @@ class HomeParentItemAdapterPreview( } }) private val bookmarkAdapter = HomeChildItemAdapter( - fragment, id = "bookmarkAdapter".hashCode(), nextFocusUp = itemView.nextFocusUpId, nextFocusDown = itemView.nextFocusDownId @@ -293,9 +321,14 @@ class HomeParentItemAdapterPreview( private val bookmarkRecyclerView: RecyclerView = itemView.findViewById(R.id.home_bookmarked_child_recyclerview) - 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 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 topPadding: View? = itemView.findViewById(R.id.home_padding) @@ -306,38 +339,73 @@ class HomeParentItemAdapterPreview( fun onSelect(item: LoadResponse, position: Int) { (binding as? FragmentHomeHeadTvBinding)?.apply { - homePreviewDescription.isGone = - item.plot.isNullOrBlank() - homePreviewDescription.text = - item.plot ?: "" + homePreviewDescription.isGone = item.plot.isNullOrBlank() + homePreviewDescription.text = item.plot?.html() ?: "" + + 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 - homePreviewText.text = item.name + 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() populateChips( homePreviewTags, item.tags?.take(6) ?: emptyList(), - R.style.ChipFilledSemiTransparent + R.style.ChipFilledSemiTransparent, + null + ) + + + bindLogo( + url = item.logoUrl, + headers = item.posterHeaders, + titleView = homePreviewText, + logoView = homeBackgroundPosterWatermarkBadgeHolder ) 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) @@ -422,7 +490,7 @@ class HomeParentItemAdapterPreview( } } - override fun onViewDetachedFromWindow() { + fun onViewDetachedFromWindow() { previewViewpager.unregisterOnPageChangeCallback(previewCallback) } @@ -443,12 +511,14 @@ 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 @@ -469,36 +539,80 @@ class HomeParentItemAdapterPreview( } } - homeAccount?.isGone = isLayout(TV or EMULATOR) + headProfilePicCard?.isGone = isLayout(TV or EMULATOR) + alternateHeadProfilePicCard?.isGone = isLayout(TV or EMULATOR) + + (headProfilePic ?: alternateHeadProfilePic)?.observe(viewModel.currentAccount) { currentAccount -> + headProfilePic?.loadImage(currentAccount?.image) + alternateHeadProfilePic?.loadImage(currentAccount?.image) + } - homeAccount?.setOnClickListener { + headProfilePicCard?.setOnClickListener { activity?.showAccountSelectLinear() } - alternativeHomeAccount?.setOnClickListener { + 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 { 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("") - } - - // 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 + }*/ + + // 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) + } } homePreviewHiddenNextFocus.setOnFocusChangeListener { _, hasFocus -> @@ -516,7 +630,8 @@ class HomeParentItemAdapterPreview( )?.requestFocus() } else { previewViewpager.setCurrentItem(previewViewpager.currentItem - 1, true) - binding.homePreviewPlayBtt.requestFocus() + binding.homePreviewInfoBtt.requestFocus() + //binding.homePreviewPlayBtt.requestFocus() } } } @@ -543,9 +658,7 @@ class HomeParentItemAdapterPreview( params.height = 0 layoutParams = params } - } else { - fixPaddingStatusbarView(homeNonePadding) - } + } else fixPaddingStatusbarView(homeNonePadding) when (preview) { is Resource.Success -> { @@ -569,6 +682,15 @@ 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 -> { @@ -577,6 +699,9 @@ class HomeParentItemAdapterPreview( previewViewpager.isVisible = false previewViewpagerText.isVisible = false alternativeAccountPadding?.isVisible = true + (binding as? FragmentHomeHeadTvBinding)?.apply { + homePreviewInfoBtt.isVisible = false + } //previewHeader.isVisible = false } } @@ -645,18 +770,19 @@ class HomeParentItemAdapterPreview( } } - override fun onViewAttachedToWindow() { + fun onViewAttachedToWindow() { previewViewpager.registerOnPageChangeCallback(previewCallback) - binding.root.findViewTreeLifecycleOwner()?.apply { + previewViewpager.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) } @@ -672,7 +798,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 4c4dd2d84dc..e42e774b5ba 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,23 +1,27 @@ 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( - fragment: Fragment -) : NoStateAdapter(fragment) { + val callback: ((View, Int, LoadResponse) -> Unit) +) : NoStateAdapter(diffCallback = BaseDiffCallback(itemSame = { a, b -> + a.uniqueUrl == b.uniqueUrl && a.name == b.name +})) { var hasMoreItems: Boolean = false override fun onCreateContent(parent: ViewGroup): ViewHolderState { @@ -31,19 +35,26 @@ 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 = - if (isHorizontal) item.backgroundPosterUrl ?: item.posterUrl else item.posterUrl - ?: item.backgroundPosterUrl + val posterUrl = item.backgroundPosterUrl ?: item.posterUrl when (binding) { is HomeScrollViewBinding -> { @@ -53,10 +64,21 @@ class HomeScrollAdapter( isGone = item.tags.isNullOrEmpty() maxLines = 2 } - binding.homeScrollPreviewTitle.text = item.name + binding.homeScrollPreviewTitle.text = item.name.html() + + bindLogo( + url = item.logoUrl, + headers = item.posterHeaders, + titleView = binding.homeScrollPreviewTitle, + logoView = binding.homePreviewLogo + ) } 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 60f2c18015f..e0609c0e57b 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.AcraApplication.Companion.context -import com.lagradost.cloudstream3.AcraApplication.Companion.getKey +import com.lagradost.cloudstream3.CloudStreamApp.Companion.context +import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey +import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey import com.lagradost.cloudstream3.CommonActivity.activity import com.lagradost.cloudstream3.HomePageList import com.lagradost.cloudstream3.LoadResponse import com.lagradost.cloudstream3.MainAPI import com.lagradost.cloudstream3.MainActivity -import com.lagradost.cloudstream3.MainActivity.Companion.lastError import com.lagradost.cloudstream3.SearchResponse import com.lagradost.cloudstream3.amap import com.lagradost.cloudstream3.mvvm.Resource @@ -40,6 +40,7 @@ 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 @@ -49,13 +50,12 @@ 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.VideoDownloadHelper +import com.lagradost.cloudstream3.utils.downloader.DownloadObjects 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,11 +67,26 @@ class HomeViewModel : ViewModel() { } val resumeWatchingResult = withContext(Dispatchers.IO) { resumeWatching?.mapNotNull { resume -> - - val data = getKey( + val headerCache = getKey( DOWNLOAD_HEADER_CACHE, resume.parentId.toString() - ) ?: return@mapNotNull null + ) + + 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 + } val watchPos = getViewPos(resume.episodeId) @@ -520,18 +535,18 @@ 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.checkSafeModeFile() || lastError != null) { + if (PluginManager.loadedOnlinePlugins || PluginManager.isSafeMode()) { 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 if (fromUI) DataStoreHelper.currentHomePage = api.name loadAndCancel(api) - reloadAccount() } + reloadAccount() } } \ No newline at end of file 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 bfac72067f0..6e28c128d1c 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,22 +7,16 @@ 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 @@ -30,35 +24,33 @@ 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.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.CloudStreamApp.Companion.getKey +import com.lagradost.cloudstream3.CloudStreamApp.Companion.openBrowser +import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey 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.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.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.fixPaddingStatusbar +import com.lagradost.cloudstream3.utils.UIHelper.fixSystemBarsPadding import com.lagradost.cloudstream3.utils.UIHelper.getSpanCount import java.util.concurrent.CopyOnWriteArrayList import kotlin.math.abs @@ -84,10 +76,10 @@ data class ProviderLibraryData( val apiName: String ) -class LibraryFragment : Fragment() { +class LibraryFragment : BaseFragment( + BaseFragment.BindingCreator.Bind(FragmentLibraryBinding::bind) +) { companion object { - - val listLibraryItems = mutableListOf() fun newInstance() = LibraryFragment() /** @@ -98,35 +90,10 @@ class LibraryFragment : Fragment() { private val libraryViewModel: LibraryViewModel by activityViewModels() - var binding: FragmentLibraryBinding? = null private var toggleRandomButton = false - 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 pickLayout(): Int? = + if (isLayout(PHONE)) R.layout.fragment_library else R.layout.fragment_library_tv override fun onSaveInstanceState(outState: Bundle) { binding?.viewpager?.currentItem?.let { currentItem -> @@ -135,48 +102,52 @@ class LibraryFragment : Fragment() { super.onSaveInstanceState(outState) } - private fun updateRandom() { + private fun updateRandomVisibility(binding: FragmentLibraryBinding) { + if (!toggleRandomButton) { + binding.libraryRandom.isGone = true + binding.libraryRandomButtonTv.isGone = true + return + } val position = libraryViewModel.currentPage.value ?: 0 val pages = (libraryViewModel.pages.value as? Resource.Success)?.value ?: return - if (toggleRandomButton) { - listLibraryItems.clear() - listLibraryItems.addAll(pages[position].items) - binding?.libraryRandom?.isVisible = listLibraryItems.isNotEmpty() - } else { - binding?.libraryRandom?.isGone = true - } + 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) + ) } @SuppressLint("ResourceType", "CutPasteId") - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - fixPaddingStatusbar(binding?.searchStatusBarPadding) - - 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) + override fun onBindingCreated( + binding: FragmentLibraryBinding, + savedInstanceState: Bundle? + ) { + 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() ?: return@Runnable + val newText = binding.mainSearch.query.toString() 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 @@ -192,11 +163,11 @@ class LibraryFragment : Fragment() { 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 } @@ -204,11 +175,12 @@ class LibraryFragment : Fragment() { 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, @@ -225,17 +197,9 @@ class LibraryFragment : Fragment() { settingsManager.getBoolean( getString(R.string.random_button_key), false - ) && 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) - } - } + ) + binding.libraryRandom.visibility = View.GONE + binding.libraryRandomButtonTv.visibility = View.GONE } /** @@ -305,22 +269,21 @@ class LibraryFragment : Fragment() { } } - 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( - fragment = this, + binding.viewpager.adapter = ViewpagerAdapter( { 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 @@ -353,15 +316,15 @@ class LibraryFragment : Fragment() { } } - binding?.apply { + binding.apply { viewpager.offscreenPageLimit = 2 viewpager.reduceDragSensitivity() searchBar.setExpanded(true) } val startLoading = Runnable { - binding?.apply { - gridview.numColumns = context?.getSpanCount() ?: 3 + binding.apply { + gridview.numColumns = root.context.getSpanCount() gridview.adapter = context?.let { LoadingPosterAdapter(it, 6 * 3) } libraryLoadingOverlay.isVisible = true @@ -371,7 +334,7 @@ class LibraryFragment : Fragment() { } val stopLoading = Runnable { - binding?.apply { + binding.apply { gridview.adapter = null libraryLoadingOverlay.isVisible = false libraryLoadingShimmer.stopShimmer() @@ -387,7 +350,7 @@ class LibraryFragment : Fragment() { 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) { @@ -415,10 +378,23 @@ class LibraryFragment : Fragment() { )*/ libraryViewModel.currentPage.value?.let { page -> - binding?.viewpager?.setCurrentItem(page, false) + binding.viewpager.setCurrentItem(page, false) + binding.searchBar.setExpanded(true) } - updateRandom() + // 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) // Only stop loading after 300ms to hide the fade effect the viewpager produces when updating // Without this there would be a flashing effect: @@ -459,21 +435,20 @@ class LibraryFragment : Fragment() { tab.view.nextFocusDownId = R.id.search_result_root tab.view.setOnClickListener { - val currentItem = - binding?.viewpager?.currentItem ?: return@setOnClickListener + val currentItem = binding.viewpager.currentItem 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) } } @@ -498,11 +473,11 @@ class LibraryFragment : Fragment() { } observe(libraryViewModel.currentPage) { position -> - updateRandom() - val all = binding?.viewpager?.allViews?.toList() - ?.filterIsInstance() + updateRandomVisibility(binding) + val all = binding.viewpager.allViews.toList() + .filterIsInstance() - all?.forEach { view -> + all.forEach { view -> view.isVisible = view.tag == position view.isFocusable = view.tag == position @@ -512,14 +487,6 @@ class LibraryFragment : Fragment() { view.descendantFocusability = FOCUS_BLOCK_DESCENDANTS } } - - /*binding?.viewpager?.registerOnPageChangeCallback(object : - ViewPager2.OnPageChangeCallback() { - override fun onPageSelected(position: Int) { - - super.onPageSelected(position) - } - })*/ } private fun loadLibraryItem( @@ -578,10 +545,10 @@ class LibraryFragment : Fragment() { } - @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 -> @@ -589,7 +556,8 @@ class LibraryFragment : Fragment() { 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 6c602e6c527..38f7fcf9dcb 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,12 +4,13 @@ import androidx.annotation.StringRes import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel -import com.lagradost.cloudstream3.AcraApplication.Companion.getKey -import com.lagradost.cloudstream3.AcraApplication.Companion.setKey +import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey +import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey import com.lagradost.cloudstream3.MainActivity import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.mvvm.Resource -import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.SyncApis +import com.lagradost.cloudstream3.mvvm.throwAbleToResource +import com.lagradost.cloudstream3.syncproviders.AccountManager import com.lagradost.cloudstream3.syncproviders.SyncAPI import com.lagradost.cloudstream3.utils.Coroutines.ioSafe import com.lagradost.cloudstream3.utils.DataStoreHelper @@ -30,13 +31,13 @@ enum class ListSorting(@StringRes val stringRes: Int) { const val LAST_SYNC_API_KEY = "last_sync_api" class LibraryViewModel : ViewModel() { - fun switchPage(page : Int) { + fun switchPage(page: Int) { _currentPage.postValue(page) } private val _currentPage: MutableLiveData = MutableLiveData(0) val currentPage: LiveData = _currentPage - + private val _pages: MutableLiveData>> = MutableLiveData(null) val pages: LiveData>> = _pages @@ -44,7 +45,7 @@ class LibraryViewModel : ViewModel() { val currentApiName: LiveData = _currentApiName private val availableSyncApis - get() = SyncApis.filter { it.hasAccount() } + get() = AccountManager.syncApis.filter { it.isAvailable } var currentSyncApi = availableSyncApis.let { allApis -> val lastSelection = getKey("$currentAccount/$LAST_SYNC_API_KEY") @@ -97,12 +98,17 @@ class LibraryViewModel : ViewModel() { currentSyncApi?.let { repo -> _currentApiName.postValue(repo.name) _pages.postValue(Resource.Loading()) - val libraryResource = repo.getPersonalLibrary() - if (libraryResource is Resource.Failure) { - _pages.postValue(libraryResource) + val libraryResource = repo.library() + val err = libraryResource.exceptionOrNull() + if (err != null) { + _pages.postValue(throwAbleToResource(err)) + return@let + } + val library = libraryResource.getOrNull() + if (library == null) { + _pages.postValue(Resource.Failure(false, "Unable to fetch library")) return@let } - val library = (libraryResource as? Resource.Success)?.value ?: return@let sortingMethods = library.supportedListSorting.toList() repo.requireLibraryRefresh = false @@ -116,7 +122,10 @@ class LibraryViewModel : ViewModel() { val desiredSortingMethod = ListSorting.entries.getOrNull(DataStoreHelper.librarySortingMode) - if (desiredSortingMethod != null && library.supportedListSorting.contains(desiredSortingMethod)) { + if (desiredSortingMethod != null && library.supportedListSorting.contains( + desiredSortingMethod + ) + ) { sort(desiredSortingMethod, null, pages) } else { // null query = no sorting 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 98156efc879..066cf468d20 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,35 +1,34 @@ package com.lagradost.cloudstream3.ui.library -import android.content.res.ColorStateList -import android.graphics.Color import android.view.LayoutInflater import android.view.ViewGroup import android.widget.FrameLayout -import androidx.core.content.ContextCompat -import androidx.core.graphics.ColorUtils import androidx.core.view.isVisible -import androidx.recyclerview.widget.RecyclerView -import com.lagradost.cloudstream3.AcraApplication -import com.lagradost.cloudstream3.R 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 ) : - AppContextUtils.DiffAdapter(items) { + 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() - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { - return LibraryItemViewHolder( + override fun onCreateContent(parent: ViewGroup): ViewHolderState { + return ViewHolderState( SearchResultGridExpandedBinding.inflate( LayoutInflater.from(parent.context), parent, @@ -38,86 +37,45 @@ class PageAdapter( ) } - override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { - when (holder) { - is LibraryItemViewHolder -> { - holder.bind(items[position], position) + override fun onClearView(holder: ViewHolderState) { + when (val binding = holder.view) { + is SearchResultGridExpandedBinding -> { + clearImage(binding.imageView) } } } - private fun isDark(color: Int): Boolean { - return ColorUtils.calculateLuminance(color) < 0.5 - } - - 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) - } - } - - 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() + override fun onBindContent( + holder: ViewHolderState, + item: SyncAPI.LibraryItem, + position: Int + ) { + val binding = holder.view as? SearchResultGridExpandedBinding ?: return - 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 - ) - } - } + /** https://stackoverflow.com/questions/8817522/how-to-get-color-code-of-image-view */ + SearchResultBuilder.bind( + this@PageAdapter.clickCallback, + item, + position, + holder.itemView, + ) - val showProgress = item.episodesCompleted != null && item.episodesTotal != null - binding.watchProgress.isVisible = showProgress - if (showProgress) { - binding.watchProgress.max = item.episodesTotal!! - binding.watchProgress.progress = item.episodesCompleted!! - } + // 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 + } - binding.imageText.text = item.name + 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 } + + 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 0110187f603..68b6eb2735a 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(fragment, +) : BaseAdapter( 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,7 +66,8 @@ class ViewpagerAdapter( ) { val binding = holder.view if (binding !is LibraryViewpagerPageBinding) return - (binding.pageRecyclerview.adapter as? PageAdapter)?.updateList(item.items) + (binding.pageRecyclerview.adapter as? PageAdapter)?.submitList(item.items) + binding.pageRecyclerview.scrollToPosition(0) } override fun onBindContent(holder: ViewHolderState, item: SyncAPI.Page, position: Int) { @@ -75,21 +76,21 @@ class ViewpagerAdapter( binding.pageRecyclerview.tag = position binding.pageRecyclerview.apply { - spanCount = - binding.root.context.getSpanCount() ?: 3 + spanCount = binding.root.context.getSpanCount() 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)?.updateList(item.items) + (adapter as? PageAdapter)?.submitList(item.items) // scrollToPosition(0) } @@ -100,7 +101,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 75a589a0e83..e5a460b9a02 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/AbstractPlayerFragment.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/AbstractPlayerFragment.kt @@ -1,62 +1,16 @@ package com.lagradost.cloudstream3.ui.player -import android.annotation.SuppressLint -import android.content.BroadcastReceiver -import android.content.Context -import android.content.Intent -import android.content.IntentFilter -import android.graphics.drawable.AnimatedImageDrawable -import android.graphics.drawable.AnimatedVectorDrawable -import android.media.metrics.PlaybackErrorEvent -import android.os.Build import android.os.Bundle -import android.util.Log -import android.view.LayoutInflater import android.view.View -import android.view.ViewGroup -import android.view.WindowManager -import android.widget.FrameLayout import android.widget.ImageView -import android.widget.ProgressBar -import android.widget.Toast -import androidx.annotation.LayoutRes +import androidx.annotation.OptIn import androidx.annotation.StringRes -import androidx.core.view.isGone -import androidx.core.view.isVisible -import androidx.fragment.app.Fragment -import androidx.media3.common.PlaybackException -import androidx.media3.exoplayer.ExoPlayer +import androidx.media3.common.util.UnstableApi import androidx.media3.session.MediaSession -import androidx.media3.ui.AspectRatioFrameLayout -import androidx.media3.ui.DefaultTimeBar -import androidx.media3.ui.PlayerView import androidx.media3.ui.SubtitleView -import androidx.media3.ui.TimeBar -import androidx.preference.PreferenceManager -import androidx.vectordrawable.graphics.drawable.AnimatedVectorDrawableCompat -import com.github.rubensousa.previewseekbar.PreviewBar -import com.github.rubensousa.previewseekbar.media3.PreviewTimeBar -import com.lagradost.cloudstream3.CommonActivity.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 androidx.viewbinding.ViewBinding import com.lagradost.cloudstream3.R -import com.lagradost.cloudstream3.mvvm.logError -import com.lagradost.cloudstream3.mvvm.safe -import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.unixTimeMs -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 +import com.lagradost.cloudstream3.ui.BaseFragment enum class PlayerResize(@StringRes val nameRes: Int) { Fit(R.string.resize_fit), @@ -76,669 +30,132 @@ 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 -abstract class AbstractPlayerFragment( - var player: IPlayer = CS3IPlayer() -) : Fragment() { - var resizeMode: Int = 0 - var subView: SubtitleView? = null - var isBuffering = true - protected open var hasPipModeSupport = true - - var playerPausePlayHolderHolder: FrameLayout? = null - var playerPausePlay: ImageView? = null - var playerBuffering: ProgressBar? = null - var playerView: PlayerView? = null - var piphide: FrameLayout? = null - var subtitleHolder: FrameLayout? = null - - @LayoutRes - protected open var layout: Int = R.layout.fragment_player - - open fun nextEpisode() { - throw NotImplementedError() - } +@OptIn(UnstableApi::class) +abstract class AbstractPlayerFragment( + bindingCreator: BindingCreator +) : BaseFragment(bindingCreator), PlayerView.Callbacks { - open fun prevEpisode() { - throw NotImplementedError() - } + // Stored pre-initialization so subclasses can set them before onBindingCreated. + private var _player: IPlayer = CS3IPlayer() - open fun playerPositionChanged(position: Long, duration: Long) { - throw NotImplementedError() - } + /** The shared [PlayerView] host that owns all player state and view references. */ + protected var playerHostView: PlayerView? = null - open fun playerStatusChanged() {} + var player: IPlayer + get() = playerHostView?.player ?: _player + set(value) { + _player = value + playerHostView?.player = value + } - open fun playerDimensionsLoaded(width: Int, height: Int) { - throw NotImplementedError() - } + val subView: SubtitleView? get() = playerHostView?.subView + val playerPausePlay: ImageView? get() = playerHostView?.playerPausePlay - open fun subtitlesChanged() { - throw NotImplementedError() - } + /** The underlying [androidx.media3.ui.PlayerView] widget (named to avoid conflict with our [PlayerView]). */ + val playerView: androidx.media3.ui.PlayerView? + get() = playerHostView?.exoPlayerView - open fun embeddedSubtitlesFetched(subtitles: List) { - throw NotImplementedError() - } + var currentPlayerStatus: CSPlayerLoading + get() = playerHostView?.currentPlayerStatus ?: CSPlayerLoading.IsBuffering + set(value) { playerHostView?.currentPlayerStatus = value } - open fun onTracksInfoChanged() { - throw NotImplementedError() - } + protected var mMediaSession: MediaSession? + get() = playerHostView?.mMediaSession + set(value) { playerHostView?.mMediaSession = value } - open fun onTimestamp(timestamp: EpisodeSkip.SkipStamp?) { + // No-op callbacks (nextEpisode, prevEpisode, etc.) are intentionally left as + // open so subclasses can override only what they need. The ones below throw + // to make it obvious when an implementation is missing. + override fun nextEpisode() { + throw NotImplementedError() } - open fun onTimestampSkipped(timestamp: EpisodeSkip.SkipStamp) { - + override fun prevEpisode() { + throw NotImplementedError() } - open fun exitedPipMode() { + override fun playerPositionChanged(position: Long, duration: Long) { throw NotImplementedError() } - private fun keepScreenOn(on: Boolean) { - if (on) { - activity?.window?.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) - } else { - activity?.window?.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) - } + override fun playerDimensionsLoaded(width: Int, height: Int) { + throw NotImplementedError() } - private fun updateIsPlaying( - wasPlaying: CSPlayerLoading, - isPlaying: CSPlayerLoading - ) { - val isPlayingRightNow = CSPlayerLoading.IsPlaying == isPlaying - val 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) - } - } - - canEnterPipMode = isPlayingRightNow && hasPipModeSupport - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - activity?.let { act -> - PlayerPipHelper.updatePIPModeActions( - act, - isPlayingRightNow, - player.getAspectRatio() - ) - } - } + override fun subtitlesChanged() { + throw NotImplementedError() } - private var pipReceiver: BroadcastReceiver? = null - override fun onPictureInPictureModeChanged(isInPictureInPictureMode: Boolean) { - super.onPictureInPictureModeChanged(isInPictureInPictureMode) - try { - isInPIPMode = isInPictureInPictureMode - if (isInPictureInPictureMode) { - // Hide the full-screen UI (controls, etc.) while in picture-in-picture mode. - piphide?.isVisible = false - pipReceiver = object : BroadcastReceiver() { - override fun onReceive( - context: Context, - intent: Intent, - ) { - if (ACTION_MEDIA_CONTROL != intent.action) { - return - } - player.handleEvent( - CSPlayerEvent.entries[intent.getIntExtra( - EXTRA_CONTROL_TYPE, - 0 - )], source = PlayerEventSource.UI - ) - } - } - val filter = IntentFilter() - filter.addAction(ACTION_MEDIA_CONTROL) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - activity?.registerReceiver(pipReceiver, filter, Context.RECEIVER_EXPORTED) - } else activity?.registerReceiver(pipReceiver, filter) - val isPlaying = player.getIsPlaying() - val isPlayingValue = - if (isPlaying) CSPlayerLoading.IsPlaying else CSPlayerLoading.IsPaused - updateIsPlaying(isPlayingValue, isPlayingValue) - } else { - // Restore the full-screen UI. - piphide?.isVisible = true - exitedPipMode() - pipReceiver?.let { - // Prevents java.lang.IllegalArgumentException: Receiver not registered - safe { - activity?.unregisterReceiver(it) - } - } - activity?.hideSystemUI() - this.view?.let { UIHelper.hideKeyboard(it) } - } - } catch (e: Exception) { - logError(e) - } + override fun embeddedSubtitlesFetched(subtitles: List) { + throw NotImplementedError() } - open fun hasNextMirror(): Boolean { + override fun onTracksInfoChanged() { throw NotImplementedError() } - open fun nextMirror() { + override fun exitedPipMode() { throw NotImplementedError() } - private fun requestAudioFocus() { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - activity?.requestLocalAudioFocus(AppContextUtils.getFocusRequest()) - } + override fun hasNextMirror(): Boolean { + throw NotImplementedError() } - open fun playerError(exception: Throwable) { - fun showToast(message: String, gotoNext: Boolean = false) { - if (gotoNext && hasNextMirror()) { - showToast( - message, - Toast.LENGTH_SHORT - ) - nextMirror() - } else { - showToast( - context?.getString(R.string.no_links_found_toast) + "\n" + message, - Toast.LENGTH_LONG - ) - activity?.popCurrentPage() - } - } - - val ctx = context ?: return - when (exception) { - is PlaybackException -> { - val msg = exception.message ?: "" - val errorName = exception.errorCodeName - when (val code = exception.errorCode) { - PlaybackException.ERROR_CODE_IO_FILE_NOT_FOUND, - PlaybackException.ERROR_CODE_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 - ) - } - } + override fun nextMirror() { + throw NotImplementedError() } - private fun onSubStyleChanged(style: SaveCaptionStyle) { - player.updateSubtitleStyle(style) - // Forcefully update the subtitle encoding in case the edge size is changed - player.seekTime(-1) + /** Delegates to [PlayerView.playerError] by default; override to customize. */ + override fun playerError(exception: Throwable) { + playerHostView?.playerError(exception) } + /** Player fragments don't need system-bar padding adjustment by default. */ + override fun fixLayout(view: View) = Unit - @SuppressLint("UnsafeOptInUsageError") - open fun playerUpdated(player: Any?) { - if (player is ExoPlayer) { - context?.let { ctx -> - mMediaSession?.release() - mMediaSession = MediaSession.Builder(ctx, player) - // Ensure unique ID for concurrent players - .setId(unixTimeMs.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 - } + override fun onBindingCreated(binding: T, savedInstanceState: Bundle?) { + val ctx = context ?: return + playerHostView = PlayerView(ctx) + playerHostView?.player = _player + playerHostView?.callbacks = this + playerHostView?.bindViews(binding.root) + playerHostView?.initialize() } - @SuppressLint("SetTextI18n", "UnsafeOptInUsageError") - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - resizeMode = DataStoreHelper.resizeMode - resize(resizeMode, false) - - player.releaseCallbacks() - player.initCallbacks( - eventHandler = ::mainCallback, - requestedListeningPercentages = listOf( - SKIP_OP_VIDEO_PERCENTAGE, - PRELOAD_NEXT_EPISODE_PERCENTAGE, - NEXT_WATCH_EPISODE_PERCENTAGE, - UPDATE_SYNC_PROGRESS_PERCENTAGE, - ), - ) - - val player = player - if (player is CS3IPlayer) { - // preview bar - val progressBar: PreviewTimeBar? = playerView?.findViewById(R.id.exo_progress) - val previewImageView: ImageView? = playerView?.findViewById(R.id.previewImageView) - val previewFrameLayout: FrameLayout? = playerView?.findViewById(R.id.previewFrameLayout) - if (progressBar != null && previewImageView != null && previewFrameLayout != null) { - var resume = false - progressBar.addOnScrubListener(object : PreviewBar.OnScrubListener { - override fun onScrubStart(previewBar: PreviewBar?) { - val hasPreview = player.hasPreview() - progressBar.isPreviewEnabled = hasPreview - resume = player.getIsPlaying() - if (resume) player.handleEvent( - CSPlayerEvent.Pause, - PlayerEventSource.Player - ) - - // No clashing UI - if (hasPreview) { - subView?.isVisible = false - } - } - - override fun onScrubMove( - previewBar: PreviewBar?, - progress: Int, - fromUser: Boolean - ) { - } - - override fun onScrubStop(previewBar: PreviewBar?) { - if (resume) player.handleEvent(CSPlayerEvent.Play, PlayerEventSource.Player) - // Delay to prevent the small flicker of subtitle before seeking - subView?.postDelayed({ - // If we are not scrubbing then show subtitles again - if (previewBar == null || !previewBar.isPreviewEnabled || !previewBar.isShowingPreview) { - subView?.isVisible = true - } - }, 200) - } - }) - progressBar.attachPreviewView(previewFrameLayout) - progressBar.setPreviewLoader { currentPosition, max -> - val bitmap = player.getPreview(currentPosition.toFloat().div(max.toFloat())) - previewImageView.isGone = bitmap == null - previewImageView.setImageBitmap(bitmap) - } - } - - subView = playerView?.findViewById(androidx.media3.ui.R.id.exo_subtitles) - player.initSubtitles(subView, subtitleHolder, CustomDecoder.style) - (player.imageGenerator as? PreviewGenerator)?.params = ImageParams.new16by9(screenWidth) - - /*previewImageView?.doOnLayout { - (player.imageGenerator as? PreviewGenerator)?.params = ImageParams( - it.measuredWidth, - it.measuredHeight - ) - }*/ - /** this might seam a bit fucky and that is because it is, the seek event is captured twice, once by the player - * and once by the UI even if it should only be registered once by the UI */ - playerView?.findViewById(R.id.exo_progress) - ?.addListener(object : TimeBar.OnScrubListener { - override fun onScrubStart(timeBar: TimeBar, position: Long) = Unit - override fun onScrubMove(timeBar: TimeBar, position: Long) = Unit - override fun onScrubStop(timeBar: TimeBar, position: Long, canceled: Boolean) { - if (canceled) return - val playerDuration = player.getDuration() ?: return - val playerPosition = player.getPosition() ?: return - mainCallback( - PositionEvent( - source = PlayerEventSource.UI, - durationMs = playerDuration, - fromMs = playerPosition, - toMs = position - ) - ) - } - }) - - SubtitlesFragment.applyStyleEvent += ::onSubStyleChanged - - try { - context?.let { ctx -> - val settingsManager = PreferenceManager.getDefaultSharedPreferences( - ctx - ) - - val currentPrefCacheSize = - settingsManager.getInt(getString(R.string.video_buffer_size_key), 0) - val currentPrefDiskSize = - settingsManager.getInt(getString(R.string.video_buffer_disk_key), 0) - val currentPrefBufferSec = - settingsManager.getInt(getString(R.string.video_buffer_length_key), 0) - - player.cacheSize = currentPrefCacheSize * 1024L * 1024L - player.simpleCacheSize = currentPrefDiskSize * 1024L * 1024L - player.videoBufferMs = currentPrefBufferSec * 1000L - } - } catch (e: Exception) { - logError(e) - } - } - - /*context?.let { ctx -> - player.loadPlayer( - ctx, - false, - ExtractorLink( - "idk", - "bunny", - "http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4", - "", - Qualities.P720.value, - false - ), - ) - }*/ + override fun onPictureInPictureModeChanged(isInPictureInPictureMode: Boolean) { + super.onPictureInPictureModeChanged(isInPictureInPictureMode) + playerHostView?.onPictureInPictureModeChanged(isInPictureInPictureMode, activity) } override fun onDestroy() { - playerEventListener = null - keyEventListener = null - canEnterPipMode = false - mMediaSession?.release() - mMediaSession = null - playerView?.player = null - SubtitlesFragment.applyStyleEvent -= ::onSubStyleChanged - - keepScreenOn(false) + playerHostView?.release() super.onDestroy() } - fun nextResize() { - resizeMode = (resizeMode + 1) % PlayerResize.entries.size - resize(resizeMode, true) - } - - fun resize(resize: Int, showToast: Boolean) { - resize(PlayerResize.entries[resize], showToast) - } - - @SuppressLint("UnsafeOptInUsageError") - fun resize(resize: PlayerResize, showToast: Boolean) { - DataStoreHelper.resizeMode = resize.ordinal - val type = when (resize) { - PlayerResize.Fill -> AspectRatioFrameLayout.RESIZE_MODE_FILL - PlayerResize.Fit -> AspectRatioFrameLayout.RESIZE_MODE_FIT - PlayerResize.Zoom -> AspectRatioFrameLayout.RESIZE_MODE_ZOOM - } - playerView?.resizeMode = type - - if (showToast) - showToast(resize.nameRes, Toast.LENGTH_SHORT) + override fun onPause() { + playerHostView?.releaseKeyEventListener() + super.onPause() } override fun onStop() { - player.onStop() + playerHostView?.onStop() super.onStop() } override fun onResume() { context?.let { ctx -> - player.onResume(ctx) + playerHostView?.onResume(ctx) } - super.onResume() } - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View? { - val root = inflater.inflate(layout, container, false) - playerPausePlayHolderHolder = root.findViewById(R.id.player_pause_play_holder_holder) - playerPausePlay = root.findViewById(R.id.player_pause_play) - playerBuffering = root.findViewById(R.id.player_buffering) - playerView = root.findViewById(R.id.player_view) - piphide = root.findViewById(R.id.piphide) - subtitleHolder = root.findViewById(R.id.subtitle_holder) - return root + fun nextResize() { + playerHostView?.nextResize() } -} \ No newline at end of file + + open fun resize(resize: PlayerResize, showToast: Boolean) { + playerHostView?.resize(resize, showToast) + } +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/CS3IPlayer.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/CS3IPlayer.kt index e4be1f625e5..aa44b92359b 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/CS3IPlayer.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/CS3IPlayer.kt @@ -12,9 +12,11 @@ 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 @@ -28,6 +30,7 @@ import androidx.media3.common.TrackGroup import androidx.media3.common.TrackSelectionOverride import androidx.media3.common.Tracks import androidx.media3.common.VideoSize +// import androidx.media3.common.util.ExperimentalApi import androidx.media3.common.util.UnstableApi import androidx.media3.database.StandaloneDatabaseProvider import androidx.media3.datasource.DataSource @@ -37,33 +40,42 @@ 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.AcraApplication.Companion.getKey -import com.lagradost.cloudstream3.AcraApplication.Companion.setKey +import com.lagradost.cloudstream3.AudioFile +import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey +import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey import com.lagradost.cloudstream3.CommonActivity.activity import com.lagradost.cloudstream3.ErrorLoadingException import com.lagradost.cloudstream3.MainActivity.Companion.deleteFileOnExit @@ -75,28 +87,34 @@ 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.settings.Globals.TV +import com.lagradost.cloudstream3.ui.player.live.LiveHelper +import com.lagradost.cloudstream3.ui.player.live.PREFERRED_LIVE_OFFSET +import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR +import com.lagradost.cloudstream3.ui.settings.Globals.PHONE import com.lagradost.cloudstream3.ui.settings.Globals.isLayout 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_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.SubtitleHelper.fromTwoLettersToLanguage -import io.github.anilbeesetti.nextlib.media3ext.ffdecoder.NextRenderersFactory +import com.lagradost.cloudstream3.utils.PLAYREADY_UUID +import com.lagradost.cloudstream3.utils.SubtitleHelper.fromTagToLanguageName +import com.lagradost.cloudstream3.utils.WIDEVINE_UUID +import com.lagradost.cloudstream3.utils.videoskip.VideoSkipStamp 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 @@ -116,6 +134,7 @@ 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) { @@ -189,36 +208,42 @@ class CS3IPlayer : IPlayer { private var eventHandler: ((PlayerEvent) -> Unit)? = null + @AnyThread fun event(event: PlayerEvent) { - eventHandler?.invoke(event) + // Ensure that all work is done on the main thread. + if (Looper.getMainLooper().isCurrentThread) { + eventHandler?.invoke(event) + } else runOnMainThread { + 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( - eventHandler: ((PlayerEvent) -> Unit), + @MainThread eventHandler: ((PlayerEvent) -> Unit), requestedListeningPercentages: List?, ) { this.requestedListeningPercentages = requestedListeningPercentages this.eventHandler = eventHandler - } - - // 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) - } + if (!isPlayerActive) { + isPlayerActive = true + activePlayers += 1 } } @@ -235,6 +260,10 @@ class CS3IPlayer : IPlayer { } override fun hasPreview(): Boolean { + // No previews on livestreams because the previews get outdated + if (exoPlayer?.isCurrentMediaItemDynamic == true) { + return false + } return imageGenerator.hasPreview() } @@ -348,44 +377,47 @@ class CS3IPlayer : IPlayer { ?: return } - override fun setPreferredAudioTrack(trackLanguage: String?, id: String?) { + override fun setPreferredAudioTrack(trackLanguage: String?, id: String?, formatIndex: Int?) { preferredAudioTrackLanguage = trackLanguage - - 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 + 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 + ) ) - ) - ?.build() - ?: return - return - } + ?.build() + } + ?.let { newParams -> + exoPlayer?.trackSelectionParameters = newParams + 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.map { + return this.flatMap { it.getFormats() - }.flatten() + } } private fun Tracks.Group.getFormats(): List> { @@ -396,11 +428,14 @@ class CS3IPlayer : IPlayer { } } - private fun Format.toAudioTrack(): AudioTrack { + private fun Format.toAudioTrack(formatIndex: Int?): AudioTrack { return AudioTrack( - this.id?.stripTrackId(), + this.id, this.label, - this.language + this.language, + this.sampleMimeType, + this.channelCount, + formatIndex ?: 0, ) } @@ -409,7 +444,7 @@ class CS3IPlayer : IPlayer { this.id?.stripTrackId(), this.label, this.language, - this.sampleMimeType + this.sampleMimeType, ) } @@ -420,27 +455,35 @@ class CS3IPlayer : IPlayer { this.language, this.width, this.height, + this.sampleMimeType ) } override fun getVideoTracks(): CurrentTracks { - val allTracks = exoPlayer?.currentTracks?.groups ?: emptyList() - val videoTracks = allTracks.filter { it.type == TRACK_TYPE_VIDEO } + val allTrackGroups = exoPlayer?.currentTracks?.groups ?: emptyList() + val videoTracks = allTrackGroups.filter { it.type == TRACK_TYPE_VIDEO } .getFormats() .map { it.first.toVideoTrack() } - val audioTracks = allTracks.filter { it.type == TRACK_TYPE_AUDIO }.getFormats() - .map { it.first.toAudioTrack() } - - val textTracks = allTracks.filter { it.type == TRACK_TYPE_TEXT }.getFormats() + 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() .map { it.first.toSubtitleTrack() } - val currentTextTracks = textTracks.filter { track -> playerSelectedSubtitleTracks.any { it.second && it.first == track.id } } - return CurrentTracks( exoPlayer?.videoFormat?.toVideoTrack(), - exoPlayer?.audioFormat?.toAudioTrack(), + currentAudioTrack, currentTextTracks, videoTracks, audioTracks, @@ -454,60 +497,43 @@ 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 + } - 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.NOT_FOUND -> { + Log.i(TAG, "setPreferredSubtitles NOT_FOUND") + return true + } + 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() - .apply { - val track = getTextTrack(subtitle.getId()) - if (track != null) { - setTrackTypeDisabled(TRACK_TYPE_TEXT, false) - setOverrideForType( - TrackSelectionOverride( - track.first, - track.second - ) - ) - } - } + .setTrackTypeDisabled(TRACK_TYPE_TEXT, false) + .setOverrideForType(TrackSelectionOverride(trackGroup, trackIndex)) ) - - // ugliest code I have written, it seeks 1ms to *update* the subtitles - //exoPlayer?.applicationLooper?.let { - // Handler(it).postDelayed({ - // seekTime(1L) - // }, 1) - //} } - - SubtitleStatus.NOT_FOUND -> { - Log.i(TAG, "setPreferredSubtitles NOT_FOUND") - return@let true - } - } + return false } - return false - } ?: false + } } private var currentSubtitleOffset: Long = 0 @@ -516,10 +542,10 @@ class CS3IPlayer : IPlayer { currentSubtitleOffset = offset CustomDecoder.subtitleOffset = offset if (currentTextRenderer?.state == STATE_ENABLED || currentTextRenderer?.state == STATE_STARTED) { - exoPlayer?.currentPosition?.let { pos -> + exoPlayer?.currentPosition?.also { 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) + currentTextRenderer?.resetPosition(pos, false) } } } @@ -581,7 +607,10 @@ class CS3IPlayer : IPlayer { // No documented exception, but just to be extra safe logError(t) } - + playerListener?.let { + removeListener(it) + playerListener = null + } stop() release() } @@ -628,6 +657,62 @@ 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. **/ @@ -646,42 +731,98 @@ class CS3IPlayer : IPlayer { } private var simpleCache: SimpleCache? = null - private fun createOnlineSource(headers: Map): HttpDataSource.Factory { - val source = OkHttpDataSource.Factory(app.baseClient).setUserAgent(USER_AGENT) - return source.apply { - setDefaultRequestProperties(headers) + + /// 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 } - private fun createOnlineSource(link: ExtractorLink): HttpDataSource.Factory { - val provider = getApiFromNameNull(link.source) - val interceptor = provider?.getVideoInterceptor(link) + 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 createVideoSource( + link: ExtractorLink, + engine: CronetEngine?, + interceptor: Interceptor?, + ): HttpDataSource.Factory { val userAgent = link.headers.entries.find { it.key.equals("User-Agent", ignoreCase = true) - }?.value + }?.value ?: USER_AGENT val source = if (interceptor == null) { - 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 + 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) + } } else { + Log.d(TAG, "Using OkHttpDataSource for $link") val client = app.baseClient.newBuilder() .addInterceptor(interceptor) .build() - OkHttpDataSource.Factory(client).setUserAgent(userAgent ?: USER_AGENT) + OkHttpDataSource.Factory(client).setUserAgent(userAgent) } // 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) - 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 + + // 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 return source.apply { setDefaultRequestProperties(headers) @@ -741,10 +882,10 @@ class CS3IPlayer : IPlayer { private var currentTextRenderer: TextRenderer? = null } - private fun getCurrentTimestamp(writePosition: Long? = null): EpisodeSkip.SkipStamp? { + private fun getCurrentTimestamp(writePosition: Long? = null): VideoSkipStamp? { val position = writePosition ?: this@CS3IPlayer.getPosition() ?: return null for (lastTimeStamp in lastTimeStamps) { - if (lastTimeStamp.startMs <= position && (position + (toleranceBeforeUs / 1000L) + 1) < lastTimeStamp.endMs) { + if (lastTimeStamp.timestamp.startMs <= position && (position + (toleranceBeforeUs / 1000L) + 1) < lastTimeStamp.timestamp.endMs) { return lastTimeStamp } } @@ -803,6 +944,22 @@ class CS3IPlayer : IPlayer { when (event) { CSPlayerEvent.Play -> { event(PlayEvent(source)) + // If the player was stopped (e.g. notification dismissed) it lands in + // STATE_IDLE. A bare play() call is a no-op in that state, re-prepare and + // then resume to the current position once we are in STATE_READY again. + if (playbackState == Player.STATE_IDLE) { + val seekPosition = currentPosition + exoPlayer?.addListener(object : Player.Listener { + private var seekApplied = false + override fun onPlaybackStateChanged(playbackState: Int) { + if (seekApplied || playbackState != Player.STATE_READY) return + seekApplied = true + exoPlayer?.seekTo(currentWindow, seekPosition) + exoPlayer?.removeListener(this) + } + }) + prepare() + } play() } @@ -856,7 +1013,7 @@ class CS3IPlayer : IPlayer { if (lastTimeStamp.skipToNextEpisode) { handleEvent(CSPlayerEvent.NextEpisode, source) } else { - seekTo(lastTimeStamp.endMs + 1L) + seekTo(lastTimeStamp.timestamp.endMs + 1L) } event(TimestampSkippedEvent(timestamp = lastTimeStamp, source = source)) } @@ -912,39 +1069,57 @@ 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 + maxVideoHeight: Int? = null, + /** External audio tracks to merge with the video */ + audioSources: List = emptyList() ): ExoPlayer { val exoPlayerBuilder = ExoPlayer.Builder(context) - .setRenderersFactory { eventHandler, videoRendererEventListener, audioRendererEventListener, textRendererOutput, metadataRendererOutput -> + .setMediaSourceFactory( + DefaultMediaSourceFactory(context).setLiveTargetOffsetMs( + PREFERRED_LIVE_OFFSET + ) + ) + .setLivePlaybackSpeedControl( + DefaultLivePlaybackSpeedControl.Builder() + .setFallbackMaxPlaybackSpeed(1.03f) + .setFallbackMinPlaybackSpeed(0.97f) + .build() + ) + .setRenderersFactory { eventHandler, videoRendererEventListener, audioRendererEventListener, _, metadataRendererOutput -> val settingsManager = PreferenceManager.getDefaultSharedPreferences(context) val current = settingsManager.getInt( context.getString(R.string.software_decoding_key), -1 ) - val softwareDecoding = when (current) { - 0 -> true // yes - 1 -> false // no + 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 // -1 = automatic - else -> { - // we do not want tv to have software decoding, because of crashes - !isLayout(TV) - } + // We do not want tv to have software decoding, because of crashes + else -> isLayout(PHONE or EMULATOR) to false } - val factory = if (softwareDecoding) { - NextRenderersFactory(context).apply { + val factory = if (isSoftwareDecodingEnabled) { + FixedNextRenderersFactory(context).apply { setEnableDecoderFallback(true) - setExtensionRendererMode(DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON) + setExtensionRendererMode( + if (isSoftwareDecodingPreferred) + DefaultRenderersFactory.EXTENSION_RENDERER_MODE_PREFER + else + DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON + ) } } else { + // no nextlib = EXTENSION_RENDERER_MODE_OFF DefaultRenderersFactory(context) } @@ -952,7 +1127,8 @@ class CS3IPlayer : IPlayer { // Custom TextOutput to apply cue styling and rules to all subtitles val customTextOutput = TextOutput { cue -> // Do not remove filterNotNull as Java typesystem is fucked - val (bitmapCues, textCues) = cue.cues.filterNotNull().partition { it.bitmap != null } + val (bitmapCues, textCues) = cue.cues.toList() + .partition { it.bitmap != null } val styledBitmapCues = bitmapCues.map { bitmapCue -> bitmapCue @@ -962,16 +1138,38 @@ 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) -> - val combinedCueText = entries.joinToString("\n") { - it.text?.toString() ?: "" + 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 = buffer.toString() + + // Use the style of the first entry as the base entries .firstOrNull() ?.buildUpon() @@ -997,6 +1195,7 @@ class CS3IPlayer : IPlayer { CustomDecoder.subtitleOffset = subtitleOffset val decoder = CustomSubtitleDecoderFactory() + // @OptIn(ExperimentalApi::class) val currentTextRenderer = TextRenderer( customTextOutput, eventHandler.looper, @@ -1051,10 +1250,27 @@ 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) - val factory = - if (cacheFactory == null) DefaultMediaSourceFactory(context, extractorFactor) - else DefaultMediaSourceFactory(cacheFactory, extractorFactor) + // 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) + } // If there is only one item then treat it as normal, if multiple: concatenate the items. val videoMediaSource = if (mediaItemSlices.size == 1) { @@ -1063,8 +1279,9 @@ class CS3IPlayer : IPlayer { item.drm?.let { drm -> when (drm.uuid) { CLEARKEY_UUID -> { - val client = - OkHttpDataSource.Factory(app.baseClient).setUserAgent(USER_AGENT) + // Use headers from DrmMetadata for media requests + val client = dataSourceFactory + ?: throw IllegalArgumentException("Must supply onlineSource") val drmCallback = LocalMediaDrmCallback("{\"keys\":[{\"kty\":\"${drm.kty}\",\"k\":\"${drm.key}\",\"kid\":\"${drm.kid}\"}],\"type\":\"temporary\"}".toByteArray()) val manager = DefaultDrmSessionManager.Builder() @@ -1084,8 +1301,9 @@ class CS3IPlayer : IPlayer { WIDEVINE_UUID, PLAYREADY_UUID -> { - val client = - OkHttpDataSource.Factory(app.baseClient).setUserAgent(USER_AGENT) + // Use headers from DrmMetadata for media requests + val client = dataSourceFactory + ?: throw IllegalArgumentException("Must supply onlineSource") val drmCallback = HttpMediaDrmCallback(drm.licenseUrl, client) val manager = DefaultDrmSessionManager.Builder() .setPlayClearSamplesWithoutKeys(true) @@ -1111,16 +1329,16 @@ class CS3IPlayer : IPlayer { } } } ?: run { - factory.createMediaSource(item.mediaItem) + defaultMediaSourceFactory.createMediaSource(item.mediaItem) } } else { try { val source = ConcatenatingMediaSource2.Builder() - mediaItemSlices.map { item -> + mediaItemSlices.forEach { item -> source.add( // The duration MUST be known for it to work properly, see https://github.com/google/ExoPlayer/issues/4727 ClippingMediaSource( - factory.createMediaSource(item.mediaItem), + defaultMediaSourceFactory.createMediaSource(item.mediaItem), item.durationUs ) ) @@ -1130,11 +1348,11 @@ class CS3IPlayer : IPlayer { @Suppress("DEPRECATION") val source = ConcatenatingMediaSource() // FIXME figure out why ConcatenatingMediaSource2 seems to fail with Torrents only - mediaItemSlices.map { item -> + mediaItemSlices.forEach { item -> source.addMediaSource( // The duration MUST be known for it to work properly, see https://github.com/google/ExoPlayer/issues/4727 ClippingMediaSource( - factory.createMediaSource(item.mediaItem), + defaultMediaSourceFactory.createMediaSource(item.mediaItem), item.durationUs ) ) @@ -1142,19 +1360,18 @@ 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( - videoMediaSource, *subSources.toTypedArray() - ), + MergingMediaSource(*allSources.toTypedArray()), playbackPosition ) setHandleAudioBecomingNoisy(true) setPlaybackSpeed(playBackSpeed) + this.addAnalyticsListener(tracksAnalyticsListener) } } @@ -1162,7 +1379,8 @@ class CS3IPlayer : IPlayer { context: Context, mediaSlices: List, subSources: List, - cacheFactory: CacheDataSource.Factory? = null + audioSources: List = emptyList(), + onlineSource: HttpDataSource.Factory? = null, ) { Log.i(TAG, "loadExo") val settingsManager = PreferenceManager.getDefaultSharedPreferences(context) @@ -1186,14 +1404,32 @@ class CS3IPlayer : IPlayer { cacheSize = cacheSize, videoBufferMs = videoBufferMs, playWhenReady = isPlaying, // this keep the current state of the player - cacheFactory = cacheFactory, subtitleOffset = currentSubtitleOffset, - maxVideoHeight = maxVideoHeight + maxVideoHeight = maxVideoHeight, + audioSources = audioSources, + onlineSource = onlineSource, ) 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 @@ -1206,6 +1442,8 @@ class CS3IPlayer : IPlayer { return } + LiveHelper.registerPlayer(exoPlayer) + exoPlayer?.addListener(object : Player.Listener { override fun onTracksChanged(tracks: Tracks) { safe { @@ -1230,7 +1468,7 @@ class CS3IPlayer : IPlayer { return@mapNotNull SubtitleData( // Nicer looking displayed names - fromTwoLettersToLanguage(format.language!!) + fromTagToLanguageName(format.language) ?: format.language!!, format.label ?: "", // See setPreferredTextLanguage @@ -1248,13 +1486,19 @@ class CS3IPlayer : IPlayer { } } - //fixme: Use onPlaybackStateChanged(int) and onPlayWhenReadyChanged(boolean, int) instead. + // fixme: Use onPlaybackStateChanged(int) and onPlayWhenReadyChanged(boolean, int) instead. + @Suppress("OVERRIDE_DEPRECATION") override fun onPlayerStateChanged(playWhenReady: Boolean, playbackState: Int) { exoPlayer?.let { exo -> event( StatusEvent( wasPlaying = if (isPlaying) CSPlayerLoading.IsPlaying else CSPlayerLoading.IsPaused, - isPlaying = if (playbackState == Player.STATE_BUFFERING) CSPlayerLoading.IsBuffering else if (exo.isPlaying) CSPlayerLoading.IsPlaying else CSPlayerLoading.IsPaused + isPlaying = + when (playbackState) { + Player.STATE_ENDED -> CSPlayerLoading.IsEnded + Player.STATE_BUFFERING -> CSPlayerLoading.IsBuffering + else -> if (exo.isPlaying) CSPlayerLoading.IsPlaying else CSPlayerLoading.IsPaused + } ) ) isPlaying = exo.isPlaying @@ -1308,6 +1552,23 @@ class CS3IPlayer : IPlayer { exoPlayer?.prepare() } + // PlaylistStuckException usually happens when the player position is ahead of the live window. + // Seek to the default location in that case + error.cause is HlsPlaylistTracker.PlaylistStuckException -> { + val position = exoPlayer?.currentPosition ?: exoPlayer?.duration ?: 0 + + // Seek to live head + val aheadOfLive = LiveHelper.getLiveManager(exoPlayer)?.getTimeAheadOfLive(position) ?: 0 + + if (aheadOfLive > 100) { + exoPlayer?.seekTo(position - aheadOfLive) + } else { + exoPlayer?.seekToDefaultPosition() + } + exoPlayer?.prepare() + } + + else -> { event(ErrorEvent(error)) } @@ -1336,9 +1597,6 @@ 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( @@ -1375,16 +1633,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 { _, _ -> @@ -1393,7 +1651,7 @@ class CS3IPlayer : IPlayer { // onTimestampInvoked?.invoke(payload) } ?.setLooper(Looper.getMainLooper()) - ?.setPosition(timestamp.startMs) + ?.setPosition(timestamp.timestamp.startMs) //?.setPayload(timestamp) ?.setDeleteAfterDelivery(false) ?.send() @@ -1407,20 +1665,6 @@ 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 @@ -1451,12 +1695,11 @@ 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, - subtitleHelper, + subHelper = subtitleHelper, + interceptor = null, ) subtitleHelper.setActiveSubtitles(activeSubtitles.toSet()) @@ -1468,20 +1711,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(Uri.parse(sub.getFixedUrl())) + val subConfig = MediaItem.SubtitleConfiguration.Builder(sub.getFixedUrl().toUri()) .setMimeType(sub.mimeType) .setLanguage("_${sub.name}") .setId(sub.getId()) .setSelectionFlags(0) .build() when (sub.origin) { - SubtitleOrigin.DOWNLOADED_FILE -> { + SubtitleOrigin.DOWNLOADED_FILE, SubtitleOrigin.EMBEDDED_IN_VIDEO -> { if (offlineSourceFactory != null) { activeSubtitles.add(sub) SingleSampleMediaSource.Factory(offlineSourceFactory) @@ -1492,37 +1735,41 @@ class CS3IPlayer : IPlayer { } SubtitleOrigin.URL -> { - 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 - } + val dataSourceFactory = createOnlineSource(sub.headers, interceptor) + activeSubtitles.add(sub) + SingleSampleMediaSource.Factory(dataSourceFactory) + .createMediaSource(subConfig, TIME_UNSET) } } } 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 { @@ -1572,7 +1819,7 @@ class CS3IPlayer : IPlayer { defaultSet ) ?.mapNotNull { it.toIntOrNull() ?: return@mapNotNull null } - } catch (e: Throwable) { + } catch (_: Throwable) { null } ?: default @@ -1592,7 +1839,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(), null) + loadExo(context, listOf(), listOf()) } event( StatusEvent( @@ -1645,7 +1892,7 @@ class CS3IPlayer : IPlayer { if (ignoreSSL) { // Disables ssl check val sslContext: SSLContext = SSLContext.getInstance("TLS") - sslContext.init(null, arrayOf(SSLTrustManager()), java.security.SecureRandom()) + sslContext.init(null, arrayOf(SSLTrustManager()), SecureRandom()) sslContext.createSSLEngine() HttpsURLConnection.setDefaultHostnameVerifier { _: String, _: SSLSession -> true @@ -1670,7 +1917,7 @@ class CS3IPlayer : IPlayer { uuid = link.uuid, kty = link.kty, licenseUrl = link.licenseUrl, - keyRequestParameters = link.keyRequestParameters + keyRequestParameters = link.keyRequestParameters, ) ) ) @@ -1682,26 +1929,46 @@ class CS3IPlayer : IPlayer { ) } - val onlineSourceFactory = createOnlineSource(link) + // 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 offlineSourceFactory = context.createOfflineSource() val (subSources, activeSubtitles) = getSubSources( - onlineSourceFactory = onlineSourceFactory, offlineSourceFactory = offlineSourceFactory, - subtitleHelper + subHelper = subtitleHelper, + interceptor = interceptor, // Backwards compatibility, needs a new api to work properly ) - subtitleHelper.setActiveSubtitles(activeSubtitles.toSet()) - - if (simpleCache == null) - simpleCache = getCache(context, simpleCacheSize) + // Create audio sources from ExtractorLink's audioTracks + val audioSources = getAudioSources( + audioTracks = link.audioTracks, + interceptor = interceptor, // Backwards compatibility, needs a new api to work properly + ) - val cacheFactory = CacheDataSource.Factory().apply { - simpleCache?.let { setCache(it) } - setUpstreamDataSourceFactory(onlineSourceFactory) - } + subtitleHelper.setActiveSubtitles(activeSubtitles.toSet()) - loadExo(context, mediaItems, subSources, cacheFactory) + loadExo( + context = context, + mediaSlices = mediaItems, + subSources = subSources, + audioSources = audioSources, + onlineSource = onlineSourceFactory + ) } catch (t: Throwable) { Log.e(TAG, "loadOnlinePlayer error", t) event(ErrorEvent(t)) @@ -1718,4 +1985,38 @@ 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 new file mode 100644 index 00000000000..c26a4f2dfdb --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/CustomSubripParser.kt @@ -0,0 +1,296 @@ +/* + * 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 dfef0de005d..61d6f556450 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,7 +18,6 @@ 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 @@ -35,8 +34,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. - **/ -@UnstableApi + */ +@OptIn(UnstableApi::class) class CustomDecoder(private val fallbackFormat: Format?) : SubtitleParser { companion object { fun updateForcedEncoding(context: Context) { @@ -53,15 +52,15 @@ class CustomDecoder(private val fallbackFormat: Format?) : SubtitleParser { } private const val DEFAULT_MARGIN: Float = 0.05f - 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 + 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 /** Subtitle offset in milliseconds */ var subtitleOffset: Long = 0 @@ -108,7 +107,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 * * ``` @@ -148,37 +147,7 @@ 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 - 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) - } + this.setSubtitleAlignment(alignment) } // remove all matches, so we do not display \anx @@ -186,6 +155,42 @@ 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 @@ -245,14 +250,14 @@ class CustomDecoder(private val fallbackFormat: Format?) : SubtitleParser { ignoreCase = true )) -> SsaParser(fallbackFormat?.initializationData) - trimmedText.startsWith("1", ignoreCase = true) -> SubripParser() + trimmedText.startsWith("1", ignoreCase = true) -> CustomSubripParser() fallbackFormat != null -> { - when (val mimeType = fallbackFormat.sampleMimeType) { + when (fallbackFormat.sampleMimeType) { MimeTypes.TEXT_VTT -> WebvttParser() MimeTypes.TEXT_SSA -> SsaParser(fallbackFormat.initializationData) MimeTypes.APPLICATION_MP4VTT -> Mp4WebvttParser() MimeTypes.APPLICATION_TTML -> TtmlParser() - MimeTypes.APPLICATION_SUBRIP -> SubripParser() + MimeTypes.APPLICATION_SUBRIP -> CustomSubripParser() MimeTypes.APPLICATION_TX3G -> Tx3gParser(fallbackFormat.initializationData) // These decoders are not converted to parsers yet // TODO @@ -386,7 +391,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 @@ -398,8 +403,8 @@ class CustomSubtitleDecoderFactory : SubtitleDecoderFactory { } } -@OptIn(UnstableApi::class) /** We need to convert the newer SubtitleParser to an older SubtitleDecoder */ +@OptIn(UnstableApi::class) 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 16eb8832705..35f8dcfd8ae 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/DownloadFileGenerator.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/DownloadFileGenerator.kt @@ -1,60 +1,25 @@ package com.lagradost.cloudstream3.ui.player import android.net.Uri -import com.lagradost.cloudstream3.AcraApplication.Companion.context +import com.lagradost.cloudstream3.CloudStreamApp.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.VideoDownloadManager.getDownloadFileInfoAndUpdateSettings -import com.lagradost.cloudstream3.utils.VideoDownloadManager.getFolder -import kotlin.math.max -import kotlin.math.min +import com.lagradost.cloudstream3.utils.downloader.DownloadFileManagement.getFolder +import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager.getDownloadFileInfo class DownloadFileGenerator( - private val episodes: List, - private var currentIndex: Int = 0 -) : IGenerator { + episodes: List +) : VideoGenerator(episodes) { override val hasCache = false override val canSkipLoading = false - 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 fun getId(index: Int): Int? = this.videos.getOrNull(index)?.id override suspend fun generateLinks( clearCache: Boolean, @@ -64,14 +29,14 @@ class DownloadFileGenerator( offset: Int, isCasting: Boolean ): Boolean { - val meta = episodes[currentIndex + offset] + val meta = videos.getOrNull(offset) ?: return false if (meta.uri == Uri.EMPTY) { // We do this here so that we only load it when // we actually need it as it can be more expensive. val info = meta.id?.let { id -> activity?.let { act -> - getDownloadFileInfoAndUpdateSettings(act, id) + getDownloadFileInfo(act, id) } } @@ -90,17 +55,19 @@ class DownloadFileGenerator( getFolder(ctx, relative, meta.basePath)?.forEach { (name, uri) -> if (isMatchingSubtitle(name, display, cleanDisplay)) { val cleanName = cleanDisplayName(name) - val realName = cleanName.removePrefix(cleanDisplay) + val lastNum = Regex(" ([0-9]+)$") + val nameSuffix = lastNum.find(cleanName)?.groupValues?.get(1) ?: "" + val originalName = cleanName.removePrefix(cleanDisplay).replace(lastNum, "").trim() subtitleCallback( SubtitleData( - realName.ifBlank { ctx.getString(R.string.default_subtitles) }, - "", + originalName.ifBlank { ctx.getString(R.string.default_subtitles) }, + nameSuffix, uri.toString(), SubtitleOrigin.DOWNLOADED_FILE, name.toSubtitleMimeType(), emptyMap(), - null + fromLanguageToTagIETF(originalName, true) ) ) } 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 7fc297235af..7a42cea93f7 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/DownloadedPlayerActivity.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/DownloadedPlayerActivity.kt @@ -11,9 +11,12 @@ 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() { - private val dTAG = "DownloadedPlayerAct" + companion object { + const val TAG = "DownloadedPlayerActivity" + } override fun dispatchKeyEvent(event: KeyEvent): Boolean = CommonActivity.dispatchKeyEvent(this, event) ?: super.dispatchKeyEvent(event) @@ -26,52 +29,69 @@ class DownloadedPlayerActivity : AppCompatActivity() { CommonActivity.onUserLeaveHint(this) } + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + // Ignore same intent so the player doesnt totally + // reload if you are playing the same thing. + if (isSameIntent(intent)) return + setIntent(intent) + Log.i(TAG, "onNewIntent") + handleIntent(intent) + } + + private fun isSameIntent(newIntent: Intent): Boolean { + val old = intent ?: return false + // Compare URIs first + val oldUri = old.data ?: old.clipData?.getItemAt(0)?.uri + val newUri = newIntent.data ?: newIntent.clipData?.getItemAt(0)?.uri + if (oldUri != null && oldUri == newUri) return true + // Fall back to comparing EXTRA_TEXT links + val oldText = safe { old.getStringExtra(Intent.EXTRA_TEXT) } + val newText = safe { newIntent.getStringExtra(Intent.EXTRA_TEXT) } + return oldText != null && oldText == newText + } + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) CommonActivity.loadThemes(this) CommonActivity.init(this) + enableEdgeToEdgeCompat() setContentView(R.layout.empty_layout) - Log.i(dTAG, "onCreate") + Log.i(TAG, "onCreate") - val data = intent.data + handleIntent(intent) + attachBackPressedCallback("DownloadedPlayerActivity") { finish() } + } + private fun handleIntent(intent: Intent) { + val data = intent.data if (OfflinePlaybackHelper.playIntent(activity = this, intent = intent)) { return } - if (intent?.action == Intent.ACTION_SEND || intent?.action == Intent.ACTION_OPEN_DOCUMENT || intent?.action == Intent.ACTION_VIEW) { - val extraText = safe { // I dont trust android - intent.getStringExtra(Intent.EXTRA_TEXT) - } + if ( + intent.action == Intent.ACTION_SEND || + intent.action == Intent.ACTION_OPEN_DOCUMENT || + intent.action == Intent.ACTION_VIEW + ) { + val extraText = safe { intent.getStringExtra(Intent.EXTRA_TEXT) } val cd = intent.clipData val item = if (cd != null && cd.itemCount > 0) cd.getItemAt(0) else null val url = item?.text?.toString() - - // idk what I am doing, just hope any of these work - if (item?.uri != null) - playUri(this, item.uri) - else if (url != null) - playLink(this, url) - else if (data != null) - playUri(this, data) - else if (extraText != null) - playLink(this, extraText) - else { - finish() - return + when { + item?.uri != null -> playUri(this, item.uri) + url != null -> playLink(this, url) + data != null -> playUri(this, data) + extraText != null -> playLink(this, extraText) + else -> { finish(); return } } } else if (data?.scheme == "content") { playUri(this, data) - } else { - finish() - return - } - - attachBackPressedCallback("DownloadedPlayerActivity") { finish() } + } else finish() } override fun onResume() { super.onResume() CommonActivity.setActivityInstance(this) } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/ExtractorLinkGenerator.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/ExtractorLinkGenerator.kt index 794dd762df2..85db33fc094 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/ExtractorLinkGenerator.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/ExtractorLinkGenerator.kt @@ -6,36 +6,7 @@ import com.lagradost.cloudstream3.utils.ExtractorLinkType class ExtractorLinkGenerator( private val links: List, private val subtitles: List, -) : 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() {} - +) : NoVideoGenerator(null) { 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 new file mode 100644 index 00000000000..025267cc9ed --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/FixedNextRenderersFactory.kt @@ -0,0 +1,28 @@ +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 5a3d9d0318f..26706699bcc 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/FullScreenPlayer.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/FullScreenPlayer.kt @@ -5,55 +5,45 @@ 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.WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES +import android.view.animation.AccelerateDecelerateInterpolator import android.view.animation.AlphaAnimation -import android.view.animation.Animation -import android.view.animation.AnimationUtils +import android.view.animation.DecelerateInterpolator import android.widget.LinearLayout import androidx.annotation.OptIn +import androidx.appcompat.app.AlertDialog import androidx.core.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.screenHeight -import com.lagradost.cloudstream3.CommonActivity.screenWidth -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 @@ -63,57 +53,38 @@ import com.lagradost.cloudstream3.ui.settings.Globals.PHONE import com.lagradost.cloudstream3.ui.settings.Globals.TV import com.lagradost.cloudstream3.ui.settings.Globals.isLayout import com.lagradost.cloudstream3.utils.AppContextUtils.isUsingMobileData +import com.lagradost.cloudstream3.utils.AppContextUtils.shouldShowPlayerMetadata +import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.attachBackPressedCallback +import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.detachBackPressedCallback import com.lagradost.cloudstream3.utils.DataStoreHelper -import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showDialog import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute import com.lagradost.cloudstream3.utils.UIHelper.dismissSafe -import com.lagradost.cloudstream3.utils.UIHelper.getNavigationBarHeight -import com.lagradost.cloudstream3.utils.UIHelper.getStatusBarHeight +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.showSystemUI import com.lagradost.cloudstream3.utils.UIHelper.toPx -import com.lagradost.cloudstream3.utils.UserPreferenceDelegate -import com.lagradost.cloudstream3.utils.Vector2 import com.lagradost.cloudstream3.utils.setText import com.lagradost.cloudstream3.utils.txt -import kotlin.math.abs -import kotlin.math.ceil -import kotlin.math.max -import kotlin.math.min -import kotlin.math.round import kotlin.math.roundToInt - -const val MINIMUM_SEEK_TIME = 7000L // when swipe seeking -const val MINIMUM_VERTICAL_SWIPE = 2.0f // in percentage -const val MINIMUM_HORIZONTAL_SWIPE = 2.0f // in percentage -const val VERTICAL_MULTIPLIER = 2.0f -const val HORIZONTAL_MULTIPLIER = 2.0f -const val DOUBLE_TAB_MAXIMUM_HOLD_TIME = 200L -const val DOUBLE_TAB_MINIMUM_TIME_BETWEEN = 200L // this also affects the UI show response time -const val DOUBLE_TAB_PAUSE_PERCENTAGE = 0.15 // in both directions private const val SUBTITLE_DELAY_BUNDLE_KEY = "subtitle_delay" // All the UI Logic for the player -open class FullScreenPlayer : AbstractPlayerFragment() { - private var isVerticalOrientation: Boolean = false +@OptIn(UnstableApi::class) +open class FullScreenPlayer : AbstractPlayerFragment( + BindingCreator.Bind(FragmentPlayerBinding::bind) +) { + override fun pickLayout(): Int = R.layout.fragment_player protected open var lockRotation = true - protected open var isFullScreenPlayer = true protected var playerBinding: PlayerCustomLayoutBinding? = null - 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 @@ -122,21 +93,13 @@ open class FullScreenPlayer : AbstractPlayerFragment() { **/ protected var currentQualityProfile = 1 - // protected var currentPrefQuality = -// Qualities.P2160.value // preferred maximum quality, used for ppl w bad internet or on cell - protected var 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 autoPlayerRotateEnabled = false + protected var rotatedManually = false private var hideControlsNames = false - protected var speedupEnabled = false protected var subtitleDelay set(value) = try { player.setSubtitleOffset(-value) @@ -150,47 +113,115 @@ open class FullScreenPlayer : AbstractPlayerFragment() { 0L } - //private var useSystemBrightness = false - protected var useTrueSystemBrightness = true - private val fullscreenNotch = true //TODO SETTING - - private var statusBarHeight: Int? = null - private var navigationBarHeight: Int? = null - - private val brightnessIcons = listOf( - R.drawable.sun_1, - R.drawable.sun_2, - R.drawable.sun_3, - R.drawable.sun_4, - R.drawable.sun_5, - R.drawable.sun_6, - //R.drawable.sun_7, - // R.drawable.ic_baseline_brightness_1_24, - // R.drawable.ic_baseline_brightness_2_24, - // R.drawable.ic_baseline_brightness_3_24, - // R.drawable.ic_baseline_brightness_4_24, - // R.drawable.ic_baseline_brightness_5_24, - // R.drawable.ic_baseline_brightness_6_24, - // R.drawable.ic_baseline_brightness_7_24, - ) - - private val volumeIcons = listOf( - R.drawable.ic_baseline_volume_mute_24, - R.drawable.ic_baseline_volume_down_24, - R.drawable.ic_baseline_volume_up_24, - ) - - 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 + private var isShowingEpisodeOverlay: Boolean = false + private var previousPlayStatus: Boolean = false + + override fun fixLayout(view: View) = Unit + + /** + * Wet code but this can not be made into a function as it is a setter. + * + * The reason for this setter is to fix a bug with the titlecard popup, as we want it to autohide + * when pressing back. + * + * Note that we move the call to autoHide after field assignment with prevField to avoid inf recursion. */ + protected var selectSourceDialog: Dialog? = null + set(value) { + val prevField = field + field = value + if (value == null && prevField != null) { + autoHide() + } + } + protected var selectTrackDialog: Dialog? = null + set(value) { + val prevField = field + field = value + if (value == null && prevField != null) { + autoHide() + } + } + protected var selectSpeedDialog: Dialog? = null + set(value) { + val prevField = field + field = value + if (value == null && prevField != null) { + autoHide() + } + } + protected var selectSubtitlesDialog: Dialog? = null + set(value) { + val prevField = field + field = value + if (value == null && prevField != null) { + autoHide() + } + } + + /** Checks if any top level dialog is open and showing */ + fun isDialogOpen() = + selectSourceDialog?.isShowing == true + || selectTrackDialog?.isShowing == true + || selectSpeedDialog?.isShowing == true + || selectSubtitlesDialog?.isShowing == true + || isShowingEpisodeOverlay + + private fun scheduleMetadataVisibility() { + val metadataScrim = playerBinding?.playerMetadataScrim ?: return + val ctx = metadataScrim.context ?: return + + 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 onDestroyView() { + playerHostView?.releaseOverlayLayoutListener() playerBinding = null super.onDestroyView() } @@ -211,41 +242,12 @@ open class FullScreenPlayer : AbstractPlayerFragment() { 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 } - ?: screenHeight - val realScreenWidth = - windowMetrics?.let { windowMetrics.bounds.right - windowMetrics.bounds.left } - ?: screenWidth - - val insets = - rootWindowInsets.getInsetsIgnoringVisibility(WindowInsets.Type.systemBars()) - val isOutsideHeight = rawY < insets.top || rawY > (realScreenHeight - insets.bottom) - val isOutsideWidth = if (windowMetrics == null) { - rawX < screenWidth - } else rawX < insets.left || rawX > realScreenWidth - insets.right - - return !(isOutsideWidth || isOutsideHeight) - } else { - val statusHeight = statusBarHeight ?: 0 - return rawY > statusHeight && rawX < screenWidth - } + open fun showEpisodesOverlay() { + throw NotImplementedError() + } + + open fun isThereEpisodes(): Boolean { + return false } override fun exitedPipMode() { @@ -255,7 +257,6 @@ 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 @@ -273,18 +274,19 @@ open class FullScreenPlayer : AbstractPlayerFragment() { } protected fun animateLayoutChanges() { - if(isLayout(PHONE)) { // isEnabled also disables the onKeyDown + if (isLayout(PHONE)) { // isEnabled also disables the onKeyDown playerBinding?.exoProgress?.isEnabled = isShowing // Prevent accidental clicks/drags } if (isShowing) { updateUIVisibility() } else { + toggleEpisodesOverlay(false) playerBinding?.playerHolder?.postDelayed({ updateUIVisibility() }, 200) } val titleMove = if (isShowing) 0f else -50.toPx.toFloat() - playerBinding?.playerVideoTitle?.let { + playerBinding?.playerVideoTitleHolder?.let { ObjectAnimator.ofFloat(it, "translationY", titleMove).apply { duration = 200 start() @@ -296,6 +298,19 @@ 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 { @@ -303,7 +318,15 @@ 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) @@ -323,25 +346,10 @@ open class FullScreenPlayer : AbstractPlayerFragment() { } if (!isLocked) { - playerFfwdHolder.alpha = 1f - playerRewHolder.alpha = 1f - // player_pause_play_holder?.alpha = 1f + playerHostView?.gestureHelper?.animateCenterControls(fadeTo) shadowOverlay.isVisible = true shadowOverlay.startAnimation(fadeAnimation) - playerFfwdHolder.startAnimation(fadeAnimation) - playerRewHolder.startAnimation(fadeAnimation) - playerPausePlay.startAnimation(fadeAnimation) downloadBothHeader.startAnimation(fadeAnimation) - - /*if (isBuffering) { - player_pause_play?.isVisible = false - player_pause_play_holder?.isVisible = false - } else { - player_pause_play?.isVisible = true - player_pause_play_holder?.startAnimation(fadeAnimation) - player_pause_play?.startAnimation(fadeAnimation) - }*/ - //player_buffering?.startAnimation(fadeAnimation) } bottomPlayerBar.startAnimation(fadeAnimation) @@ -350,11 +358,10 @@ open class FullScreenPlayer : AbstractPlayerFragment() { } } - @OptIn(UnstableApi::class) override fun subtitlesChanged() { val tracks = player.getVideoTracks() val isBuiltinSubtitles = tracks.currentTextTracks.all { track -> - track.mimeType == MimeTypes.APPLICATION_MEDIA3_CUES + track.sampleMimeType == MimeTypes.APPLICATION_MEDIA3_CUES } // Subtitle offset is not possible on built-in media3 tracks playerBinding?.playerSubtitleOffsetBtt?.isGone = @@ -370,7 +377,7 @@ open class FullScreenPlayer : AbstractPlayerFragment() { Configuration.ORIENTATION_PORTRAIT -> ActivityInfo.SCREEN_ORIENTATION_SENSOR_PORTRAIT - else -> dynamicOrientation() + else -> playerHostView?.dynamicOrientation() ?: return } activity.requestedOrientation = orientation } @@ -384,14 +391,14 @@ open class FullScreenPlayer : AbstractPlayerFragment() { Configuration.ORIENTATION_PORTRAIT -> ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE - else -> dynamicOrientation() + else -> playerHostView?.dynamicOrientation() ?: return } activity.requestedOrientation = orientation } - open fun lockOrientation(activity: Activity) { - @Suppress("DEPRECATION") + private fun lockOrientation(activity: Activity) { val display = if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) + @Suppress("DEPRECATION") (activity.getSystemService(Context.WINDOW_SERVICE) as WindowManager).defaultDisplay else activity.display!! val rotation = display.rotation @@ -412,7 +419,7 @@ open class FullScreenPlayer : AbstractPlayerFragment() { else ActivityInfo.SCREEN_ORIENTATION_REVERSE_PORTRAIT - else -> orientation = dynamicOrientation() + else -> orientation = playerHostView?.dynamicOrientation() ?: return } activity.requestedOrientation = orientation } @@ -423,55 +430,63 @@ open class FullScreenPlayer : AbstractPlayerFragment() { if (isLocked) { lockOrientation(this) } else { - if (ignoreDynamicOrientation) { - // restore when lock is disabled + if (ignoreDynamicOrientation || rotatedManually) { + // Restore when lock is disabled. restoreOrientationWithSensor(this) } else { - this.requestedOrientation = dynamicOrientation() + this.requestedOrientation = + playerHostView?.dynamicOrientation() ?: return@apply } } } } } - protected fun enterFullscreen() { - if (isFullScreenPlayer) { - activity?.hideSystemUI() - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P && fullscreenNotch) { - val params = activity?.window?.attributes - params?.layoutInDisplayCutoutMode = LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES - activity?.window?.attributes = params + 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 } } - 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 + override fun onResume() { + playerHostView?.enterFullscreen { updateOrientation() } + setupKeyEventListener() + playerHostView?.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 + onClickChange() + } else { + activity?.popCurrentPage("FullScreenPlayer") + } } - activity?.window?.attributes = lp - activity?.showSystemUI() + playerHostView?.requestUpdateBrightnessOverlayOnNextLayout() + super.onResume() } - override fun onResume() { - enterFullscreen() - verifyVolume() - super.onResume() + override fun onStop() { + activity?.detachBackPressedCallback("FullScreenPlayer") + super.onStop() } override fun onDestroy() { - exitFullscreen() - player.release() - player.releaseCallbacks() - player = CS3IPlayer() + playerHostView?.exitFullscreen() super.onDestroy() } @@ -504,30 +519,21 @@ 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.AlertDialogCustomBlack).apply { + val dialog = Dialog(ctx, R.style.DialogFullscreenPlayer).apply { setContentView(binding.root) } + this.selectSubtitlesDialog = dialog dialog.show() - val beforeOffset = subtitleDelay + val isPortrait = + ctx.resources.configuration.orientation == Configuration.ORIENTATION_PORTRAIT + fixSystemBarsPadding(binding.root, fixIme = isPortrait) + var currentOffset = subtitleDelay binding.apply { - var subtitleAdapter: SubtitleOffsetItemAdapter? = null - subtitleOffsetInput.doOnTextChanged { text, _, _, _ -> text?.toString()?.toLongOrNull()?.let { time -> - 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) - } - + currentOffset = time val str = when { time > 0L -> { txt(R.string.subtitle_offset_extra_hint_later_format, time) @@ -545,19 +551,21 @@ open class FullScreenPlayer : AbstractPlayerFragment() { } } subtitleOffsetInput.text = - Editable.Factory.getInstance()?.newEditable(beforeOffset.toString()) + Editable.Factory.getInstance()?.newEditable(currentOffset.toString()) val subtitles = player.getSubtitleCues().toMutableList() subtitleOffsetRecyclerview.isVisible = subtitles.isNotEmpty() noSubtitlesLoadedNotice.isVisible = subtitles.isEmpty() - val initialSubtitlePosition = (player.getPosition() ?: 0) - subtitleDelay - subtitleAdapter = - SubtitleOffsetItemAdapter(initialSubtitlePosition, subtitles) { subtitleCue -> + val initialSubtitlePosition = (player.getPosition() ?: 0) - currentOffset + val subtitleAdapter = + SubtitleOffsetItemAdapter(initialSubtitlePosition) { subtitleCue -> val playerPosition = player.getPosition() ?: 0 subtitleOffsetInput.text = Editable.Factory.getInstance() ?.newEditable((playerPosition - subtitleCue.startTimeMs).toString()) + }.apply { + submitList(subtitles) } subtitleOffsetRecyclerview.adapter = subtitleAdapter @@ -591,147 +599,109 @@ open class FullScreenPlayer : AbstractPlayerFragment() { } dialog.setOnDismissListener { - if (isFullScreenPlayer) - activity?.hideSystemUI() + selectSubtitlesDialog = null + 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 { - subtitleDelay = beforeOffset + selectSubtitlesDialog = null dialog.dismissSafe(activity) } } } - - private fun showSpeedDialog() { - 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()) - - 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]) - } - } - } - - fun resetRewindText() { - playerBinding?.exoRewText?.text = - getString(R.string.rew_text_regular_format).format(fastForwardTime / 1000) - } - - fun resetFastForwardText() { - playerBinding?.exoFfwdText?.text = - getString(R.string.ffw_text_regular_format).format(fastForwardTime / 1000) + @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 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?) {} + private fun showSpeedDialog() { + val act = activity ?: return + val isPlaying = player.getIsPlaying() + player.handleEvent(CSPlayerEvent.Pause, PlayerEventSource.UI) - override fun onAnimationRepeat(animation: Animation?) {} + val binding: SpeedDialogBinding = SpeedDialogBinding.inflate( + LayoutInflater.from(act) + ) - 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) + 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) } - player.seekTime(-fastForwardTime) - } catch (e: Exception) { - logError(e) } - } - - private fun fastForward() { - try { - playerBinding?.apply { - playerCenterMenu.isGone = false - playerFfwdHolder.alpha = 1f - val rotateRight = AnimationUtils.loadAnimation(context, R.anim.rotate_right) - playerFfwd.startAnimation(rotateRight) + binding.speedMinus.setOnClickListener { + setPlayBackSpeed(maxOf((player.getPlaybackSpeed() - 0.1f), 0.1f)) + updateSpeedDialogBinding(binding) + } - val goRight = AnimationUtils.loadAnimation(context, R.anim.go_right) - goRight.setAnimationListener(object : Animation.AnimationListener { - override fun onAnimationStart(animation: Animation?) {} + binding.speedPlus.setOnClickListener { + setPlayBackSpeed(minOf((player.getPlaybackSpeed() + 0.1f), 2.0f)) + updateSpeedDialogBinding(binding) + } - override fun onAnimationRepeat(animation: Animation?) {} + binding.speedBar.addOnChangeListener { _, value, fromUser -> + if (fromUser) { + setPlayBackSpeed(value) + updateSpeedDialogBinding(binding) + } + } - 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) + val dismiss = DialogInterface.OnDismissListener { + activity?.hideSystemUI() + if (isPlaying) { + player.handleEvent(CSPlayerEvent.Play, PlayerEventSource.UI) } - player.seekTime(fastForwardTime) - } catch (e: Exception) { - logError(e) + selectSpeedDialog = null } + + // 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) { - playerBinding?.playerIntroPlay?.isGone = true - autoHide() - } - if (isFullScreenPlayer) - activity?.hideSystemUI() + if (isShowing) autoHide() + activity?.hideSystemUI() animateLayoutChanges() - playerBinding?.playerPausePlay?.requestFocus() + if (playerBinding?.playerEpisodeOverlay?.isGone == true) playerBinding?.playerPausePlay?.requestFocus() } private fun toggleLock() { @@ -740,6 +710,7 @@ open class FullScreenPlayer : AbstractPlayerFragment() { } isLocked = !isLocked + playerHostView?.isLocked = isLocked updateOrientation(true) // set true to ignore auto rotate to stay in current orientation if (isLocked && isShowing) { @@ -751,41 +722,37 @@ open class FullScreenPlayer : AbstractPlayerFragment() { } val fadeTo = if (isLocked) 0f else 1f + playerHostView?.gestureHelper?.animateCenterControls(fadeTo) playerBinding?.apply { - val fadeAnimation = AlphaAnimation(playerVideoTitle.alpha, fadeTo).apply { + val fadeAnimation = AlphaAnimation(playerVideoTitleHolder.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) - // player_episodes_button?.startAnimation(fadeAnimation) - //player_media_route_button?.startAnimation(fadeAnimation) - //video_bar.startAnimation(fadeAnimation) + if (hasEpisodes) + playerEpisodesButton.startAnimation(fadeAnimation) + // player_media_route_button?.startAnimation(fadeAnimation) + // video_bar.startAnimation(fadeAnimation) - //TITLE + // TITLE playerVideoTitleRez.startAnimation(fadeAnimation) + playerVideoInfo.startAnimation(fadeAnimation) playerEpisodeFiller.startAnimation(fadeAnimation) - playerVideoTitle.startAnimation(fadeAnimation) + playerVideoTitleHolder.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() } - open fun updateUIVisibility() { + private fun updateUIVisibility() { val isGone = isLocked || !isShowing var togglePlayerTitleGone = isGone context?.let { @@ -796,22 +763,23 @@ open class FullScreenPlayer : AbstractPlayerFragment() { } } playerBinding?.apply { - playerLockHolder.isGone = isGone playerVideoBar.isGone = isGone - playerPausePlay.isGone = isGone - //player_buffering?.isGone = isGone + playerPausePlayHolderHolder.isGone = + isGone || currentPlayerStatus == CSPlayerLoading.IsBuffering playerTopHolder.isGone = isGone - //player_episodes_button?.isVisible = !isGone && hasEpisodes - playerVideoTitle.isGone = togglePlayerTitleGone -// player_video_title_rez?.isGone = isGone + val showPlayerEpisodes = !isGone && isThereEpisodes() + playerEpisodesButtonRoot.isVisible = showPlayerEpisodes + playerEpisodesButton.isVisible = showPlayerEpisodes + playerVideoTitleHolder.isGone = togglePlayerTitleGone + playerVideoTitleRez.isGone = isGone || playerVideoTitleRez.text.isBlank() playerEpisodeFiller.isGone = isGone playerCenterMenu.isGone = isGone playerLock.isGone = !isShowing - //player_media_route_button?.isClickable = !isGone playerGoBackHolder.isGone = isGone playerSourcesBtt.isGone = isGone + shadowOverlay.isGone = isGone playerSkipEpisode.isClickable = !isGone } } @@ -819,485 +787,231 @@ open class FullScreenPlayer : AbstractPlayerFragment() { private fun updateLockUI() { playerBinding?.apply { playerLock.setIconResource(if (isLocked) R.drawable.video_locked else R.drawable.video_unlocked) - if (layout == R.layout.fragment_player) { - val color = if (isLocked) context?.colorFromAttribute(R.attr.colorPrimary) - else Color.WHITE - if (color != null) { - playerLock.setTextColor(color) - playerLock.iconTint = ColorStateList.valueOf(color) - playerLock.rippleColor = - ColorStateList.valueOf(Color.argb(50, color.red, color.green, color.blue)) - } + val color = if (isLocked) context?.colorFromAttribute(R.attr.colorPrimary) + else Color.WHITE + if (color != null) { + playerLock.setTextColor(color) + playerLock.iconTint = ColorStateList.valueOf(color) + playerLock.rippleColor = + ColorStateList.valueOf(Color.argb(50, color.red, color.green, color.blue)) } } } - private var currentTapIndex = 0 protected fun autoHide() { - currentTapIndex++ - delayHide() + metadataVisibilityToken++ + playerHostView?.scheduleAutoHide() + scheduleMetadataVisibility() } - override fun playerStatusChanged() { - super.playerStatusChanged() - delayHide() + override fun onAutoHideUI() { + if (player.getIsPlaying()) onClickChange() } - private fun delayHide() { - val index = currentTapIndex - playerBinding?.playerHolder?.postDelayed({ - if (!isCurrentTouchValid && isShowing && index == currentTapIndex && player.getIsPlaying()) { - onClickChange() - } - }, 2000) + protected fun hidePlayerUI() { + if (isShowing) { + isShowing = false + animateLayoutChanges() + } } - // 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() - } + /** PlayerView.Callbacks touch overrides */ + + override fun isUIShowing(): Boolean = isShowing + + override fun onSingleTap() { + onClickChange() } - private var isCurrentTouchValid = false - private var currentTouchStart: Vector2? = null - private var currentTouchLast: Vector2? = null - private var currentTouchAction: TouchAction? = null - private var currentLastTouchAction: TouchAction? = null - private var currentTouchStartPlayerTime: Long? = - null // the time in the player when you first click - private var currentTouchStartTime: Long? = null // the system time when you first click - private var currentLastTouchEndTime: Long = 0 // the system time when you released your finger - private var currentClickCount: Int = - 0 // amount of times you have double clicked, will reset when other action is taken - - // requested volume and brightness is used to make swiping smoother - // to make it not jump between values, - // this value is within the range [0,2] where 1+ is loudness - private var currentRequestedVolume: Float = 0.0f - - // this value is within the range [0,1] - private var currentRequestedBrightness: Float = 1.0f - - enum class TouchAction { - Brightness, - Volume, - Time, + override fun onTouchDown() { + if (isShowingEpisodeOverlay) toggleEpisodesOverlay(show = false) } - 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() - } + @SuppressLint("SetTextI18n") + override fun onSeekPreviewText(text: String?) { + playerBinding?.playerTimeText?.apply { + isVisible = text != null + if (text != null) this.text = text } + } - 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 - ) + override fun onHidePlayerUI() { + hidePlayerUI() + } + + override fun onGestureEnd(hadSwipe: Boolean, wasUiShowing: Boolean) { + if (!player.getIsPlaying() && hadSwipe && wasUiShowing && !isShowing) { + isShowing = true + animateLayoutChanges() } + autoHide() } - 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 / screenWidth.toFloat() - val duration = player.getDuration() ?: return null - return max( - min( - startTime + ((duration * (diffX * diffX)) * (if (diffX < 0) -1 else 1)).toLong(), - duration - ), 0 - ) + override fun playerStatusChanged() { + super.playerStatusChanged() + scheduleMetadataVisibility() } - 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 + // When the hold-speedup gesture fires, hide controls so the video is unobstructed. + // The speedup button show/hide and speed change are handled by PlayerView. + override fun onHoldSpeedUp(show: Boolean) { + if (show && isShowing) onClickChange() + } + + override fun onConfigurationChanged(newConfig: Configuration) { + super.onConfigurationChanged(newConfig) + + // If we rotate the device we need to recalculate the zoom + val 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 fun setBrightness(brightness: Float) { - if (useTrueSystemBrightness) { - try { - Settings.System.putInt( - context?.contentResolver, - Settings.System.SCREEN_BRIGHTNESS_MODE, - Settings.System.SCREEN_BRIGHTNESS_MODE_MANUAL - ) + override fun resize(resize: PlayerResize, showToast: Boolean) { + super.resize(resize, showToast) + playerHostView?.requestUpdateBrightnessOverlayOnNextLayout() + } - Settings.System.putInt( - context?.contentResolver, - Settings.System.SCREEN_BRIGHTNESS, (brightness * 255).toInt() - ) - } catch (e: Exception) { - useTrueSystemBrightness = false - setBrightness(brightness) + 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) } - } else { - try { - val lp = activity?.window?.attributes - lp?.screenBrightness = brightness - activity?.window?.attributes = lp - } catch (e: Exception) { - logError(e) + + KeyEvent.KEYCODE_A, KeyEvent.KEYCODE_MEDIA_SKIP_BACKWARD, KeyEvent.KEYCODE_MEDIA_REWIND -> { + player.handleEvent(CSPlayerEvent.SeekBack) } - } - } - private var isVolumeLocked: Boolean = false - private var hasShownVolumeToast: Boolean = false - - private var progressBarLeftHideRunnable: Runnable? = null - private var progressBarRightHideRunnable: Runnable? = null - - // Verifies that the currentRequestedVolume matches the system volume - // if not, then it removes changes currentRequestedVolume and removes the loudnessEnhancer - // if the real volume is less than 100% - // - // This is here to make returning to the player less jarring, if we change the volume outside - // the app. Note that this will make it a bit wierd when using loudness in PiP, then returning - // however that is the cost of correctness. - private fun verifyVolume() { - (activity?.getSystemService(Context.AUDIO_SERVICE) as? AudioManager)?.let { audioManager -> - val currentVolumeStep = - audioManager.getStreamVolume(AudioManager.STREAM_MUSIC) - val maxVolumeStep = - audioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC) - - // if we can set the volume directly then do it - if (currentVolumeStep < maxVolumeStep || currentRequestedVolume <= 1.0f) { - currentRequestedVolume = - currentVolumeStep.toFloat() / maxVolumeStep.toFloat() - - loudnessEnhancer?.release() - loudnessEnhancer = null + KeyEvent.KEYCODE_MEDIA_NEXT, KeyEvent.KEYCODE_BUTTON_R1, KeyEvent.KEYCODE_N, KeyEvent.KEYCODE_NUMPAD_2, KeyEvent.KEYCODE_CHANNEL_UP -> { + player.handleEvent(CSPlayerEvent.NextEpisode) } - } - } - val holdhandler = Handler(Looper.getMainLooper()) - var hasTriggeredSpeedUp = false - val holdRunnable = Runnable { - player.setPlaybackSpeed(2.0f) - playerBinding?.playerSpeedupButton?.isGone = false - hasTriggeredSpeedUp = true - } + KeyEvent.KEYCODE_MEDIA_PREVIOUS, KeyEvent.KEYCODE_BUTTON_L1, KeyEvent.KEYCODE_B, KeyEvent.KEYCODE_NUMPAD_1, KeyEvent.KEYCODE_CHANNEL_DOWN -> { + player.handleEvent(CSPlayerEvent.PrevEpisode) + } - @SuppressLint("SetTextI18n") - private fun handleMotionEvent(view: View?, event: MotionEvent?): Boolean { - if (event == null || view == null) return false - val currentTouch = Vector2(event.x, event.y) - val startTouch = currentTouchStart + KeyEvent.KEYCODE_MEDIA_PAUSE -> { + player.handleEvent(CSPlayerEvent.Pause) + } - 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 - } + KeyEvent.KEYCODE_MEDIA_PLAY, KeyEvent.KEYCODE_BUTTON_START -> { + player.handleEvent(CSPlayerEvent.Play) + } - currentTouchStartTime = System.currentTimeMillis() - currentTouchStart = currentTouch - currentTouchLast = currentTouch - currentTouchStartPlayerTime = player.getPosition() + KeyEvent.KEYCODE_L, KeyEvent.KEYCODE_NUMPAD_7, KeyEvent.KEYCODE_7 -> { + toggleLock() + } - getBrightness()?.let { - currentRequestedBrightness = it - } - verifyVolume() - } + 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) {} } + } - 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) - } - } - } - } - } + KeyEvent.KEYCODE_E, KeyEvent.KEYCODE_NUMPAD_3, KeyEvent.KEYCODE_3 -> { + showSpeedDialog() + } - // 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 < screenWidth / 2 - (DOUBLE_TAB_PAUSE_PERCENTAGE * screenWidth) -> { - if (doubleTapEnabled) - rewind() - } - - currentTouch.x > screenWidth / 2 + (DOUBLE_TAB_PAUSE_PERCENTAGE * screenWidth) -> { - if (doubleTapEnabled) - fastForward() - } - - else -> { - player.handleEvent( - CSPlayerEvent.PlayPauseToggle, - PlayerEventSource.UI - ) - } - } - } else if (doubleTapEnabled && isFullScreenPlayer) { - if (currentTouch.x < screenWidth / 2) { - rewind() - } else { - fastForward() - } - } - } - } else { - // is a valid click but not fast enough for seek - currentClickCount = 0 - if(!hasTriggeredSpeedUp){ - toggleShowDelayed() - } - //onClickChange() - } - } else { - currentClickCount = 0 - } + KeyEvent.KEYCODE_R, KeyEvent.KEYCODE_NUMPAD_0, KeyEvent.KEYCODE_0 -> { + nextResize() + } - // call auto hide as it wont hide when you have your finger down - autoHide() + KeyEvent.KEYCODE_C, KeyEvent.KEYCODE_NUMPAD_4, KeyEvent.KEYCODE_4 -> { + skipOp() + } - // reset variables - isCurrentTouchValid = false - currentTouchStart = null - currentLastTouchAction = currentTouchAction - currentTouchAction = null - currentTouchStartPlayerTime = null - currentTouchLast = null - currentTouchStartTime = null + KeyEvent.KEYCODE_V, KeyEvent.KEYCODE_NUMPAD_5, KeyEvent.KEYCODE_5 -> { + player.handleEvent(CSPlayerEvent.SkipCurrentChapter) + } - // resets UI - playerTimeText.isVisible = false + KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE, KeyEvent.KEYCODE_P, KeyEvent.KEYCODE_SPACE, KeyEvent.KEYCODE_NUMPAD_ENTER, KeyEvent.KEYCODE_ENTER -> { // space is not captured due to navigation + player.handleEvent(CSPlayerEvent.PlayPauseToggle) + } - currentLastTouchEndTime = System.currentTimeMillis() + KeyEvent.KEYCODE_DPAD_CENTER -> { + if (isShowing) { + return null } + // If UI is not shown make click instantly skip to next chapter even if locked + if (timestampShowState) { + player.handleEvent(CSPlayerEvent.SkipCurrentChapter) + } else if (!isLocked) { + player.handleEvent(CSPlayerEvent.PlayPauseToggle) + } + onClickChange() + } - MotionEvent.ACTION_MOVE -> { - // if current touch is valid + KeyEvent.KEYCODE_DPAD_DOWN, + KeyEvent.KEYCODE_DPAD_UP -> { + if (isShowing || isShowingEpisodeOverlay) { + return null + } + onClickChange() + } - 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 / screenHeight) > MINIMUM_VERTICAL_SWIPE) { - // left = Brightness, right = Volume, but the UI is reversed to show the UI better - currentTouchAction = if (startTouch.x < screenWidth / 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 / screenHeight) > MINIMUM_HORIZONTAL_SWIPE) { - currentTouchAction = TouchAction.Time - } - } - } + 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 + } + } - // display action - val lastTouch = currentTouchLast - if (lastTouch != null) { - val diffFromLast = lastTouch - currentTouch - val verticalAddition = - diffFromLast.y * VERTICAL_MULTIPLIER / screenHeight.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 - } - } - } + KeyEvent.KEYCODE_DPAD_RIGHT -> { + if (!isShowing && !isLocked && !isShowingEpisodeOverlay) { + player.seekTime(androidTVInterfaceOffSeekTime) + } else if (playerBinding?.playerPausePlay?.isFocused == true) { + player.seekTime(androidTVInterfaceOnSeekTime) + } else { + return null + } + } + + KeyEvent.KEYCODE_VOLUME_DOWN, + KeyEvent.KEYCODE_VOLUME_UP -> { + // Handled entirely by PlayerView.handleVolumeKey (checks PHONE/EMULATOR). + if (playerHostView?.handleVolumeKey(keyCode) != true) { + return null + } + } + + KeyEvent.KEYCODE_MENU, + KeyEvent.KEYCODE_SETTINGS -> { + if (isLocked || !isThereEpisodes()) { + return null } + toggleEpisodesOverlay(true) } + else -> return null // Avoid capturing all input } - currentTouchLast = currentTouch return true } - @SuppressLint("GestureBackNavigation") private fun handleKeyEvent(event: KeyEvent, hasNavigated: Boolean): Boolean { if (hasNavigated) { autoHide() @@ -1306,69 +1020,9 @@ open class FullScreenPlayer : AbstractPlayerFragment() { val keyCode = event.keyCode if (event.action == KeyEvent.ACTION_DOWN) { - when (keyCode) { - KeyEvent.KEYCODE_DPAD_CENTER -> { - if (!isShowing) { - if (!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 - } - } + val value = handleKeyDownEvent(keyCode) + if (value != null) { + return value } } @@ -1389,131 +1043,27 @@ open class FullScreenPlayer : AbstractPlayerFragment() { } // netflix capture back and hide ~monke - KeyEvent.KEYCODE_BACK -> { + // This is removed due to inconsistent behavior on A36 vs A22, see https://github.com/recloudstream/cloudstream/issues/1804 + /*KeyEvent.KEYCODE_BACK -> { if (isShowing && isLayout(TV or EMULATOR)) { onClickChange() return true } - } + }*/ } return 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) - } - - // 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) { - loudnessEnhancer = LoudnessEnhancer(audioSessionId).apply { - setTargetGain(boostFactor) - enabled = true - } - } - } - } else { - loudnessEnhancer?.release() - loudnessEnhancer = null - } - - currentRequestedVolume = nextVolume - - // Update the progress bar - playerBinding?.apply { - val level1ProgressBar = playerProgressbarLeftLevel1 - val level2ProgressBar = playerProgressbarLeftLevel2 - - level1ProgressBar.max = 100_000 - level1ProgressBar.progress = (nextVolume * 100_000f).toInt().coerceIn(2_000, 100_000) - - level2ProgressBar.max = 100_000 - level2ProgressBar.progress = - if (nextVolume > 1.0f) ((nextVolume - 1.0) * 100_000f).toInt() - .coerceIn(2_000, 100_000) else 0 - level2ProgressBar.isVisible = nextVolume > 1.0f - - // Calculate the clamped index for the volume icon based on the requested volume - val iconIndex = (nextVolume * (volumeIcons.lastIndex)) - .roundToInt() - .coerceIn(0, volumeIcons.lastIndex) - - // Update icon - playerProgressbarLeftIcon.setImageResource(volumeIcons[iconIndex]) - } - - // alpha fade - playerBinding?.playerProgressbarLeftHolder?.apply { - if (!isVisible || alpha < 1f) { - alpha = 1f - isVisible = true - } - - progressBarLeftHideRunnable?.let { removeCallbacks(it) } - progressBarLeftHideRunnable = Runnable { - // Fade out the progress bar - animate().cancel() - animate() - .alpha(0f) - .setDuration(300) - .withEndAction { isVisible = false } - .start() - } - // Show the progress bar for 1.5 seconds - postDelayed(progressBarLeftHideRunnable, 1500) - } - } - protected fun uiReset() { + metadataVisibilityToken++ + playerBinding?.playerMetadataScrim?.let { + it.animate().cancel() + it.alpha = 0f + it.isVisible = false + } isShowing = false - + toggleEpisodesOverlay(false) // if nothing has loaded these buttons should not be visible playerBinding?.apply { playerSkipEpisode.isVisible = false @@ -1525,8 +1075,8 @@ open class FullScreenPlayer : AbstractPlayerFragment() { updateLockUI() updateUIVisibility() animateLayoutChanges() - resetFastForwardText() - resetRewindText() + playerHostView?.gestureHelper?.resetFastForwardText() + playerHostView?.gestureHelper?.resetRewindText() } override fun onSaveInstanceState(outState: Bundle) { @@ -1535,109 +1085,35 @@ open class FullScreenPlayer : AbstractPlayerFragment() { super.onSaveInstanceState(outState) } - @SuppressLint("ClickableViewAccessibility") - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - // init variables - setPlayBackSpeed(DataStoreHelper.playBackSpeed) - savedInstanceState?.getLong(SUBTITLE_DELAY_BUNDLE_KEY)?.let { - subtitleDelay = it - } + 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)) - // handle tv controls - playerEventListener = { eventType -> - when (eventType) { - PlayerEventType.Lock -> { - toggleLock() - } + super.onBindingCreated(binding, savedInstanceState) - PlayerEventType.NextEpisode -> { - player.handleEvent(CSPlayerEvent.NextEpisode) - } + // This player is always full-screen; tell PlayerView so volume-key handling is active. + playerHostView?.isFullScreen = true - 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) - } + // Wire up the snap-hint outline view and schedule brightness overlay bounds update + playerHostView?.videoOutline = playerBinding?.videoOutline + playerHostView?.requestUpdateBrightnessOverlayOnNextLayout() - PlayerEventType.ToggleMute -> { - player.handleEvent(CSPlayerEvent.ToggleMute) - } - - PlayerEventType.ToggleHide -> { - onClickChange() - } - - PlayerEventType.ShowMirrors -> { - showMirrorsDialogue() - } - - PlayerEventType.SearchSubtitlesOnline -> { - if (subsProvidersIsActive) { - openOnlineSubPicker(view.context, null) {} - } - } - - PlayerEventType.SkipOp -> { - skipOp() - } - } + val view = binding.root + // init variables + setPlayBackSpeed(DataStoreHelper.playBackSpeed) + savedInstanceState?.getLong(SUBTITLE_DELAY_BUNDLE_KEY)?.let { + subtitleDelay = it } // handle tv controls directly based on player state - keyEventListener = { eventNav -> - // Don't hook player keys if player isn't active - if (player.isActive()) { - val (event, hasNavigated) = eventNav - if (event != null) - handleKeyEvent(event, hasNavigated) - else false - } else false - } + setupKeyEventListener() try { context?.let { ctx -> val settingsManager = PreferenceManager.getDefaultSharedPreferences(ctx) - fastForwardTime = - settingsManager.getInt(ctx.getString(R.string.double_tap_seek_time_key), 10) - .toLong() * 1000L - androidTVInterfaceOffSeekTime = settingsManager.getInt( ctx.getString(R.string.android_tv_interface_off_seek_key), @@ -1651,16 +1127,6 @@ open class FullScreenPlayer : AbstractPlayerFragment() { ) .toLong() * 1000L - navigationBarHeight = ctx.getNavigationBarHeight() - statusBarHeight = ctx.getStatusBarHeight() - - swipeHorizontalEnabled = - settingsManager.getBoolean(ctx.getString(R.string.swipe_enabled_key), true) - swipeVerticalEnabled = - settingsManager.getBoolean( - ctx.getString(R.string.swipe_vertical_enabled_key), - true - ) playBackSpeedEnabled = settingsManager.getBoolean( ctx.getString(R.string.playback_speed_enabled_key), false @@ -1669,58 +1135,31 @@ 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.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) + profiles.firstOrNull { it.types.contains(type) }?.id + ?: profiles.firstOrNull()?.id + ?: currentQualityProfile } playerBinding?.apply { playerSpeedBtt.isVisible = playBackSpeedEnabled playerResizeBtt.isVisible = playerResizeEnabled - playerRotateBtt.isVisible = playerRotateEnabled + playerRotateBtt.isVisible = + if (isLayout(TV or EMULATOR)) false else playerRotateEnabled if (hideControlsNames) { hideControlsNames() } @@ -1730,13 +1169,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) { @@ -1744,25 +1183,17 @@ 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) } @@ -1812,18 +1243,8 @@ open class FullScreenPlayer : AbstractPlayerFragment() { showSubtitleOffsetDialog() } - playerRew.setOnClickListener { - autoHide() - rewind() - } - - playerFfwd.setOnClickListener { - autoHide() - fastForward() - } - playerGoBack.setOnClickListener { - activity?.popCurrentPage() + activity?.popCurrentPage("FullScreenPlayer") } playerSourcesBtt.setOnClickListener { @@ -1834,20 +1255,21 @@ open class FullScreenPlayer : AbstractPlayerFragment() { showTracksDialogue() } - // it is !not! a bug that you cant touch the right side, it does not register inputs on navbar or status bar - playerHolder.setOnTouchListener { callView, event -> - return@setOnTouchListener handleMotionEvent(callView, event) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + playerControlsScroll.setOnScrollChangeListener { _, _, _, _, _ -> + autoHide() + } } + exoProgress.registerPlayerView(playerView) + + @SuppressLint("ClickableViewAccessibility") exoProgress.setOnTouchListener { _, event -> // this makes the bar not disappear when sliding when (event.action) { - MotionEvent.ACTION_DOWN -> { - currentTapIndex++ - } - + MotionEvent.ACTION_DOWN, MotionEvent.ACTION_MOVE -> { - currentTapIndex++ + playerHostView?.cancelAutoHide() } MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL, MotionEvent.ACTION_BUTTON_RELEASE -> { @@ -1856,11 +1278,9 @@ open class FullScreenPlayer : AbstractPlayerFragment() { } return@setOnTouchListener false } - } - // cs3 is peak media center - setRemainingTimeCounter(durationMode || isLayout(TV)) - playerBinding?.exoPosition?.doOnTextChanged { _, _, _, _ -> - updateRemainingTime() + playerEpisodesButton.setOnClickListener { + toggleEpisodesOverlay(show = true) + } } // init UI try { @@ -1870,10 +1290,10 @@ open class FullScreenPlayer : AbstractPlayerFragment() { } } - @SuppressLint("SourceLockedOrientationActivity") private fun toggleRotate() { activity?.let { toggleOrientationWithSensor(it) + rotatedManually = true } } @@ -1894,37 +1314,48 @@ open class FullScreenPlayer : AbstractPlayerFragment() { } override fun playerDimensionsLoaded(width: Int, height: Int) { - isVerticalOrientation = height > width + // PlayerView already set isVerticalOrientation; skip rotation on TV (pillarbox instead). + if (isLayout(TV or EMULATOR)) return + // Skip zero-size events emitted when the player transitions to STATE_IDLE, + // acting on them would reset auto-detected orientation to landscape. + if (width <= 0 || height <= 0) return updateOrientation() } - private fun updateRemainingTime() { - val duration = player.getDuration() - val position = player.getPosition() - - if (duration != null && duration > 1 && position != null) { - val remainingTimeSeconds = (duration - position + 500) / 1000 - val formattedTime = "-${DateUtils.formatElapsedTime(remainingTimeSeconds)}" - - playerBinding?.timeLeft?.text = formattedTime + private fun 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 setRemainingTimeCounter(showRemaining: Boolean) { - durationMode = showRemaining - playerBinding?.exoDuration?.isInvisible = showRemaining - playerBinding?.timeLeft?.isVisible = showRemaining - } + private fun animateEpisodesOverlay(show: Boolean) { + playerBinding?.playerEpisodeOverlay?.let { overlay -> + overlay.animate().cancel() + (overlay.parent as? ViewGroup)?.layoutTransition = null // Disable layout transitions - 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 + 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() } } } 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 5b925c71e11..2dfd5ef4df0 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/GeneratorPlayer.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/GeneratorPlayer.kt @@ -28,6 +28,7 @@ import androidx.core.animation.addListener import androidx.core.app.NotificationCompat import androidx.core.app.PendingIntentCompat import androidx.core.content.ContextCompat +import androidx.core.content.edit import androidx.core.text.toSpanned import androidx.core.view.isGone import androidx.core.view.isVisible @@ -37,15 +38,16 @@ 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.AcraApplication -import com.lagradost.cloudstream3.AcraApplication.Companion.setKey +import com.lagradost.cloudstream3.CloudStreamApp +import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey import com.lagradost.cloudstream3.CommonActivity.showToast import com.lagradost.cloudstream3.LoadResponse import com.lagradost.cloudstream3.LoadResponse.Companion.getAniListId @@ -66,50 +68,62 @@ import com.lagradost.cloudstream3.isLiveStream import com.lagradost.cloudstream3.isMovieType import com.lagradost.cloudstream3.mvvm.Resource import com.lagradost.cloudstream3.mvvm.logError -import com.lagradost.cloudstream3.mvvm.safe import com.lagradost.cloudstream3.mvvm.observe import com.lagradost.cloudstream3.mvvm.observeNullable -import com.lagradost.cloudstream3.subtitles.AbstractSubApi +import com.lagradost.cloudstream3.mvvm.safe import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities.SubtitleSearch -import com.lagradost.cloudstream3.subtitles.SubRepository 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.getAutoSelectLanguageISO639_1 +import com.lagradost.cloudstream3.ui.subtitles.SubtitlesFragment.Companion.getAutoSelectLanguageTagIETF +import com.lagradost.cloudstream3.utils.AppContextUtils.getShortSeasonText import com.lagradost.cloudstream3.utils.AppContextUtils.html import com.lagradost.cloudstream3.utils.AppContextUtils.sortSubs import com.lagradost.cloudstream3.utils.Coroutines.ioSafe import com.lagradost.cloudstream3.utils.Coroutines.runOnMainThread import com.lagradost.cloudstream3.utils.DataStoreHelper -import com.lagradost.cloudstream3.utils.EpisodeSkip +import com.lagradost.cloudstream3.utils.DataStoreHelper.getViewPos 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.fromTwoLettersToLanguage +import com.lagradost.cloudstream3.utils.SubtitleHelper.fromTagToEnglishLanguageName +import com.lagradost.cloudstream3.utils.SubtitleHelper.fromTagToLanguageName import com.lagradost.cloudstream3.utils.SubtitleHelper.languages import com.lagradost.cloudstream3.utils.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.VideoDownloadManager.getImageBitmapFromUrl +import com.lagradost.cloudstream3.utils.downloader.DownloadUtils.getImageBitmapFromUrl import com.lagradost.cloudstream3.utils.setText import com.lagradost.cloudstream3.utils.txt +import com.lagradost.cloudstream3.utils.videoskip.VideoSkipStamp import com.lagradost.safefile.SafeFile import kotlinx.coroutines.Job import kotlinx.coroutines.delay @@ -117,84 +131,79 @@ import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import java.io.Serializable import java.util.Calendar -import kotlin.math.abs -import androidx.core.content.edit +import java.util.UUID +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.atomic.AtomicBoolean - -@UnstableApi +@OptIn(UnstableApi::class) class GeneratorPlayer : FullScreenPlayer() { companion object { const val NOTIFICATION_ID = 2326 const val CHANNEL_ID = 7340 const val STOP_ACTION = "stopcs3" - private var lastUsedGenerator: IGenerator? = null - fun newInstance(generator: IGenerator, syncData: HashMap? = null): Bundle { + private val generators = ConcurrentHashMap>() + fun newInstance( + generator: VideoGenerator<*>, + index: Int, + syncData: HashMap? = null + ): Bundle { Log.i(TAG, "newInstance = $syncData") - lastUsedGenerator = generator + val uuid = UUID.randomUUID().toString() + generators[uuid] = generator return Bundle().apply { + putString("uuid", uuid) + putInt("index", index) if (syncData != null) putSerializable("syncData", syncData) } } - val subsProviders - get() = subtitleProviders.filter { provider -> - (provider as? AbstractSubApi)?.let { !it.requiresLogin || it.loginInfo() != null } - ?: true - }.map { SubRepository(it) } + val subsProviders = subtitleProviders val subsProvidersIsActive get() = subsProviders.isNotEmpty() } - 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 var currentMeta: Any? = null - private var nextMeta: Any? = null - private var isActive: Boolean = false + private val currentMeta: Any? get() = viewModel.state.generatorState?.meta + private val nextMeta: Any? get() = viewModel.state.generatorState?.nextMeta + + private var isPlayerActive: AtomicBoolean = AtomicBoolean(false) private var isNextEpisode: Boolean = false // this is used to reset the watch time private var preferredAutoSelectSubtitles: String? = null // null means do nothing, "" means none + private 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 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 + 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 } else { - null + subtitle.getIETF_tag() } - if (subtitleLanguage639 != null) { - setKey(SUBTITLE_AUTO_SELECT_KEY, subtitleLanguage639) - preferredAutoSelectSubtitles = subtitleLanguage639 + if (subtitleLanguageTagIETF != null) { + Log.i(TAG, "Set SUBTITLE_AUTO_SELECT_KEY to '$subtitleLanguageTagIETF'") + setKey(SUBTITLE_AUTO_SELECT_KEY, subtitleLanguageTagIETF) + preferredAutoSelectSubtitles = subtitleLanguageTagIETF } } @@ -212,10 +221,11 @@ 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() { @@ -226,11 +236,11 @@ class GeneratorPlayer : FullScreenPlayer() { } private fun noSubtitles(): Boolean { - return setSubtitles(null) + return setSubtitles(null, true) } private fun getPos(): Long { - val durPos = DataStoreHelper.getViewPos(viewModel.getId()) ?: return 0L + val durPos = getViewPos(viewModel.state.generatorState?.id) ?: return 0L if (durPos.duration == 0L) return 0L if (durPos.position * 100L / durPos.duration > 95L) { return 0L @@ -260,21 +270,15 @@ class GeneratorPlayer : FullScreenPlayer() { ): PendingIntent { val intent: Intent = Intent(action).setPackage(context.packageName) intent.putExtra(EXTRA_INSTANCE_ID, instanceId) - val pendingFlags = if (Util.SDK_INT >= 23) { + val pendingFlags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { 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 @@ -351,16 +355,13 @@ class GeneratorPlayer : FullScreenPlayer() { } // retry several times with a preview in case the preview generator is slow - for (i in 0..10) { + repeat(10) { val preview = this@GeneratorPlayer.player.getPreview(0.5f) - if (preview == null) { - delay(1000L) - continue + if (preview != null) { + callback.onBitmap(preview) + return@repeat } - callback.onBitmap( - preview - ) - break + delay(1000L) } } @@ -376,6 +377,7 @@ class GeneratorPlayer : FullScreenPlayer() { return mutableMapOf( STOP_ACTION to NotificationCompat.Action( R.drawable.baseline_stop_24, + @SuppressLint("PrivateResource") context.getString(androidx.media3.ui.R.string.exo_controls_stop_description), createBroadcastIntent(STOP_ACTION, context, instanceId) ) @@ -389,9 +391,7 @@ class GeneratorPlayer : FullScreenPlayer() { override fun onCustomAction(player: Player, action: String, intent: Intent) { when (action) { STOP_ACTION -> { - exitFullscreen() - this@GeneratorPlayer.player.release() - activity?.popCurrentPage() + exitPlayer() } } } @@ -491,9 +491,9 @@ class GeneratorPlayer : FullScreenPlayer() { } } - private fun loadLink(link: Pair?, sameEpisode: Boolean) { + private fun loadLink(link: VideoLink?, sameEpisode: Boolean) { if (link == null) return - + isPlayerActive.set(true) // manage UI binding?.playerLoadingOverlay?.isVisible = false val isTorrent = @@ -501,15 +501,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,6 +519,7 @@ class GeneratorPlayer : FullScreenPlayer() { // load player context?.let { ctx -> val (url, uri) = link + val subtitles = viewModel.state.subtitles player.loadPlayer( ctx, sameEpisode, @@ -527,43 +528,18 @@ class GeneratorPlayer : FullScreenPlayer() { startPosition = if (sameEpisode) null else { if (isNextEpisode) 0L else getPos() }, - currentSubs, + subtitles, (if (sameEpisode) currentSelectedSubtitles else null) ?: getAutoSelectSubtitle( - currentSubs, settings = true, downloads = true + subtitles, settings = true, downloads = true ), - preview = isFullScreenPlayer + preview = true ) } - 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) + if (!sameEpisode) { + player.addTimeStamps(emptyList()) // clear stamps + // Resets subtitle delay, as we watch some other content + player.setSubtitleOffset(0) } } @@ -601,25 +577,25 @@ class GeneratorPlayer : FullScreenPlayer() { if (entry.lang.isBlank() || !withLanguage) { return entry.name } - val language = fromTwoLettersToLanguage(entry.lang.trim()) ?: entry.lang + val language = fromTagToLanguageName(entry.lang.trim()) ?: entry.lang return "$language ${entry.name}" } override fun openOnlineSubPicker( context: Context, loadResponse: LoadResponse?, dismissCallback: (() -> Unit) ) { - val providers = subsProviders + val providers = subsProviders.toList() val isSingleProvider = subsProviders.size == 1 - val dialog = Dialog(context, R.style.AlertDialogCustomBlack) + val dialog = Dialog(context, R.style.DialogFullscreenPlayer) 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) { @@ -645,7 +621,6 @@ class GeneratorPlayer : FullScreenPlayer() { imageViewEnd.setImageDrawable(drawableEnd) } - @SuppressLint("SetTextI18n") override fun getView(position: Int, convertView: View?, parent: ViewGroup): View { val view = convertView ?: LayoutInflater.from(context).inflate(layout, null) @@ -658,9 +633,10 @@ class GeneratorPlayer : FullScreenPlayer() { mainTextView?.text = item?.let { getName(it, false) } val language = - item?.let { fromTwoLettersToLanguage(it.lang.trim()) ?: it.lang } ?: "" + item?.let { fromTagToLanguageName(it.lang) ?: it.lang } ?: "" val providerSuffix = if (isSingleProvider || item == null) "" else " · ${item.source}" + @SuppressLint("SetTextI18n") secondaryTextView?.text = language + providerSuffix setHearingImpairedIcon(drawableEnd, position) @@ -680,7 +656,7 @@ class GeneratorPlayer : FullScreenPlayer() { currentSubtitle = currentSubtitles.getOrNull(position) ?: return@setOnItemClickListener } - var currentLanguageTwoLetters: String = getAutoSelectLanguageISO639_1() + var currentLanguageTagIETF: String = getAutoSelectLanguageTagIETF() fun setSubtitlesList(list: List) { @@ -745,13 +721,13 @@ class GeneratorPlayer : FullScreenPlayer() { aniListId = loadResponse?.getAniListId()?.toInt(), epNumber = currentTempMeta.episode, seasonNumber = currentTempMeta.season, - lang = currentLanguageTwoLetters.ifBlank { null }, + lang = currentLanguageTagIETF.ifBlank { null }, year = viewModel.currentSubtitleYear.value ) // TODO Make ui a lot better, like search with tabs val results = providers.amap { - when (val response = it.search(search)) { + when (val response = Resource.fromResult(it.search(search))) { is Resource.Success -> { response.value } @@ -793,15 +769,22 @@ class GeneratorPlayer : FullScreenPlayer() { }) binding.searchFilter.setOnClickListener { view -> - val lang639_1 = languages.map { it.ISO_639_1 } + 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() + activity?.showDialog( - languages.map { it.languageName }, - lang639_1.indexOf(currentLanguageTwoLetters), + langNames, + langTagsIETF.indexOf(currentLanguageTagIETF), view?.context?.getString(R.string.subs_subtitle_languages) ?: return@setOnClickListener, true, { }) { index -> - currentLanguageTwoLetters = lang639_1[index] + currentLanguageTagIETF = langTagsIETF[index] binding.subtitlesSearch.setQuery(binding.subtitlesSearch.query, true) } } @@ -810,7 +793,8 @@ class GeneratorPlayer : FullScreenPlayer() { currentSubtitle?.let { currentSubtitle -> providers.firstOrNull { it.idPrefix == currentSubtitle.idPrefix }?.let { api -> ioSafe { - when (val apiResource = api.getResource(currentSubtitle)) { + when (val apiResource = + Resource.fromResult(api.resource(currentSubtitle))) { is Resource.Success -> { val subtitles = apiResource.value.getSubtitles().map { resource -> SubtitleData( @@ -823,7 +807,7 @@ class GeneratorPlayer : FullScreenPlayer() { origin = resource.origin, mimeType = resource.url.toSubtitleMimeType(), headers = currentSubtitle.headers, - currentSubtitle.lang + languageCode = currentSubtitle.lang ) } if (subtitles.isEmpty()) { @@ -859,7 +843,6 @@ class GeneratorPlayer : FullScreenPlayer() { //dialog.subtitles_search_year?.setText(currentTempMeta.year) } - @OptIn(UnstableApi::class) private fun openSubPicker() { try { subsPathPicker.launch( @@ -885,22 +868,21 @@ class GeneratorPlayer : FullScreenPlayer() { vararg subtitleData: SubtitleData ) { if (subtitleData.isEmpty()) return - val selectedSubtitle = subtitleData.first() val ctx = context ?: return - - val subs = currentSubs + subtitleData + val selectedSubtitle = subtitleData.first() + viewModel.addSubtitles(subtitleData.toSet()) // this is used instead of observe(viewModel._currentSubs), because observe is too slow - player.setActiveSubtitles(subs) + player.setActiveSubtitles(viewModel.state.subtitles) // Save current time as to not reset player to 00:00 player.saveData() player.reloadPlayer(ctx) - setSubtitles(selectedSubtitle) - viewModel.addSubtitles(subtitleData.toSet()) + setSubtitles(selectedSubtitle, false) selectSourceDialog?.dismissSafe() + selectSourceDialog = null showToast( String.format(ctx.getString(R.string.player_loaded_subtitles), selectedSubtitle.name), @@ -914,7 +896,7 @@ class GeneratorPlayer : FullScreenPlayer() { safe { // It lies, it can be null if file manager quits. if (uri == null) return@safe - val ctx = context ?: AcraApplication.context ?: return@safe + val ctx = context ?: CloudStreamApp.context ?: return@safe // RW perms for the path ctx.contentResolver.takePersistableUriPermission( uri, @@ -941,10 +923,6 @@ 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) = @@ -955,9 +933,11 @@ class GeneratorPlayer : FullScreenPlayer() { // first come first served with these subtitles // we might want to change it to prefer different sources when used multiple times, // however caching might make this random after the first click too - subsProviders.amap { provider -> - val success = when (val result = provider.search( - query = query + subsProviders.toList().amap { provider -> + val success = when (val result = Resource.fromResult( + provider.search( + query = query + ) )) { is Resource.Failure -> { // scope might cancel, so we do an extra check @@ -983,21 +963,7 @@ class GeneratorPlayer : FullScreenPlayer() { break } - val subtitleResources = - when (val result = provider.getResource(subtitleEntry)) { - is Resource.Failure -> { - continue - } - - is Resource.Loading -> { - // unreachable - continue - } - - is Resource.Success -> { - result.value - } - } + val subtitleResources = provider.resource(subtitleEntry).getOrNull() ?: continue val subtitles = subtitleResources.getSubtitles().map { resource -> SubtitleData( @@ -1012,7 +978,7 @@ class GeneratorPlayer : FullScreenPlayer() { } // checks for both a race condition and if any of the subs generated is new - if (this.isActive && !currentSubs.containsAll(subtitles) && !hasSelectASubtitle) { + if (this.isActive && !viewModel.state.subtitles.containsAll(subtitles) && !hasSelectASubtitle) { hasSelectASubtitle = true runOnMainThread { addAndSelectSubtitles(*subtitles.toTypedArray()) @@ -1035,13 +1001,14 @@ class GeneratorPlayer : FullScreenPlayer() { context?.let { ctx -> val isPlaying = player.getIsPlaying() player.handleEvent(CSPlayerEvent.Pause, PlayerEventSource.UI) - val currentSubtitles = sortSubs(currentSubs) + val currentSubtitles = sortSubs(viewModel.state.subtitles) - val sourceDialog = Dialog(ctx, R.style.AlertDialogCustomBlack) + val sourceDialog = Dialog(ctx, R.style.DialogFullscreenPlayer) val binding = PlayerSelectSourceAndSubsBinding.inflate(LayoutInflater.from(ctx), null, false) sourceDialog.setContentView(binding.root) + fixSystemBarsPadding(binding.root) selectSourceDialog = sourceDialog sourceDialog.show() @@ -1062,7 +1029,9 @@ class GeneratorPlayer : FullScreenPlayer() { binding.subtitleSettingsBtt.setOnClickListener { safe { - SubtitlesFragment().show(this.parentFragmentManager, "SubtitleSettings") + val subtitlesFragment = SubtitlesFragment() + subtitlesFragment.systemBarsAddPadding = true + subtitlesFragment.show(this.parentFragmentManager, "SubtitleSettings") } } @@ -1074,7 +1043,7 @@ class GeneratorPlayer : FullScreenPlayer() { } if (subsProvidersIsActive) { - val currentLoadResponse = viewModel.getLoadResponse() + val currentLoadResponse = viewModel.state.generatorState?.response val loadFromOpenSubsFooter: TextView = layoutInflater.inflate( R.layout.sort_bottom_footer_add_choice, null @@ -1086,6 +1055,7 @@ class GeneratorPlayer : FullScreenPlayer() { loadFromOpenSubsFooter.setOnClickListener { shouldDismiss = false sourceDialog.dismissSafe(activity) + selectSourceDialog = null openOnlineSubPicker(it.context, currentLoadResponse) { dismiss() } @@ -1096,7 +1066,7 @@ class GeneratorPlayer : FullScreenPlayer() { val metadata = getMetaData() val queryName = metadata.name ?: currentLoadResponse?.name if (queryName != null) { - val currentLanguageTwoLetters: String = getAutoSelectLanguageISO639_1() + val currentLanguageTagIETF: String = getAutoSelectLanguageTagIETF() val loadFromFirstSubsFooter: TextView = layoutInflater.inflate( R.layout.sort_bottom_footer_add_choice, null ) as TextView @@ -1106,6 +1076,7 @@ class GeneratorPlayer : FullScreenPlayer() { loadFromFirstSubsFooter.setOnClickListener { sourceDialog.dismissSafe(activity) + selectSourceDialog = null showToast(R.string.loading) addFirstSub( SubtitleSearch( @@ -1116,7 +1087,7 @@ class GeneratorPlayer : FullScreenPlayer() { aniListId = currentLoadResponse?.getAniListId()?.toInt(), epNumber = metadata.episode, seasonNumber = metadata.season, - lang = currentLanguageTwoLetters.ifBlank { null }, + lang = currentLanguageTagIETF.ifBlank { null }, year = viewModel.currentSubtitleYear.value ) ) @@ -1130,7 +1101,7 @@ class GeneratorPlayer : FullScreenPlayer() { var sortedUrls = emptyList>() fun refreshLinks(qualityProfile: Int) { - sortedUrls = sortLinks(qualityProfile) + sortedUrls = viewModel.state.sortLinks(qualityProfile) if (sortedUrls.isEmpty()) { sourceDialog.findViewById(R.id.sort_sources_holder)?.isGone = true @@ -1215,7 +1186,7 @@ class GeneratorPlayer : FullScreenPlayer() { subsOptionsArrayAdapter.clear() val subtitleOptions = - subtitlesGrouped.entries.toList() + subtitlesGroupedList .getOrNull(subtitleGroupIndex - 1)?.value?.map { subtitle -> val nameSuffix = subtitle.nameSuffix.html() nameSuffix.ifBlank { @@ -1281,6 +1252,7 @@ class GeneratorPlayer : FullScreenPlayer() { binding.cancelBtt.setOnClickListener { sourceDialog.dismissSafe(activity) + this.selectSourceDialog = null } fun setProfileName(profile: Int) { @@ -1294,16 +1266,28 @@ class GeneratorPlayer : FullScreenPlayer() { binding.profilesClickSettings.setOnClickListener { val activity = activity ?: return@setOnClickListener - QualityProfileDialog( + val dialog = QualityProfileDialog( activity, - R.style.AlertDialogCustomBlack, - currentLinks.mapNotNull { it.first }, + R.style.DialogFullscreenPlayer, + viewModel.state.links.mapNotNull { + it.first?.let { extractorLink -> + LinkSource( + extractorLink + ) + } + }, currentQualityProfile ) { profile -> currentQualityProfile = profile.id setProfileName(profile.id) - refreshLinks(profile.id) - }.show() + } + + dialog.setOnDismissListener { + viewModel.state.clearSortedLinksCache() + refreshLinks(currentQualityProfile) + } + + dialog.show() } binding.subtitlesEncodingFormat.apply { @@ -1331,6 +1315,7 @@ class GeneratorPlayer : FullScreenPlayer() { shouldDismiss = false sourceDialog.dismissSafe(activity) + selectSourceDialog = null val index = prefValues.indexOf(currentPrefMedia) activity?.showDialog( @@ -1351,18 +1336,15 @@ class GeneratorPlayer : FullScreenPlayer() { } binding.applyBtt.setOnClickListener { - var init = false - if (sourceIndex != startSource) { - init = true - } + var init = sourceIndex != startSource if (subtitleGroupIndex != subtitleGroupIndexStart || subtitleOptionIndex != subtitleOptionIndexStart) { - init = init || if (subtitleGroupIndex <= 0) { + init = init or if (subtitleGroupIndex <= 0) { noSubtitles() } else { subtitlesGroupedList.getOrNull(subtitleGroupIndex - 1)?.value?.getOrNull( subtitleOptionIndex )?.let { - setSubtitles(it) + setSubtitles(it, true) } ?: false } } @@ -1372,6 +1354,7 @@ class GeneratorPlayer : FullScreenPlayer() { } } sourceDialog.dismissSafe(activity) + selectSourceDialog = null } } } catch (e: Exception) { @@ -1394,11 +1377,14 @@ class GeneratorPlayer : FullScreenPlayer() { val currentAudioTracks = tracks.allAudioTracks val binding: PlayerSelectTracksBinding = PlayerSelectTracksBinding.inflate(LayoutInflater.from(ctx), null, false) - val trackDialog = Dialog(ctx, R.style.AlertDialogCustomBlack) + val trackDialog = Dialog(ctx, R.style.DialogFullscreenPlayer) + this.selectTrackDialog = trackDialog trackDialog.setContentView(binding.root) trackDialog.show() -// selectTracksDialog = tracksDialog + fixSystemBarsPadding(binding.root) + + // selectTracksDialog = tracksDialog val videosList = binding.videoTracksList val audioList = binding.autoTracksList @@ -1441,29 +1427,56 @@ class GeneratorPlayer : FullScreenPlayer() { trackDialog.setOnDismissListener { dismiss() -// selectTracksDialog = null + // selectTracksDialog = null } - var audioIndexStart = currentAudioTracks.indexOf(tracks.currentAudioTrack).takeIf { - it != -1 - } ?: currentVideoTracks.indexOfFirst { - tracks.currentAudioTrack?.id == it.id - } + var audioIndexStart = currentAudioTracks.indexOfFirst { track -> + track.id == tracks.currentAudioTrack?.id && + track.formatIndex == tracks.currentAudioTrack?.formatIndex + }.coerceAtLeast(0) val audioArrayAdapter = ArrayAdapter(ctx, R.layout.sort_bottom_single_choice) - audioArrayAdapter.addAll(currentAudioTracks.mapIndexed { index, format -> - when { - format.label != null && format.language != null -> - "${format.label} - [${fromTwoLettersToLanguage(format.language) ?: format.language}]" + 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(" • ") + - else -> format.label - ?: format.language?.let { fromTwoLettersToLanguage(it) } - ?: format.language - ?: index.toString() } - }) + ) audioList.adapter = audioArrayAdapter audioList.choiceMode = AbsListView.CHOICE_MODE_SINGLE @@ -1478,12 +1491,15 @@ 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?.language, + currentTrack?.id, + currentTrack?.formatIndex, ) val currentVideo = currentVideoTracks.getOrNull(videoIndex) @@ -1492,8 +1508,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) { @@ -1501,9 +1517,21 @@ class GeneratorPlayer : FullScreenPlayer() { } } - override fun playerError(exception: Throwable) { - Log.i(TAG, "playerError = $currentSelectedLink") + val currentUrl = + currentSelectedLink?.let { it.first?.url ?: it.second?.uri?.toString() } ?: "unknown" + val headers = currentSelectedLink?.first?.headers?.toString() ?: "none" + val referer = currentSelectedLink?.first?.referer ?: "none" + Log.e( + TAG, + "playerError: $currentSelectedLink, " + + "type=${exception::class.java.canonicalName}, " + + "message=${exception.message}, url=$currentUrl, headers=$headers, " + + "referer=$referer, position=${player.getPosition() ?: "unknown"}, " + + "duration=${player.getDuration() ?: "unknown"}, " + + "isPlaying=${player.getIsPlaying()}", exception + ) + if (!hasNextMirror()) { viewModel.forceClearCache = true } @@ -1518,35 +1546,94 @@ class GeneratorPlayer : FullScreenPlayer() { } private fun startPlayer() { - if (isActive) return // we don't want double load when you skip loading + // We don't want double load when you skip loading + if (isPlayerActive.get()) { + return + } - val links = sortLinks(currentQualityProfile) + val links = viewModel.state.sortLinks(currentQualityProfile) if (links.isEmpty()) { noLinksFound() return } + // Atomic operation to prevent double loading + if (!isPlayerActive.compareAndSet(false, true)) { + return + } loadLink(links.first(), false) + showPlayerMetadata() + } + + 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() { - isNextEpisode = true - player.release() - viewModel.loadLinksNext() + if (viewModel.hasNextEpisode() == true) { + isNextEpisode = true + releasePlayer() + viewModel.loadLinksNext() + } } override fun prevEpisode() { - isNextEpisode = true - player.release() - viewModel.loadLinksPrev() + if (viewModel.hasPrevEpisode() == true) { + isNextEpisode = true + releasePlayer() + viewModel.loadLinksPrev() + } } override fun hasNextMirror(): Boolean { - val links = sortLinks(currentQualityProfile) + val links = viewModel.state.sortLinks(currentQualityProfile) return links.isNotEmpty() && links.indexOf(currentSelectedLink) + 1 < links.size } override fun nextMirror() { - val links = sortLinks(currentQualityProfile) + val links = viewModel.state.sortLinks(currentQualityProfile) if (links.isEmpty()) { noLinksFound() return @@ -1590,49 +1677,15 @@ class GeneratorPlayer : FullScreenPlayer() { viewModel.loadStamps(duration) } - viewModel.getId()?.let { - DataStoreHelper.setViewPos(it, position, duration) - } - val percentage = position * 100L / duration - 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 - ) - } - } - } + DataStoreHelper.setViewPosAndResume( + viewModel.state.generatorState?.id, + position, + duration, + currentMeta, + nextMeta + ) var isOpVisible = false when (val meta = currentMeta) { @@ -1661,8 +1714,12 @@ class GeneratorPlayer : FullScreenPlayer() { playerBinding?.playerSkipEpisode?.isVisible = !isOpVisible && viewModel.hasNextEpisode() == true - else -> - playerBinding?.playerGoForwardRoot?.isVisible = viewModel.hasNextEpisode() == true + else -> { + val hasNextEpisode = viewModel.hasNextEpisode() == true + playerBinding?.playerGoForward?.isVisible = hasNextEpisode + playerBinding?.playerGoForwardRoot?.isVisible = hasNextEpisode + } + } if (percentage >= PRELOAD_NEXT_EPISODE_PERCENTAGE) { @@ -1674,33 +1731,28 @@ class GeneratorPlayer : FullScreenPlayer() { subtitles: Set, settings: Boolean, downloads: Boolean ): SubtitleData? { val langCode = preferredAutoSelectSubtitles ?: return null - val lang = fromTwoLettersToLanguage(langCode) ?: return null if (downloads) { - return subtitles.firstOrNull { sub -> - (sub.origin == SubtitleOrigin.DOWNLOADED_FILE && sub.name == context?.getString( - R.string.default_subtitles - )) + return sortSubs(subtitles).firstOrNull { + it.origin == SubtitleOrigin.DOWNLOADED_FILE && it.matchesLanguageCode( + langCode + ) } } - 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 - } + if (!settings) return null - return null + return sortSubs(subtitles).firstOrNull { it.matchesLanguageCode(langCode) } } private fun autoSelectFromSettings(): Boolean { - // auto select subtitle based of settings + // auto select subtitle based on settings val langCode = preferredAutoSelectSubtitles val current = player.getCurrentPreferredSubtitle() Log.i(TAG, "autoSelectFromSettings = $current") context?.let { ctx -> - if (current != null) { - if (setSubtitles(current)) { + // Only use the player preferred subtitle if it matches the available language + if (current != null && (langCode == null || current.matchesLanguageCode(langCode))) { + if (setSubtitles(current, false)) { player.saveData() player.reloadPlayer(ctx) player.handleEvent(CSPlayerEvent.Play) @@ -1708,9 +1760,9 @@ class GeneratorPlayer : FullScreenPlayer() { } } else if (!langCode.isNullOrEmpty()) { getAutoSelectSubtitle( - currentSubs, settings = true, downloads = false + viewModel.state.subtitles, settings = true, downloads = false )?.let { sub -> - if (setSubtitles(sub)) { + if (setSubtitles(sub, false)) { player.saveData() player.reloadPlayer(ctx) player.handleEvent(CSPlayerEvent.Play) @@ -1722,20 +1774,20 @@ class GeneratorPlayer : FullScreenPlayer() { return false } - private fun autoSelectFromDownloads(): Boolean { - if (player.getCurrentPreferredSubtitle() == null) { - getAutoSelectSubtitle(currentSubs, settings = false, downloads = true)?.let { sub -> - context?.let { ctx -> - if (setSubtitles(sub)) { - player.saveData() - player.reloadPlayer(ctx) - player.handleEvent(CSPlayerEvent.Play) - return true - } - } - } + private fun autoSelectFromDownloads() { + if (player.getCurrentPreferredSubtitle() != null) { + return } - return false + val sub = + getAutoSelectSubtitle(viewModel.state.subtitles, settings = false, downloads = true) + ?: return + val ctx = context ?: return + if (!setSubtitles(sub, false)) { + return + } + player.saveData() + player.reloadPlayer(ctx) + player.handleEvent(CSPlayerEvent.Play) } private fun autoSelectSubtitles() { @@ -1747,6 +1799,14 @@ 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 @@ -1793,8 +1853,6 @@ class GeneratorPlayer : FullScreenPlayer() { return "" } - - @SuppressLint("SetTextI18n") fun setTitle() { var playerVideoTitle = getPlayerVideoTitle() @@ -1813,29 +1871,105 @@ 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 extra = if (widthHeight != null) { - val (width, height) = widthHeight - "- ${width}x${height}" - } else { - "" + 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 source = currentSelectedLink?.first?.name ?: currentSelectedLink?.second?.name ?: "NULL" - val title = when (titleRez) { - 0 -> "" - 1 -> extra - 2 -> source - 3 -> "$source $extra" + private fun videoCodecName(mime: String?): String? { + val m = mime?.lowercase() ?: return null + return when { + m.contains("avc") || m.contains("h264") -> "AVC" + m.contains("hevc") || m.contains("h265") -> "HEVC" + m.contains("av1") -> "AV1" + m.contains("vp9") -> "VP9" + m.contains("vp8") -> "VP8" + "/" in m -> m.substringAfter("/").uppercase() + else -> m.uppercase() + } + } + + private fun audioCodecName(mime: String?): String { + val m = mime?.lowercase()?.trim().orEmpty() + if (m.isBlank()) return "" + return when { + m.contains("eac3-joc") -> "Dolby Atmos" + m.contains("truehd") -> "TrueHD" + m.contains("eac3") -> "E-AC3" + m.contains("ac-3") || m.contains("ac3") -> "AC3" + m.contains("aac") || m.contains("mp4a") -> "AAC" + m.contains("opus") -> "Opus" + m.contains("vorbis") -> "Vorbis" + m.contains("mp3") -> "MP3" + m.contains("flac") -> "FLAC" + m.contains("dts") -> "DTS" + m.contains("pcm") -> "PCM" + m.contains("alac") -> "ALAC" + m.contains("amr") -> "AMR" + m.contains("/") -> m.substringAfter("/").uppercase().takeIf { it.isNotBlank() } ?: "" else -> "" } - playerBinding?.playerVideoTitleRez?.apply { - text = title - isVisible = title.isNotBlank() + } + + 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() } } @@ -1851,31 +1985,13 @@ class GeneratorPlayer : FullScreenPlayer() { } } - override fun onCreateView( - inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? - ): View? { - // this is used instead of layout-television to follow the settings and some TV devices are not classified as TV for some reason - layout = - if (isLayout(TV or EMULATOR)) R.layout.fragment_player_tv else R.layout.fragment_player - - viewModel = ViewModelProvider(this)[PlayerGeneratorViewModel::class.java] - sync = ViewModelProvider(this)[SyncViewModel::class.java] - - viewModel.attachGenerator(lastUsedGenerator) - unwrapBundle(savedInstanceState) - unwrapBundle(arguments) - - val root = super.onCreateView(inflater, container, savedInstanceState) ?: return null - binding = FragmentPlayerBinding.bind(root) - return root - } - - override fun onDestroyView() { - binding = null - super.onDestroyView() - } - - var timestampShowState = false + /** + * This is used instead of layout-television to follow the + * settings and some TV devices are not classified as TV + * for some reason. + */ + override fun pickLayout(): Int = + if (isLayout(TV or EMULATOR)) R.layout.fragment_player_tv else R.layout.fragment_player var skipAnimator: ValueAnimator? = null var skipIndex = 0 @@ -1896,6 +2012,12 @@ class GeneratorPlayer : FullScreenPlayer() { skipAnimator?.cancel() isVisible = true + /** Focus instantly to make the focus color appear instantly */ + if (show && !isShowing) { + // Automatically request focus if the menu is not opened + playerBinding?.skipChapterButton?.requestFocus() + } + // just in case val lay = layoutParams lay.width = from @@ -1904,12 +2026,7 @@ class GeneratorPlayer : FullScreenPlayer() { from, to ).apply { addListener(onEnd = { - if (show) { - if (!isShowing) { - // Automatically request focus if the menu is not opened - playerBinding?.skipChapterButton?.requestFocus() - } - } else { + if (!show) { playerBinding?.skipChapterButton?.isVisible = false if (!isShowing) { // Automatically return focus to play pause @@ -1929,11 +2046,11 @@ class GeneratorPlayer : FullScreenPlayer() { } } - override fun onTimestampSkipped(timestamp: EpisodeSkip.SkipStamp) { + override fun onTimestampSkipped(timestamp: VideoSkipStamp) { displayTimeStamp(false) } - override fun onTimestamp(timestamp: EpisodeSkip.SkipStamp?) { + override fun onTimestamp(timestamp: VideoSkipStamp?) { if (timestamp != null) { playerBinding?.skipChapterButton?.setText(timestamp.uiText) displayTimeStamp(true) @@ -1947,26 +2064,143 @@ class GeneratorPlayer : FullScreenPlayer() { } } - @SuppressLint("SetTextI18n") - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - var langFilterList = listOf() - var filterSubByLang = false + 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) context?.let { ctx -> val settingsManager = PreferenceManager.getDefaultSharedPreferences(ctx) - 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) + showName = settingsManager.getBoolean(ctx.getString(R.string.show_name_key), true) + showResolution = + settingsManager.getBoolean(ctx.getString(R.string.show_resolution_key), true) + showMediaInfo = + settingsManager.getBoolean(ctx.getString(R.string.show_media_info_key), false) + limitTitle = settingsManager.getInt(ctx.getString(R.string.prefer_title_limit_key), 0) updateForcedEncoding(ctx) - - filterSubByLang = + viewModel.filterSubByLang = settingsManager.getBoolean(getString(R.string.filter_sub_lang_key), false) - if (filterSubByLang) { + if (viewModel.filterSubByLang) { val langFromPrefMedia = settingsManager.getStringSet( this.getString(R.string.provider_lang_key), mutableSetOf("en") ) - langFilterList = langFromPrefMedia?.mapNotNull { - fromTwoLettersToLanguage(it)?.lowercase() ?: return@mapNotNull null + viewModel.langFilterList = langFromPrefMedia?.mapNotNull { + fromTagToEnglishLanguageName(it)?.lowercase() ?: return@mapNotNull null } ?: listOf() } } @@ -1976,20 +2210,25 @@ class GeneratorPlayer : FullScreenPlayer() { sync.updateUserData() - preferredAutoSelectSubtitles = getAutoSelectLanguageISO639_1() + preferredAutoSelectSubtitles = getAutoSelectLanguageTagIETF() - if (currentSelectedLink == null) { + val selectedLink = currentSelectedLink + if (selectedLink == null) { viewModel.loadLinks() + } else { + // Recreated view, so we need to recreate the + loadLink(selectedLink, true) } - binding?.overlayLoadingSkipButton?.setOnClickListener { - startPlayer() + binding.overlayLoadingSkipButton.setOnClickListener { + // Mark as "success" early + viewModel.modifyState { + copy(loading = Resource.Success(Unit)) + } } - binding?.playerLoadingGoBack?.setOnClickListener { - exitFullscreen() - player.release() - activity?.popCurrentPage() + binding.playerLoadingGoBack.setOnClickListener { + exitPlayer() } playerBinding?.downloadHeader?.setOnClickListener { @@ -2002,14 +2241,29 @@ class GeneratorPlayer : FullScreenPlayer() { } } - observe(viewModel.currentStamps) { stamps -> + observe(viewModel.currentStamps) { (stamps, instance) -> + if (instance != viewModel.state.instance) return@observe // Outdated observe player.addTimeStamps(stamps) } - observe(viewModel.loadingLinks) { - when (it) { + observe(viewModel.currentSubtitles) { (subtitles, instance) -> + if (instance != viewModel.state.instance) return@observe // Outdated observe + player.setActiveSubtitles(subtitles) + + // If the file is downloaded then do not select auto select the subtitles + // Downloaded subtitles cannot be selected immediately after loading since + // player.getCurrentPreferredSubtitle() cannot fetch data from non-loaded subtitles + // Resulting in unselecting the downloaded subtitle + if (subtitles.lastOrNull()?.origin != SubtitleOrigin.DOWNLOADED_FILE) { + autoSelectSubtitles() + } + } + observe(viewModel.loadingLinks) { (loading, instance) -> + if (instance != viewModel.state.instance) return@observe // Outdated observe + + when (loading) { is Resource.Loading -> { - startLoading() + releasePlayer() } is Resource.Success -> { @@ -2021,30 +2275,31 @@ class GeneratorPlayer : FullScreenPlayer() { } is Resource.Failure -> { - showToast(it.errorString, Toast.LENGTH_LONG) + showToast(loading.errorString, Toast.LENGTH_LONG) startPlayer() } } } - observe(viewModel.currentLinks) { - currentLinks = it - val turnVisible = it.isNotEmpty() && lastUsedGenerator?.canSkipLoading == true - val wasGone = binding?.overlayLoadingSkipButton?.isGone == true + observe(viewModel.currentLinks) { (links, instance) -> + if (instance != viewModel.state.instance) return@observe // Outdated observe - binding?.overlayLoadingSkipButton?.apply { + val turnVisible = links.isNotEmpty() && viewModel.generator?.canSkipLoading == true + val wasGone = binding.overlayLoadingSkipButton.isGone + + binding.overlayLoadingSkipButton.apply { isVisible = turnVisible - val value = viewModel.currentLinks.value - if (value.isNullOrEmpty()) { + if (links.isEmpty()) { setText(R.string.skip_loading) } else { - text = "${context.getString(R.string.skip_loading)} (${value.size})" + @SuppressLint("SetTextI18n") + text = "${context.getString(R.string.skip_loading)} (${links.size})" } } safe { - if (currentLinks.any { link -> - getLinkPriority(currentQualityProfile, link) >= + if (!isPlayerActive.get() && viewModel.state.links.any { link -> + getLinkPriority(currentQualityProfile, link.first) >= QualityDataHelper.AUTO_SKIP_PRIORITY } ) { @@ -2053,33 +2308,7 @@ class GeneratorPlayer : FullScreenPlayer() { } if (turnVisible && wasGone) { - binding?.overlayLoadingSkipButton?.requestFocus() - } - } - - observe(viewModel.currentSubs) { set -> - val setOfSub = mutableSetOf() - if (langFilterList.isNotEmpty() && filterSubByLang) { - Log.i("subfilter", "Filtering subtitle") - langFilterList.forEach { lang -> - Log.i("subfilter", "Lang: $lang") - setOfSub += set.filter { - it.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() + binding.overlayLoadingSkipButton.requestFocus() } } } @@ -2090,4 +2319,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 4aaee7bb7ad..3ab46ce215a 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/IGenerator.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/IGenerator.kt @@ -25,27 +25,27 @@ val LOADTYPE_CHROMECAST = setOf( val LOADTYPE_ALL = ExtractorLinkType.entries.toSet() -interface IGenerator { - val hasCache: Boolean - val canSkipLoading: Boolean - - fun hasNext(): Boolean - fun hasPrev(): Boolean - fun next() - fun prev() - fun goto(index: Int) - - fun getCurrentId(): Int? // this is used to save data or read data about this id - fun getCurrent(offset: Int = 0): Any? // this is used to get metadata about the current playing, can return null - fun getAll(): List? // this us used to get the metadata about all entries, not needed - - /* not safe, must use try catch */ - suspend fun generateLinks( +abstract class NoVideoGenerator(val id : Int?) : VideoGenerator(emptyList()) { + override val hasCache = false + override val canSkipLoading = false + override fun getId(index: Int): Int? = id +} + +abstract class VideoGenerator(val videos: List) { + abstract val hasCache: Boolean + abstract val canSkipLoading: Boolean + abstract fun getId(index : Int) : Int? + + fun hasNext(videoIndex : Int): Boolean = videoIndex < videos.lastIndex + fun hasPrev(videoIndex : Int): Boolean = videoIndex > 0 + + @Throws + abstract suspend fun generateLinks( clearCache: Boolean, sourceTypes: Set, callback: (Pair) -> Unit, subtitleCallback: (SubtitleData) -> Unit, - offset: Int = 0, - isCasting: Boolean = false + offset: Int, + isCasting: Boolean ): Boolean } diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/IPlayer.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/IPlayer.kt index 01f2b17021c..0342372667f 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/IPlayer.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/IPlayer.kt @@ -3,30 +3,11 @@ 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 - -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), -} +import com.lagradost.cloudstream3.utils.videoskip.VideoSkipStamp enum class CSPlayerEvent(val value: Int) { Pause(0), @@ -47,6 +28,7 @@ enum class CSPlayerLoading { IsPaused, IsPlaying, IsBuffering, + IsEnded, } enum class PlayerEventSource { @@ -85,13 +67,13 @@ data class ErrorEvent( /** Event when timestamps appear, null when it should disappear */ data class TimestampInvokedEvent( - val timestamp: EpisodeSkip.SkipStamp, + val timestamp: VideoSkipStamp, override val source: PlayerEventSource = PlayerEventSource.Player, ) : PlayerEvent() /** Event for when a chapter is skipped, aka when event is handled (or for future use when skip automatically ads/sponsor) */ data class TimestampSkippedEvent( - val timestamp: EpisodeSkip.SkipStamp, + val timestamp: VideoSkipStamp, override val source: PlayerEventSource = PlayerEventSource.Player, ) : PlayerEvent() @@ -181,6 +163,7 @@ interface Track { val id: String? val label: String? val language: String? + val sampleMimeType : String? } data class VideoTrack( @@ -189,19 +172,23 @@ 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?, - val mimeType: String?, + override val sampleMimeType: String?, ) : Track @@ -214,8 +201,6 @@ data class CurrentTracks( val allTextTracks: List, ) -class InvalidFileException(msg: String) : Exception(msg) - //http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4 const val ACTION_MEDIA_CONTROL = "media_control" const val EXTRA_CONTROL_TYPE = "control_type" @@ -237,8 +222,9 @@ interface IPlayer { fun getSubtitleOffset(): Long // in ms fun setSubtitleOffset(offset: Long) // in ms + @AnyThread fun initCallbacks( - eventHandler: ((PlayerEvent) -> Unit), + @MainThread eventHandler: ((PlayerEvent) -> Unit), /** this is used to request when the player should report back view percentage */ requestedListeningPercentages: List? = null, ) @@ -248,7 +234,7 @@ interface IPlayer { fun updateSubtitleStyle(style: SaveCaptionStyle) fun saveData() - fun addTimeStamps(timeStamps: List) + fun addTimeStamps(timeStamps: List) fun loadPlayer( context: Context, @@ -301,8 +287,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) + fun setPreferredAudioTrack(trackLanguage: String?, id: String? = null, formatIndex: Int? = 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 4416ce3b9cf..db06e26e9a6 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/LinkGenerator.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/LinkGenerator.kt @@ -40,36 +40,8 @@ class LinkGenerator( private val links: List, private val extract: Boolean = true, private val refererUrl: String? = 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() {} - + id: Int? +) : NoVideoGenerator(id) { override suspend fun generateLinks( clearCache: Boolean, sourceTypes: Set, @@ -107,37 +79,8 @@ class LinkGenerator( class MinimalLinkGenerator( private val links: List, private val subs: List, - 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() {} - + id: Int? +) : NoVideoGenerator(id) { override suspend fun generateLinks( clearCache: Boolean, sourceTypes: Set, diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/OfflinePlaybackHelper.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/OfflinePlaybackHelper.kt index eb9f5c249eb..ac25347b6bd 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/OfflinePlaybackHelper.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/OfflinePlaybackHelper.kt @@ -1,7 +1,6 @@ package com.lagradost.cloudstream3.ui.player import android.app.Activity -import android.content.ContentUris import android.content.Intent import android.net.Uri import androidx.core.content.ContextCompat.getString @@ -19,8 +18,8 @@ object OfflinePlaybackHelper { LinkGenerator( listOf( BasicLink(url) - ) - ) + ), id = url.hashCode() + ), 0 ) ) } @@ -52,7 +51,7 @@ object OfflinePlaybackHelper { links, subs, if (id != -1) id else null, - ) + ), 0 ) ) return true @@ -73,11 +72,10 @@ object OfflinePlaybackHelper { name = name ?: getString(activity, R.string.downloaded_file), // well not the same as a normal id, but we take it as users may want to // play downloaded files and save the location - id = kotlin.runCatching { ContentUris.parseId(uri) }.getOrNull() - ?.hashCode() + id = uri.lastPathSegment?.toLongOrNull()?.hashCode() ?: uri.lastPathSegment?.hashCode() ) ) - ) + ), 0 ) ) } diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerGeneratorViewModel.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerGeneratorViewModel.kt index 023cedd8a9a..e3c390d504c 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerGeneratorViewModel.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerGeneratorViewModel.kt @@ -9,34 +9,188 @@ import com.lagradost.cloudstream3.LoadResponse import com.lagradost.cloudstream3.mvvm.Resource import com.lagradost.cloudstream3.mvvm.launchSafe import com.lagradost.cloudstream3.mvvm.logError -import com.lagradost.cloudstream3.mvvm.safe import com.lagradost.cloudstream3.mvvm.safeApiCall +import com.lagradost.cloudstream3.ui.player.source_priority.QualityDataHelper.getLinkPriority import com.lagradost.cloudstream3.ui.result.ResultEpisode import com.lagradost.cloudstream3.utils.Coroutines.ioSafe -import com.lagradost.cloudstream3.utils.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" } - private var generator: IGenerator? = null + @Volatile + var generator: VideoGenerator<*>? = null - private val _currentLinks = MutableLiveData>>(setOf()) - val currentLinks: LiveData>> = _currentLinks + @Volatile + var episodeIndex: Int = 0 - private val _currentSubs = MutableLiveData>(setOf()) - val currentSubs: LiveData> = _currentSubs + /** + * The state of the video player, only modify it by modifyState to make sure observe is called, + * and avoid concurrency issues. + * + * This value can be used without Synchronized or locking when reading, as all fields are immutable. + * */ + @Volatile + var state = VideoState(instance = 0) + private set + + private val _currentLinks = + MutableLiveData>>>(null) + val currentLinks: LiveData>>> = _currentLinks + + private val _currentSubtitles = MutableLiveData>>(null) + val currentSubtitles: LiveData>> = _currentSubtitles + + private val _loadingLinks = MutableLiveData>>() + val loadingLinks: LiveData>> = _loadingLinks - private val _loadingLinks = MutableLiveData>() - val loadingLinks: LiveData> = _loadingLinks + private val _currentStamps = MutableLiveData>>(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 + } - private val _currentStamps = MutableLiveData>(emptyList()) - val currentStamps: LiveData> = _currentStamps + /** + * Only post the changed values, this makes sure we do not invoke the "observe" + * + * We do this by "Referential equality" https://kotlinlang.org/docs/equality.html#referential-equality + * to avoid comparing the entire set or list as "Persistent" classes will hold the same reference if they are unchanged. + * */ + if (state.links !== oldState.links) + _currentLinks.postValue(VideoLive(state.links, state.instance)) + if (state.stamps !== oldState.stamps) + _currentStamps.postValue(VideoLive(state.stamps, state.instance)) + if (state.subtitles !== oldState.subtitles) + _currentSubtitles.postValue(VideoLive(state.subtitles, state.instance)) + + /** Normal equality here as it is not a collection */ + if (state.loading != oldState.loading) + _loadingLinks.postValue(VideoLive(state.loading, state.instance)) + } private val _currentSubtitleYear = MutableLiveData(null) val currentSubtitleYear: LiveData = _currentSubtitleYear @@ -52,37 +206,32 @@ class PlayerGeneratorViewModel : ViewModel() { _currentSubtitleYear.postValue(year) } - fun getId(): Int? { - return generator?.getCurrentId() - } - - fun loadLinks(episode: Int) { - generator?.goto(episode) - loadLinks() - } - fun loadLinksPrev() { Log.i(TAG, "loadLinksPrev") - if (generator?.hasPrev() == true) { - generator?.prev() + if (generator?.hasPrev(episodeIndex) == true) { + episodeIndex += 1 loadLinks() } } fun loadLinksNext() { Log.i(TAG, "loadLinksNext") - if (generator?.hasNext() == true) { - generator?.next() + if (generator?.hasNext(episodeIndex) == true) { + episodeIndex += 1 loadLinks() } } fun hasNextEpisode(): Boolean? { - return generator?.hasNext() + return generator?.hasNext(episodeIndex) + } + + fun hasPrevEpisode(): Boolean? { + return generator?.hasPrev(episodeIndex) } fun preLoadNextLinks() { - val id = getId() + val id = generator?.getId(episodeIndex) // Do not preload if already loading if (id == currentLoadingEpisodeId) return @@ -92,14 +241,15 @@ class PlayerGeneratorViewModel : ViewModel() { currentJob = viewModelScope.launch { try { - if (generator?.hasCache == true && generator?.hasNext() == true) { + if (generator?.hasCache == true && generator?.hasNext(episodeIndex) == true) { safeApiCall { generator?.generateLinks( sourceTypes = LOADTYPE_INAPP, clearCache = false, + isCasting = false, callback = {}, subtitleCallback = {}, - offset = 1 + offset = episodeIndex + 1 ) } } @@ -113,119 +263,137 @@ class PlayerGeneratorViewModel : ViewModel() { } } - fun getLoadResponse(): LoadResponse? { - return safe { (generator as? RepoLinkGenerator?)?.page } - } - - fun getMeta(): Any? { - return safe { generator?.getCurrent() } - } - - fun getAllMeta(): List? { - return safe { generator?.getAll() } - } - - fun getNextMeta(): Any? { - return safe { - if (generator?.hasNext() == false) return@safe null - generator?.getCurrent(offset = 1) - } + fun loadThisEpisode(index: Int) { + episodeIndex = index + loadLinks() } - fun attachGenerator(newGenerator: IGenerator?) { - if (generator == null) { - generator = newGenerator - } + fun attachGenerator(newGenerator: VideoGenerator<*>, index: Int) { + Log.i(TAG, "attachGenerator with generator=$newGenerator and index=$index") + generator = newGenerator + episodeIndex = index } - private var extraSubtitles : MutableSet = mutableSetOf() - /** * If duplicate nothing will happen * */ - fun addSubtitles(file: Set) = synchronized(extraSubtitles) { - extraSubtitles += file - val current = _currentSubs.value ?: emptySet() - val next = extraSubtitles + current - - // if it is of a different size then we have added distinct items - if (next.size != current.size) { - // Posting will refresh subtitles which will in turn - // make the subs to english if previously unselected - _currentSubs.postValue(next) - } + fun addSubtitles(file: Set) { + val validFile = file.filter(::isValidSubtitle) + if (validFile.isNotEmpty()) + modifyState { + add(validFile) + } } private var currentJob: Job? = null private var currentStampJob: Job? = null fun loadStamps(duration: Long) { - //currentStampJob?.cancel() currentStampJob = ioSafe { - val meta = generator?.getCurrent() - val page = (generator as? RepoLinkGenerator?)?.page - if (page != null && meta is ResultEpisode) { - _currentStamps.postValue(listOf()) - _currentStamps.postValue( - EpisodeSkip.getStamps( - page, - meta, - duration, - hasNextEpisode() ?: false - ) - ) + val genState = state.generatorState ?: return@ioSafe + val meta = genState.meta + val page = genState.response + val id = genState.id + if (page == null || meta !is ResultEpisode) { + return@ioSafe + } + val stamps = SkipAPI.videoStamps( + page, + meta, + duration, + hasNextEpisode() ?: false + ) + + /** Avoid adding stamps to the wrong video */ + modifyState { + if (id != this.generatorState?.id) { + this + } else { + set(stamps) + } } } } + var langFilterList = listOf() + var filterSubByLang = false + + fun isValidSubtitle(subtitle: SubtitleData): Boolean { + if (langFilterList.isEmpty() || !filterSubByLang) { + return true + } + + /** Only filter out subtitles fetched online */ + if (subtitle.origin != SubtitleOrigin.URL) { + return true + } + + return langFilterList.any { lang -> + subtitle.originalName.contains(lang, ignoreCase = true) + } + } + fun loadLinks(sourceTypes: Set = LOADTYPE_INAPP) { - Log.i(TAG, "loadLinks") + Log.i(TAG, "loadLinks with generator=$generator and index=$episodeIndex") currentJob?.cancel() + val index = episodeIndex + + // Clear old data and reset the state + modifyState { + VideoState( + loading = Resource.Loading(), + generatorState = generator?.let { gen -> + GeneratorState( + meta = gen.videos.getOrNull(index), + nextMeta = gen.videos.getOrNull(index + 1), + id = gen.getId(index), + response = (gen as? RepoLinkGenerator)?.page, + index = index, + allMeta = gen.videos + ) + }, + instance = instance + 1 + ) + } currentJob = viewModelScope.launchSafe { - // if we load links then we clear the prev loaded links - synchronized(extraSubtitles) { - extraSubtitles.clear() - } - val currentLinks = mutableSetOf>() - val currentSubs = mutableSetOf() - - // clear old data - _currentSubs.postValue(emptySet()) - _currentLinks.postValue(emptySet()) - - // load more data - _loadingLinks.postValue(Resource.Loading()) + // Load more data val loadingState = safeApiCall { generator?.generateLinks( sourceTypes = sourceTypes, clearCache = forceClearCache, - callback = { - synchronized(currentLinks) { - currentLinks.add(it) - // Clone to prevent ConcurrentModificationException - safe { - // Extra safe since .toSet() iterates. - _currentLinks.postValue(currentLinks.toSet()) + callback = { link -> + if (isActive) + modifyState { + add(link) } - } }, - subtitleCallback = { - synchronized(extraSubtitles) { - currentSubs.add(it) - safe { - _currentSubs.postValue(currentSubs + extraSubtitles) + isCasting = false, + offset = index, + subtitleCallback = { link -> + if (isActive && isValidSubtitle(link)) + modifyState { + add(link) } - } }) + Unit } - _loadingLinks.postValue(loadingState) - _currentLinks.postValue(currentLinks) - synchronized(extraSubtitles) { - _currentSubs.postValue(currentSubs + extraSubtitles) + if (!isActive) { + return@launchSafe } - } + /** Only mark as success if we have not skipped loading */ + modifyState { + if (!isActive) { + this + } else { + when (loading) { + is Resource.Loading -> copy(loading = loadingState) + else -> this + } + } + } + } } } \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerGestureHelper.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerGestureHelper.kt new file mode 100644 index 00000000000..1c7086d1238 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerGestureHelper.kt @@ -0,0 +1,1220 @@ +package com.lagradost.cloudstream3.ui.player + +import android.animation.ValueAnimator +import android.annotation.SuppressLint +import android.app.Activity +import android.content.Context +import android.content.res.ColorStateList +import android.graphics.Matrix +import android.media.AudioManager +import android.media.audiofx.LoudnessEnhancer +import android.os.Build +import android.os.Handler +import android.os.Looper +import android.provider.Settings +import android.view.KeyEvent +import android.view.LayoutInflater +import android.view.MotionEvent +import android.view.ScaleGestureDetector +import android.view.View +import android.view.ViewGroup +import android.view.WindowInsets +import android.view.animation.AlphaAnimation +import android.view.animation.Animation +import android.view.animation.AnimationUtils +import androidx.annotation.OptIn +import androidx.core.content.ContextCompat +import androidx.core.view.isGone +import androidx.core.view.isVisible +import androidx.media3.common.util.UnstableApi +import androidx.media3.exoplayer.ExoPlayer +import androidx.media3.ui.AspectRatioFrameLayout +import androidx.preference.PreferenceManager +import com.lagradost.cloudstream3.CommonActivity.keyEventListener +import com.lagradost.cloudstream3.CommonActivity.screenHeightWithOrientation +import com.lagradost.cloudstream3.CommonActivity.screenWidthWithOrientation +import com.lagradost.cloudstream3.CommonActivity.showToast +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.mvvm.logError +import com.lagradost.cloudstream3.mvvm.safe +import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR +import com.lagradost.cloudstream3.ui.settings.Globals.PHONE +import com.lagradost.cloudstream3.ui.settings.Globals.isLayout +import com.lagradost.cloudstream3.utils.DataStoreHelper +import com.lagradost.cloudstream3.utils.UIHelper.getStatusBarHeight +import com.lagradost.cloudstream3.utils.Vector2 +import kotlin.math.abs +import kotlin.math.absoluteValue +import kotlin.math.ceil +import kotlin.math.max +import kotlin.math.min +import kotlin.math.round +import kotlin.math.roundToInt + +/** + * Handles all gesture, volume, brightness, speed-up, zoom, and hardware-key-event input for a + * [PlayerView]. Keeps these separate from the player-view setup and lifecycle + * code in [PlayerView] itself. + * + * Instantiated and owned by [PlayerView]; accessed from host fragments via the delegate + * properties [PlayerView] exposes. + */ +@OptIn(UnstableApi::class) +class PlayerGestureHelper(private val playerView: PlayerView) { + + companion object { + /** Swipe-seek constants */ + const val MINIMUM_SEEK_TIME = 7000L + const val MINIMUM_VERTICAL_SWIPE = 2.0f // % of screen height + const val MINIMUM_HORIZONTAL_SWIPE = 2.0f // % of screen height + const val VERTICAL_MULTIPLIER = 2.0f + const val HORIZONTAL_MULTIPLIER = 2.0f + + /** Double-tap constants */ + /** Maximum finger-hold time (ms) for a tap to qualify as a double-tap seek. */ + const val DOUBLE_TAP_MAXIMUM_HOLD_TIME = 200L + /** Time window (ms) between taps to count as a double-tap. + * Also determines how long a single-tap is delayed before firing. */ + const val DOUBLE_TAP_MINIMUM_TIME_BETWEEN = 200L + /** Fraction of view width on each side that counts as "left" / "right" seek zone. */ + const val DOUBLE_TAP_PAUSE_PERCENTAGE = 0.15 + + /** Zoom constants */ + /** Minimum zoom; allows zooming out past 100% but snaps back. */ + const val MINIMUM_ZOOM = 0.95f + /** Sensitivity for the auto-snap to 100% at the minimum zoom boundary. */ + const val ZOOM_SNAP_SENSITIVITY = 0.07f + /** Maximum zoom to prevent the user from getting lost. */ + const val MAXIMUM_ZOOM = 4.0f + + /** Extracts translation and uniform scale from a matrix with no rotation. */ + fun matrixToTranslationAndScale(matrix: Matrix): Triple { + val points = floatArrayOf(0f, 0f, 1f, 1f) + matrix.mapPoints(points) + val translationX = points[0] + val translationY = points[1] + val scale = points[2] - translationX + return Triple(translationX, translationY, scale) + } + } + + private val context: Context get() = playerView.context + + /** Set true by the host when the player occupies the full screen. + * Controls whether hardware volume-key overrides are active (phones/emulators only). */ + var isFullScreen: Boolean = false + + /** Volume state */ + var currentRequestedVolume: Float = 0.0f + var isVolumeLocked: Boolean = false + var hasShownVolumeToast: Boolean = false + private var loudnessEnhancer: LoudnessEnhancer? = null + private var progressBarLeftHideRunnable: Runnable? = null + + /** Brightness state */ + var currentRequestedBrightness: Float = 1.0f + var currentExtraBrightness: Float = 0.0f + var isBrightnessLocked: Boolean = false + var hasShownBrightnessToast: Boolean = false + /** When true, read/write system brightness via [Settings.System.SCREEN_BRIGHTNESS]. + * Automatically falls back to window-attribute brightness if the permission is missing. */ + var useTrueSystemBrightness: Boolean = true + /** White overlay inflated into exo_content_frame; alpha encodes extra brightness (0–1). */ + var brightnessOverlay: View? = null + private var progressBarRightHideRunnable: Runnable? = null + + /** Gesture settings (read from prefs in initialize) */ + var swipeVerticalEnabled: Boolean = true + var swipeHorizontalEnabled: Boolean = false + var extraBrightnessEnabled: Boolean = false + var speedupEnabled: Boolean = false + + /** Hold / speed-up */ + val holdHandler = Handler(Looper.getMainLooper()) + var hasTriggeredSpeedUp = false + val holdRunnable = Runnable { + playerView.player.setPlaybackSpeed(2.0f) + showOrHideSpeedUp(true) + playerView.callbacks?.onHoldSpeedUp(true) + hasTriggeredSpeedUp = true + } + + enum class TouchAction { Brightness, Volume, Time } + + /** Mirrors the host's lock state; suppresses gesture interactions when true. */ + var isLocked: Boolean = false + + /** Touch tracking */ + var isCurrentTouchValid = false + private set + private var currentTouchStart: Vector2? = null + private var currentTouchLast: Vector2? = null + /** Current in-progress swipe action, null when no swipe is active. */ + var currentTouchAction: TouchAction? = null + /** Action from the previous touch sequence; guards against mis-detected double-taps after swipes. */ + var currentLastTouchAction: TouchAction? = null + /** The time in the player when you first click. */ + private var currentTouchStartPlayerTime: Long? = null + /** The system time when you first click. */ + private var currentTouchStartTime: Long? = null + /** Whether the player UI was visible when the current swipe gesture began. */ + var uiShowingBeforeGesture: Boolean = false + + /** Icons */ + private val brightnessIcons = listOf( + R.drawable.sun_1, R.drawable.sun_2, R.drawable.sun_3, + R.drawable.sun_4, R.drawable.sun_5, R.drawable.sun_6, R.drawable.sun_7, + ) + private val volumeIcons = listOf( + R.drawable.ic_baseline_volume_mute_24, + R.drawable.ic_baseline_volume_down_24, + R.drawable.ic_baseline_volume_up_24, + ) + + /** Double-tap / tap state */ + + /** Whether double-tapping left/right seeks backward/forward. */ + var doubleTapEnabled: Boolean = false + + /** Whether double-tapping the center of the screen pauses (left/right still seeks if [doubleTapEnabled]). */ + var doubleTapPauseEnabled: Boolean = false + + /** Seek distance (ms) for each double-tap seek. Read from prefs in [initialize]. */ + var fastForwardTime: Long = 10_000L + + /** Monotonically-incremented token; cancels any pending single-tap runnable when a double-tap arrives. */ + private var doubleTapToken = 0 + + /** Number of consecutive taps in the current double-tap window. */ + private var tapCount = 0 + + /** System time of the most-recent touch end. Updated by callers at the end of every ACTION_UP. */ + var lastTouchEndTime: Long = 0L + + /** Zoom state */ + + /** Optional view for showing the snap-hint outline during zoom (set by FullScreenPlayer). */ + var videoOutline: View? = null + + /** Current zoom+pan matrix, or null when no zoom is active. */ + var zoomMatrix: Matrix? = null + + /** The matrix the zoom will animate to after the user lifts fingers. */ + var desiredMatrix: Matrix? = null + + /** Running snap-back animation, or null. */ + var matrixAnimation: ValueAnimator? = null + + private var scaleGestureDetector: ScaleGestureDetector? = null + + /** Midpoint of the two-finger pan, null when no pan is active. */ + var lastPan: Vector2? = null + + private var overlayLayoutListener: View.OnLayoutChangeListener? = null + + /** Called from [PlayerView.initialize] after views are bound. */ + fun initialize() { + try { + val sm = PreferenceManager.getDefaultSharedPreferences(context) + swipeVerticalEnabled = sm.getBoolean(context.getString(R.string.swipe_vertical_enabled_key), true) + swipeHorizontalEnabled = sm.getBoolean(context.getString(R.string.swipe_enabled_key), true) + extraBrightnessEnabled = sm.getBoolean(context.getString(R.string.extra_brightness_key), false) + speedupEnabled = sm.getBoolean(context.getString(R.string.speedup_key), false) + doubleTapEnabled = sm.getBoolean(context.getString(R.string.double_tap_enabled_key), false) + doubleTapPauseEnabled = sm.getBoolean(context.getString(R.string.double_tap_pause_enabled_key), false) + fastForwardTime = sm.getInt(context.getString(R.string.double_tap_seek_time_key), 10).toLong() * 1000L + } catch (_: Exception) { + } + + // Inject the brightness overlay into the ExoPlayer content frame so it sits + // directly on top of the video surface. Alpha is set by handleBrightnessAdjustment. + safe { + val pkg = context.packageName + @SuppressLint("DiscouragedApi") + val contentId = context.resources.getIdentifier("exo_content_frame", "id", pkg) + val contentFrame = playerView.exoPlayerView?.findViewById(contentId) + if (contentFrame != null) { + brightnessOverlay?.let { + (it.parent as? ViewGroup)?.removeView(it) + } + brightnessOverlay = LayoutInflater.from(context) + .inflate(R.layout.extra_brightness_overlay, contentFrame, false) + contentFrame.addView(brightnessOverlay) + } + } + + setupTouchGestures() + } + + /** Called from [PlayerView.release]. */ + fun release() { + safe { + brightnessOverlay?.let { + (it.parent as? ViewGroup)?.removeView(it) + } + } + brightnessOverlay = null + loudnessEnhancer?.release() + loudnessEnhancer = null + holdHandler.removeCallbacksAndMessages(null) + clearZoomState() + releaseOverlayLayoutListener() + } + + /** Key-event listener */ + + /** + * Registers the basic volume-key listener on [keyEventListener]. + * Called from [PlayerView.initialize] and from the host fragment's onResume. + */ + fun setupKeyEventListener() { + keyEventListener = { (event, _) -> + if (event != null && event.action == KeyEvent.ACTION_DOWN) + handleVolumeKey(event.keyCode) + else false + } + } + + /** Nulls [keyEventListener]. Called from the host fragment's onPause. */ + fun releaseKeyEventListener() { + keyEventListener = null + } + + /** Speed-up */ + + fun showOrHideSpeedUp(show: Boolean) { + playerView.playerSpeedupButton?.let { btn -> + btn.clearAnimation() + btn.alpha = if (show) 0f else 1f + btn.isVisible = show + btn.animate() + .alpha(if (show) 1f else 0f) + .setDuration(200L) + .withEndAction { if (!show) btn.isVisible = false } + .start() + } + } + + /** Volume helpers */ + + /** + * Syncs [currentRequestedVolume] with the current system stream volume. + * + * This is here to make returning to the player less jarring, if we change the volume outside + * the app. Note that this will make it a bit wierd when using loudness in PiP, then returning + * however that is the cost of correctness. + */ + fun verifyVolume() { + ((context as? Activity)?.getSystemService(Context.AUDIO_SERVICE) as? AudioManager)?.let { am -> + val cur = am.getStreamVolume(AudioManager.STREAM_MUSIC) + val max = am.getStreamMaxVolume(AudioManager.STREAM_MUSIC) + if (cur < max || currentRequestedVolume <= 1.0f) { + currentRequestedVolume = cur.toFloat() / max.toFloat() + loudnessEnhancer?.release() + loudnessEnhancer = null + } + } + } + + /** + * Handles a hardware volume key press. + * Only active on phones/emulators when [isFullScreen] is true. + * + * @return true if the key was consumed (suppresses the system volume UI). + */ + fun handleVolumeKey(keyCode: Int): Boolean { + /** + * Some TVs do not support volume boosting, and overriding + * the volume buttons can be inconvenient for TV users. + * Since boosting volume is mainly useful on phones and emulators, + * we limit this feature to those devices. + */ + if (!isLayout(PHONE or EMULATOR) || !isFullScreen) return false + if (keyCode != KeyEvent.KEYCODE_VOLUME_UP && keyCode != KeyEvent.KEYCODE_VOLUME_DOWN) return false + verifyVolume() + if (currentRequestedVolume <= 1.0f) hasShownVolumeToast = false + isVolumeLocked = currentRequestedVolume < 1.0f + // +- 5% + handleVolumeAdjustment(if (keyCode == KeyEvent.KEYCODE_VOLUME_UP) 0.05f else -0.05f, fromButton = true) + return true + } + + fun handleVolumeAdjustment(delta: Float, fromButton: Boolean) { + val am = (context as? Activity)?.getSystemService(Context.AUDIO_SERVICE) as? AudioManager ?: return + val curStep = am.getStreamVolume(AudioManager.STREAM_MUSIC) + val maxStep = am.getStreamMaxVolume(AudioManager.STREAM_MUSIC) + + val cur = currentRequestedVolume + val locked = isVolumeLocked + val next = (cur + delta).coerceIn(0.0f, if (locked) 1.0f else 2.0f) + val nextStep = (next * maxStep.toFloat()).roundToInt().coerceIn(0, maxStep) + + // Show toast + if (fromButton) { + // For button related request we only show a toast when we exceeded the volume. + if (cur <= 1.0f && next > 1.0f && !hasShownVolumeToast) { + showToast(R.string.volume_exceeded_100) + hasShownVolumeToast = true + } + } else { + val raw = cur + delta + // For swipes, we show toast that we need to swipe again. + if (raw > 1.0 && locked && !hasShownVolumeToast) { + showToast(R.string.slide_up_again_to_exceed_100) + hasShownVolumeToast = true + } + } + + // Set the current volume step. + if (nextStep != curStep) am.setStreamVolume(AudioManager.STREAM_MUSIC, nextStep, 0) + + var hasBoostError = false + // Apply loudness enhancer for volumes > 100%, removes it if less. + if (next > 1.0f) { + val boost = ((next - 1.0f) * 1000).toInt() + val existing = loudnessEnhancer + if (existing != null) { + existing.setTargetGain(boost) + } else { + val sessionId = (playerView.exoPlayerView?.player as? ExoPlayer)?.audioSessionId + if (sessionId != null && sessionId != AudioManager.ERROR) { + try { + loudnessEnhancer = LoudnessEnhancer(sessionId).apply { + setTargetGain(boost); enabled = true + } + } catch (t: Throwable) { logError(t); hasBoostError = true } + } + } + } else { + loudnessEnhancer?.release(); loudnessEnhancer = null + } + + currentRequestedVolume = next + + val leftHolder = playerView.playerProgressbarLeftHolder ?: return + val level1 = playerView.playerProgressbarLeftLevel1 ?: return + val level2 = playerView.playerProgressbarLeftLevel2 ?: return + val icon = playerView.playerProgressbarLeftIcon ?: return + + if (next > 1.0f) { + // Change color to show that LoudnessEnhancer broke + // this is not a real fix, but solves the crash issue. + level2.progressTintList = ColorStateList.valueOf( + ContextCompat.getColor(context, if (hasBoostError) R.color.colorPrimaryRed else R.color.colorPrimaryOrange) + ) + } + // Max is set high to make it smooth. + level1.max = 100_000 + level1.progress = (next * 100_000f).toInt().coerceIn(2_000, 100_000) + level2.max = 100_000 + level2.progress = if (next > 1.0f) ((next - 1.0) * 100_000f).toInt().coerceIn(2_000, 100_000) else 0 + level2.isVisible = next > 1.0f + // Calculate the clamped index for the volume icon based on the requested volume. + val iconIdx = (next * volumeIcons.lastIndex).roundToInt().coerceIn(0, volumeIcons.lastIndex) + icon.setImageResource(volumeIcons[iconIdx]) + + if (!leftHolder.isVisible || leftHolder.alpha < 1f) { + leftHolder.animate().cancel(); leftHolder.alpha = 1f; leftHolder.isVisible = true + } + progressBarLeftHideRunnable?.let { leftHolder.removeCallbacks(it) } + progressBarLeftHideRunnable = Runnable { + leftHolder.animate().cancel() + leftHolder.animate().alpha(0f).setDuration(300).withEndAction { leftHolder.isVisible = false }.start() + } + // Show the progress bar for 1.5 seconds. + leftHolder.postDelayed(progressBarLeftHideRunnable, 1500) + } + + /** Brightness helpers */ + + /** + * Reads from [Settings.System.SCREEN_BRIGHTNESS], falling back to the window + * attribute if the permission is absent. + */ + fun getBrightness(): Float? { + return if (useTrueSystemBrightness) { + try { + Settings.System.getInt( + (context as? Activity)?.contentResolver, + Settings.System.SCREEN_BRIGHTNESS + ) / 255f + } catch (_: Exception) { + // Because true system brightness requires + // permission, this is a lazy way to check + // as it will throw an error if we do not have it. + useTrueSystemBrightness = false + getBrightness() + } + } else { + try { + (context as? Activity)?.window?.attributes?.screenBrightness?.takeIf { it >= 0f } + } catch (e: Exception) { + logError(e) + null + } + } + } + + /** + * Sets [Settings.System.SCREEN_BRIGHTNESS], falling back to the window + * attribute if the permission is absent. + */ + fun setBrightness(brightness: Float) { + if (useTrueSystemBrightness) { + try { + Settings.System.putInt( + (context as? Activity)?.contentResolver, + Settings.System.SCREEN_BRIGHTNESS_MODE, + Settings.System.SCREEN_BRIGHTNESS_MODE_MANUAL + ) + Settings.System.putInt( + (context as? Activity)?.contentResolver, + Settings.System.SCREEN_BRIGHTNESS, + min(1, (brightness.coerceIn(0.0f, 1.0f) * 255).toInt()) + ) + } catch (_: Exception) { + useTrueSystemBrightness = false + setBrightness(brightness) + } + } else { + try { + val lp = (context as? Activity)?.window?.attributes ?: return + // Use 0.004f instead of 0: on some devices a value too close to 0 causes the + // system to override with its own brightness, making fine-tuning impossible. + lp.screenBrightness = brightness.coerceIn(0.004f, 1.0f) + (context as? Activity)?.window?.attributes = lp + } catch (e: Exception) { + logError(e) + } + } + } + + fun handleBrightnessAdjustment(verticalAddition: Float) { + val lastBrightness = currentRequestedBrightness + val raw = currentRequestedBrightness + verticalAddition + val next = raw.coerceIn(0.0f, if (extraBrightnessEnabled && !isBrightnessLocked) 2.0f else 1.0f) + + if (extraBrightnessEnabled && isBrightnessLocked && raw > 1.0f && !hasShownBrightnessToast) { + showToast(R.string.slide_up_again_to_exceed_100) + hasShownBrightnessToast = true + } + + currentRequestedBrightness = next + if (lastBrightness != currentRequestedBrightness) setBrightness(currentRequestedBrightness) + + currentExtraBrightness = if (extraBrightnessEnabled && next > 1.0f) min(2.0f, next) - 1.0f else 0.0f + brightnessOverlay?.alpha = currentExtraBrightness + playerView.callbacks?.onBrightnessExtra(currentExtraBrightness) + + val rightHolder = playerView.playerProgressbarRightHolder ?: return + val level1 = playerView.playerProgressbarRightLevel1 ?: return + val level2 = playerView.playerProgressbarRightLevel2 ?: return + val icon = playerView.playerProgressbarRightIcon ?: return + + level1.max = 100_000 + level1.progress = max(2_000, (min(1.0f, next) * 100_000f).toInt()) + + if (extraBrightnessEnabled) { + level2.max = 100_000 + level2.progress = (currentExtraBrightness * 100_000f).toInt().coerceIn(2_000, 100_000) + level2.isVisible = next > 1.0f + } + + icon.setImageResource( + // Clamp the value in case of extra brightness. + brightnessIcons[min(brightnessIcons.lastIndex, max(0, round(next * brightnessIcons.lastIndex).toInt()))] + ) + + if (!rightHolder.isVisible || rightHolder.alpha < 1f) { + rightHolder.animate().cancel(); rightHolder.alpha = 1f; rightHolder.isVisible = true + } + progressBarRightHideRunnable?.let { rightHolder.removeCallbacks(it) } + progressBarRightHideRunnable = Runnable { + rightHolder.animate().cancel() + rightHolder.animate().alpha(0f).setDuration(300).withEndAction { rightHolder.isVisible = false }.start() + } + rightHolder.postDelayed(progressBarRightHideRunnable, 1500) + } + + /** Zoom helpers */ + + /** + * Returns the current zoom matrix, accounting for RESIZE_MODE_ZOOM which already has + * an implicit zoom applied. + * + * This is different from `zoomMatrix ?: Matrix()` + * because it allows used to start zooming at different resizeModes. + * + * The main issue is that RESIZE_MODE_FIT = 100% zoom, but if you are in RESIZE_MODE_ZOOM + * 100% will make the zoom snap to less zoomed in then you already are. + */ + fun currentZoomMatrix(): Matrix { + val current = zoomMatrix + if (current != null) return current + + val exoView = playerView.exoPlayerView + val videoView = exoView?.videoSurfaceView + + if (exoView == null || videoView == null || + exoView.resizeMode != AspectRatioFrameLayout.RESIZE_MODE_ZOOM) { + return Matrix() + } + + val videoWidth = videoView.width.toFloat() + val videoHeight = videoView.height.toFloat() + val playerWidth = screenWidthWithOrientation.toFloat() + val playerHeight = screenHeightWithOrientation.toFloat() + + if (videoWidth <= 1f || videoHeight <= 1f || playerWidth <= 1f || playerHeight <= 1f) { + return Matrix() + } + + val initAspect = (playerHeight * videoWidth) / (playerWidth * videoHeight) + val aspect = max(initAspect, 1f / initAspect) + return Matrix().apply { postScale(aspect, aspect) } + } + + /** + * Applies [newMatrix] (scale + translation only) to the video surface view. + * + * @param newMatrix The new zoom matrix + * @param animation If this zoom is part of an animation, as then it will not auto zoom after we are done. + */ + fun applyZoomMatrix(newMatrix: Matrix, animation: Boolean) { + val exoView = playerView.exoPlayerView ?: return + if (!animation) { + matrixAnimation?.cancel() + matrixAnimation = null + } + val (translationX, translationY, scale) = matrixToTranslationAndScale(newMatrix) + + if (exoView.resizeMode == AspectRatioFrameLayout.RESIZE_MODE_FIT) { + exoView.resizeMode = AspectRatioFrameLayout.RESIZE_MODE_ZOOM + } + + val videoView = exoView.videoSurfaceView ?: return + val videoWidth = videoView.width.toFloat() + val videoHeight = videoView.height.toFloat() + val playerWidth = screenWidthWithOrientation.toFloat() + val playerHeight = screenHeightWithOrientation.toFloat() + + // Sanity check + if (videoWidth <= 1f || videoHeight <= 1f || playerWidth <= 1f || playerHeight <= 1f || scale <= 0.01f) return + + // Calculate the scaled aspect ratio as the view height is not real, check the debugger + // and you will see videoView.height > screen.height. + val initAspect = (playerHeight * videoWidth) / (playerWidth * videoHeight) + val aspect = min(initAspect, 1f / initAspect) + val scaledAspect = scale * aspect + + // Calculate clamp, this is very weird because we need to use aspect here as videoHeight > playerHeight. + val maxTransX = max(0f, videoWidth * scaledAspect - playerWidth) * 0.5f + val maxTransY = max(0f, videoHeight * scaledAspect - playerHeight) * 0.5f + + // Correct the translation to clamp within the viewing area. + val expectedTranslationX = translationX.coerceIn(-maxTransX, maxTransX) + val expectedTranslationY = translationY.coerceIn(-maxTransY, maxTransY) + + // Set the transform to the correct x and y. + newMatrix.postTranslate( + expectedTranslationX - translationX, + expectedTranslationY - translationY + ) + zoomMatrix = newMatrix + + if (!animation) { + // If we are not in an animation, set up the values for the animation. + if ((scaledAspect - 1f).absoluteValue < ZOOM_SNAP_SENSITIVITY) { + // We are within the correct scaling, so center and fit it. + videoOutline?.isVisible = true + val desired = Matrix() + desired.setScale(1f / aspect, 1f / aspect) + desiredMatrix = desired + } else if (scale < 1f) { + // We have zoomed too far, zoom to 100%. + videoOutline?.isVisible = false + desiredMatrix = Matrix() + } else { + // Keep the same scaling after zoom. + videoOutline?.isVisible = false + desiredMatrix = null + } + } + + // Finally set the actual scale + translation. + videoView.scaleX = scaledAspect + videoView.scaleY = scaledAspect + videoView.translationX = expectedTranslationX + videoView.translationY = expectedTranslationY + updateBrightnessOverlayBounds() + } + + /** + * Clears all zoom state and resets the video surface view to 1:1 scale. + * Does NOT change the ExoPlayer resize mode - call [PlayerView.resize] separately. + */ + fun clearZoomState() { + matrixAnimation?.cancel() + matrixAnimation = null + zoomMatrix = null + desiredMatrix = null + scaleGestureDetector = null + lastPan = null + playerView.exoPlayerView?.videoSurfaceView?.apply { + scaleX = 1f + scaleY = 1f + translationX = 0f + translationY = 0f + } + } + + /** + * Resets zoom to fit mode if any zoom is currently active. + * Calls [PlayerView.resize] to update the ExoPlayer resize mode. + */ + fun resetZoomToDefault() { + if (zoomMatrix != null) { + clearZoomState() + playerView.resize(PlayerResize.Fit, false) + } + } + + private fun createScaleGestureDetector(ctx: Context) { + scaleGestureDetector = ScaleGestureDetector( + ctx, + object : ScaleGestureDetector.SimpleOnScaleGestureListener() { + override fun onScale(detector: ScaleGestureDetector): Boolean { + val matrix = currentZoomMatrix() + val (_, _, scale) = matrixToTranslationAndScale(matrix) + // Clamp scale of the zoom, do it here as it is easier than doing it within applyZoomMatrix. + val newScale = (scale * detector.scaleFactor).coerceIn(MINIMUM_ZOOM, MAXIMUM_ZOOM) + // This is how much we should scale it with to prevent infinite scaling. + val actualScaleFactor = newScale / scale + // Scale around the focus point, this is more natural than just zoom. + val pivotX = detector.focusX - screenWidthWithOrientation.toFloat() * 0.5f + val pivotY = detector.focusY - screenHeightWithOrientation.toFloat() * 0.5f + matrix.postScale(actualScaleFactor, actualScaleFactor, pivotX, pivotY) + applyZoomMatrix(matrix, false) + return true + } + } + ) + } + + /** + * Processes a two-finger zoom/pan gesture event. + * Handles scale detection, panning, and the snap-back animation after finger lift. + * + * @param event The motion event (should have pointerCount >= 2 or [lastPan] != null). + * @param ctx Context used to create the [ScaleGestureDetector] on first call. + * @param onFirstPointerDown Called on [MotionEvent.ACTION_POINTER_DOWN] (e.g. hide player UI). + * @param onGestureEnd Called when the gesture ends (e.g. reset caller touch state). + * @return Always true (event consumed). + */ + fun handleZoomPanGesture( + event: MotionEvent, + ctx: Context, + onFirstPointerDown: () -> Unit, + onGestureEnd: () -> Unit + ): Boolean { + if (scaleGestureDetector == null) createScaleGestureDetector(ctx) + scaleGestureDetector?.onTouchEvent(event) + + when (event.actionMasked) { + MotionEvent.ACTION_POINTER_DOWN -> { + onFirstPointerDown() + } + + MotionEvent.ACTION_MOVE -> { + if (event.pointerCount >= 2) { + val newPan = Vector2( + (event.getX(0) + event.getX(1)) / 2f, + (event.getY(0) + event.getY(1)) / 2f + ) + val oldPan = lastPan + if (oldPan != null) { + val matrix = currentZoomMatrix() + matrix.postTranslate(newPan.x - oldPan.x, newPan.y - oldPan.y) + applyZoomMatrix(matrix, false) + } + lastPan = newPan + } + } + + MotionEvent.ACTION_CANCEL, + MotionEvent.ACTION_POINTER_UP, + MotionEvent.ACTION_UP -> { + lastPan = null + videoOutline?.isVisible = false + matrixAnimation?.cancel() + matrixAnimation = null + + // Snap to desired matrix after zoom gesture ends + matrixAnimation = ValueAnimator.ofFloat(0f, 1f).apply { + startDelay = 0 + duration = 200 + val startMatrix = currentZoomMatrix() + val endMatrix = desiredMatrix ?: return@apply + val (startX, startY, startScale) = matrixToTranslationAndScale(startMatrix) + val (endX, endY, endScale) = matrixToTranslationAndScale(endMatrix) + addUpdateListener { anim -> + val v = anim.animatedValue as Float + val vInv = 1f - v + val m = Matrix() + m.setScale(startScale * vInv + endScale * v, startScale * vInv + endScale * v) + m.postTranslate(startX * vInv + endX * v, startY * vInv + endY * v) + applyZoomMatrix(m, true) + } + start() + } + + onGestureEnd() + } + } + return true + } + + /** + * Resizes and repositions [brightnessOverlay] to exactly match the visible video surface, + * accounting for zoom scale and translation. + */ + fun updateBrightnessOverlayBounds() { + val overlay = brightnessOverlay ?: return + val pv = playerView.exoPlayerView ?: return + val video = pv.videoSurfaceView ?: return + + // Compute accurate transformed bounding box of the video view after scale+translation. + val vw = video.width.toFloat() + val vh = video.height.toFloat() + val sx = video.scaleX + val sy = video.scaleY + if (vw <= 0f || vh <= 0f) return + + // Pivot defaults to center if not set. + val pivotX = if (video.pivotX != 0f) video.pivotX else vw * 0.5f + val pivotY = if (video.pivotY != 0f) video.pivotY else vh * 0.5f + // Use view position (includes translation) as base; avoid double-counting translation. + val tx = video.x + val ty = video.y + + // Transform function for a local point (lx,ly). + fun transform(lx: Float, ly: Float): Pair { + val gx = tx + pivotX + (lx - pivotX) * sx + val gy = ty + pivotY + (ly - pivotY) * sy + return Pair(gx, gy) + } + + val p0 = transform(0f, 0f); val p1 = transform(vw, 0f) + val p2 = transform(0f, vh); val p3 = transform(vw, vh) + + val minX = min(min(p0.first, p1.first), min(p2.first, p3.first)) + val maxX = max(max(p0.first, p1.first), max(p2.first, p3.first)) + val minY = min(min(p0.second, p1.second), min(p2.second, p3.second)) + val maxY = max(max(p0.second, p1.second), max(p2.second, p3.second)) + + val newW = ceil(maxX - minX).toInt().coerceAtLeast(0) + val newH = ceil(maxY - minY).toInt().coerceAtLeast(0) + + val lp = overlay.layoutParams + if (lp == null) { + overlay.layoutParams = ViewGroup.LayoutParams(newW, newH) + } else if (lp.width != newW || lp.height != newH) { + lp.width = newW; lp.height = newH + overlay.layoutParams = lp + } + + overlay.scaleX = 1f; overlay.scaleY = 1f + overlay.x = minX; overlay.y = minY + } + + /** + * Attaches a persistent layout-change listener to the ExoPlayer view so + * [updateBrightnessOverlayBounds] is called on every layout pass (orientation change, + * aspect-ratio change, zoom, PiP transition, etc.). + */ + fun requestUpdateBrightnessOverlayOnNextLayout() { + val exoView = playerView.exoPlayerView ?: return + overlayLayoutListener?.let { exoView.removeOnLayoutChangeListener(it) } + val listener = View.OnLayoutChangeListener { _, _, _, _, _, _, _, _, _ -> + safe { updateBrightnessOverlayBounds() } + } + overlayLayoutListener = listener + exoView.addOnLayoutChangeListener(listener) + } + + /** Removes the overlay layout listener registered by [requestUpdateBrightnessOverlayOnNextLayout]. */ + fun releaseOverlayLayoutListener() { + overlayLayoutListener?.let { playerView.exoPlayerView?.removeOnLayoutChangeListener(it) } + overlayLayoutListener = null + } + + /** Rewind / fast-forward animations */ + + /** Resets the rewind button label to the standard "–Xs" format. */ + fun resetRewindText() { + playerView.exoRewText?.text = context.getString(R.string.rew_text_regular_format) + .format(fastForwardTime / 1000) + } + + /** Resets the fast-forward button label to the standard "+Xs" format. */ + fun resetFastForwardText() { + playerView.exoFfwdText?.text = context.getString(R.string.ffw_text_regular_format) + .format(fastForwardTime / 1000) + } + + /** + * Fades playerRewHolder, playerFfwdHolder, and playerPausePlay to [fadeTo] (0f or 1f). + * Always resets the holder alphas to 1f first so any stale fillAfter state is cleared. + * Called from host fragments' show/hide control animations so both GeneratorPlayer and trailer share + * the same fade logic. + */ + fun animateCenterControls(fadeTo: Float) { + val from = if (fadeTo > 0.5f) 0f else 1f + fun makeAnim() = AlphaAnimation(from, fadeTo).apply { duration = 100; fillAfter = true } + // Each view needs its own Animation instance; sharing one causes fillAfter to + // not hold reliably across all views once any of them restarts the animation. + playerView.playerRewHolder?.let { it.alpha = 1f; it.startAnimation(makeAnim()) } + playerView.playerFfwdHolder?.let { it.alpha = 1f; it.startAnimation(makeAnim()) } + playerView.playerPausePlay?.startAnimation(makeAnim()) + } + + /** Plays the rewind animation and seeks back by [fastForwardTime]. */ + fun rewind() { + try { + val rewHolder = playerView.playerRewHolder ?: return + val rew = playerView.playerRew + val rewText = playerView.exoRewText + val wasShowing = playerView.callbacks?.isUIShowing() ?: false + + // Only expose the parent chain when controls are currently hidden. + val prevCenterMenuGone = playerView.playerCenterMenu?.isGone ?: false + val prevVideoHolderVisible = playerView.playerVideoHolder?.isVisible ?: true + if (!wasShowing) { + playerView.playerCenterMenu?.isGone = false + playerView.playerVideoHolder?.isVisible = true + } + // Always clear any stale fillAfter alpha so the button is visible during animation. + rewHolder.alpha = 1f + + rew?.startAnimation(AnimationUtils.loadAnimation(context, R.anim.rotate_left)) + val goLeft = AnimationUtils.loadAnimation(context, R.anim.go_left) + goLeft.setAnimationListener(object : Animation.AnimationListener { + override fun onAnimationStart(animation: Animation?) {} + override fun onAnimationRepeat(animation: Animation?) {} + override fun onAnimationEnd(animation: Animation?) { + rewText?.post { + resetRewindText() + // Restore parent chain only if we changed it and controls are still hidden. + if (!wasShowing && !(playerView.callbacks?.isUIShowing() ?: false)) { + playerView.playerCenterMenu?.isGone = prevCenterMenuGone + playerView.playerVideoHolder?.isVisible = prevVideoHolderVisible + rewHolder.alpha = 0f + } + } + } + }) + rewText?.startAnimation(goLeft) + rewText?.text = context.getString(R.string.rew_text_format).format(fastForwardTime / 1000) + playerView.player.seekTime(-fastForwardTime) + } catch (e: Exception) { logError(e) } + } + + /** Plays the fast-forward animation and seeks forward by [fastForwardTime]. */ + fun fastForward() { + try { + val ffwdHolder = playerView.playerFfwdHolder ?: return + val ffwd = playerView.playerFfwd + val ffwdText = playerView.exoFfwdText + val wasShowing = playerView.callbacks?.isUIShowing() ?: false + + val prevCenterMenuGone = playerView.playerCenterMenu?.isGone ?: false + val prevVideoHolderVisible = playerView.playerVideoHolder?.isVisible ?: true + if (!wasShowing) { + playerView.playerCenterMenu?.isGone = false + playerView.playerVideoHolder?.isVisible = true + } + // Always clear any stale fillAfter alpha so the button is visible during animation. + ffwdHolder.alpha = 1f + + ffwd?.startAnimation(AnimationUtils.loadAnimation(context, R.anim.rotate_right)) + val goRight = AnimationUtils.loadAnimation(context, R.anim.go_right) + goRight.setAnimationListener(object : Animation.AnimationListener { + override fun onAnimationStart(animation: Animation?) {} + override fun onAnimationRepeat(animation: Animation?) {} + override fun onAnimationEnd(animation: Animation?) { + ffwdText?.post { + resetFastForwardText() + if (!wasShowing && !(playerView.callbacks?.isUIShowing() ?: false)) { + playerView.playerCenterMenu?.isGone = prevCenterMenuGone + playerView.playerVideoHolder?.isVisible = prevVideoHolderVisible + ffwdHolder.alpha = 0f + } + } + } + }) + ffwdText?.startAnimation(goRight) + ffwdText?.text = context.getString(R.string.ffw_text_format).format(fastForwardTime / 1000) + playerView.player.seekTime(fastForwardTime) + } catch (e: Exception) { logError(e) } + } + + /** Double-tap detection */ + + /** + * Call when a valid tap is detected (short hold, minimal movement, valid touch area). + * Routes to double-tap seeking/pausing or schedules a delayed single-tap callback. + * + * Updates [lastTouchEndTime] when a confirmed tap (single or double) is recorded. + * + * @param x X coordinate of the tap in the view's coordinate space. + * @param viewWidth Width of the view (used to compute left/center/right zones). + * @param isLocked Whether player controls are locked (suppresses double-tap seek). + * @param onSingleTap Invoked when it is determined to be a single tap; may be deferred. + * @return true if a double-tap action was performed. + */ + fun onTapDetected(x: Float, viewWidth: Int, isLocked: Boolean, onSingleTap: () -> Unit): Boolean { + val anyDoubleTap = doubleTapEnabled || doubleTapPauseEnabled + if (!anyDoubleTap) { + onSingleTap() + return false + } + + val timeSinceLast = System.currentTimeMillis() - lastTouchEndTime + return if (!isLocked && timeSinceLast < DOUBLE_TAP_MINIMUM_TIME_BETWEEN) { + /** Double-tap */ + tapCount++ + doubleTapToken++ // cancel any pending single-tap runnable + if (doubleTapPauseEnabled) { + when { + x < viewWidth / 2f - (DOUBLE_TAP_PAUSE_PERCENTAGE * viewWidth) -> { + if (doubleTapEnabled) rewind() + } + x > viewWidth / 2f + (DOUBLE_TAP_PAUSE_PERCENTAGE * viewWidth) -> { + if (doubleTapEnabled) fastForward() + } + else -> { + playerView.player.handleEvent(CSPlayerEvent.PlayPauseToggle, PlayerEventSource.UI) + } + } + } else if (doubleTapEnabled) { + if (x < viewWidth / 2f) rewind() else fastForward() + } + true + } else { + /** Single tap (first tap, or too slow for double-tap) */ + tapCount = 0 + val token = ++doubleTapToken + playerView.playerHolder?.postDelayed({ + if (token == doubleTapToken) { + onSingleTap() + } + }, DOUBLE_TAP_MINIMUM_TIME_BETWEEN) + false + } + } + + /** Seek time helpers */ + + private fun calculateNewTime(startTime: Long?, touchStart: Vector2?, touchEnd: Vector2?): Long? { + if (touchStart == null || touchEnd == null || startTime == null) return null + val diffX = (touchEnd.x - touchStart.x) * HORIZONTAL_MULTIPLIER / screenWidthWithOrientation.toFloat() + val duration = playerView.player.getDuration() ?: return null + return max(min(startTime + ((duration * (diffX * diffX)) * (if (diffX < 0) -1 else 1)).toLong(), duration), 0) + } + + private fun forceLetters(inp: Long, letters: Int = 2): String { + val added = letters - inp.toString().length + return if (added > 0) "0".repeat(added) + inp.toString() else inp.toString() + } + + private fun convertTimeToString(sec: Long): String { + val rsec = sec % 60L + val min = ceil((sec - rsec) / 60.0).toInt() + val rmin = min % 60L + val h = ceil((min - rmin) / 60.0).toLong() + // int rh = h;// h % 24; + return (if (h > 0) forceLetters(h) + ":" else "") + + (if (rmin >= 0 || h >= 0) forceLetters(rmin) + ":" else "") + + forceLetters(rsec) + } + + /** Touch gestures */ + + fun setupTouchGestures() { + val holder = playerView.playerHolder ?: return + @SuppressLint("ClickableViewAccessibility") + holder.setOnTouchListener(::handleGesture) + } + + private fun isValidTouch(rawX: Float, rawY: Float): Boolean { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + val holder = playerView.playerHolder ?: return true + val insets = holder.rootWindowInsets.getInsetsIgnoringVisibility(WindowInsets.Type.systemBars()) + val validHeight = rawY > insets.top && rawY < screenHeightWithOrientation - insets.bottom + val validWidth = rawX > insets.left && rawX < screenWidthWithOrientation - insets.right + return validHeight && validWidth + } + + return rawY > context.getStatusBarHeight() && rawX < screenWidthWithOrientation + } + + private fun handleGesture(view: View, event: MotionEvent): Boolean { + val currentTouch = Vector2(event.x, event.y) + val startTouch = currentTouchStart + + /** Two-finger zoom/pan (fullscreen, unlocked) */ + if ((event.pointerCount >= 2 || lastPan != null) && isFullScreen && !isLocked + && !hasTriggeredSpeedUp && currentTouchAction == null) { + holdHandler.removeCallbacks(holdRunnable) // Remove 2x speed. + isCurrentTouchValid = false // Prevent other touches + return handleZoomPanGesture( + event = event, + ctx = view.context, + onFirstPointerDown = { + uiShowingBeforeGesture = playerView.callbacks?.isUIShowing() ?: false + playerView.callbacks?.onHidePlayerUI() + }, + onGestureEnd = { + currentTouchStart = null + currentLastTouchAction = null + currentTouchAction = null + currentTouchStartPlayerTime = null + currentTouchLast = null + currentTouchStartTime = null + } + ) + } + + when (event.actionMasked) { + MotionEvent.ACTION_DOWN -> { + isCurrentTouchValid = isValidTouch(event.rawX, event.rawY) + if (isCurrentTouchValid) { + playerView.callbacks?.onTouchDown() + hasTriggeredSpeedUp = false + if (speedupEnabled && playerView.player.getIsPlaying() && !isLocked) { + holdHandler.postDelayed(holdRunnable, 500) + } + isVolumeLocked = currentRequestedVolume < 1.0f + if (currentRequestedVolume <= 1.0f) hasShownVolumeToast = false + isBrightnessLocked = currentRequestedBrightness < 1.0f + if (currentRequestedBrightness <= 1.0f) hasShownBrightnessToast = false + currentTouchStartTime = System.currentTimeMillis() + currentTouchStart = currentTouch + currentTouchLast = currentTouch + currentTouchStartPlayerTime = playerView.player.getPosition() + getBrightness()?.let { currentRequestedBrightness = it + currentExtraBrightness } + verifyVolume() + } + return true + } + + MotionEvent.ACTION_MOVE -> { + if (hasTriggeredSpeedUp) return true + if (!isCurrentTouchValid) return true + + if (currentTouchAction == null && startTouch != null) { + val diffFromStart = startTouch - currentTouch + if (swipeVerticalEnabled) { + if (abs(diffFromStart.y * 100 / screenHeightWithOrientation) > MINIMUM_VERTICAL_SWIPE) { + holdHandler.removeCallbacks(holdRunnable) + uiShowingBeforeGesture = playerView.callbacks?.isUIShowing() ?: false + playerView.callbacks?.onHidePlayerUI() + currentTouchAction = if ((startTouch.x) >= view.width / 2f) + TouchAction.Volume else TouchAction.Brightness + } + } + if (swipeHorizontalEnabled && !isLocked) { + if (abs(diffFromStart.x * 100 / screenHeightWithOrientation) > MINIMUM_HORIZONTAL_SWIPE) { + holdHandler.removeCallbacks(holdRunnable) + currentTouchAction = TouchAction.Time + } + } + } + + val lastTouch = currentTouchLast + if (lastTouch != null) { + val diffFromLast = lastTouch - currentTouch + val verticalAddition = diffFromLast.y * VERTICAL_MULTIPLIER / view.height.toFloat() + when (currentTouchAction) { + TouchAction.Time -> { + // This simply updates UI as the seek logic happens on release + // startTime is rounded to make the UI sync in a nice way. + val startTime = currentTouchStartPlayerTime?.div(1000L)?.times(1000L) + if (startTime != null) { + calculateNewTime(startTime, startTouch, currentTouch)?.let { newMs -> + val skipMs = newMs - startTime + playerView.callbacks?.onSeekPreviewText( + "${convertTimeToString(newMs / 1000)} [${ + if (abs(skipMs) < 1000) "" else if (skipMs > 0) "+" else "-" + }${convertTimeToString(abs(skipMs / 1000))}]" + ) + } + } + } + TouchAction.Brightness -> if (!isLocked) handleBrightnessAdjustment(verticalAddition) + TouchAction.Volume -> if (!isLocked) handleVolumeAdjustment(verticalAddition, false) + null -> Unit + } + if (currentTouchAction != TouchAction.Time) { + playerView.callbacks?.onSeekPreviewText(null) + } + } + currentTouchLast = currentTouch + return true + } + + MotionEvent.ACTION_CANCEL, MotionEvent.ACTION_UP -> { + holdHandler.removeCallbacks(holdRunnable) + if (hasTriggeredSpeedUp) { + playerView.player.setPlaybackSpeed(DataStoreHelper.playBackSpeed) + showOrHideSpeedUp(false) + playerView.callbacks?.onHoldSpeedUp(false) + hasTriggeredSpeedUp = false + } + + if (isCurrentTouchValid) { + // Horizontal seek on release + if (swipeHorizontalEnabled && currentTouchAction == TouchAction.Time && !isLocked) { + val startTime = currentTouchStartPlayerTime + if (startTime != null) { + calculateNewTime(startTime, startTouch, currentTouch)?.let { seekTo -> + if (abs(seekTo - startTime) > MINIMUM_SEEK_TIME) { + playerView.player.seekTo(seekTo, PlayerEventSource.UI) + } + } + } + } + // Tap detection: only fire if the finger was held briefly (not a long-press). + val holdTime = currentTouchStartTime?.let { System.currentTimeMillis() - it } + if (currentTouchAction == null && currentLastTouchAction == null + && !hasTriggeredSpeedUp + && (holdTime == null || holdTime < DOUBLE_TAP_MAXIMUM_HOLD_TIME)) { + onTapDetected( + x = currentTouch.x, + viewWidth = view.width, + isLocked = isLocked, + onSingleTap = { playerView.callbacks?.onSingleTap() } + ) + } + } + + playerView.callbacks?.onSeekPreviewText(null) + val hadSwipe = currentTouchAction != null || currentLastTouchAction != null + playerView.callbacks?.onGestureEnd(hadSwipe, uiShowingBeforeGesture) + + // Reset touch + lastTouchEndTime = System.currentTimeMillis() + isCurrentTouchValid = false + currentTouchStart = null + currentLastTouchAction = currentTouchAction + currentTouchAction = null + currentTouchStartPlayerTime = null + currentTouchLast = null + currentTouchStartTime = null + uiShowingBeforeGesture = false + return true + } + } + return false + } +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerPipHelper.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerPipHelper.kt index cc99b585f54..0db06499efe 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerPipHelper.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerPipHelper.kt @@ -1,121 +1,205 @@ 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 -class PlayerPipHelper { - companion object { - @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 - ) +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 } + } - @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) - ) + /** 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) - fun updatePIPModeActions(activity: Activity, isPlaying: Boolean, aspectRatio: Rational?) { - val actions: ArrayList = ArrayList() + @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( + activity, + R.drawable.baseline_headphones_24, + R.string.audio_singular, + CSPlayerEvent.PlayAsAudio + ) + ) + /*actions.add( + getRemoteAction( + activity, + R.drawable.go_back_30, + R.string.go_back_30, + CSPlayerEvent.SeekBack + ) + )*/ + + if (status == CSPlayerLoading.IsPlaying) { actions.add( getRemoteAction( activity, - R.drawable.baseline_headphones_24, - R.string.audio_singluar, - CSPlayerEvent.PlayAsAudio + R.drawable.netflix_pause, + R.string.pause, + CSPlayerEvent.Pause ) ) - /*actions.add( - getRemoteAction( - activity, - R.drawable.go_back_30, - R.string.go_back_30, - CSPlayerEvent.SeekBack - ) - )*/ - - 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 - ) - ) - } - + } else { actions.add( getRemoteAction( activity, - R.drawable.go_forward_30, - R.string.go_forward_30, - CSPlayerEvent.SeekForward + R.drawable.ic_baseline_play_arrow_24, + R.string.pause, + CSPlayerEvent.Play ) ) + } + + 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() - ) + // 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) } + + 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() + ) } } -} \ No newline at end of file + +} 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 0257521bd6e..ee6170aa53f 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,6 +11,7 @@ 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 { @@ -30,7 +31,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 Not guaranteed to follow any standard. Could be something like "English 4" or "en". + * @param languageCode usually, tags such as "en", "es-mx", or "zh-hant-TW". But it could be something like "English 4" * */ data class SubtitleData( val originalName: String, @@ -41,17 +42,23 @@ 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" } - val name = constructName(originalName, nameSuffix) + /** 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" /** * Gets the URL, but tries to fix it if it is malformed. @@ -103,8 +110,8 @@ class PlayerSubtitleHelper { url = subtitleFile.url, origin = SubtitleOrigin.URL, mimeType = subtitleFile.url.toSubtitleMimeType(), - headers = emptyMap(), - languageCode = subtitleFile.lang + headers = subtitleFile.headers ?: emptyMap(), + languageCode = subtitleFile.langTag ?: subtitleFile.lang ) } } @@ -122,7 +129,7 @@ class PlayerSubtitleHelper { fun setSubStyle(style: SaveCaptionStyle) { Log.i(TAG, "SET STYLE = $style") subtitleView?.translationY = -style.elevation.toPx.toFloat() - setSubtitleViewStyle(subtitleView, style) + setSubtitleViewStyle(subtitleView, style, true) } 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 new file mode 100644 index 00000000000..0e6f1a3677d --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerView.kt @@ -0,0 +1,842 @@ +package com.lagradost.cloudstream3.ui.player + +import android.annotation.SuppressLint +import android.app.Activity +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.content.pm.ActivityInfo +import android.graphics.drawable.AnimatedImageDrawable +import android.graphics.drawable.AnimatedVectorDrawable +import android.media.metrics.PlaybackErrorEvent +import android.os.Build +import android.os.Handler +import android.os.Looper +import android.text.format.DateUtils +import android.util.AttributeSet +import android.util.Log +import android.view.View +import android.view.WindowManager +import android.view.WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES +import android.widget.FrameLayout +import android.widget.ImageView +import android.widget.ProgressBar +import android.widget.RelativeLayout +import android.widget.TextView +import android.widget.Toast +import androidx.annotation.MainThread +import androidx.annotation.OptIn +import androidx.core.view.isGone +import androidx.core.view.isInvisible +import androidx.core.view.isVisible +import androidx.core.widget.doOnTextChanged +import androidx.fragment.app.FragmentActivity +import androidx.media3.common.PlaybackException +import androidx.media3.common.util.UnstableApi +import androidx.media3.exoplayer.ExoPlayer +import androidx.media3.session.MediaSession +import androidx.media3.ui.AspectRatioFrameLayout +import androidx.media3.ui.DefaultTimeBar +import androidx.media3.ui.SubtitleView +import androidx.media3.ui.TimeBar +import androidx.preference.PreferenceManager +import androidx.vectordrawable.graphics.drawable.AnimatedVectorDrawableCompat +import com.github.rubensousa.previewseekbar.PreviewBar +import com.github.rubensousa.previewseekbar.media3.PreviewTimeBar +import com.lagradost.cloudstream3.CommonActivity.isInPIPMode +import com.lagradost.cloudstream3.CommonActivity.screenWidth +import com.lagradost.cloudstream3.CommonActivity.showToast +import com.lagradost.cloudstream3.ErrorLoadingException +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.mvvm.logError +import com.lagradost.cloudstream3.mvvm.safe +import com.lagradost.cloudstream3.ui.player.live.LivePreviewTimeBar +import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR +import com.lagradost.cloudstream3.ui.settings.Globals.PHONE +import com.lagradost.cloudstream3.ui.settings.Globals.TV +import com.lagradost.cloudstream3.ui.settings.Globals.isLayout +import com.lagradost.cloudstream3.ui.subtitles.SaveCaptionStyle +import com.lagradost.cloudstream3.ui.subtitles.SubtitlesFragment +import com.lagradost.cloudstream3.utils.AppContextUtils +import com.lagradost.cloudstream3.utils.AppContextUtils.requestLocalAudioFocus +import com.lagradost.cloudstream3.utils.DataStoreHelper +import com.lagradost.cloudstream3.utils.UIHelper.hideKeyboard +import com.lagradost.cloudstream3.utils.UIHelper.hideSystemUI +import com.lagradost.cloudstream3.utils.UIHelper.popCurrentPage +import com.lagradost.cloudstream3.utils.UIHelper.showSystemUI +import com.lagradost.cloudstream3.utils.UserPreferenceDelegate +import com.lagradost.cloudstream3.utils.videoskip.VideoSkipStamp +import java.net.SocketTimeoutException + +/** + * Shared player view - manages ExoPlayer setup, view binding, lifecycle, and event + * dispatching. Gesture/volume/brightness/key-event input is handled by [gestureHelper] + * ([PlayerGestureHelper]), which is exposed via delegate properties for easier access. + */ +@OptIn(UnstableApi::class) +class PlayerView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null +) : FrameLayout(context, attrs) { + + companion object { + private const val TAG = "PlayerView" + } + + /** All gesture, volume, brightness and key-event logic lives here. */ + val gestureHelper = PlayerGestureHelper(this) + + /** Delegate properties (forwarded to gestureHelper for external callers to have easier access) */ + var isFullScreen: Boolean + get() = gestureHelper.isFullScreen + set(value) { gestureHelper.isFullScreen = value } + + var isLocked: Boolean + get() = gestureHelper.isLocked + set(value) { gestureHelper.isLocked = value } + + var videoOutline: View? + get() = gestureHelper.videoOutline + set(value) { gestureHelper.videoOutline = value } + + /** Delegate methods */ + fun handleVolumeKey(keyCode: Int) = gestureHelper.handleVolumeKey(keyCode) + fun verifyVolume() = gestureHelper.verifyVolume() + fun setupKeyEventListener() = gestureHelper.setupKeyEventListener() + fun releaseKeyEventListener() = gestureHelper.releaseKeyEventListener() + fun requestUpdateBrightnessOverlayOnNextLayout() = gestureHelper.requestUpdateBrightnessOverlayOnNextLayout() + fun releaseOverlayLayoutListener() = gestureHelper.releaseOverlayLayoutListener() + + /** Callbacks */ + + /** Host-fragment-level callbacks invoked by [mainCallback]. */ + interface Callbacks { + fun nextEpisode() {} + fun prevEpisode() {} + fun playerPositionChanged(position: Long, duration: Long) {} + fun playerStatusChanged() {} + fun playerDimensionsLoaded(width: Int, height: Int) {} + fun subtitlesChanged() {} + fun embeddedSubtitlesFetched(subtitles: List) {} + fun onTracksInfoChanged() {} + fun onTimestamp(timestamp: VideoSkipStamp?) {} + fun onTimestampSkipped(timestamp: VideoSkipStamp) {} + fun exitedPipMode() {} + fun hasNextMirror(): Boolean = false + fun nextMirror() {} + fun onDownload(event: DownloadEvent) {} + fun playerError(exception: Throwable) {} + /** Called after [PlayerView] finishes its own player-attached setup (MediaSession, ExoPlayer view). */ + fun playerUpdated(player: Any?) {} + /** Called on a short single-tap on empty player area (no swipe, no double-tap). */ + fun onSingleTap() {} + /** Called when the hold-for-speedup gesture starts (show=true) or ends (show=false). */ + fun onHoldSpeedUp(show: Boolean) {} + /** Called during brightness swipe with the current extra-brightness alpha (0–1). */ + fun onBrightnessExtra(alpha: Float) {} + + /** Touch event callbacks */ + + /** Returns whether the player UI (controls overlay) is currently visible. */ + fun isUIShowing(): Boolean = false + /** Called on a valid ACTION_DOWN; use for e.g. dismissing an episode overlay. */ + fun onTouchDown() {} + /** Called with seek-preview text during a horizontal-swipe, or null to clear it. */ + fun onSeekPreviewText(text: String?) {} + /** Called when a swipe gesture begins; hide the player UI if desired. */ + fun onHidePlayerUI() {} + /** + * Called at the end of each touch sequence. + * @param hadSwipe true if a swipe (brightness/volume/time) was in progress. + * @param wasUiShowing true if the UI was visible when the swipe began. + */ + fun onGestureEnd(hadSwipe: Boolean, wasUiShowing: Boolean) {} + /** + * Called when the auto-hide timer fires: UI is showing, no touch is active. + * Implement to hide the player controls. + */ + fun onAutoHideUI() {} + } + + var callbacks: Callbacks? = null + + /** Player state */ + + var player: IPlayer = CS3IPlayer() + var resizeMode: Int = 0 + var hasPipModeSupport: Boolean = true + var currentPlayerStatus: CSPlayerLoading = CSPlayerLoading.IsBuffering + var mMediaSession: MediaSession? = null + private var pipReceiver: BroadcastReceiver? = null + + /** Auto-hide */ + private var autoHideToken = 0 + private val autoHideHandler = Handler(Looper.getMainLooper()) + + /** View references (populated by bindViews) */ + + var subView: SubtitleView? = null + var playerPausePlayHolderHolder: FrameLayout? = null + var playerPausePlay: ImageView? = null + var playerBuffering: ProgressBar? = null + /** The Media3/ExoPlayer [androidx.media3.ui.PlayerView] widget. */ + var exoPlayerView: androidx.media3.ui.PlayerView? = null + var piphide: FrameLayout? = null + var subtitleHolder: FrameLayout? = null + internal var playerRew: View? = null + internal var playerFfwd: View? = null + internal var exoRewText: TextView? = null + internal var exoFfwdText: TextView? = null + internal var playerCenterMenu: View? = null + internal var playerRewHolder: View? = null + internal var playerFfwdHolder: View? = null + internal var playerVideoHolder: View? = null + var playerProgressbarLeftHolder: RelativeLayout? = null + var playerProgressbarLeftIcon: ImageView? = null + var playerProgressbarLeftLevel1: ProgressBar? = null + var playerProgressbarLeftLevel2: ProgressBar? = null + var playerProgressbarRightHolder: RelativeLayout? = null + var playerProgressbarRightIcon: ImageView? = null + var playerProgressbarRightLevel1: ProgressBar? = null + var playerProgressbarRightLevel2: ProgressBar? = null + /** Accessed by [PlayerGestureHelper.showOrHideSpeedUp]. */ + internal var playerSpeedupButton: View? = null + var playerHolder: FrameLayout? = null + private var exoDuration: TextView? = null + private var timeLeft: TextView? = null + private var exoPosition: TextView? = null + private var timeLive: View? = null + private var exoProgress: LivePreviewTimeBar? = null + + /** Seek delta used by the basic rew/ffwd click listeners. Read from settings in [initialize]. */ + var seekTime: Long = 10_000L + + /** True when the current video is taller than it is wide. Set by [mainCallback] on [ResizedEvent]. */ + var isVerticalOrientation: Boolean = false + + /** When true, [dynamicOrientation] returns portrait for portrait videos. Read from settings in [initialize]. */ + var autoPlayerRotateEnabled: Boolean = false + + var durationMode: Boolean by UserPreferenceDelegate("duration_mode", false) + + // Kept so SubtitlesFragment can unsubscribe the exact same reference. + private val subStyleListener: (SaveCaptionStyle) -> Unit = ::onSubStyleChanged + + /** View discovery */ + + /** + * Discovers player-related views from [root]. IDs absent in compact layouts (e.g. trailer) simply + * remain null, all usage is null-safe. + */ + fun bindViews(root: View) { + exoDuration = root.findViewById(androidx.media3.ui.R.id.exo_duration) + exoFfwdText = root.findViewById(R.id.exo_ffwd_text) + exoPlayerView = root.findViewById(R.id.player_view) + exoPosition = root.findViewById(R.id.exo_position) + exoRewText = root.findViewById(R.id.exo_rew_text) + piphide = root.findViewById(R.id.piphide) + playerBuffering = root.findViewById(R.id.player_buffering) + playerCenterMenu = root.findViewById(R.id.player_center_menu) + playerFfwd = root.findViewById(R.id.player_ffwd) + playerFfwdHolder = root.findViewById(R.id.player_ffwd_holder) + playerHolder = root.findViewById(R.id.player_holder) + playerPausePlay = root.findViewById(R.id.player_pause_play) + playerPausePlayHolderHolder = root.findViewById(R.id.player_pause_play_holder_holder) + playerProgressbarLeftHolder = root.findViewById(R.id.player_progressbar_left_holder) + playerProgressbarLeftIcon = root.findViewById(R.id.player_progressbar_left_icon) + playerProgressbarLeftLevel1 = root.findViewById(R.id.player_progressbar_left_level1) + playerProgressbarLeftLevel2 = root.findViewById(R.id.player_progressbar_left_level2) + playerProgressbarRightHolder = root.findViewById(R.id.player_progressbar_right_holder) + playerProgressbarRightIcon = root.findViewById(R.id.player_progressbar_right_icon) + playerProgressbarRightLevel1 = root.findViewById(R.id.player_progressbar_right_level1) + playerProgressbarRightLevel2 = root.findViewById(R.id.player_progressbar_right_level2) + playerRew = root.findViewById(R.id.player_rew) + playerRewHolder = root.findViewById(R.id.player_rew_holder) + playerSpeedupButton = root.findViewById(R.id.player_speedup_button) + playerVideoHolder = root.findViewById(R.id.player_video_holder) + subtitleHolder = root.findViewById(R.id.subtitle_holder) + timeLeft = root.findViewById(R.id.time_left) + timeLive = root.findViewById(R.id.time_live) + } + + /** + * Called once after [bindViews]. Sets up the preview seek-bar, subtitle style listener, + * player callbacks and basic controls; then delegates gesture/input setup to [gestureHelper]. + */ + fun initialize() { + resizeMode = DataStoreHelper.resizeMode + resize(resizeMode, false) + + player.releaseCallbacks() + player.initCallbacks( + eventHandler = ::mainCallback, + requestedListeningPercentages = listOf( + SKIP_OP_VIDEO_PERCENTAGE, + PRELOAD_NEXT_EPISODE_PERCENTAGE, + NEXT_WATCH_EPISODE_PERCENTAGE, + UPDATE_SYNC_PROGRESS_PERCENTAGE, + ), + ) + + if (player is CS3IPlayer) { + // Preview bar + val progressBar: PreviewTimeBar? = exoPlayerView?.findViewById(R.id.exo_progress) + exoProgress = progressBar as? LivePreviewTimeBar + val previewImageView: ImageView? = exoPlayerView?.findViewById(R.id.previewImageView) + val previewFrameLayout: FrameLayout? = + exoPlayerView?.findViewById(R.id.previewFrameLayout) + + /** Hide the previewFrameLayout on TV to make the skip op button not float, + * as previewFrameLayout is normally invisible */ + if(isLayout(TV)) { + previewFrameLayout?.isVisible = false + } + + if (progressBar != null && previewImageView != null && previewFrameLayout != null && !isLayout(TV)) { + var resume = false + progressBar.addOnScrubListener(object : PreviewBar.OnScrubListener { + override fun onScrubStart(previewBar: PreviewBar?) { + val cs3 = player as? CS3IPlayer ?: return + val hasPreview = cs3.hasPreview() + progressBar.isPreviewEnabled = hasPreview + resume = cs3.getIsPlaying() + if (resume) cs3.handleEvent(CSPlayerEvent.Pause, PlayerEventSource.Player) + // No clashing UI + if (hasPreview) subView?.isVisible = false + } + + override fun onScrubMove(previewBar: PreviewBar?, progress: Int, fromUser: Boolean) {} + + override fun onScrubStop(previewBar: PreviewBar?) { + val cs3 = player as? CS3IPlayer ?: return + if (resume) cs3.handleEvent(CSPlayerEvent.Play, PlayerEventSource.Player) + // Delay to prevent the small flicker of subtitle before seeking. + subView?.postDelayed({ + // If we are not scrubbing then show subtitles again. + if (previewBar == null || !previewBar.isPreviewEnabled || !previewBar.isShowingPreview) { + subView?.isVisible = true + } + }, 200) + } + }) + progressBar.attachPreviewView(previewFrameLayout) + progressBar.setPreviewLoader { currentPosition, max -> + val cs3 = player as? CS3IPlayer ?: return@setPreviewLoader + val bitmap = cs3.getPreview(currentPosition.toFloat().div(max.toFloat())) + previewImageView.isGone = bitmap == null + previewImageView.setImageBitmap(bitmap) + } + } + + subView = exoPlayerView?.findViewById(androidx.media3.ui.R.id.exo_subtitles) + (player as? CS3IPlayer)?.initSubtitles(subView, subtitleHolder, CustomDecoder.style) + (player as? CS3IPlayer)?.let { + (it.imageGenerator as? PreviewGenerator)?.params = + ImageParams.new16by9(screenWidth) + } + + /** + * This might seam a bit fucky and that is because it is, the seek event is captured twice, once by the player + * and once by the UI even if it should only be registered once by the UI. + */ + exoPlayerView?.findViewById(R.id.exo_progress) + ?.addListener(object : TimeBar.OnScrubListener { + override fun onScrubStart(timeBar: TimeBar, position: Long) = Unit + override fun onScrubMove(timeBar: TimeBar, position: Long) = Unit + override fun onScrubStop(timeBar: TimeBar, position: Long, canceled: Boolean) { + if (canceled) return + val playerDuration = player.getDuration() ?: return + val playerPosition = player.getPosition() ?: return + mainCallback( + PositionEvent( + source = PlayerEventSource.UI, + durationMs = playerDuration, + fromMs = playerPosition, + toMs = position + ) + ) + } + }) + + // Read seek time and rotation settings. + try { + val sm = PreferenceManager.getDefaultSharedPreferences(context) + seekTime = sm.getInt(context.getString(R.string.double_tap_seek_time_key), 10) + .toLong() * 1000L + autoPlayerRotateEnabled = sm.getBoolean( + context.getString(R.string.auto_rotate_video_key), true + ) + } catch (_: Exception) { + } + + val seekSecs = (seekTime / 1000).toInt() + exoRewText?.text = context.getString(R.string.rew_text_regular_format).format(seekSecs) + exoFfwdText?.text = context.getString(R.string.ffw_text_regular_format).format(seekSecs) + + playerPausePlay?.setOnClickListener { + scheduleAutoHide() + if (currentPlayerStatus == CSPlayerLoading.IsEnded && isLayout(PHONE)) { + player.handleEvent(CSPlayerEvent.Restart, PlayerEventSource.UI) + } else { + player.handleEvent(CSPlayerEvent.PlayPauseToggle, PlayerEventSource.UI) + } + } + playerRew?.setOnClickListener { + scheduleAutoHide() + gestureHelper.rewind() + } + playerFfwd?.setOnClickListener { + scheduleAutoHide() + gestureHelper.fastForward() + } + + SubtitlesFragment.applyStyleEvent += subStyleListener + + try { + val ctx = context + val settingsManager = PreferenceManager.getDefaultSharedPreferences(ctx) + val cs3 = player as? CS3IPlayer ?: return + cs3.cacheSize = + settingsManager.getInt(context.getString(R.string.video_buffer_size_key), 0) * 1024L * 1024L + cs3.simpleCacheSize = + settingsManager.getInt(context.getString(R.string.video_buffer_disk_key), 0) * 1024L * 1024L + cs3.videoBufferMs = + settingsManager.getInt(context.getString(R.string.video_buffer_length_key), 0) * 1000L + } catch (e: Exception) { + logError(e) + } + + // Duration toggle click listeners + exoDuration?.setOnClickListener { setRemainingTimeCounter(true) } + timeLeft?.setOnClickListener { setRemainingTimeCounter(false) } + // Keep remaining-time text in sync with playback position + exoPosition?.doOnTextChanged { _, _, _, _ -> updateRemainingTime() } + + // Delegate gesture/input setup (settings, brightness overlay, touch gestures, key listener) + gestureHelper.initialize() + setupKeyEventListener() + + // Apply duration-mode display (remaining time vs elapsed); TV always shows remaining + setRemainingTimeCounter(durationMode || isLayout(TV)) + } + } + + /** Lifecycle delegation */ + + var fullscreenNotch: Boolean = true // TODO SETTING + + fun enterFullscreen(updateOrientation: () -> Unit = {}) { + val activity = context as? Activity + if (isFullScreen) { + activity?.hideSystemUI() + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P && fullscreenNotch) { + val params = activity?.window?.attributes + params?.layoutInDisplayCutoutMode = LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES + activity?.window?.attributes = params + } + } + updateOrientation() + } + + fun exitFullscreen() { + val activity = context as? Activity + gestureHelper.resetZoomToDefault() + activity?.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_USER + // Simply resets brightness and notch settings that might have been overridden. + val lp = activity?.window?.attributes + lp?.screenBrightness = WindowManager.LayoutParams.BRIGHTNESS_OVERRIDE_NONE + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + lp?.layoutInDisplayCutoutMode = WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_DEFAULT + } + activity?.window?.attributes = lp + activity?.showSystemUI() + } + + fun onStop() { + player.onStop() + } + + fun onResume(ctx: Context) { + player.onResume(ctx) + } + + /** Releases all player resources. */ + fun release() { + player.release() + player.releaseCallbacks() + player = CS3IPlayer() + + // keyEventListener is deregistered in onPause so that the incoming player's + // onResume can register its own listener without racing against release(). + + PlayerPipHelper.updatePIPModeActions( + context as? Activity, + CSPlayerLoading.IsPaused, + false, + null + ) + + mMediaSession?.release() + mMediaSession = null + exoPlayerView?.player = null + + SubtitlesFragment.applyStyleEvent -= subStyleListener + + gestureHelper.release() + autoHideHandler.removeCallbacksAndMessages(null) + + keepScreenOn(false) + } + + fun onPictureInPictureModeChanged( + isInPictureInPictureMode: Boolean, + activity: Activity? + ) { + try { + isInPIPMode = isInPictureInPictureMode + if (isInPictureInPictureMode) { + // Hide the full-screen UI (controls, etc.) while in picture-in-picture mode. + piphide?.isVisible = false + pipReceiver = object : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + if (ACTION_MEDIA_CONTROL != intent.action) return + player.handleEvent( + CSPlayerEvent.entries[intent.getIntExtra(EXTRA_CONTROL_TYPE, 0)], + source = PlayerEventSource.UI + ) + } + } + val filter = IntentFilter().apply { addAction(ACTION_MEDIA_CONTROL) } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + activity?.registerReceiver(pipReceiver, filter, Context.RECEIVER_EXPORTED) + } else { + @SuppressLint("UnspecifiedRegisterReceiverFlag") + activity?.registerReceiver(pipReceiver, filter) + } + val isPlaying = player.getIsPlaying() + val status = if (isPlaying) CSPlayerLoading.IsPlaying else CSPlayerLoading.IsPaused + updateIsPlaying(status, status) + } else { + // Restore the full-screen UI. + piphide?.isVisible = true + callbacks?.exitedPipMode() + pipReceiver?.let { + // Prevents java.lang.IllegalArgumentException: Receiver not registered + safe { activity?.unregisterReceiver(it) } + } + activity?.hideSystemUI() + hideKeyboard(this) + } + } catch (e: Exception) { + logError(e) + } + } + + /** Player UI helpers */ + + private fun keepScreenOn(on: Boolean) { + val window = (context as? Activity)?.window ?: return + if (on) window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + else window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + } + + fun updateIsPlaying(wasPlaying: CSPlayerLoading, isPlaying: CSPlayerLoading) { + val isPlayingRightNow = CSPlayerLoading.IsPlaying == isPlaying + val isBuffering = CSPlayerLoading.IsBuffering == isPlaying + currentPlayerStatus = isPlaying + + keepScreenOn(isPlayingRightNow || isBuffering) + + if (isBuffering) { + playerPausePlayHolderHolder?.isVisible = false + playerBuffering?.isVisible = true + } else { + playerPausePlayHolderHolder?.isVisible = true + playerBuffering?.isVisible = false + + if (isPlaying == CSPlayerLoading.IsEnded && isLayout(PHONE)) { + playerPausePlay?.setImageResource(R.drawable.ic_baseline_replay_24) + } else if (wasPlaying != isPlaying) { + playerPausePlay?.setImageResource( + if (isPlayingRightNow) R.drawable.play_to_pause else R.drawable.pause_to_play + ) + val drawable = playerPausePlay?.drawable + var startedAnimation = false + if (Build.VERSION.SDK_INT > Build.VERSION_CODES.P) { + if (drawable is AnimatedImageDrawable) { drawable.start(); startedAnimation = true } + } + if (drawable is AnimatedVectorDrawable) { drawable.start(); startedAnimation = true } + if (drawable is AnimatedVectorDrawableCompat) { drawable.start(); startedAnimation = true } + // Somehow the phone is wacked + if (!startedAnimation) { + playerPausePlay?.setImageResource( + if (isPlayingRightNow) R.drawable.netflix_pause else R.drawable.netflix_play + ) + } + } else { + playerPausePlay?.setImageResource( + if (isPlayingRightNow) R.drawable.netflix_pause else R.drawable.netflix_play + ) + } + } + + PlayerPipHelper.updatePIPModeActions( + context as? Activity, + isPlaying, + hasPipModeSupport, + player.getAspectRatio() + ) + } + + private fun requestAudioFocus() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + (context as? Activity)?.requestLocalAudioFocus(AppContextUtils.getFocusRequest()) + } + } + + private fun playerUpdated(player: Any?) { + if (player is ExoPlayer) { + mMediaSession?.release() + mMediaSession = MediaSession.Builder(context, player) + // Ensure unique ID for concurrent players. + .setId(System.currentTimeMillis().toString()) + .build() + + // Necessary for multiple combined videos. + @Suppress("DEPRECATION") + exoPlayerView?.setShowMultiWindowTimeBar(true) + exoPlayerView?.player = player + exoPlayerView?.performClick() + } + callbacks?.playerUpdated(player) + } + + private fun onSubStyleChanged(style: SaveCaptionStyle) { + player.updateSubtitleStyle(style) + // Forcefully update the subtitle encoding in case the edge size is changed. + player.seekTime(-1) + } + + /** Error handling */ + + @MainThread + fun playerError(exception: Throwable) { + fun showErrorToast(message: String) { + if (callbacks?.hasNextMirror() == true) { + showToast(message, Toast.LENGTH_SHORT) + callbacks?.nextMirror() + } else { + showToast( + context.getString(R.string.no_links_found_toast) + "\n" + message, + Toast.LENGTH_LONG + ) + (context as? FragmentActivity)?.popCurrentPage() + } + } + + when (exception) { + is PlaybackException -> { + val msg = exception.message ?: "" + val errorName = exception.errorCodeName + when (val code = exception.errorCode) { + PlaybackException.ERROR_CODE_IO_FILE_NOT_FOUND, + PlaybackException.ERROR_CODE_IO_NO_PERMISSION, + PlaybackException.ERROR_CODE_IO_READ_POSITION_OUT_OF_RANGE, + PlaybackException.ERROR_CODE_IO_UNSPECIFIED, + PlaybackException.ERROR_CODE_PARSING_CONTAINER_UNSUPPORTED -> + showErrorToast("${context.getString(R.string.source_error)}\n$errorName ($code)\n$msg") + + PlaybackException.ERROR_CODE_REMOTE_ERROR, + PlaybackException.ERROR_CODE_IO_BAD_HTTP_STATUS, + PlaybackException.ERROR_CODE_TIMEOUT, + PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_FAILED, + PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_TIMEOUT, + PlaybackException.ERROR_CODE_IO_INVALID_HTTP_CONTENT_TYPE -> + showErrorToast("${context.getString(R.string.remote_error)}\n$errorName ($code)\n$msg") + + PlaybackErrorEvent.ERROR_AUDIO_TRACK_INIT_FAILED, + PlaybackErrorEvent.ERROR_AUDIO_TRACK_OTHER, + PlaybackException.ERROR_CODE_DECODING_FAILED, + PlaybackException.ERROR_CODE_AUDIO_TRACK_WRITE_FAILED, + PlaybackException.ERROR_CODE_DECODER_INIT_FAILED, + PlaybackException.ERROR_CODE_DECODER_QUERY_FAILED -> + showErrorToast("${context.getString(R.string.render_error)}\n$errorName ($code)\n$msg") + + PlaybackException.ERROR_CODE_DECODING_FORMAT_UNSUPPORTED, + PlaybackException.ERROR_CODE_DECODING_FORMAT_EXCEEDS_CAPABILITIES -> + showErrorToast("${context.getString(R.string.unsupported_error)}\n$errorName ($code)\n$msg") + + PlaybackException.ERROR_CODE_PARSING_CONTAINER_MALFORMED, + PlaybackException.ERROR_CODE_PARSING_MANIFEST_MALFORMED -> + showErrorToast("${context.getString(R.string.encoding_error)}\n$errorName ($code)\n$msg") + + else -> + showErrorToast("${context.getString(R.string.unexpected_error)}\n$errorName ($code)\n$msg") + } + } + + is SocketTimeoutException -> + showErrorToast("${context.getString(R.string.remote_error)}\n${exception.message}") + + is ErrorLoadingException -> + exception.message?.let { showErrorToast(it) } + ?: showErrorToast(exception.toString()) + + else -> + exception.message?.let { showErrorToast(it) } + ?: showErrorToast(exception.toString()) + } + } + + /** Resize */ + + fun nextResize() { + resizeMode = (resizeMode + 1) % PlayerResize.entries.size + resize(resizeMode, true) + } + + fun resize(resize: Int, showToast: Boolean) { + // Clear all zoom state before applying the new resize mode + gestureHelper.clearZoomState() + resize(PlayerResize.entries[resize], showToast) + } + + fun resize(resize: PlayerResize, showToast: Boolean) { + DataStoreHelper.resizeMode = resize.ordinal + val type = when (resize) { + PlayerResize.Fill -> AspectRatioFrameLayout.RESIZE_MODE_FILL + PlayerResize.Fit -> AspectRatioFrameLayout.RESIZE_MODE_FIT + PlayerResize.Zoom -> AspectRatioFrameLayout.RESIZE_MODE_ZOOM + } + exoPlayerView?.resizeMode = type + if (showToast) showToast(resize.nameRes, Toast.LENGTH_SHORT) + } + + /** Orientation */ + + /** + * Returns the desired [ActivityInfo] orientation constant based on [isVerticalOrientation] + * and [autoPlayerRotateEnabled]. TV/emulator always returns sensor-landscape. + * Host fragments call this from [Callbacks.playerDimensionsLoaded] to apply rotation. + */ + fun dynamicOrientation(): Int { + if (isLayout(TV or EMULATOR)) return ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE + return if (autoPlayerRotateEnabled && isVerticalOrientation) + ActivityInfo.SCREEN_ORIENTATION_SENSOR_PORTRAIT + else ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE + } + + /** Event dispatch */ + + /** + * This receives the events from the player, if you want to append functionality + * you do it here, do note that this only receives events for UI changes, + * and returning early WON'T stop it from changing in e.g. the player time + * or pause status. + */ + @MainThread + fun mainCallback(event: PlayerEvent) { + // We don't want to spam DownloadEvent. + if (event !is DownloadEvent) Log.i(TAG, "Handle event: $event") + when (event) { + is DownloadEvent -> callbacks?.onDownload(event) + is ResizedEvent -> { + // Skip 0x0 dimensions that the player emits when going to STATE_IDLE + // to avoid incorrectly resetting the auto-detected orientation. + if (event.width > 0 && event.height > 0) { + // TV never rotates; otherwise track whether the video is portrait. + isVerticalOrientation = !isLayout(TV or EMULATOR) && event.height > event.width + } + callbacks?.playerDimensionsLoaded(event.width, event.height) + } + is PlayerAttachedEvent -> playerUpdated(event.player) + is SubtitlesUpdatedEvent -> callbacks?.subtitlesChanged() + is TimestampSkippedEvent -> callbacks?.onTimestampSkipped(event.timestamp) + is TimestampInvokedEvent -> callbacks?.onTimestamp(event.timestamp) + is TracksChangedEvent -> callbacks?.onTracksInfoChanged() + is EmbeddedSubtitlesFetchedEvent -> callbacks?.embeddedSubtitlesFetched(event.tracks) + is ErrorEvent -> callbacks?.playerError(event.error) ?: playerError(event.error) + is RequestAudioFocusEvent -> requestAudioFocus() + is EpisodeSeekEvent -> when (event.offset) { + -1 -> callbacks?.prevEpisode() + 1 -> callbacks?.nextEpisode() + } + is StatusEvent -> { + updateIsPlaying(wasPlaying = event.wasPlaying, isPlaying = event.isPlaying) + scheduleAutoHide() + callbacks?.playerStatusChanged() + } + is PositionEvent -> callbacks?.playerPositionChanged( + position = event.toMs, + duration = event.durationMs + ) + is VideoEndedEvent -> { + // Only play next episode if autoplay is on (default). + val ctx = context + if (PreferenceManager.getDefaultSharedPreferences(ctx) + ?.getBoolean(ctx.getString(R.string.autoplay_next_key), true) == true + ) { + player.handleEvent(CSPlayerEvent.NextEpisode, source = PlayerEventSource.Player) + } + } + is PauseEvent -> Unit + is PlayEvent -> Unit + } + } + + /** Duration display */ + + fun setRemainingTimeCounter(showRemaining: Boolean) { + durationMode = showRemaining + exoDuration?.isInvisible = showRemaining + timeLeft?.isVisible = showRemaining + if (showRemaining) updateRemainingTime() + } + + fun updateRemainingTime() { + val duration = player.getDuration() + val position = player.getPosition() + + if (exoProgress?.isAtLiveEdge() == true) { + timeLeft?.alpha = 0f + exoDuration?.alpha = 0f + timeLive?.isVisible = true + } else { + timeLeft?.alpha = 1f + exoDuration?.alpha = 1f + timeLive?.isVisible = false + } + + if (duration != null && duration > 1 && position != null) { + val remainingTimeSeconds = (duration - position + 500) / 1000 + @SuppressLint("SetTextI18n") + timeLeft?.text = "-${DateUtils.formatElapsedTime(remainingTimeSeconds)}" + } + } + + /** Auto-hide */ + + /** + * Schedules a delayed auto-hide of the player UI after [delayMs] ms. + * Any previously pending hide is canceled first. + * The hide fires only when no touch is active and [Callbacks.isUIShowing] is true; + * the actual hide action is delegated to [Callbacks.onAutoHideUI]. + */ + fun scheduleAutoHide(delayMs: Long = 3000L) { + val token = ++autoHideToken + autoHideHandler.removeCallbacksAndMessages(null) + autoHideHandler.postDelayed({ + if (token != autoHideToken) return@postDelayed + if (gestureHelper.isCurrentTouchValid) return@postDelayed + if (callbacks?.isUIShowing() != true) return@postDelayed + callbacks?.onAutoHideUI() + }, delayMs) + } + + /** Cancels any pending auto-hide scheduled by [scheduleAutoHide]. */ + fun cancelAutoHide() { + autoHideToken++ + autoHideHandler.removeCallbacksAndMessages(null) + } +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/PreviewGenerator.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/PreviewGenerator.kt index 30e8d99ad8f..2893bcc47fd 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.AcraApplication +import com.lagradost.cloudstream3.CloudStreamApp 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 = AcraApplication.context?.let { ctx -> + val userDisabled = CloudStreamApp.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 bfddd9e0c53..0668a194bc3 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/RepoLinkGenerator.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/RepoLinkGenerator.kt @@ -9,8 +9,8 @@ import com.lagradost.cloudstream3.ui.result.ResultEpisode import com.lagradost.cloudstream3.utils.AppContextUtils.html import com.lagradost.cloudstream3.utils.ExtractorLink import com.lagradost.cloudstream3.utils.ExtractorLinkType -import kotlin.math.max -import kotlin.math.min +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.atomic.AtomicInteger data class Cache( val linkCache: MutableSet, @@ -22,10 +22,9 @@ data class Cache( ) class RepoLinkGenerator( - private val episodes: List, - private var currentIndex: Int = 0, + episodes: List, val page: LoadResponse? = null, -) : IGenerator { +) : VideoGenerator(episodes) { companion object { const val TAG = "RepoLink" val cache: HashMap, Cache> = @@ -34,44 +33,7 @@ class RepoLinkGenerator( override val hasCache = true override val canSkipLoading = true - - 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 - } + override fun getId(index: Int): Int? = videos.getOrNull(index)?.id // this is a simple array that is used to instantly load links if they are already loaded //var linkCache = Array>(size = episodes.size, init = { setOf() }) @@ -80,14 +42,13 @@ class RepoLinkGenerator( @Throws override suspend fun generateLinks( clearCache: Boolean, - allowedTypes: Set, + sourceTypes: Set, callback: (Pair) -> Unit, subtitleCallback: (SubtitleData) -> Unit, offset: Int, isCasting: Boolean, ): Boolean { - val index = currentIndex - val current = episodes.getOrNull(index + offset) ?: return false + val current = videos.getOrNull(offset) ?: return false val currentCache = synchronized(cache) { cache[current.apiName to current.id] ?: Cache( @@ -100,10 +61,12 @@ class RepoLinkGenerator( } } - // these act as a general filter to prevent duplication of links or names - val currentLinksUrls = mutableSetOf() // makes all urls unique - val currentSubsUrls = mutableSetOf() // makes all subs urls unique - val currentSubsNames = mutableSetOf() // makes all subs names unique + // These act as a general filter to prevent duplication of links or names + // Avoid any possible ConcurrentModificationException + val currentLinksUrls = ConcurrentHashMap.newKeySet() + val currentSubsUrls = ConcurrentHashMap.newKeySet() + // Use atomics as otherwise we get race conditions when incrementing, while rare it did actually happen! + val lastCountedSuffix = ConcurrentHashMap() synchronized(currentCache) { val outdatedCache = @@ -114,20 +77,23 @@ 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 (allowedTypes.contains(link.type)) { + if (sourceTypes.contains(link.type)) { callback(link to null) } } currentCache.subtitleCache.forEach { sub -> currentSubsUrls.add(sub.url) - currentSubsNames.add(sub.name) + lastCountedSuffix.getOrPut(sub.originalName) { AtomicInteger(0) }.incrementAndGet() subtitleCallback(sub) } @@ -146,25 +112,18 @@ class RepoLinkGenerator( subtitleCallback = { file -> Log.d(TAG, "Loaded SubtitleFile: $file") val correctFile = PlayerSubtitleHelper.getSubtitleData(file) - if (correctFile.url.isBlank() || currentSubsUrls.contains(correctFile.url)) { + if (correctFile.url.isBlank() || !currentSubsUrls.add(correctFile.url)) { return@loadLinks } - currentSubsUrls.add(correctFile.url) // this part makes sure that all names are unique for UX - val fixedName = correctFile.name.html().toString().trim() - - var name = fixedName - var count = 1 - while (currentSubsNames.contains(name)) { - count++ - name = - SubtitleData.constructName(originalName = fixedName, nameSuffix = "$count") - } + val nameDecoded = correctFile.originalName.html().toString() + .trim() // `%3Ch1%3Esub%20name…` → `

sub name…` → `sub name…` + val suffixCount = + lastCountedSuffix.getOrPut(nameDecoded) { AtomicInteger(0) }.incrementAndGet() - currentSubsNames.add(name) val updatedFile = - correctFile.copy(originalName = fixedName, nameSuffix = "$count") + correctFile.copy(originalName = nameDecoded, nameSuffix = "$suffixCount") synchronized(currentCache) { if (currentCache.subtitleCache.add(updatedFile)) { @@ -175,14 +134,13 @@ class RepoLinkGenerator( }, callback = { link -> Log.d(TAG, "Loaded ExtractorLink: $link") - if (link.url.isBlank() || currentLinksUrls.contains(link.url)) { + if (link.url.isBlank() || !currentLinksUrls.add(link.url)) { return@loadLinks } - currentLinksUrls.add(link.url) synchronized(currentCache) { if (currentCache.linkCache.add(link)) { - if (allowedTypes.contains(link.type)) { + if (sourceTypes.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 fcc5d85892f..824b5d1a2f3 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,6 +23,7 @@ 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 @@ -58,9 +59,16 @@ class RoundedBackgroundColorSpan( return } - // we cant use StaticLayout.Builder() due to API val width = p.measureText(text, start, end) - val textLayout = + 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") StaticLayout( text, TextPaint(p), @@ -70,6 +78,7 @@ 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 9e3e778beb9..fa65c322ec1 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,9 +5,10 @@ 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.utils.AppContextUtils +import com.lagradost.cloudstream3.ui.BaseDiffCallback +import com.lagradost.cloudstream3.ui.NoStateAdapter +import com.lagradost.cloudstream3.ui.ViewHolderState import kotlin.math.roundToInt data class SubtitleCue(val startTimeMs: Long, val durationMs: Long, val text: List) { @@ -16,25 +17,67 @@ 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 ) : - AppContextUtils.DiffAdapter(items) { + NoStateAdapter(diffCallback = BaseDiffCallback(itemSame = { a, b -> + a.startTimeMs == b.startTimeMs + })) { - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { + override fun onCreateContent(parent: ViewGroup): ViewHolderState { val inflater = LayoutInflater.from(parent.context) val binding = SubtitleOffsetItemBinding.inflate(inflater, parent, false) - return SubtitleViewHolder(binding) + return ViewHolderState(binding) } - override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { - when (holder) { - is SubtitleViewHolder -> holder.bind(items[position]) + 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..= it.value.startTimeMs }?.index ?: 0 } @@ -45,7 +88,9 @@ class SubtitleOffsetItemAdapter( val earlyTime = minOf(previousTime, timeMs) val lateTime = maxOf(previousTime, timeMs) - val affectedItems = items.withIndex().filter { cue -> + + // TODO Add binary search and notifyItemRangeChanged + val affectedItems = immutableCurrentList.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) @@ -56,57 +101,4 @@ 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 = ImmutableList.copyOf(subtitleFormats) + tsSubtitleFormats = subtitleFormats?.let { ImmutableList.copyOf(it) } return this } @@ -335,6 +349,14 @@ 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. * @@ -350,6 +372,21 @@ 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()) @@ -457,21 +494,26 @@ class UpdatedDefaultExtractorsFactory : ExtractorsFactory { extractors.add( FragmentedMp4Extractor( subtitleParserFactory, - fragmentedMp4Flags - or (if (textTrackTranscodingEnabled) - 0 - else - FragmentedMp4Extractor.FLAG_EMIT_RAW_SUBTITLE_DATA) + fragmentedMp4Flags or + FragmentedMp4Extractor + .codecsToParseWithinGopSampleDependenciesAsFlags( + codecsToParseWithinGopSampleDependencies + ) or + if (textTrackTranscodingEnabled) 0 + else FragmentedMp4Extractor.FLAG_EMIT_RAW_SUBTITLE_DATA ) ) + extractors.add( Mp4Extractor( subtitleParserFactory, - mp4Flags - or (if (textTrackTranscodingEnabled) - 0 - else - Mp4Extractor.FLAG_EMIT_RAW_SUBTITLE_DATA) + mp4Flags or + Mp4Extractor + .codecsToParseWithinGopSampleDependenciesAsFlags( + codecsToParseWithinGopSampleDependencies + ) or + if (textTrackTranscodingEnabled) 0 + else Mp4Extractor.FLAG_EMIT_RAW_SUBTITLE_DATA ) ) } @@ -513,12 +555,7 @@ class UpdatedDefaultExtractorsFactory : ExtractorsFactory { FileTypes.PNG -> extractors.add(PngExtractor()) FileTypes.WEBP -> extractors.add(WebpExtractor()) FileTypes.BMP -> extractors.add(BmpExtractor()) - FileTypes.HEIF -> if ((mp4Flags and Mp4Extractor.FLAG_READ_MOTION_PHOTO_METADATA) == 0 - && (mp4Flags and Mp4Extractor.FLAG_READ_SEF_DATA) == 0 - ) { - extractors.add(HeifExtractor()) - } - + FileTypes.HEIF -> extractors.add(HeifExtractor(heifFlags)) 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 016e7d20317..5937b1973ed 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,3 +1,14 @@ +@file:Suppress( + "ALL", + "DEPRECATION", + "RedundantVisibilityModifier", + "RemoveRedundantQualifierName", + "UNCHECKED_CAST", + "UNUSED", + "UNUSED_PARAMETER", + "UNUSED_VARIABLE" +) + /* * Copyright (C) 2016 The Android Open Source Project * @@ -30,18 +41,20 @@ 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.container.DolbyVisionConfig +import androidx.media3.extractor.ChunkIndexProvider +import androidx.media3.extractor.DtsUtil import androidx.media3.extractor.Extractor import androidx.media3.extractor.ExtractorInput import androidx.media3.extractor.ExtractorOutput @@ -50,12 +63,18 @@ 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.Unseekable +import androidx.media3.extractor.SeekMap.SeekPoints +import androidx.media3.extractor.SeekPoint +import androidx.media3.extractor.TrackAwareSeekMap 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 @@ -63,13 +82,14 @@ 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 internal constructor( +class UpdatedMatroskaExtractor private constructor( private val reader: EbmlReader, flags: @Flags Int, subtitleParserFactory: SubtitleParser.Factory @@ -108,6 +128,8 @@ class UpdatedMatroskaExtractor internal 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 @@ -120,6 +142,13 @@ class UpdatedMatroskaExtractor internal 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() @@ -128,9 +157,6 @@ class UpdatedMatroskaExtractor internal 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 @@ -207,6 +233,7 @@ class UpdatedMatroskaExtractor internal 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() @@ -222,6 +249,7 @@ class UpdatedMatroskaExtractor internal constructor( encryptionSubsampleData = ParsableByteArray() supplementalData = ParsableByteArray() blockSampleSizes = IntArray(1) + pendingEndTracks = true } @Throws(IOException::class) @@ -244,6 +272,17 @@ class UpdatedMatroskaExtractor internal 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_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_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_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 @@ -330,11 +369,27 @@ class UpdatedMatroskaExtractor internal constructor( } ID_CUES -> { - cueTimesUs = androidx.media3.common.util.LongArray() - cueClusterPositions = androidx.media3.common.util.LongArray() + 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() + } } - 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()) { @@ -347,7 +402,7 @@ class UpdatedMatroskaExtractor internal constructor( } else { // We don't know where the Cues element is located. It's most likely omitted. Allow // playback, but disable seeking. - extractorOutput!!.seekMap(Unseekable(durationUs)) + extractorOutput!!.seekMap(SeekMap.Unseekable(durationUs)) sentSeekMap = true } } @@ -359,7 +414,10 @@ class UpdatedMatroskaExtractor internal constructor( ID_CONTENT_ENCODING -> {} ID_CONTENT_ENCRYPTION -> getCurrentTrack(id).hasContentEncryption = true - ID_TRACK_ENTRY -> currentTrack = Track() + ID_TRACK_ENTRY -> { + currentTrack = Track() + currentTrack!!.isWebm = isWebm + } ID_MASTERING_METADATA -> getCurrentTrack(id).hasColorInfo = true else -> {} } @@ -398,7 +456,7 @@ class UpdatedMatroskaExtractor internal constructor( } else { // Otherwise, if we not found any cues nor any more seek heads then we mark // this as unseekable. - extractorOutput!!.seekMap(Unseekable(durationUs)) + extractorOutput!!.seekMap(SeekMap.Unseekable(durationUs)) sentSeekMap = true } } @@ -427,13 +485,67 @@ class UpdatedMatroskaExtractor internal constructor( ID_CUES -> { if (!sentSeekMap) { - extractorOutput!!.seekMap(buildSeekMap(cueTimesUs, cueClusterPositions)) + 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) + } sentSeekMap = true - } else { - // We have already built the cues. Ignore. + 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 + ) + ) + } } - this.cueTimesUs = null - this.cueClusterPositions = null } ID_BLOCK_GROUP -> { @@ -509,17 +621,15 @@ class UpdatedMatroskaExtractor internal constructor( } ID_TRACK_ENTRY -> { - val currentTrack = Assertions.checkStateNotNull(this.currentTrack) + val currentTrack = checkNotNull(this.currentTrack) if (currentTrack.codecId == null) { throw ParserException.createForMalformedContainer( "CodecId is missing in TrackEntry element", /* cause= */null ) } else { - if (isCodecSupported( - currentTrack.codecId!! - ) - ) { - currentTrack.initializeOutput(extractorOutput!!, currentTrack.number) + if (isCodecSupported(currentTrack.codecId!!)) { + currentTrack.initializeFormat(currentTrack.number); + currentTrack.output = extractorOutput!!.track(currentTrack.number, currentTrack.type); tracks.put(currentTrack.number, currentTrack) } } @@ -529,10 +639,63 @@ class UpdatedMatroskaExtractor internal constructor( ID_TRACKS -> { if (tracks.size() == 0) { throw ParserException.createForMalformedContainer( - "No valid tracks were found", /* cause= */null + "No valid tracks were found", /* cause= */ null ) } - extractorOutput!!.endTracks() + + // 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() + } } else -> {} @@ -575,7 +738,16 @@ class UpdatedMatroskaExtractor internal 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 -> getCurrentTrack(id).type = value.toInt() + 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_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() @@ -621,17 +793,35 @@ class UpdatedMatroskaExtractor internal constructor( } ID_CUE_TIME -> { - assertInCues(id) - cueTimesUs!!.add(scaleTimecodeToUs(value)) + if (!sentSeekMap) { + assertInCues(id) + currentCueTimeUs = 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_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_CUE_RELATIVE_POSITION -> { + if (!sentSeekMap) { + assertInCues(id) + if (currentCueRelativePosition == C.INDEX_UNSET.toLong()) { + currentCueRelativePosition = value + } + } } ID_TIME_CODE -> clusterTimecodeUs = scaleTimecodeToUs(value) @@ -943,7 +1133,7 @@ class UpdatedMatroskaExtractor internal constructor( (scratch.data[0].toInt() shl 8) or (scratch.data[1].toInt() and 0xFF) blockTimeUs = clusterTimecodeUs + scaleTimecodeToUs(timecode.toLong()) val isKeyframe = - track.type == TRACK_TYPE_AUDIO + track.type == C.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 @@ -1035,9 +1225,7 @@ class UpdatedMatroskaExtractor internal constructor( } } - @Throws( - ParserException::class - ) + @Throws(ParserException::class) private fun assertInTrackEntry(id: Int) { if (currentTrack == null) { throw ParserException.createForMalformedContainer( @@ -1046,11 +1234,9 @@ class UpdatedMatroskaExtractor internal constructor( } } - @Throws( - ParserException::class - ) + @Throws(ParserException::class) private fun assertInCues(id: Int) { - if (cueTimesUs == null || cueClusterPositions == null) { + if (!inCuesElement) { throw ParserException.createForMalformedContainer( "Element $id must be in a Cues", /* cause= */null ) @@ -1079,6 +1265,7 @@ class UpdatedMatroskaExtractor internal 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) { @@ -1168,7 +1355,7 @@ class UpdatedMatroskaExtractor internal constructor( if (CODEC_ID_SUBRIP == track.codecId) { writeSubtitleSampleData(input, SUBRIP_PREFIX, size) return finishWriteSampleData() - } else if (CODEC_ID_ASS == track.codecId) { + } else if (CODEC_ID_ASS == track.codecId || CODEC_ID_SSA == track.codecId) { writeSubtitleSampleData(input, SSA_PREFIX, size) return finishWriteSampleData() } else if (CODEC_ID_VTT == track.codecId) { @@ -1176,6 +1363,20 @@ class UpdatedMatroskaExtractor internal 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) { @@ -1342,7 +1543,7 @@ class UpdatedMatroskaExtractor internal constructor( } } else { if (track.trueHdSampleRechunker != null) { - Assertions.checkState(sampleStrippedBytes.limit() == 0) + checkState(sampleStrippedBytes.limit() == 0) track.trueHdSampleRechunker!!.startSample(input) } while (sampleBytesRead < size) { @@ -1441,57 +1642,6 @@ class UpdatedMatroskaExtractor internal 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 @@ -1511,7 +1661,7 @@ class UpdatedMatroskaExtractor internal 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) { - Assertions.checkArgument(pendingSeekHeads.isNotEmpty(), "Illegal value of seekForSeekContent") + 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 @@ -1558,11 +1708,22 @@ class UpdatedMatroskaExtractor internal constructor( } private fun assertInitialized() { - Assertions.checkStateNotNull( + checkNotNull( 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 { @@ -1607,10 +1768,11 @@ class UpdatedMatroskaExtractor internal 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: Int = 0 + var type: @C.TrackType Int = 0 var defaultSampleDurationNs: Int = 0 var maxBlockAdditionId: Int = 0 var blockAddIdType: Int = 0 @@ -1660,23 +1822,24 @@ class UpdatedMatroskaExtractor internal constructor( var sampleRate: Int = 8000 var codecDelayNs: Long = 0 var seekPreRollNs: Long = 0 - var trueHdSampleRechunker: TrueHdSampleRechunker? = - null + var trueHdSampleRechunker: TrueHdSampleRechunker? = null + var waitingForDtsAnalysis: Boolean = false // 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 - /** Initializes the track with an output. */ - @Throws( - ParserException::class - ) - fun initializeOutput(output: ExtractorOutput, trackId: Int) { + /** Builds the [Format] for the track. */ + @Throws(ParserException::class) + fun initializeFormat(trackId: Int) { var mimeType: String var maxInputSize = Format.NO_VALUE var pcmEncoding: @PcmEncoding Int = Format.NO_VALUE @@ -1684,8 +1847,20 @@ class UpdatedMatroskaExtractor internal constructor( var codecs: String? = null when (codecId) { CODEC_ID_VP8 -> mimeType = MimeTypes.VIDEO_VP8 - CODEC_ID_VP9 -> mimeType = MimeTypes.VIDEO_VP9 - CODEC_ID_AV1 -> mimeType = MimeTypes.VIDEO_AV1 + 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_MPEG2 -> mimeType = MimeTypes.VIDEO_MPEG2 CODEC_ID_MPEG4_SP, CODEC_ID_MPEG4_ASP, CODEC_ID_MPEG4_AP -> { mimeType = MimeTypes.VIDEO_MP4V @@ -1797,7 +1972,10 @@ class UpdatedMatroskaExtractor internal constructor( trueHdSampleRechunker = TrueHdSampleRechunker() } - CODEC_ID_DTS, CODEC_ID_DTS_EXPRESS -> mimeType = MimeTypes.AUDIO_DTS + CODEC_ID_DTS, CODEC_ID_DTS_EXPRESS -> { + mimeType = MimeTypes.AUDIO_DTS // temporary + waitingForDtsAnalysis = true + } CODEC_ID_DTS_LOSSLESS -> mimeType = MimeTypes.AUDIO_DTS_HD CODEC_ID_FLAC -> { mimeType = MimeTypes.AUDIO_FLAC @@ -1896,7 +2074,7 @@ class UpdatedMatroskaExtractor internal constructor( } CODEC_ID_SUBRIP -> mimeType = MimeTypes.APPLICATION_SUBRIP - CODEC_ID_ASS -> { + CODEC_ID_ASS, CODEC_ID_SSA -> { mimeType = MimeTypes.TEXT_SSA initializationData = ImmutableList.of( SSA_DIALOGUE_FORMAT, getCodecPrivate( @@ -1942,18 +2120,15 @@ class UpdatedMatroskaExtractor internal 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 @@ -2014,7 +2189,6 @@ class UpdatedMatroskaExtractor internal constructor( || MimeTypes.APPLICATION_PGS == mimeType || MimeTypes.APPLICATION_DVBSUBS == mimeType ) { - type = C.TRACK_TYPE_TEXT } else { throw ParserException.createForMalformedContainer( "Unexpected MIME type.", /* cause= */null @@ -2025,9 +2199,10 @@ class UpdatedMatroskaExtractor internal constructor( formatBuilder.setLabel(name) } - val format = + format = formatBuilder .setId(trackId) + .setContainerMimeType(if (isWebm) MimeTypes.VIDEO_WEBM else MimeTypes.VIDEO_MATROSKA) .setSampleMimeType(mimeType) .setMaxInputSize(maxInputSize) .setLanguage(language) @@ -2036,9 +2211,6 @@ class UpdatedMatroskaExtractor internal 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. */ @@ -2113,6 +2285,90 @@ class UpdatedMatroskaExtractor internal 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. * @@ -2122,14 +2378,12 @@ class UpdatedMatroskaExtractor internal constructor( * fact at runtime. */ fun assertOutputInitialized() { - Assertions.checkNotNull( + checkNotNull( output ) } - @Throws( - ParserException::class - ) + @Throws(ParserException::class) private fun getCodecPrivate(codecId: String): ByteArray { if (codecPrivate == null) { throw ParserException.createForMalformedContainer( @@ -2368,6 +2622,7 @@ class UpdatedMatroskaExtractor internal 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" @@ -2444,8 +2699,10 @@ class UpdatedMatroskaExtractor internal 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 @@ -2500,6 +2757,12 @@ class UpdatedMatroskaExtractor internal 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. * @@ -2721,8 +2984,8 @@ class UpdatedMatroskaExtractor internal 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] or - * [.CODEC_ID_VTT]. + * @param codecId The subtitle codec; must be [.CODEC_ID_SUBRIP], [.CODEC_ID_ASS], + * [.CODEC_ID_SSA] 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). @@ -2741,7 +3004,7 @@ class UpdatedMatroskaExtractor internal constructor( endTimecodeOffset = SUBRIP_PREFIX_END_TIMECODE_OFFSET } - CODEC_ID_ASS -> { + CODEC_ID_ASS, CODEC_ID_SSA -> { endTimecode = formatSubtitleTimecode( durationUs, SSA_TIMECODE_FORMAT, SSA_TIMECODE_LAST_VALUE_SCALING_FACTOR @@ -2769,7 +3032,7 @@ class UpdatedMatroskaExtractor internal constructor( timeUs: Long, timecodeFormat: String, lastTimecodeValueScalingFactor: Long ): ByteArray { var timeUs = timeUs - Assertions.checkArgument(timeUs != C.TIME_UNSET) + checkArgument(timeUs != C.TIME_UNSET) val timeCodeData: ByteArray val hours = (timeUs / (3600 * C.MICROS_PER_SECOND)).toInt() timeUs -= (hours * 3600L * C.MICROS_PER_SECOND) @@ -2787,7 +3050,7 @@ class UpdatedMatroskaExtractor internal 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_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_SSA, CODEC_ID_VTT, CODEC_ID_VOBSUB, CODEC_ID_PGS, CODEC_ID_DVBSUB -> true else -> false } @@ -2811,4 +3074,169 @@ class UpdatedMatroskaExtractor internal constructor( } } } -} \ No newline at end of file + + 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) + } + } + } +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/live/LiveHelper.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/live/LiveHelper.kt new file mode 100644 index 00000000000..52cd4361bab --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/live/LiveHelper.kt @@ -0,0 +1,77 @@ +package com.lagradost.cloudstream3.ui.player.live + +import androidx.annotation.OptIn +import androidx.media3.common.Player +import androidx.media3.common.Timeline +import androidx.media3.common.util.UnstableApi +import com.lagradost.cloudstream3.mvvm.debugWarning +import java.util.WeakHashMap + +object LiveHelper { + private val liveManagers = WeakHashMap>() + + @OptIn(UnstableApi::class) + fun registerPlayer(player: Player?) { + if (player == null) { + debugWarning { "LiveHelper registerPlayer called with null player!" } + return + } + + // Prevent duplicates + if (liveManagers.contains(player)) { + return + } + + val liveManager = LiveManager(player) + val listener = object : Player.Listener { + override fun onTimelineChanged(timeline: Timeline, reason: Int) { + val window = Timeline.Window() + timeline.getWindow(player.currentMediaItemIndex, window) + if (window.isDynamic) { + liveManager.submitLivestreamChunk(LivestreamChunk(window.durationMs)) + } + super.onTimelineChanged(timeline, reason) + } + + override fun onPositionDiscontinuity( + oldPosition: Player.PositionInfo, + newPosition: Player.PositionInfo, + reason: Int + ) { + super.onPositionDiscontinuity(oldPosition, newPosition, reason) + val timeAheadOfLive = liveManager.getTimeAheadOfLive(newPosition.positionMs) + + // Seek back to the optimal live spot + if (timeAheadOfLive > 100) { + player.seekTo(newPosition.positionMs - timeAheadOfLive) + } + } + } + + synchronized(liveManagers) { + player.addListener(listener) + liveManagers[player] = liveManager to listener + } + } + + fun unregisterPlayer(player: Player?) { + if (player == null) { + debugWarning { "LiveHelper unregisterPlayer called with null player!" } + return + } + + // Prevent duplicates + if (!liveManagers.contains(player)) { + return + } + + synchronized(liveManagers) { + liveManagers[player]?.let { (_, listener) -> + player.removeListener(listener) + } + liveManagers.remove(player) + } + } + + fun getLiveManager(player: Player?) = liveManagers[player]?.first +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/live/LiveManager.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/live/LiveManager.kt new file mode 100644 index 00000000000..8d848d46aa9 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/live/LiveManager.kt @@ -0,0 +1,97 @@ +package com.lagradost.cloudstream3.ui.player.live + +import androidx.media3.common.C +import androidx.media3.common.Player +import java.lang.ref.WeakReference + +// How much margin from the live point is still considered "live" +const val LIVE_MARGIN = 6_000L + +// How many ms should we be behind the real live point? +// Too low, and we cannot pre-buffer +// Too high, and we are no longer live +const val PREFERRED_LIVE_OFFSET = 5_000L + +// An extra offset from the optimal calculated timestamp +// This is to account for chunk updates not always being the same size +const val CHUNK_VARIANCE = 3000L + +// A livestream chunk from the player, the time we get it and the duration can be used to calculate +// the expected live timestamp. +class LivestreamChunk( + durationMs: Long, val receiveTimeMs: Long = System.currentTimeMillis() +) { + // We want to be PREFERRED_LIVE_OFFSET ms after the latest update, but we cannot be ahead of the middle point. + // If we are ahead of the middle point we will reach the end before the new chunk is expected to be released. + val targetPosition = maxOf(0,minOf( + durationMs - PREFERRED_LIVE_OFFSET, + durationMs / 2 - CHUNK_VARIANCE + )) + + fun isPositionLive(position: Long): Boolean { + val currentTime = System.currentTimeMillis() + val livePosition = targetPosition + (currentTime - receiveTimeMs) + val withinLive = position + LIVE_MARGIN > livePosition - PREFERRED_LIVE_OFFSET + // println("Position: $position, livePosition: ${livePosition}, Behind live: ${livePosition-position}, within live: $withinLive") + return withinLive + } + + fun getTimeAheadOfLive(position: Long): Long { + val currentTime = System.currentTimeMillis() + val livePosition = targetPosition + (currentTime - receiveTimeMs) + // println("Ahead of live: ${position-livePosition}") + return position - livePosition + } +} + +// There are two types of livestreams we need to manage +// 1. A livestream with no history, a continually sliding window. +// This livestream has no currentLiveOffset, which means we need to calculate +// the real live point based on when we receive the latest update and the size of that update. +// 2. A livestream with history. +// This livestream has a currentLiveOffset and therefore requires no calculation to get the live point. +// currentLiveOffset can however be inaccurate, and we need to be able to fall back to manual calculations. +class LiveManager { + private var _currentPlayer: WeakReference? = null + val currentPlayer: Player? get() = _currentPlayer?.get() + + constructor(player: Player?) { + _currentPlayer = WeakReference(player) + } + + private var lastLivestreamChunk: LivestreamChunk? = null + + fun submitLivestreamChunk(chunk: LivestreamChunk) { + lastLivestreamChunk = chunk + } + + /** Returns how much a position is ahead of the calculated live window. Returns 0 if not ahead of live window */ + fun getTimeAheadOfLive(position: Long): Long { + val player = currentPlayer ?: return 0 + if (!player.isCurrentMediaItemDynamic || player.duration == C.TIME_UNSET) return 0 + + // If the currentLiveOffset is wrong we fall back to manual calculations + val ahead = if (player.currentLiveOffset != C.TIME_UNSET && player.currentLiveOffset < player.duration) { + val relativeOffset = player.currentLiveOffset - player.currentPosition + position + PREFERRED_LIVE_OFFSET - relativeOffset + } else { + lastLivestreamChunk?.getTimeAheadOfLive(position) ?: 0 + } + + // Ensure min of 0 + return maxOf(0, ahead) + } + + /** Check if the stream is currently at the expected live edge, with margins */ + fun isAtLiveEdge(): Boolean { + val player = currentPlayer ?: return false + if (!player.isCurrentMediaItemDynamic || player.duration == C.TIME_UNSET) return false + + // If the currentLiveOffset is wrong we fall back to manual calculations + return if (player.currentLiveOffset != C.TIME_UNSET && player.currentLiveOffset < player.duration) { + player.currentLiveOffset < LIVE_MARGIN + PREFERRED_LIVE_OFFSET + } else { + lastLivestreamChunk?.isPositionLive(player.currentPosition) == true + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/live/LivePreviewTimeBar.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/live/LivePreviewTimeBar.kt new file mode 100644 index 00000000000..3001281fd45 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/live/LivePreviewTimeBar.kt @@ -0,0 +1,38 @@ +package com.lagradost.cloudstream3.ui.player.live + +import android.content.Context +import android.util.AttributeSet +import androidx.annotation.OptIn +import androidx.media3.common.Player +import androidx.media3.common.util.UnstableApi +import androidx.media3.ui.PlayerControlView +import androidx.media3.ui.PlayerView +import androidx.media3.ui.R +import com.github.rubensousa.previewseekbar.media3.PreviewTimeBar +import java.lang.ref.WeakReference + + +@OptIn(UnstableApi::class) +class LivePreviewTimeBar(val ctx: Context, attrs: AttributeSet) : PreviewTimeBar(ctx, attrs) { + + private var _currentPlayerView: WeakReference? = null + val currentPlayer: Player? get() = _currentPlayerView?.get()?.player + + fun registerPlayerView(player: PlayerView?) { + _currentPlayerView = WeakReference(player) + val controller = + _currentPlayerView?.get()?.findViewById(R.id.exo_controller) + + controller?.setProgressUpdateListener { position, bufferedPosition -> + currentPlayer?.let { player -> + if (isAtLiveEdge()) { + setPosition(player.duration) + } + } + } + } + + fun isAtLiveEdge(): Boolean { + return LiveHelper.getLiveManager(currentPlayer)?.isAtLiveEdge() == true + } +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/source_priority/PriorityAdapter.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/source_priority/PriorityAdapter.kt index ce457740d34..11dd39105a6 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.utils.AppContextUtils +import com.lagradost.cloudstream3.ui.NoStateAdapter +import com.lagradost.cloudstream3.ui.ViewHolderState data class SourcePriority( val data: T, @@ -12,41 +12,41 @@ data class SourcePriority( var priority: Int ) -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), +class PriorityAdapter() : + NoStateAdapter>() { + + override fun onCreateContent(parent: ViewGroup): ViewHolderState { + return ViewHolderState( + PlayerPrioritizeItemBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false + ) ) } - override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { - when (holder) { - is PriorityViewHolder -> holder.bind(items[position]) - } - } + override fun onBindContent( + holder: ViewHolderState, + item: SourcePriority, + position: Int + ) { + val binding = holder.view as? PlayerPrioritizeItemBinding ?: return + binding.priorityText.text = item.name - 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() + } - fun updatePriority() { - binding.priorityNumber.text = item.priority.toString() - } + 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() - 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 821bccd6a84..85c2a85df39 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,45 +10,25 @@ 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.utils.AppContextUtils +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 import com.lagradost.cloudstream3.utils.drawableToBitmap +import com.lagradost.cloudstream3.utils.setText class ProfilesAdapter( - override val items: MutableList, - val usedProfile: Int, + val usedProfile: Int?, val clickCallback: (oldIndex: Int?, newIndex: Int) -> Unit, ) : - 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) - ) - } - - 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 - } + NoStateAdapter(diffCallback = BaseDiffCallback(itemSame = { a, b -> + a.id == b.id + })) { - inner class ProfilesViewHolder( - val binding: PlayerQualityProfileItemBinding, - ) : RecyclerView.ViewHolder(binding.root) { - private val art = listOf( + companion object { + private val art = arrayOf( R.drawable.profile_bg_teal, R.drawable.profile_bg_blue, R.drawable.profile_bg_dark_blue, @@ -57,67 +37,101 @@ class ProfilesAdapter( R.drawable.profile_bg_red, R.drawable.profile_bg_orange, ) + } - 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 - - 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 onCreateContent(parent: ViewGroup): ViewHolderState { + return ViewHolderState( + PlayerQualityProfileItemBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false + ) + ) + } + + override fun onClearView(holder: ViewHolderState) { + when (val binding = holder.view) { + is PlayerQualityProfileItemBinding -> { + clearImage(binding.profileImageBackground) } + } + } - outline.isVisible = currentItem?.second?.id == item.id - val drawableResId = art[index % 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 - ) + override fun onBindContent( + holder: ViewHolderState, + item: QualityDataHelper.QualityProfile, + position: Int + ) { + val binding = holder.view as? PlayerQualityProfileItemBinding ?: return + + 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 ) + ) - if (color != null) { - wifiText.backgroundTintList = ColorStateList.valueOf(color) - dataText.backgroundTintList = ColorStateList.valueOf(color) - } + if (color != null) { + wifiText.backgroundTintList = ColorStateList.valueOf(color) + dataText.backgroundTintList = ColorStateList.valueOf(color) + downloadText.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) + priorityText.setTypeface(null, textStyle) - cardView.setOnClickListener { - setCurrentItem() - } + 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 0922bdb5aa6..02470484ea1 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,22 +1,32 @@ package com.lagradost.cloudstream3.ui.player.source_priority import androidx.annotation.StringRes -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.CloudStreamApp.Companion.getKey +import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKeys +import com.lagradost.cloudstream3.CloudStreamApp.Companion.removeKey +import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey import com.lagradost.cloudstream3.R 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 **/ @@ -33,13 +43,14 @@ 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) + Data(R.string.mobile_data, true), + Download(R.string.download, true) } data class QualityProfile( val name: UiText, val id: Int, - val type: QualityProfileType + val types: Set ) fun getSourcePriority(profile: Int, name: String?): Int { @@ -51,8 +62,21 @@ 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) { - setKey("$currentAccount/$VIDEO_SOURCE_PRIORITY/$profile", name, priority) + val folder = "$currentAccount/$VIDEO_SOURCE_PRIORITY/$profile" + // Prevent unnecessary keys + if (priority == DEFAULT_SOURCE_PRIORITY) { + removeKey(folder, name) + } else { + setKey(folder, name, priority) + } } fun setProfileName(profile: Int, name: String?) { @@ -85,16 +109,40 @@ object QualityDataHelper { ) } - fun getQualityProfileType(profile: Int): QualityProfileType { - return getKey("$currentAccount/$VIDEO_PROFILE_TYPE/$profile") ?: QualityProfileType.None - } - fun setQualityProfileType(profile: Int, type: QualityProfileType?) { - val path = "$currentAccount/$VIDEO_PROFILE_TYPE/$profile" - if (type == QualityProfileType.None) { - removeKey(path) + @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() } else { - setKey(path, type) + 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()) } } @@ -106,37 +154,39 @@ object QualityDataHelper { val availableTypes = QualityProfileType.entries.toMutableList() val profiles = (1..PROFILE_COUNT).map { profileNumber -> // Get the real type - val type = getQualityProfileType(profileNumber) - - // 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 - } + val types = getQualityProfileTypes(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() QualityProfile( getProfileName(profileNumber), profileNumber, - uniqueType + uniqueTypes ) }.toMutableList() /** - * If no profile of this type exists: insert it on the earliest profile with None type + * If no profile of this type exists: insert it on the earliest profile **/ fun insertType( list: MutableList, type: QualityProfileType ) { - 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) - } + 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) + } } QualityProfileType.entries.forEach { @@ -145,7 +195,7 @@ object QualityDataHelper { debugAssert({ !QualityProfileType.entries.all { type -> - !type.unique || profiles.any { it.type == type } + !type.unique || profiles.any { it.types.contains(type) } } }, { "All unique quality types do not exist" }) @@ -155,4 +205,22 @@ 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 19e98138cbd..6a0f12e9a4e 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,47 +2,78 @@ 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.showBottomDialog +import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showMultiDialog import com.lagradost.cloudstream3.utils.UIHelper.dismissSafe +import com.lagradost.cloudstream3.utils.UIHelper.fixSystemBarsPadding +import com.lagradost.cloudstream3.utils.setText -class QualityProfileDialog( +/** Simplified ExtractorLink for the quality profile dialog */ +data class LinkSource( + val source: String +) { + constructor(extractorLink: ExtractorLink) : this(extractorLink.source) +} + + +class QualityProfileDialog private constructor( val activity: FragmentActivity, @StyleRes val themeRes: Int, - private val links: List, - private val usedProfile: Int, - private val profileSelectionCallback: (QualityDataHelper.QualityProfile) -> Unit + private val links: List, + private val usedProfile: Int?, + private val profileSelectionCallback: ((QualityDataHelper.QualityProfile) -> Unit)?, + private val useProfileSelection: Boolean ) : Dialog(activity, themeRes) { - override fun show() { + 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)//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*/ + setContentView(binding.root) + fixSystemBarsPadding(binding.root) binding.apply { fun getCurrentProfile(): QualityDataHelper.QualityProfile? { return (profilesRecyclerview.adapter as? ProfilesAdapter)?.getCurrentProfile() } fun refreshProfiles() { - currentlySelectedProfileText.text = getProfileName(usedProfile).asString(context) - (profilesRecyclerview.adapter as? ProfilesAdapter)?.updateList(getProfiles()) + if (usedProfile != null) { + currentlySelectedProfileText.setText(getProfileName(usedProfile)) + } + (profilesRecyclerview.adapter as? ProfilesAdapter)?.submitList(getProfiles()) } profilesRecyclerview.adapter = ProfilesAdapter( - mutableListOf(), usedProfile, ) { oldIndex: Int?, newIndex: Int -> profilesRecyclerview.adapter?.notifyItemChanged(newIndex) @@ -65,37 +96,52 @@ class QualityProfileDialog( 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.showBottomDialog( + activity.showMultiDialog( choiceNames, - choices.indexOf(currentProfile.type), + selectedIndices, txt(R.string.set_default).asString(context), - false, {}, { index -> - val pickedChoice = choices.getOrNull(index) ?: return@showBottomDialog - // Remove previous picks - if (pickedChoice.unique) { - getProfiles().filter { it.type == pickedChoice }.forEach { - QualityDataHelper.setQualityProfileType(it.id, null) + 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) + } } + + QualityDataHelper.addQualityProfileType(currentProfile.id, pickedChoice) } - QualityDataHelper.setQualityProfileType(currentProfile.id, pickedChoice) refreshProfiles() }) } - cancelBtt.setOnClickListener { - this@QualityProfileDialog.dismissSafe() - } + cancelBtt.isVisible = useProfileSelection + useBtt.isVisible = useProfileSelection + applyBtt.isVisible = !useProfileSelection - useBtt.setOnClickListener { - getCurrentProfile()?.let { - profileSelectionCallback.invoke(it) + if (useProfileSelection) { + cancelBtt.setOnClickListener { + this@QualityProfileDialog.dismissSafe() + } + + useBtt.setOnClickListener { + getCurrentProfile()?.let { + profileSelectionCallback?.invoke(it) + this@QualityProfileDialog.dismissSafe() + } + } + } else { + applyBtt.setOnClickListener { 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 4c74ec80f39..c8ac96ebbf6 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,8 +24,10 @@ 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 @@ -36,45 +38,46 @@ class SourcePriorityDialog( profileText.setText(QualityDataHelper.getProfileName(profile.id).asString(context)) profileText.hint = txt(R.string.profile_number, profile.id).asString(context) - sourcesRecyclerView.adapter = PriorityAdapter( - links.map { link -> + sourcesRecyclerView.adapter = PriorityAdapter( + ).apply { + submitList(links.map { link -> SourcePriority( null, link.source, QualityDataHelper.getSourcePriority(profile.id, link.source) ) - }.distinctBy { it.name }.sortedBy { -it.priority }.toMutableList() - ) + }.distinctBy { it.name }.sortedBy { -it.priority }) + } - qualitiesRecyclerView.adapter = PriorityAdapter( - Qualities.entries.mapNotNull { + qualitiesRecyclerView.adapter = PriorityAdapter( + ).apply { + submitList(Qualities.entries.mapNotNull { SourcePriority( it, Qualities.getStringByIntFull(it.value).ifBlank { return@mapNotNull null }, QualityDataHelper.getQualityPriority(profile.id, it) ) - }.sortedBy { -it.priority }.toMutableList() - ) + }.sortedBy { -it.priority }) + } @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?.items ?: emptyList() - val sources = sourcesAdapter?.items ?: emptyList() + val qualities = qualityAdapter?.immutableCurrentList ?: emptyList() + val sources = sourcesAdapter?.immutableCurrentList ?: emptyList() qualities.forEach { - val data = it.data as? Qualities ?: return@forEach - QualityDataHelper.setQualityPriority(profile.id, data, it.priority) + QualityDataHelper.setQualityPriority(profile.id, it.data, it.priority) } sources.forEach { QualityDataHelper.setSourcePriority(profile.id, it.name, it.priority) } - qualityAdapter?.updateList(qualities.sortedBy { -it.priority }) - sourcesAdapter?.updateList(sources.sortedBy { -it.priority }) + qualityAdapter?.submitList(qualities.sortedBy { -it.priority }) + sourcesAdapter?.submitList(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 12adc04007f..cf9bc9975c0 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,9 +2,7 @@ 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 @@ -13,9 +11,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 @@ -25,28 +23,35 @@ 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.UIHelper -import com.lagradost.cloudstream3.utils.UIHelper.fixPaddingStatusbar +import com.lagradost.cloudstream3.utils.Coroutines.ioSafe +import com.lagradost.cloudstream3.utils.UIHelper.fixSystemBarsPadding 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 : Fragment() { +class QuickSearchFragment : BaseFragment( + BaseFragment.BindingCreator.Inflate(QuickSearchBinding::inflate) +) { companion object { const val AUTOSEARCH_KEY = "autosearch" const val PROVIDER_KEY = "providers" @@ -85,30 +90,29 @@ class QuickSearchFragment : Fragment() { 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() - 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() + return super.onCreateView(inflater, container, savedInstanceState) } override fun onDestroy() { @@ -130,25 +134,7 @@ class QuickSearchFragment : Fragment() { return false } - 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() - + override fun onBindingCreated(binding: QuickSearchBinding) { arguments?.getStringArray(PROVIDER_KEY)?.let { providers = it.toSet() } @@ -158,55 +144,101 @@ class QuickSearchFragment : Fragment() { getApiFromNameNull(providers?.first())?.hasQuickSearch ?: false } else false - if (isSingleProvider) { - binding?.quickSearchAutofitResults?.apply { + val firstProvider = providers?.firstOrNull() + if (isSingleProvider && firstProvider != null) { + binding.quickSearchAutofitResults.apply { + setRecycledViewPool(SearchAdapter.sharedPool) 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(providers?.first()) + binding.quickSearch.queryHint = + getString(R.string.search_hint_site).format(firstProvider) } catch (e: Exception) { logError(e) } } else { - 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.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?.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 { - updateList(list.map { ongoing -> - val ongoingList = HomePageList( - ongoing.apiName, - if (ongoing.data is Resource.Success) ongoing.data.value else ArrayList() + (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 ) - ongoingList - }) + + val expandableList = HomeViewModel.ExpandableHomepageList( + homePageList, + ongoing.value.currentPage, + ongoing.value.hasNext + ) + + expandableList + } + + submitList(newItems) + //notifyDataSetChanged() } } catch (e: Exception) { logError(e) @@ -216,24 +248,12 @@ class QuickSearchFragment : Fragment() { } val searchExitIcon = - binding?.quickSearch?.findViewById(androidx.appcompat.R.id.search_close_btn) - - //val searchMagIcon = - // binding?.quickSearch?.findViewById(androidx.appcompat.R.id.search_mag_icon) + binding.quickSearch.findViewById(androidx.appcompat.R.id.search_close_btn) - // 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 { + binding.quickSearch.setOnQueryTextListener(object : SearchView.OnQueryTextListener { override fun onQueryTextSubmit(query: String): Boolean { if (search(context, query, false)) - UIHelper.hideKeyboard(binding?.quickSearch) + hideKeyboard(binding.quickSearch) return true } @@ -243,41 +263,37 @@ class QuickSearchFragment : Fragment() { return true } }) - binding?.quickSearchLoadingBar?.alpha = 0f + binding.quickSearchLoadingBar.alpha = 0f observe(searchViewModel.searchResponse) { when (it) { is Resource.Success -> { it.value.let { data -> - (binding?.quickSearchAutofitResults?.adapter as? SearchAdapter)?.updateList( - context?.filterSearchResultByFilmQuality(data) ?: data + val adapter = + (binding.quickSearchAutofitResults.adapter as? SearchAdapter) + adapter?.submitList( + context?.filterSearchResultByFilmQuality(data.list) ?: data.list ) + 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() @@ -286,11 +302,11 @@ class QuickSearchFragment : Fragment() { } 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 b9893193be1..056588d0bb0 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,12 +6,14 @@ 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 @@ -19,163 +21,120 @@ import com.lagradost.cloudstream3.utils.ImageLoader.loadImage class ActorAdaptor( private var nextFocusUpId: Int? = null, private val focusCallback: (View?) -> Unit = {} -) : RecyclerView.Adapter() { - data class ActorMetaData( - var isInverted: Boolean, - val actor: ActorData, - ) - - private val actors: MutableList = mutableListOf() - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { - return CardViewHolder( - CastItemBinding.inflate(LayoutInflater.from(parent.context), parent, false), - focusCallback - ) - } - - 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) - } - } - } +) : NoStateAdapter(diffCallback = BaseDiffCallback(itemSame = { a, b -> + a.actor.name == b.actor.name +})) { + companion object { + val sharedPool = + newSharedPool { setMaxRecycledViews(CONTENT, 10) } } - override fun getItemCount(): Int { - return actors.size - } + // Easier to store it here than to store it in the ActorData + val inverted: HashMap = hashMapOf() - private fun updateActorList(newList: List) { - val diffResult = DiffUtil.calculateDiff( - ActorDiffCallback(this.actors, newList) + override fun onCreateContent(parent: ViewGroup): ViewHolderState { + return ViewHolderState( + CastItemBinding.inflate(LayoutInflater.from(parent.context), parent, false) ) - - actors.clear() - actors.addAll(newList) - - diffResult.dispatchUpdatesTo(this) } - 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) - }) + override fun onClearView(holder: ViewHolderState) { + when (val binding = holder.view) { + is CastItemBinding -> { + clearImage(binding.actorImage) + } } } - 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) - } + 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) - // 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 - } + val (mainImg, vaImage) = if (!isInverted || item.voiceActor?.image.isNullOrBlank()) { + Pair(item.actor.image, item.voiceActor?.image) + } else { + Pair(item.voiceActor?.image, item.actor.image) + } - itemView.setOnFocusChangeListener { v, hasFocus -> - if (hasFocus) { - focusCallback(v) + // 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.setOnClickListener { - callback(position) - } + itemView.setOnFocusChangeListener { v, hasFocus -> + if (hasFocus) { + focusCallback(v) + } + } - 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) + itemView.setOnClickListener { + inverted[item] = !isInverted + this.onUpdateContent(holder, getItem(position), position) + } + + 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) + } } } } + true } - true - } - binding.apply { - actorImage.loadImage(mainImg) + binding.apply { + actorImage.loadImage(mainImg) - actorName.text = actor.actor.name - actor.role?.let { - actorExtra.context?.getString( - when (it) { - ActorRole.Main -> { - R.string.actor_main - } + 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.Supporting -> { + R.string.actor_supporting + } - ActorRole.Background -> { - R.string.actor_background + ActorRole.Background -> { + R.string.actor_background + } } + )?.let { text -> + actorExtra.isVisible = true + actorExtra.text = text } - )?.let { text -> + } ?: item.roleString?.let { actorExtra.isVisible = true - actorExtra.text = text + actorExtra.text = it + } ?: run { + actorExtra.isVisible = false } - } ?: actor.roleString?.let { - actorExtra.isVisible = true - actorExtra.text = it - } ?: run { - actorExtra.isVisible = false - } - 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) + 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) + } } } } } -} - -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 1e0438cdd71..5e550416477 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,31 +1,37 @@ 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 androidx.recyclerview.widget.DiffUtil -import androidx.recyclerview.widget.RecyclerView +import coil3.dispose 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.VideoDownloadHelper +import com.lagradost.cloudstream3.utils.downloader.DownloadObjects +import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager import com.lagradost.cloudstream3.utils.setText import com.lagradost.cloudstream3.utils.txt import java.text.DateFormat @@ -38,7 +44,6 @@ 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 @@ -59,83 +64,74 @@ 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 action: Int, val data: ResultEpisode) +data class EpisodeClickEvent(val position: Int?, val action: Int, val data: ResultEpisode) { + constructor(action: Int, data: ResultEpisode) : this(null, action, data) +} class EpisodeAdapter( private val hasDownloadSupport: Boolean, private val clickCallback: (EpisodeClickEvent) -> Unit, private val downloadClickCallback: (DownloadClickEvent) -> Unit, -) : RecyclerView.Adapter() { +) : NoStateAdapter(diffCallback = BaseDiffCallback(itemSame = { a, b -> + a.id == b.id +}, contentSame = { a, b -> + a == b +})) { 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 } - } - var cardList: MutableList = mutableListOf() + val sharedPool = + newSharedPool { + setMaxRecycledViews(HAS_POSTER or CONTENT, 10) + setMaxRecycledViews(HAS_NO_POSTER or CONTENT, 10) + } + } - override fun onViewDetachedFromWindow(holder: RecyclerView.ViewHolder) { + override fun onClearView(holder: ViewHolderState) { if (holder.itemView.hasFocus()) { holder.itemView.clearFocus() } - } - 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 + 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 - // 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) { - 0 -> { - EpisodeCardViewHolderSmall( + HAS_NO_POSTER -> { + ViewHolderState( ResultEpisodeBinding.inflate( LayoutInflater.from(parent.context), parent, false - ), - hasDownloadSupport, - clickCallback, - downloadClickCallback + ) ) } - 1 -> { - EpisodeCardViewHolderLarge( + HAS_POSTER -> { + ViewHolderState( ResultEpisodeLargeBinding.inflate( LayoutInflater.from(parent.context), parent, false - ), - hasDownloadSupport, - clickCallback, - downloadClickCallback + ) ) } @@ -143,252 +139,223 @@ class EpisodeAdapter( } } - override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { - when (holder) { - is EpisodeCardViewHolderLarge -> { - holder.bind(getItem(position)) - } - - is EpisodeCardViewHolderSmall -> { - holder.bind(getItem(position)) - } - } - } + 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 getItemCount(): Int { - return cardList.size - } + binding.apply { + episodeLinHolder.layoutParams.width = setWidth + episodeHolderLarge.layoutParams.width = setWidth + episodeHolder.layoutParams.width = setWidth - 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 - - @SuppressLint("SetTextI18n") - fun bind(card: ResultEpisode) { - localCard = card - val setWidth = - if (isLayout(TV or EMULATOR)) TV_EP_SIZE.toPx else ViewGroup.LayoutParams.MATCH_PARENT - - binding.episodeLinHolder.layoutParams.width = setWidth - binding.episodeHolderLarge.layoutParams.width = setWidth - binding.episodeHolder.layoutParams.width = setWidth - - - 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, - rating = card.rating, - description = card.description, - cacheTime = System.currentTimeMillis(), - ), null - ) { - when (it.action) { - DOWNLOAD_ACTION_DOWNLOAD -> { - clickCallback.invoke(EpisodeClickEvent(ACTION_DOWNLOAD_EPISODE, card)) - } + if (isLayout(PHONE or EMULATOR) && CommonActivity.appliedTheme == R.style.AmoledMode) { + episodeHolderLarge.radius = 0.0f + episodeHolder.setPadding(0) + } - DOWNLOAD_ACTION_LONG_CLICK -> { - clickCallback.invoke(EpisodeClickEvent(ACTION_DOWNLOAD_MIRROR, card)) - } + 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) + 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.rating != null) { - episodeRating.text = episodeRating.context?.getString(R.string.rated_format) - ?.format(card.rating.toFloat() / 10f) - } else { - episodeRating.text = "" - } - - episodeRating.isGone = episodeRating.text.isNullOrBlank() - - episodeDescript.apply { - text = card.description.html() - isGone = text.isNullOrBlank() + 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() - var isExpanded = false - setOnClickListener { - if (isLayout(TV)) { - clickCallback.invoke(EpisodeClickEvent(ACTION_SHOW_DESCRIPTION, card)) + if (displayPos >= item.duration && displayPos > 0) { + episodePlayIcon.setImageResource(R.drawable.ic_baseline_check_24) + episodeProgress.isVisible = false } else { - isExpanded = !isExpanded - maxLines = if (isExpanded) { - Integer.MAX_VALUE - } else 4 + episodePlayIcon.setImageResource(R.drawable.netflix_play) + episodeProgress.apply { + max = durationSec + progress = progressSec + isVisible = displayPos > 0L + } } } - } - 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(), - "" - ) - ) - ) + 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 { - episodeUpcomingIcon.isVisible = false - - val formattedAirDate = SimpleDateFormat.getDateInstance( - DateFormat.LONG, - Locale.getDefault() - ).apply { - }.format(Date(card.airDate)) - - episodeDate.setText(txt(formattedAirDate)) + // Clear the image + episodePoster.dispose() } - } else { - episodeDate.isVisible = false - } - - episodeRuntime.setText( - txt( - card.runTime?.times(60L)?.toInt()?.let { secondsToReadable(it, "") } - ) - ) + episodePoster.isVisible = posterVisible - if (isLayout(EMULATOR or PHONE)) { - episodePoster.setOnClickListener { - clickCallback.invoke(EpisodeClickEvent(ACTION_CLICK_DEFAULT, card)) + 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 = "" } - episodePoster.setOnLongClickListener { - clickCallback.invoke(EpisodeClickEvent(ACTION_SHOW_TOAST, card)) - return@setOnLongClickListener true + 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 + } + } } - } - } - - 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 - } - } + 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 - 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 - } + val formattedAirDate = SimpleDateFormat.getDateInstance( + DateFormat.LONG, + Locale.getDefault() + ).apply { + }.format(Date(item.airDate)) - 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, - rating = card.rating, - description = card.description, - cacheTime = System.currentTimeMillis(), - ), null - ) { - when (it.action) { - DOWNLOAD_ACTION_DOWNLOAD -> { - clickCallback.invoke(EpisodeClickEvent(ACTION_DOWNLOAD_EPISODE, card)) + episodeDate.setText(txt(formattedAirDate)) } + } else { + episodeUpcomingIcon.isVisible = false + episodePlayIcon.isVisible = true + episodeDate.isVisible = false + } - DOWNLOAD_ACTION_LONG_CLICK -> { - clickCallback.invoke(EpisodeClickEvent(ACTION_DOWNLOAD_MIRROR, card)) + 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 + ) + ) } - else -> { - downloadClickCallback.invoke(it) + episodePoster.setOnLongClickListener { + clickCallback.invoke( + EpisodeClickEvent( + position, + ACTION_SHOW_TOAST, + item + ) + ) + return@setOnLongClickListener true } } } - 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(ACTION_CLICK_DEFAULT, card)) + clickCallback.invoke(EpisodeClickEvent(position, ACTION_CLICK_DEFAULT, item)) } if (isLayout(TV)) { @@ -398,29 +365,117 @@ class EpisodeAdapter( } itemView.setOnLongClickListener { - clickCallback.invoke(EpisodeClickEvent(ACTION_SHOW_OPTIONS, card)) + clickCallback.invoke(EpisodeClickEvent(position, ACTION_SHOW_OPTIONS, item)) return@setOnLongClickListener true } - - //binding.resultEpisodeDownload.isVisible = hasDownloadSupport - //binding.resultEpisodeProgressDownloaded.isVisible = hasDownloadSupport } - } - } -} -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 + 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) + } + } + } - override fun getOldListSize() = oldList.size + 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() - override fun getNewListSize() = newList.size + 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 + } + } + } - override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int) = - oldList[oldItemPosition] == newList[newItemPosition] -} + 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 + } + } + } + } +} \ No newline at end of file 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 eecd6262f0a..54657ed5730 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/result/ImageAdapter.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/result/ImageAdapter.kt @@ -2,11 +2,14 @@ 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 @@ -16,89 +19,54 @@ class ImageAdapter( val nextFocusUp: Int? = null, val nextFocusDown: Int? = null, ) : - RecyclerView.Adapter() { - private val images: MutableList = mutableListOf() - - 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) + NoStateAdapter( + diffCallback = BaseDiffCallback( + itemSame = Int::equals, + contentSame = Int::equals ) + ) { + companion object { + val sharedPool = + newSharedPool { setMaxRecycledViews(CONTENT, 10) } } - override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { - when (holder) { - is ImageViewHolder -> { - holder.bind(images[position], clickCallback, nextFocusUp, nextFocusDown) - } - } - } - - override fun getItemCount(): Int { - return images.size + override fun onCreateContent(parent: ViewGroup): ViewHolderState { + return ViewHolderState( + ResultMiniImageBinding.inflate(LayoutInflater.from(parent.context), parent, false) + ) } - override fun getItemId(position: Int): Long { - return images[position].toLong() + override fun onClearView(holder: ViewHolderState) { + val binding = holder.view as? ResultMiniImageBinding ?: return + clearImage(binding.root) } - fun updateList(newList: List) { - val diffResult = DiffUtil.calculateDiff( - DiffCallback(this.images, newList) - ) - - images.clear() - images.addAll(newList) - - diffResult.dispatchUpdatesTo(this) - } + override fun onBindContent(holder: ViewHolderState, item: Int, position: Int) { + val binding = holder.view as? ResultMiniImageBinding ?: return - 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 + 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 } - if (nextFocusUp != null) { - this.nextFocusUpId = nextFocusUp + setOnClickListener { + clickCallback.invoke(IMAGE_CLICK) } - 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 - } + 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 b4e3062b4da..3a0edba2f8e 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,6 +8,8 @@ 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 @@ -21,18 +23,17 @@ fun RecyclerView?.setLinearListLayout( ) { if (this == null) return val ctx = this.context ?: return - 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 - } + 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 + } } open class LinearListLayout(context: Context?) : @@ -104,13 +105,33 @@ 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 newFocus + if (direction == View.FOCUS_DOWN) getNextDirection( + focused, + FocusDirection.Down + )?.let { newFocus -> + return redirectRecycleToFirstItem(newFocus) } - if (direction == View.FOCUS_UP) getNextDirection(focused, FocusDirection.Up)?.let { newFocus -> - return newFocus + if (direction == View.FOCUS_UP) getNextDirection( + focused, + FocusDirection.Up + )?.let { newFocus -> + return redirectRecycleToFirstItem(newFocus) } if (direction == View.FOCUS_DOWN || direction == View.FOCUS_UP) { @@ -129,10 +150,16 @@ 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 } @@ -151,9 +178,15 @@ 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) @@ -166,6 +199,38 @@ 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 c12f011831b..cbf94fd9796 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,11 +1,17 @@ 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 @@ -13,6 +19,8 @@ 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 @@ -39,7 +47,7 @@ data class ResultEpisode( val index: Int, val position: Long, // time in MS val duration: Long, // duration in MS - val rating: Int?, + val score: Score?, val description: String?, val isFiller: Boolean?, val tvType: TvType, @@ -52,6 +60,7 @@ data class ResultEpisode( val totalEpisodeIndex: Int? = null, val airDate: Long? = null, val runTime: Int? = null, + val seasonData: SeasonData? = null, ) fun ResultEpisode.getRealPosition(): Long { @@ -81,7 +90,7 @@ fun buildResultEpisode( apiName: String, id: Int, index: Int, - rating: Int? = null, + rating: Score? = null, description: String? = null, isFiller: Boolean? = null, tvType: TvType, @@ -89,31 +98,33 @@ 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, - name, - poster, - episode, - seasonIndex, - season, - data, - apiName, - id, - index, - posDur?.position ?: 0, - posDur?.duration ?: 0, - rating, - description, - isFiller, - tvType, - parentId, - videoWatchState, - totalEpisodeIndex, - airDate, - runTime, + 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 ) } @@ -157,7 +168,7 @@ object ResultFragment { fun newInstance( url: String, apiName: String, - name : String, + name: String, startAction: Int = 0, startValue: Int = 0 ): Bundle { @@ -172,9 +183,10 @@ object ResultFragment { } fun updateUI(id: Int? = null) { - // updateUIListener?.invoke() + // updateUIListener?.invoke() updateUIEvent.invoke(id) } + val updateUIEvent = Event() //private var updateUIListener: (() -> Unit)? = null @@ -202,10 +214,7 @@ object ResultFragment { override fun onResume() { afterPluginsLoadedEvent += ::reloadViewModel super.onResume() - activity?.let { - it.window?.navigationBarColor = - it.colorFromAttribute(R.attr.primaryBlackBackground) - } + activity?.setNavigationBarColorCompat(R.attr.primaryBlackBackground) } override fun onDestroy() { @@ -222,14 +231,52 @@ 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) @@ -299,8 +346,6 @@ object ResultFragment { context?.updateHasTrailers() activity?.loadCache() - //activity?.fixPaddingStatusbar(result_barstatus) - /* val backParameter = result_back.layoutParams as FrameLayout.LayoutParams backParameter.setMargins( backParameter.leftMargin, @@ -310,8 +355,6 @@ 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 3d992d87331..38b24b26517 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultFragmentPhone.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultFragmentPhone.kt @@ -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,18 +17,19 @@ 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 @@ -37,53 +38,79 @@ import com.lagradost.cloudstream3.DubStatus import com.lagradost.cloudstream3.LoadResponse 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.FullScreenPlayer +import com.lagradost.cloudstream3.ui.player.IPlayer +import com.lagradost.cloudstream3.ui.player.PlayerView +import com.lagradost.cloudstream3.ui.player.source_priority.QualityProfileDialog import com.lagradost.cloudstream3.ui.quicksearch.QuickSearchFragment +import com.lagradost.cloudstream3.ui.result.ResultFragment.bindLogo 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.VideoDownloadHelper +import com.lagradost.cloudstream3.utils.UIHelper.setListViewHeightBasedOnItems +import com.lagradost.cloudstream3.utils.UIHelper.setNavigationBarColorCompat +import com.lagradost.cloudstream3.utils.downloader.DownloadFileManagement.getBasePath +import com.lagradost.cloudstream3.utils.downloader.DownloadObjects +import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager import com.lagradost.cloudstream3.utils.getImageFromDrawable import com.lagradost.cloudstream3.utils.setText import com.lagradost.cloudstream3.utils.setTextHtml - -open class ResultFragmentPhone : FullScreenPlayer() { +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 { private val gestureRegionsListener = object : PanelsChildGestureRegionObserver.GestureRegionsListener { override fun onGestureRegionsUpdate(gestureRegions: List) { @@ -91,48 +118,115 @@ open class ResultFragmentPhone : FullScreenPlayer() { } } + /** Queue of pending actions that is deferred to after a custom path is set */ + private val pendingPathActions = ConcurrentLinkedDeque>() + + /** + * Appends all actions to a queue, and asks for a user to enter the download folder if not already set up. + * + * Then processes the queue in the given order, only after the user has selected a folder. + * This is to defer the download to after a file path is set, due to perms. + * */ + private fun requirePathForActions(list: Collection>) { + pendingPathActions.addAll(list) + val (_, path) = context?.getBasePath() ?: return + if (path == null) { + /** If we have not set any download path, then ask the user for it before we download it */ + try { + /** Give the user some info of what we are doing and why, even if it may be missed */ + showToast(R.string.download_path_pref) + pathPicker.launch(Uri.EMPTY) + } catch (t: Throwable) { + logError(t) + /** Something went wrong, TV Device? + * Use the fallback behavior of just downloading it even if no path is selected, + * and hope it works */ + processPendingActions() + } + } else { + /** + * Otherwise dispatch everything, as we already have a valid download path + * Even if this is "wrong", we do not care as the user has entered something + * */ + processPendingActions() + } + } + + /** Clear all the items in the queue and dispatch them to the viewmodel in order */ + private fun processPendingActions() = viewModel.viewModelScope.launchSafe { + while (!pendingPathActions.isEmpty()) { + try { + val (action, data) = pendingPathActions.pop() + viewModel.handleAction( + EpisodeClickEvent( + action, + data + ) + ) + } catch (_: NoSuchElementException) { + /** In case of a race */ + } + } + } + + private val pathPicker = getChooseFolderLauncher { uri, path -> + if (uri == null) { + /** No path selected, clear the list without acting on it, canceling */ + if (!pendingPathActions.isEmpty()) { + /** Only show on non-empty, just in case */ + showToast(R.string.download_canceled) + pendingPathActions.clear() + } + } else { + /** Select the folder, and dispatch everything */ + pickDownloadPath(uri, path) + processPendingActions() + } + } + protected lateinit var viewModel: ResultViewModel2 protected lateinit var syncModel: SyncViewModel - protected var binding: FragmentResultSwipeBinding? = null protected var resultBinding: FragmentResultBinding? = null protected var recommendationBinding: ResultRecommendationsBinding? = null protected var syncBinding: ResultSyncBinding? = null - override var layout = R.layout.fragment_result_swipe - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View? { - viewModel = - ViewModelProvider(this)[ResultViewModel2::class.java] - syncModel = - ViewModelProvider(this)[SyncViewModel::class.java] - updateUIEvent += ::updateUI + 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 - 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 var playerHostView: PlayerView? = null + + open fun updateUIVisibility() {} + + protected fun uiReset() { + isShowing = false + updateUIVisibility() + } + + open fun showMirrorsDialogue() {} + open fun showTracksDialogue() {} + open fun openOnlineSubPicker( + context: android.content.Context, + loadResponse: LoadResponse?, + dismissCallback: () -> Unit + ) {} - return root + override fun fixLayout(view: View) { + fixSystemBarsPadding(view) } override fun onCreate(savedInstanceState: Bundle?) { 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() { @@ -146,33 +240,35 @@ open class ResultFragmentPhone : FullScreenPlayer() { override fun playerError(exception: Throwable) { if (player.getIsPlaying()) { // because we don't want random toasts in player - super.playerError(exception) + playerHostView?.playerError(exception) } else { nextMirror() } } private fun loadTrailer(index: Int? = null) { + val isSuccess = - 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 + 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 + } } ?: run { - false - } - } ?: run { false } //result_trailer_thumbnail?.setImageBitmap(result_poster_background?.drawable?.toBitmap()) @@ -181,6 +277,17 @@ open class ResultFragmentPhone : FullScreenPlayer() { // 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 { @@ -216,10 +323,10 @@ open class ResultFragmentPhone : FullScreenPlayer() { //} } - private fun setTrailers(trailers: List?) { + private fun setTrailers(trailers: List>?) { context?.updateHasTrailers() if (!LoadResponse.isTrailersEnabled) return - currentTrailers = trailers?.sortedBy { -it.quality } ?: emptyList() + currentTrailers = trailers?.sortedBy { -it.first.quality } ?: emptyList() loadTrailer() } @@ -233,11 +340,13 @@ open class ResultFragmentPhone : FullScreenPlayer() { } updateUIEvent -= ::updateUI - binding = null + playerHostView?.release() + playerBinding = null resultBinding?.resultScroll?.setOnClickListener(null) resultBinding = null syncBinding = null recommendationBinding = null + activity?.detachBackPressedCallback(this@ResultFragmentPhone.toString()) super.onDestroyView() } @@ -256,7 +365,6 @@ open class ResultFragmentPhone : FullScreenPlayer() { var selectSeason: String? = null var selectEpisodeRange: String? = null - var selectSort: EpisodeSortType? = null private fun setUrl(url: String?) { if (url == null) { @@ -298,10 +406,10 @@ open class ResultFragmentPhone : FullScreenPlayer() { override fun onResume() { afterPluginsLoadedEvent += ::reloadViewModel - activity?.let { - @Suppress("DEPRECATION") - it.window?.navigationBarColor = - it.colorFromAttribute(R.attr.primaryBlackBackground) + activity?.setNavigationBarColorCompat(R.attr.primaryBlackBackground) + context?.let { ctx -> + playerHostView?.onResume(ctx) + playerHostView?.setupKeyEventListener() } super.onResume() PanelsChildGestureRegionObserver.Provider.get() @@ -310,25 +418,44 @@ open class ResultFragmentPhone : FullScreenPlayer() { override fun onStop() { afterPluginsLoadedEvent -= ::reloadViewModel + playerHostView?.onStop() super.onStop() } + @Suppress("UNUSED_PARAMETER") private fun updateUI(id: Int?) { syncModel.updateUserData() viewModel.reloadEpisodes() } - @SuppressLint("SetTextI18n") - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) + 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() // ===== setup ===== - UIHelper.fixPaddingStatusbar(binding?.resultTopBar) val storedData = getStoredData() ?: return activity?.window?.decorView?.clearFocus() activity?.loadCache() context?.updateHasTrailers() - hideKeyboard() + hideKeyboard(binding.root) if (storedData.restart || !viewModel.hasLoaded()) viewModel.load( activity, @@ -346,7 +473,7 @@ open class ResultFragmentPhone : FullScreenPlayer() { // This may not be 100% reliable, and may delay for small period // before resultCastItems will be scrollable again, but this does work // most of the time. - binding?.resultOverlappingPanels?.registerEndPanelStateListeners( + binding.resultOverlappingPanels.registerEndPanelStateListeners( object : OverlappingPanelsLayout.PanelStateListener { override fun onPanelStateChange(panelState: PanelState) { PanelsChildGestureRegionObserver.Provider.get().apply { @@ -358,8 +485,8 @@ open class ResultFragmentPhone : FullScreenPlayer() { // ===== ===== ===== - binding?.resultSearch?.isGone = storedData.name.isBlank() - binding?.resultSearch?.setOnClickListener { + binding.resultSearch.isGone = storedData.name.isBlank() + binding.resultSearch.setOnClickListener { QuickSearchFragment.pushSearch(activity, storedData.name) } @@ -388,7 +515,7 @@ open class ResultFragmentPhone : FullScreenPlayer() { focused: View? ): Boolean { // Make the cast always focus the first visible item when focused - // from somewhere else. Otherwise it jumps to the last item. + // from somewhere else. Otherwise, it jumps to the last item. return if (parent.focusedChild == null) { scrollToPosition(this.findFirstCompletelyVisibleItemPosition()) true @@ -399,13 +526,20 @@ open class ResultFragmentPhone : FullScreenPlayer() { }.apply { this.orientation = RecyclerView.HORIZONTAL }*/ + resultCastItems.setRecycledViewPool(ActorAdaptor.sharedPool) resultCastItems.adapter = ActorAdaptor() - + resultEpisodes.setRecycledViewPool(EpisodeAdapter.sharedPool) resultEpisodes.adapter = EpisodeAdapter( api?.hasDownloadSupport == true, { episodeClick -> - viewModel.handleAction(episodeClick) + when (episodeClick.action) { + ACTION_DOWNLOAD_EPISODE, ACTION_DOWNLOAD_MIRROR -> { + requirePathForActions(listOf(episodeClick.action to episodeClick.data)) + } + + else -> viewModel.handleAction(episodeClick) + } }, { downloadClickEvent -> DownloadButtonSetup.handleDownloadClick(downloadClickEvent) @@ -428,7 +562,7 @@ open class ResultFragmentPhone : FullScreenPlayer() { activity?.showDialog( names.map { it.second }, viewModel.selectedSortingIndex.value ?: -1, - "", + ctx.getString(R.string.sort_by), false, {}) { itemId -> viewModel.setSort(names[itemId].first) @@ -440,9 +574,9 @@ open class ResultFragmentPhone : FullScreenPlayer() { resultScroll.setOnScrollChangeListener(NestedScrollView.OnScrollChangeListener { _, _, scrollY, _, oldScrollY -> val dy = scrollY - oldScrollY if (dy > 0) { //check for scroll down - binding?.resultBookmarkFab?.shrink() + binding.resultBookmarkFab.shrink() } else if (dy < -5) { - binding?.resultBookmarkFab?.extend() + binding.resultBookmarkFab.extend() } if (!isFullScreenPlayer && player.getIsPlaying()) { if (scrollY > (resultBinding?.fragmentTrailer?.playerBackground?.height @@ -454,25 +588,37 @@ open class ResultFragmentPhone : FullScreenPlayer() { }) } - 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 (binding?.resultOverlappingPanels?.getSelectedPanel()?.ordinal == 1) { - binding?.resultOverlappingPanels?.openStartPanel() - } else { - binding?.resultOverlappingPanels?.closePanels() - } + if (resultOverlappingPanels.getSelectedPanel().ordinal == 1) { + resultOverlappingPanels.openStartPanel() + } else resultOverlappingPanels.closePanels() } }) + */ resultSubscribe.setOnClickListener { viewModel.toggleSubscriptionStatus(context) { newStatus: Boolean? -> if (newStatus == null) return@toggleSubscriptionStatus @@ -532,12 +678,7 @@ open class ResultFragmentPhone : FullScreenPlayer() { CastContext.getSharedInstance(act.applicationContext) { it.run() }.addOnCompleteListener { - isGone = if (it.isSuccessful) { - it.result.castState == CastState.NO_DEVICES_AVAILABLE - } else { - true - } - + isGone = !it.isSuccessful } // this shit leaks for some reason //castContext.addCastStateListener { state -> @@ -553,8 +694,8 @@ open class ResultFragmentPhone : FullScreenPlayer() { playerBinding?.apply { playerOpenSource.setOnClickListener { - currentTrailers.getOrNull(currentTrailerIndex)?.let { - context?.openBrowser(it.url) + currentTrailers.getOrNull(currentTrailerIndex)?.let { (_, ogTrailerLink) -> + context?.openBrowser(ogTrailerLink) } } } @@ -562,9 +703,9 @@ open class ResultFragmentPhone : FullScreenPlayer() { recommendationBinding?.apply { resultRecommendationsList.apply { spanCount = 3 + setRecycledViewPool(SearchAdapter.sharedPool) adapter = SearchAdapter( - ArrayList(), this, ) { callback -> SearchHelper.handleSearchClickCallback(callback) @@ -588,10 +729,13 @@ open class ResultFragmentPhone : FullScreenPlayer() { 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 = @@ -601,8 +745,11 @@ open class ResultFragmentPhone : FullScreenPlayer() { resume.result.season ) } - - resultResumeSeriesProgressText.setText(progress.progressLeft) + if (resume.isMovie) { + resultPlayParent.isGone = true + resultResumeSeriesProgressText.isVisible = true + resultResumeSeriesProgressText.setText(progress.progressLeft) + } resultResumeSeriesProgress.apply { isVisible = true this.max = progress.maxProgress @@ -611,25 +758,30 @@ open class ResultFragmentPhone : FullScreenPlayer() { 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 { - viewModel.handleAction( - EpisodeClickEvent( - storedData.playerAction, //?: ACTION_PLAY_EPISODE_IN_PLAYER, - resume.result - ) - ) + resumeAction(storedData, resume) + } + resultNextSeriesButton.setOnClickListener { + resumeAction(storedData, resume) } } } observeNullable(viewModel.subscribeStatus) { isSubscribed -> - binding?.resultSubscribe?.isVisible = isSubscribed != null + binding.resultSubscribe.isVisible = isSubscribed != null if (isSubscribed == null) return@observeNullable val drawable = if (isSubscribed) { @@ -638,11 +790,11 @@ open class ResultFragmentPhone : FullScreenPlayer() { R.drawable.baseline_notifications_none_24 } - binding?.resultSubscribe?.setImageResource(drawable) + binding.resultSubscribe.setImageResource(drawable) } observeNullable(viewModel.favoriteStatus) { isFavorite -> - binding?.resultFavorite?.isVisible = isFavorite != null + binding.resultFavorite.isVisible = isFavorite != null if (isFavorite == null) return@observeNullable val drawable = if (isFavorite) { @@ -651,11 +803,7 @@ open class ResultFragmentPhone : FullScreenPlayer() { R.drawable.ic_baseline_favorite_border_24 } - binding?.resultFavorite?.setImageResource(drawable) - } - - observe(viewModel.trailers) { trailers -> - setTrailers(trailers.flatMap { it.mirros }) // I dont care about subtitles yet! + binding.resultFavorite.setImageResource(drawable) } observeNullable(viewModel.episodes) { episodes -> @@ -663,8 +811,58 @@ open class ResultFragmentPhone : FullScreenPlayer() { // 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)?.updateList(episodes.value) + (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() + } } } } @@ -688,16 +886,25 @@ open class ResultFragmentPhone : FullScreenPlayer() { ) 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( - VideoDownloadHelper.DownloadEpisodeCached( + DownloadObjects.DownloadEpisodeCached( name = ep.name, poster = ep.poster, episode = 0, season = null, id = ep.id, parentId = ep.id, - rating = null, - description = null, + score = ep.score, + description = ep.description, cacheTime = System.currentTimeMillis(), ), null @@ -706,18 +913,11 @@ open class ResultFragmentPhone : FullScreenPlayer() { when (click.action) { DOWNLOAD_ACTION_DOWNLOAD -> { - viewModel.handleAction( - EpisodeClickEvent(ACTION_DOWNLOAD_EPISODE, ep) - ) + requirePathForActions(listOf(ACTION_DOWNLOAD_EPISODE to ep)) } DOWNLOAD_ACTION_LONG_CLICK -> { - viewModel.handleAction( - EpisodeClickEvent( - ACTION_DOWNLOAD_MIRROR, - ep - ) - ) + requirePathForActions(listOf(ACTION_DOWNLOAD_MIRROR to ep)) } else -> DownloadButtonSetup.handleDownloadClick(click) @@ -768,6 +968,13 @@ open class ResultFragmentPhone : FullScreenPlayer() { } } + bindLogo( + url = d.logoUrl, + headers = d.posterHeaders, + titleView = resultTitle, + logoView = backgroundPosterWatermarkBadge + ) + var isExpanded = false resultDescription.apply { setTextHtml(d.plotText) @@ -784,8 +991,15 @@ open class ResultFragmentPhone : FullScreenPlayer() { resultComingSoon.isVisible = d.comingSoon resultDataHolder.isGone = d.comingSoon - resultCastItems.isGone = d.actors.isNullOrEmpty() - (resultCastItems.adapter as? ActorAdaptor)?.updateList(d.actors ?: emptyList()) + 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()) if (d.contentRatingText == null) { // If there is no rating to display, we don't want an empty gap @@ -799,7 +1013,7 @@ open class ResultFragmentPhone : FullScreenPlayer() { syncModel.addFromUrl(d.url) } - binding?.apply { + binding.apply { resultSearch.isGone = d.title.isBlank() resultSearch.setOnClickListener { QuickSearchFragment.pushSearch(activity, d.title) @@ -808,15 +1022,23 @@ open class ResultFragmentPhone : FullScreenPlayer() { 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, d.url) + i.putExtra(Intent.EXTRA_TEXT, redirectUrl) startActivity(Intent.createChooser(i, d.title)) } catch (e: Exception) { logError(e) } } - setUrl(d.url) resultBookmarkFab.apply { isVisible = true @@ -826,10 +1048,11 @@ open class ResultFragmentPhone : FullScreenPlayer() { } (data as? Resource.Failure)?.let { data -> + @SuppressLint("SetTextI18n") resultErrorText.text = storedData.url.plus("\n") + data.errorString } - binding?.resultBookmarkFab?.isVisible = data is Resource.Success + binding.resultBookmarkFab.isVisible = data is Resource.Success resultFinishLoading.isVisible = data is Resource.Success resultLoading.isVisible = data is Resource.Loading @@ -876,14 +1099,17 @@ open class ResultFragmentPhone : FullScreenPlayer() { } } + 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?.adapter as? ImageAdapter)?.updateList(newList.mapNotNull { it.icon }) + binding.resultMiniSync.isVisible = newList.isNotEmpty() } @@ -944,7 +1170,11 @@ open class ResultFragmentPhone : FullScreenPlayer() { resultSyncHolder.isVisible = true val d = status.value - resultSyncRating.value = d.score?.toFloat() ?: 0.0f + val desiredScore = d.score?.toFloat(1) ?: 0.0f + val totalSteps = (resultSyncRating.valueTo / resultSyncRating.stepSize) + val desiredStep = (totalSteps * desiredScore).roundToInt() + resultSyncRating.value = desiredStep * resultSyncRating.stepSize + resultSyncCheck.setItemChecked(d.status.internalId + 1, true) val watchedEpisodes = d.watchedEpisodes ?: 0 currentSyncProgress = watchedEpisodes @@ -962,10 +1192,10 @@ open class ResultFragmentPhone : FullScreenPlayer() { resultSyncCurrentEpisodes.text = Editable.Factory.getInstance()?.newEditable(watchedEpisodes.toString()) safe { // format might fail - context?.getString(R.string.sync_score_format)?.format(d.score ?: 0) - ?.let { - resultSyncScoreText.text = it - } + val text = d.score?.toFloat(10)?.roundToInt()?.let { + context?.getString(R.string.sync_score_format)?.format(it) + } ?: "?" + resultSyncScoreText.text = text } } @@ -974,7 +1204,7 @@ open class ResultFragmentPhone : FullScreenPlayer() { } } } - binding?.resultOverlappingPanels?.setStartPanelLockState(if (closed) OverlappingPanelsLayout.LockState.CLOSE else OverlappingPanelsLayout.LockState.UNLOCKED) + binding.resultOverlappingPanels.setStartPanelLockState(if (closed) OverlappingPanelsLayout.LockState.CLOSE else OverlappingPanelsLayout.LockState.UNLOCKED) } observe(viewModel.recommendations) { recommendations -> setRecommendations(recommendations, null) @@ -1003,14 +1233,14 @@ open class ResultFragmentPhone : FullScreenPlayer() { syncBinding?.apply { resultSyncCheck.choiceMode = AbsListView.CHOICE_MODE_SINGLE resultSyncCheck.adapter = arrayAdapter - UIHelper.setListViewHeightBasedOnItems(resultSyncCheck) + setListViewHeightBasedOnItems(resultSyncCheck) resultSyncCheck.setOnItemClickListener { _, _, which, _ -> syncModel.setStatus(which - 1) } - resultSyncRating.addOnChangeListener { _, value, _ -> - syncModel.setScore(value.toInt()) + resultSyncRating.addOnChangeListener { it, value, fromUser -> + if (fromUser) syncModel.setScore(Score.from(value, it.valueTo.roundToInt())) } resultSyncAddEpisode.setOnClickListener { @@ -1035,7 +1265,7 @@ open class ResultFragmentPhone : FullScreenPlayer() { } observe(viewModel.watchStatus) { watchType -> - binding?.resultBookmarkFab?.apply { + binding.resultBookmarkFab.apply { setText(watchType.stringRes) if (watchType == WatchType.NONE) { context?.colorFromAttribute(R.attr.white) @@ -1090,6 +1320,7 @@ open class ResultFragmentPhone : FullScreenPlayer() { viewModel.skipLoading() } isVisible = true + @SuppressLint("SetTextI18n") text = "${context.getString(R.string.skip_loading)} (${load.linksLoaded})" } } @@ -1159,7 +1390,7 @@ open class ResultFragmentPhone : FullScreenPlayer() { activity?.showDialog( names.map { it.second }, names.indexOfFirst { it.second == selectEpisodeRange }, - "", + ctx.getString(R.string.episodes), false, {}) { itemId -> viewModel.changeRange(names[itemId].first) @@ -1180,7 +1411,7 @@ open class ResultFragmentPhone : FullScreenPlayer() { activity?.showDialog( names.map { it.second }, names.indexOfFirst { it.second == selectSeason }, - "", + ctx.getString(R.string.season), false, {}) { itemId -> viewModel.changeSeason(names[itemId].first) @@ -1197,7 +1428,20 @@ open class ResultFragmentPhone : FullScreenPlayer() { } } + 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) @@ -1211,7 +1455,7 @@ open class ResultFragmentPhone : FullScreenPlayer() { root.isGone = isInvalid root.post { rec?.let { list -> - (resultRecommendationsList.adapter as? SearchAdapter)?.updateList(list.filter { it.apiName == matchAgainst }) + (resultRecommendationsList.adapter as? SearchAdapter)?.submitList(list.filter { it.apiName == matchAgainst }) } } } @@ -1255,4 +1499,4 @@ open class ResultFragmentPhone : FullScreenPlayer() { } } } -} \ 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 ebbe0cd8fbe..cfbacc5d13f 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultFragmentTv.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultFragmentTv.kt @@ -14,7 +14,6 @@ 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 @@ -30,20 +29,24 @@ 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 @@ -53,22 +56,26 @@ 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() } @@ -76,15 +83,13 @@ class ResultFragmentTv : Fragment() { inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? - ): View { + ): View? { viewModel = ViewModelProvider(this)[ResultViewModel2::class.java] viewModel.EPISODE_RANGE_SIZE = 50 updateUIEvent += ::updateUI - val localBinding = FragmentResultTvBinding.inflate(inflater, container, false) - binding = localBinding - return localBinding.root + return super.onCreateView(inflater, container, savedInstanceState) } private fun updateUI(id: Int?) { @@ -118,7 +123,7 @@ class ResultFragmentTv : Fragment() { } private fun RecyclerView?.update(data: List) { - (this?.adapter as? SelectAdaptor?)?.updateSelectionList(data) + (this?.adapter as? SelectAdaptor?)?.submitList(data) this?.isVisible = data.size > 1 } @@ -151,14 +156,14 @@ class ResultFragmentTv : Fragment() { resultRecommendationsList.isGone = isInvalid resultRecommendationsHolder.isGone = isInvalid val matchAgainst = validApiName ?: rec?.firstOrNull()?.apiName - (resultRecommendationsList.adapter as? SearchAdapter)?.updateList(rec?.filter { it.apiName == matchAgainst } + (resultRecommendationsList.adapter as? SearchAdapter)?.submitList(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 { - com.lagradost.cloudstream3.utils.txt( + txt( it ) to it }) @@ -187,11 +192,7 @@ class ResultFragmentTv : Fragment() { } override fun onResume() { - activity?.let { - @Suppress("DEPRECATION") - it.window?.navigationBarColor = - it.colorFromAttribute(R.attr.primaryBlackBackground) - } + activity?.setNavigationBarColorCompat(R.attr.primaryBlackBackground) afterPluginsLoadedEvent += ::reloadViewModel super.onResume() } @@ -230,11 +231,6 @@ class ResultFragmentTv : Fragment() { } } - override fun onDestroy() { - super.onDestroy() - activity?.detachBackPressedCallback(this@ResultFragmentTv.toString()) - } - private fun toggleEpisodes(show: Boolean) { binding?.apply { if (show) { @@ -254,10 +250,12 @@ class ResultFragmentTv : Fragment() { } } - @SuppressLint("SetTextI18n") - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) + override fun fixLayout(view: View) { + fixSystemBarsPadding(view, padTop = false) + } + @SuppressLint("SetTextI18n") + override fun onBindingCreated(binding: FragmentResultTvBinding) { // ===== setup ===== val storedData = getStoredData() ?: return activity?.window?.decorView?.clearFocus() @@ -275,7 +273,7 @@ class ResultFragmentTv : Fragment() { // ===== ===== ===== var comingSoon = false - binding?.apply { + binding.apply { //episodesShadow.rotationX = 180.0f//if(episodesShadow.isRtl()) 180.0f else 0.0f // parallax on background @@ -287,7 +285,7 @@ class ResultFragmentTv : Fragment() { if (!hasFocus) return@setOnFocusChangeListener toggleEpisodes(false) - binding?.apply { + binding.apply { val views = listOf( resultPlayMovieButton, resultPlaySeriesButton, @@ -308,7 +306,7 @@ class ResultFragmentTv : Fragment() { redirectToEpisodes.setOnFocusChangeListener { _, hasFocus -> if (!hasFocus) return@setOnFocusChangeListener toggleEpisodes(true) - binding?.apply { + binding.apply { val views = listOf( resultDubSelection, resultSeasonSelection, @@ -412,24 +410,24 @@ class ResultFragmentTv : Fragment() { 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, @@ -441,8 +439,7 @@ class ResultFragmentTv : Fragment() { } ) - resultCastItems.layoutManager = object : LinearListLayout(view.context) { - + resultCastItems.layoutManager = object : LinearListLayout(root.context) { override fun onRequestChildFocus( parent: RecyclerView, state: RecyclerView.State, @@ -458,35 +455,48 @@ class ResultFragmentTv : Fragment() { super.onRequestChildFocus(parent, state, child, focused) } } - }.apply { - setHorizontal() - } + }.apply { setHorizontal() } val aboveCast = listOf( - binding?.resultEpisodesShow, - binding?.resultBookmark, - binding?.resultFavorite, - binding?.resultSubscribe, - ).firstOrNull { - it?.isVisible == true - } + binding.resultEpisodesShow, + binding.resultBookmark, + binding.resultFavorite, + binding.resultSubscribe, + ).firstOrNull { it.isVisible } + + resultCastItems.setRecycledViewPool(ActorAdaptor.sharedPool) resultCastItems.adapter = ActorAdaptor(aboveCast?.id) { toggleEpisodes(false) } + + if (isLayout(EMULATOR)) { + episodesShadow.setOnClickListener { + 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 @@ -538,17 +548,18 @@ class ResultFragmentTv : Fragment() { observe(viewModel.trailers) { trailersLinks -> context?.updateHasTrailers() if (!LoadResponse.isTrailersEnabled) return@observe - val trailers = trailersLinks.flatMap { it.mirros } - binding?.apply { - resultPlayTrailer.isGone = trailers.isEmpty() + val extractedTrailerLinks = trailersLinks.flatMap { it.mirros } + .map { (extractedTrailerLink, _) -> extractedTrailerLink } + binding.apply { + resultPlayTrailer.isGone = extractedTrailerLinks.isEmpty() resultPlayTrailerButton.setOnClickListener { - if (trailers.isEmpty()) return@setOnClickListener + if (extractedTrailerLinks.isEmpty()) return@setOnClickListener activity.navigate( R.id.global_to_navigation_player, GeneratorPlayer.newInstance( ExtractorLinkGenerator( - trailers, + extractedTrailerLinks, emptyList() - ) + ), 0 ) ) } @@ -556,16 +567,13 @@ class ResultFragmentTv : Fragment() { } 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 -> @@ -583,19 +591,13 @@ class ResultFragmentTv : Fragment() { } 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 { @@ -604,15 +606,13 @@ class ResultFragmentTv : Fragment() { 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 - ?: com.lagradost.cloudstream3.utils.txt(R.string.no_data) + ?: txt(R.string.no_data) .asStringNull(context) ?: "" CommonActivity.showToast( - com.lagradost.cloudstream3.utils.txt( + txt( message, name ), Toast.LENGTH_SHORT @@ -621,28 +621,22 @@ class ResultFragmentTv : Fragment() { } } - 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 { @@ -653,15 +647,13 @@ class ResultFragmentTv : Fragment() { // 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 - ?: com.lagradost.cloudstream3.utils.txt(R.string.no_data) + ?: txt(R.string.no_data) .asStringNull(context) ?: "" CommonActivity.showToast( - com.lagradost.cloudstream3.utils.txt( + txt( message, name ), Toast.LENGTH_SHORT @@ -669,12 +661,10 @@ class ResultFragmentTv : Fragment() { } } - 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) } } @@ -685,10 +675,8 @@ class ResultFragmentTv : Fragment() { return@observeNullable } - binding?.apply { - + binding.apply { (data as? Resource.Success)?.value?.let { (_, ep) -> - resultPlayMovieButton.setOnClickListener { viewModel.handleAction( EpisodeClickEvent(ACTION_CLICK_DEFAULT, ep) @@ -702,10 +690,9 @@ class ResultFragmentTv : Fragment() { } 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 @@ -774,26 +761,26 @@ class ResultFragmentTv : Fragment() { 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) @@ -801,7 +788,7 @@ class ResultFragmentTv : Fragment() { if (isLayout(TV)) { observe(viewModel.episodeSynopsis) { description -> - view.context?.let { ctx -> + context?.let { ctx -> val builder: AlertDialog.Builder = AlertDialog.Builder(ctx, R.style.AlertDialogCustom) builder.setMessage(description.html()) @@ -818,15 +805,11 @@ class ResultFragmentTv : Fragment() { 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 } @@ -869,14 +852,14 @@ class ResultFragmentTv : Fragment() { } - (resultEpisodes.adapter as? EpisodeAdapter)?.updateList(episodes.value) + (resultEpisodes.adapter as? EpisodeAdapter)?.submitList(episodes.value) } } } observeNullable(viewModel.page) { data -> if (data == null) return@observeNullable - binding?.apply { + binding.apply { when (data) { is Resource.Success -> { val d = data.value @@ -906,7 +889,7 @@ class ResultFragmentTv : Fragment() { Integer.MAX_VALUE } else 10 } else { - view.context?.let { ctx -> + context?.let { ctx -> val builder: AlertDialog.Builder = AlertDialog.Builder(ctx, R.style.AlertDialogCustom) builder.setMessage(d.plotText.asString(ctx).html()) @@ -926,21 +909,32 @@ class ResultFragmentTv : Fragment() { 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 - UIHelper.populateChips(resultTag, d.tags) - resultCastItems.isGone = d.actors.isNullOrEmpty() - (resultCastItems.adapter as? ActorAdaptor)?.updateList( - d.actors ?: emptyList() + populateChips(resultTag, d.tags) + val prefs = + androidx.preference.PreferenceManager.getDefaultSharedPreferences(root.context) + val showCast = prefs.getBoolean( + root.context.getString(R.string.show_cast_in_details_key), + true ) + resultCastItems.isGone = !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 @@ -951,9 +945,7 @@ class ResultFragmentTv : Fragment() { } } - is Resource.Loading -> { - - } + is Resource.Loading -> {} is Resource.Failure -> { resultErrorText.text = @@ -970,4 +962,4 @@ class ResultFragmentTv : Fragment() { } } } -} \ 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 5253974e83e..3b1471e6ab2 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultTrailerPlayer.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultTrailerPlayer.kt @@ -3,40 +3,76 @@ 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 -open class ResultTrailerPlayer : ResultFragmentPhone() { +class ResultTrailerPlayer : ResultFragmentPhone() { override var lockRotation = false override var isFullScreenPlayer = false override var hasPipModeSupport = false companion object { - const val TAG = "RESULT_TRAILER" + const val TAG = "ResultTrailerPlayer" } private var playerWidthHeight: Pair? = null + private var introVisible = true - override fun nextEpisode() {} + // Single-tap on empty player area: toggle controls. + override fun onSingleTap() { + if (introVisible) return + if (isShowing) uiReset() else showControls() + } - override fun prevEpisode() {} + private fun showControls() { + if (introVisible) return + isShowing = true + updateUIVisibility() + playerHostView?.scheduleAutoHide() + } + + override fun isUIShowing(): Boolean = isShowing - override fun playerPositionChanged(position: Long, duration : Long) {} + override fun onAutoHideUI() { + if (player.getIsPlaying()) uiReset() + } + + override fun onHidePlayerUI() = uiReset() + + // When the hold-speedup gesture fires, hide controls so the video is unobstructed. + // The speedup button show/hide and speed change are handled by PlayerView. + override fun onHoldSpeedUp(show: Boolean) { + if (show && isShowing) uiReset() + } + + override fun onGestureEnd(hadSwipe: Boolean, wasUiShowing: Boolean) { + if (!player.getIsPlaying() && hadSwipe && wasUiShowing && !isShowing) { + isShowing = true + showControls() + } else playerHostView?.scheduleAutoHide() + } + override fun nextEpisode() {} + override fun prevEpisode() {} + override fun playerPositionChanged(position: Long, duration: Long) {} override fun nextMirror() {} override fun onConfigurationChanged(newConfig: Configuration) { @@ -46,18 +82,28 @@ open 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 @@ -65,35 +111,30 @@ open class ResultTrailerPlayer : ResultFragmentPhone() { resultBinding?.fragmentTrailer?.playerBackground?.apply { isVisible = true - layoutParams = - FrameLayout.LayoutParams( - FrameLayout.LayoutParams.MATCH_PARENT, - if (isFullScreenPlayer) FrameLayout.LayoutParams.MATCH_PARENT else to - ) + layoutParams = FrameLayout.LayoutParams( + FrameLayout.LayoutParams.MATCH_PARENT, + if (isFullScreenPlayer) FrameLayout.LayoutParams.MATCH_PARENT else to + ) } playerBinding?.playerIntroPlay?.apply { - layoutParams = - FrameLayout.LayoutParams( - FrameLayout.LayoutParams.MATCH_PARENT, - resultBinding?.resultTopHolder?.measuredHeight - ?: FrameLayout.LayoutParams.MATCH_PARENT - ) + layoutParams = FrameLayout.LayoutParams( + FrameLayout.LayoutParams.MATCH_PARENT, + resultBinding?.resultTopHolder?.measuredHeight ?: FrameLayout.LayoutParams.MATCH_PARENT + ) } if (playerBinding?.playerIntroPlay?.isGone == true) { resultBinding?.resultTopHolder?.apply { - val anim = ValueAnimator.ofInt( measuredHeight, if (isFullScreenPlayer) ViewGroup.LayoutParams.MATCH_PARENT else to ) - anim.addUpdateListener { valueAnimator -> - val `val` = valueAnimator.animatedValue as Int - val layoutParams: ViewGroup.LayoutParams = - layoutParams - layoutParams.height = `val` - setLayoutParams(layoutParams) + anim.addUpdateListener { va -> + val v = va.animatedValue as Int + val lp: ViewGroup.LayoutParams = layoutParams + lp.height = v + layoutParams = lp } anim.duration = 200 anim.start() @@ -102,9 +143,14 @@ open class ResultTrailerPlayer : ResultFragmentPhone() { } } - override fun playerDimensionsLoaded(width: Int, height : Int) { + override fun playerDimensionsLoaded(width: Int, height: Int) { playerWidthHeight = width to height fixPlayerSize() + // Apply autorotation when fullscreen (lockRotation = true). + // PlayerView already set isVerticalOrientation before this callback fires. + if (lockRotation) { + activity?.requestedOrientation = playerHostView?.dynamicOrientation() ?: return + } } override fun showMirrorsDialogue() {} @@ -114,33 +160,39 @@ open class ResultTrailerPlayer : ResultFragmentPhone() { context: Context, loadResponse: LoadResponse?, dismissCallback: () -> Unit - ) { - } + ) {} override fun subtitlesChanged() {} - override fun embeddedSubtitlesFetched(subtitles: List) {} override fun onTracksInfoChanged() {} - override fun exitedPipMode() {} + + override fun onSeekPreviewText(text: String?) { + playerBinding?.playerTimeText?.apply { + isVisible = text != null + if (text != null) this.text = text + } + } + private fun updateFullscreen(fullscreen: Boolean) { isFullScreenPlayer = fullscreen lockRotation = fullscreen + playerHostView?.isFullScreen = fullscreen - playerBinding?.playerFullscreen?.setImageResource(if (fullscreen) R.drawable.baseline_fullscreen_exit_24 else R.drawable.baseline_fullscreen_24) + playerBinding?.playerFullscreen?.setImageResource( + if (fullscreen) R.drawable.baseline_fullscreen_exit_24 else R.drawable.baseline_fullscreen_24 + ) if (fullscreen) { - enterFullscreen() + playerHostView?.enterFullscreen() binding?.apply { resultTopBar.isVisible = false resultFullscreenHolder.isVisible = true resultMainHolder.isVisible = false } - resultBinding?.fragmentTrailer?.playerBackground?.let { view -> (view.parent as ViewGroup?)?.removeView(view) binding?.resultFullscreenHolder?.addView(view) } - } else { binding?.apply { resultTopBar.isVisible = true @@ -151,36 +203,55 @@ open class ResultTrailerPlayer : ResultFragmentPhone() { resultBinding?.resultSmallscreenHolder?.addView(view) } } - exitFullscreen() + playerHostView?.exitFullscreen() } fixPlayerSize() uiReset() if (isFullScreenPlayer) { - activity?.attachBackPressedCallback("ResultTrailerPlayer") { - updateFullscreen(false) - } - } else activity?.detachBackPressedCallback("ResultTrailerPlayer") + activity?.attachBackPressedCallback("ResultTrailerPlayer") { updateFullscreen(false) } + } else { + activity?.detachBackPressedCallback("ResultTrailerPlayer") + } } override fun updateUIVisibility() { super.updateUIVisibility() - playerBinding?.playerGoBackHolder?.isVisible = false + playerBinding?.apply { + playerGoBackHolder.isVisible = false + val controlsVisible = isShowing && !introVisible + playerTopHolder.isVisible = controlsVisible + playerVideoHolder.isVisible = controlsVisible + shadowOverlay.isVisible = controlsVisible + playerPausePlayHolderHolder.isVisible = + controlsVisible && playerHostView?.currentPlayerStatus != CSPlayerLoading.IsBuffering + } + // Fade center controls in/out; also resets stale fillAfter alpha from seek animations. + playerHostView?.gestureHelper?.animateCenterControls(if (isShowing && !introVisible) 1f else 0f) } - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - playerBinding?.playerFullscreen?.setOnClickListener { - updateFullscreen(!isFullScreenPlayer) + override fun playerStatusChanged() { + if (introVisible) { + playerBinding?.playerPausePlayHolderHolder?.isVisible = false } + } + + override fun onBindingCreated(binding: FragmentResultSwipeBinding, savedInstanceState: Bundle?) { + super.onBindingCreated(binding, savedInstanceState) + + playerHostView?.videoOutline = playerBinding?.videoOutline + playerHostView?.requestUpdateBrightnessOverlayOnNextLayout() + + playerBinding?.playerFullscreen?.setOnClickListener { updateFullscreen(!isFullScreenPlayer) } updateFullscreen(isFullScreenPlayer) uiReset() playerBinding?.playerIntroPlay?.setOnClickListener { playerBinding?.playerIntroPlay?.isGone = true + introVisible = false player.handleEvent(CSPlayerEvent.Play, PlayerEventSource.UI) - updateUIVisibility() fixPlayerSize() + showControls() } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultViewModel2.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultViewModel2.kt index 8641c62be45..7dfe3cf5988 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultViewModel2.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultViewModel2.kt @@ -1,7 +1,8 @@ package com.lagradost.cloudstream3.ui.result import android.app.Activity -import android.content.* +import android.content.Context +import android.content.DialogInterface import android.util.Log import android.widget.Toast import androidx.annotation.MainThread @@ -10,33 +11,67 @@ import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.lagradost.cloudstream3.* +import com.lagradost.cloudstream3.APIHolder import com.lagradost.cloudstream3.APIHolder.apis import com.lagradost.cloudstream3.APIHolder.getApiFromNameNull import com.lagradost.cloudstream3.APIHolder.unixTime import com.lagradost.cloudstream3.APIHolder.unixTimeMS -import com.lagradost.cloudstream3.AcraApplication.Companion.setKey +import com.lagradost.cloudstream3.ActorData +import com.lagradost.cloudstream3.AnimeLoadResponse +import com.lagradost.cloudstream3.CloudStreamApp.Companion.context +import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey import com.lagradost.cloudstream3.CommonActivity.activity import com.lagradost.cloudstream3.CommonActivity.getCastSession import com.lagradost.cloudstream3.CommonActivity.showToast +import com.lagradost.cloudstream3.DubStatus +import com.lagradost.cloudstream3.EpisodeResponse +import com.lagradost.cloudstream3.IDownloadableMinimum +import com.lagradost.cloudstream3.LiveStreamLoadResponse +import com.lagradost.cloudstream3.LoadResponse import com.lagradost.cloudstream3.LoadResponse.Companion.addTrailer import com.lagradost.cloudstream3.LoadResponse.Companion.getAniListId +import com.lagradost.cloudstream3.LoadResponse.Companion.getKitsuId import com.lagradost.cloudstream3.LoadResponse.Companion.getMalId import com.lagradost.cloudstream3.LoadResponse.Companion.isMovie import com.lagradost.cloudstream3.LoadResponse.Companion.readIdFromString +import com.lagradost.cloudstream3.MainActivity +import com.lagradost.cloudstream3.MovieLoadResponse +import com.lagradost.cloudstream3.ProviderType +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.Score +import com.lagradost.cloudstream3.SearchResponse +import com.lagradost.cloudstream3.SeasonData +import com.lagradost.cloudstream3.ShowStatus +import com.lagradost.cloudstream3.SimklSyncServices +import com.lagradost.cloudstream3.SubtitleFile +import com.lagradost.cloudstream3.TorrentLoadResponse +import com.lagradost.cloudstream3.TrackerType +import com.lagradost.cloudstream3.TrailerData +import com.lagradost.cloudstream3.TvSeriesLoadResponse +import com.lagradost.cloudstream3.TvType +import com.lagradost.cloudstream3.VPNStatus import com.lagradost.cloudstream3.actions.AlwaysAskAction import com.lagradost.cloudstream3.actions.VideoClickActionHolder +import com.lagradost.cloudstream3.amap +import com.lagradost.cloudstream3.isEpisodeBased +import com.lagradost.cloudstream3.isLiveStream import com.lagradost.cloudstream3.metaproviders.SyncRedirector -import com.lagradost.cloudstream3.mvvm.* +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.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 @@ -44,8 +79,6 @@ 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 @@ -55,7 +88,13 @@ 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 @@ -81,8 +120,30 @@ 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 kotlinx.coroutines.* +import com.lagradost.cloudstream3.utils.UiText +import com.lagradost.cloudstream3.utils.VIDEO_WATCH_STATE +import com.lagradost.cloudstream3.utils.downloader.DownloadFileManagement.sanitizeFilename +import com.lagradost.cloudstream3.utils.downloader.DownloadObjects +import com.lagradost.cloudstream3.utils.downloader.DownloadQueueManager +import com.lagradost.cloudstream3.utils.downloader.DownloadUtils.downloadSubtitle +import com.lagradost.cloudstream3.utils.loadExtractor +import com.lagradost.cloudstream3.utils.newExtractorLink +import com.lagradost.cloudstream3.utils.txt +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.cancelChildren +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.isActive +import kotlinx.coroutines.job +import kotlinx.coroutines.launch import java.util.concurrent.TimeUnit /** This starts at 1 */ @@ -115,6 +176,7 @@ data class ResultData( val posterImage: String?, val posterBackgroundImage: String?, + val logoUrl: String?, val plotText: UiText, val apiName: UiText, val ratingText: UiText?, @@ -240,6 +302,7 @@ fun LoadResponse.toResultData(repo: APIRepository): ResultData { plot!! ), backgroundPosterUrl = backgroundPosterUrl, + logoUrl = logoUrl, title = name, typeText = txt( when (type) { @@ -255,17 +318,18 @@ fun LoadResponse.toResultData(repo: APIRepository): ResultData { TvType.Live -> R.string.live_singular TvType.Others -> R.string.other_singular TvType.NSFW -> R.string.nsfw_singular - TvType.Music -> R.string.music_singlar + TvType.Music -> R.string.music_singular TvType.AudioBook -> R.string.audio_book_singular - TvType.CustomMedia -> R.string.custom_media_singluar - TvType.Audio -> R.string.audio_singluar - TvType.Podcast -> R.string.podcast_singluar + TvType.CustomMedia -> R.string.custom_media_singular + TvType.Audio -> R.string.audio_singular + TvType.Podcast -> R.string.podcast_singular + TvType.Video -> R.string.video_singular } ), yearText = txt(year?.toString()), apiName = txt(apiName), - ratingText = rating?.div(1000f) - ?.let { if (it <= 0.1f) null else txt(R.string.rating_format, it) }, + ratingText = score?.toStringNull(0.1, 10, 1, false, '.') + ?.let { txt(R.string.rating_format, it) }, contentRatingText = txt(contentRating), vpnText = txt( when (repo.vpnStatus) { @@ -333,6 +397,7 @@ data class ResumeWatchingStatus( data class LinkLoadingResult( val links: List, val subs: List, + val syncData: HashMap ) sealed class SelectPopup { @@ -383,7 +448,7 @@ fun SelectPopup.getOptions(context: Context): List { } data class ExtractedTrailerData( - var mirros: List, + var mirros: List>,//Pair of extracted trailer link and original trailer link var subtitles: List = emptyList(), ) @@ -413,8 +478,8 @@ class ResultViewModel2 : ViewModel() { private var currentShowFillers: Boolean = false var currentRepo: APIRepository? = null private var currentId: Int? = null - private var fillers: Map = emptyMap() - private var generator: IGenerator? = null + private var fillers: HashSet = hashSetOf() + private var generator: RepoLinkGenerator? = null private var preferDubStatus: DubStatus? = null private var preferStartEpisode: Int? = null private var preferStartSeason: Int? = null @@ -518,6 +583,29 @@ 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 { @@ -636,226 +724,6 @@ 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, ""), - 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, - rating = episode.rating, - 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, "") }.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) @@ -925,7 +793,7 @@ class ResultViewModel2 : ViewModel() { response.syncData, plot = response.plot, tags = response.tags, - rating = response.rating + score = response.score ) ) } @@ -1022,7 +890,7 @@ class ResultViewModel2 : ViewModel() { response.year, response.syncData, plot = response.plot, - rating = response.rating, + score = response.score, tags = response.tags ) ) @@ -1034,6 +902,28 @@ 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. * @@ -1093,7 +983,7 @@ class ResultViewModel2 : ViewModel() { response.year, response.syncData, plot = response.plot, - rating = response.rating, + score = response.score, tags = response.tags ) ) @@ -1337,7 +1227,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 = AcraApplication.context + //val context = CloudStreamApp.context postPopup( text, links.links.map { txt("${it.name} ${Qualities.getStringByInt(it.quality)}") } @@ -1391,7 +1281,7 @@ class ResultViewModel2 : ViewModel() { updatePage() tempGenerator.generateLinks( clearCache, - allowedTypes = sourceTypes, + sourceTypes = sourceTypes, callback = { (link, _) -> if (link != null) { links += link @@ -1402,9 +1292,10 @@ class ResultViewModel2 : ViewModel() { subs += sub updatePage() }, - isCasting = isCasting + isCasting = isCasting, + offset = 0 ) - } catch (e : CancellationException) { + } catch (_: CancellationException) { // Do nothing } catch (e: Exception) { logError(e) @@ -1412,7 +1303,11 @@ class ResultViewModel2 : ViewModel() { _loadedLinks.postValue(null) } - return LinkLoadingResult(sortUrls(links), sortSubs(subs)) + return LinkLoadingResult( + sortUrls(links), + sortSubs(subs), + HashMap(currentResponse?.syncData ?: emptyMap()) + ) } fun handleAction(click: EpisodeClickEvent) = @@ -1424,6 +1319,40 @@ class ResultViewModel2 : ViewModel() { _episodeSynopsis.postValue(null) } + private fun markEpisodes( + editor: Editor, + episodeIds: Array, + watchState: VideoWatchState + ) { + val watchStateString = DataStore.mapper.writeValueAsString(watchState) + 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 -> { @@ -1439,7 +1368,6 @@ 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, @@ -1461,9 +1389,14 @@ 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( @@ -1537,16 +1470,17 @@ class ResultViewModel2 : ViewModel() { ACTION_DOWNLOAD_EPISODE -> { val response = currentResponse ?: return - downloadEpisode( - activity, - click.data, - response.isMovie(), - response.name, - response.type, - response.posterUrl, - response.apiName, - response.getId(), - response.url + DownloadQueueManager.addToQueue( + DownloadObjects.DownloadQueueItem( + click.data, + response.isMovie(), + response.name, + response.type, + response.posterUrl, + response.apiName, + response.getId(), + response.url, + ).toWrapper() ) } @@ -1557,9 +1491,8 @@ class ResultViewModel2 : ViewModel() { LOADTYPE_INAPP_DOWNLOAD, txt(R.string.episode_action_download_mirror) ) { (result, index) -> - ioSafe { - startDownload( - activity, + DownloadQueueManager.addToQueue( + DownloadObjects.DownloadQueueItem( click.data, response.isMovie(), response.name, @@ -1570,8 +1503,8 @@ class ResultViewModel2 : ViewModel() { response.url, listOf(result.links[index]), result.subs, - ) - } + ).toWrapper() + ) showToast( R.string.download_started, Toast.LENGTH_SHORT @@ -1610,28 +1543,25 @@ class ResultViewModel2 : ViewModel() { } ACTION_PLAY_EPISODE_IN_PLAYER -> { - 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) - } - } + 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 } + if (currentResponse?.type == TvType.CustomMedia) { - generator?.generateLinks( + generator.generateLinks( + offset = index, clearCache = true, - LOADTYPE_ALL, + isCasting = false, + sourceTypes = LOADTYPE_ALL, callback = {}, subtitleCallback = {}) } else { activity?.navigate( R.id.global_to_navigation_player, GeneratorPlayer.newInstance( - generator ?: return, list + generator, index,list ) ) } @@ -1640,17 +1570,37 @@ 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 @@ -1660,13 +1610,14 @@ class ResultViewModel2 : ViewModel() { // Show player selection dialog val players = VideoClickActionHolder.getPlayers(ctx) val options = mutableListOf>() - + // Add internal player option options.add(txt(R.string.episode_action_play_in_app) to ACTION_PLAY_EPISODE_IN_PLAYER) - + // Add external player options options.addAll(players.filter { it !is AlwaysAskAction }.map { player -> - player.name to (VideoClickActionHolder.uniqueIdToId(player.uniqueId()) ?: ACTION_PLAY_EPISODE_IN_PLAYER) + player.name to (VideoClickActionHolder.uniqueIdToId(player.uniqueId()) + ?: ACTION_PLAY_EPISODE_IN_PLAYER) }) postPopup( @@ -1723,7 +1674,7 @@ class ResultViewModel2 : ViewModel() { if (meta != null) { duration = duration ?: meta.duration - rating = rating ?: meta.publicScore + score = score ?: meta.publicScore tags = tags ?: meta.genres plot = if (plot.isNullOrBlank()) meta.synopsis else plot posterUrl = posterUrl ?: meta.posterUrl ?: meta.backgroundPosterUrl @@ -1760,7 +1711,7 @@ class ResultViewModel2 : ViewModel() { { if (this !is AnimeLoadResponse) return@runAllAsync // already exist, no need to run getTracker - if (this.getAniListId() != null && this.getMalId() != null) return@runAllAsync + if (this.getAniListId() != null && this.getKitsuId() != null && this.getMalId() != null) return@runAllAsync val res = APIHolder.getTracker( listOfNotNull( @@ -1778,9 +1729,12 @@ 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.aniListApi.idPrefix to res?.aniId, + AccountManager.kitsuApi.idPrefix to kitsuId ) if (ids.any { (id, new) -> @@ -1802,6 +1756,7 @@ 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 @@ -1876,11 +1831,10 @@ class ResultViewModel2 : ViewModel() { } - private suspend fun updateFillers(name: String) { - fillers = - ioWorkSafe { - FillerEpisodeCheck.getFillerEpisodes(name) - } ?: emptyMap() + private suspend fun updateFillers(data: LoadResponse) { + fillers = ioWorkSafe { + FillerEpisodeCheck.getFillerEpisodes(data) + } ?: hashSetOf() } fun changeDubStatus(status: DubStatus) { @@ -1942,8 +1896,11 @@ 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.rating ?: 0 } - EpisodeSortType.RATING_LOW_HIGH -> episodes.sortedBy { it.rating ?: 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 } } @@ -1961,6 +1918,7 @@ 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 @@ -2022,7 +1980,7 @@ class ResultViewModel2 : ViewModel() { return when (type) { EpisodeSortType.NUMBER_ASC, EpisodeSortType.NUMBER_DESC -> true EpisodeSortType.RATING_HIGH_LOW, EpisodeSortType.RATING_LOW_HIGH -> - episodes.any { it.rating != null } + episodes.any { it.score != null } EpisodeSortType.DATE_NEWEST, EpisodeSortType.DATE_OLDEST -> episodes.any { it.airDate != null } @@ -2076,29 +2034,8 @@ class ResultViewModel2 : ViewModel() { ) _selectedSeason.postValue( - if (isMovie || currentSeasons.size <= 1) null else - 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 - ) - } - } - } - + (currentResponse as? EpisodeResponse)?.seasonNames.getSeasonTxt(indexer.season) ) _selectedRangeIndex.postValue( @@ -2234,8 +2171,8 @@ class ResultViewModel2 : ViewModel() { ) { _episodes.postValue(Resource.Loading()) - if (updateFillers && loadResponse is AnimeLoadResponse) { - updateFillers(loadResponse.name) + if (updateFillers) { + updateFillers(loadResponse) } val allEpisodes = when (loadResponse) { @@ -2268,20 +2205,21 @@ class ResultViewModel2 : ViewModel() { filterName(i.name), i.posterUrl, episode, - seasonData?.season ?: i.season, + i.season, if (seasonData != null) seasonData.displaySeason else i.season, i.data, loadResponse.apiName, id, index, - i.rating, + i.score, i.description, - fillers.getOrDefault(episode, false), + fillers.contains(episode), loadResponse.type, mainId, totalIndex, airDate = i.date, runTime = i.runTime, + seasonData = seasonData, ) val season = eps.seasonIndex ?: 0 @@ -2324,13 +2262,13 @@ class ResultViewModel2 : ViewModel() { filterName(episode.name), episode.posterUrl, episodeIndex, - seasonData?.season ?: episode.season, + episode.season, if (seasonData != null) seasonData.displaySeason else episode.season, episode.data, loadResponse.apiName, id, index, - episode.rating, + episode.score, episode.description, null, loadResponse.type, @@ -2338,6 +2276,7 @@ class ResultViewModel2 : ViewModel() { totalIndex, airDate = episode.date, runTime = episode.runTime, + seasonData = seasonData, ) val season = ep.seasonIndex ?: 0 @@ -2436,21 +2375,7 @@ class ResultViewModel2 : ViewModel() { _dubSubSelections.postValue(dubSelection.map { txt(it) to it }) if (loadResponse is EpisodeResponse) { _seasonSelections.postValue(seasonsSelection.map { 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 + loadResponse.seasonNames.getSeasonTxt(seasonNumber) to seasonNumber }) } @@ -2528,25 +2453,34 @@ class ResultViewModel2 : ViewModel() { loadResponse.trailers.windowed(limit, limit, true).takeWhile { list -> list.amap { trailerData -> try { - val links = arrayListOf() + val links = arrayListOf>() val subs = arrayListOf() if (!loadExtractor( trailerData.extractorUrl, trailerData.referer, { subs.add(it) }, - { links.add(it) }) && trailerData.raw + { + links.add( + Pair( + it, + trailerData.extractorUrl + ) + ) + }) && trailerData.raw ) { arrayListOf( - newExtractorLink( - "", - "Trailer", - trailerData.extractorUrl, - type = INFER_TYPE - ) { - this.referer = trailerData.referer ?: "" - this.quality = Qualities.Unknown.value - this.headers = trailerData.headers - } + Pair( + newExtractorLink( + "", + "Trailer", + trailerData.extractorUrl, + type = INFER_TYPE + ) { + this.referer = trailerData.referer ?: "" + this.quality = Qualities.Unknown.value + this.headers = trailerData.headers + }, trailerData.extractorUrl + ) ) to arrayListOf() } else { links to subs @@ -2620,7 +2554,7 @@ class ResultViewModel2 : ViewModel() { override var posterUrl: String?, override var year: Int? = null, override var plot: String? = null, - override var rating: Int? = null, + override var score: Score? = null, override var tags: List? = null, override var duration: Int? = null, override var trailers: MutableList = mutableListOf(), @@ -2630,6 +2564,7 @@ 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?, @@ -2654,12 +2589,12 @@ class ResultViewModel2 : ViewModel() { ).apply { if (searchResponse is SyncAPI.LibraryItem) { this.plot = searchResponse.plot - this.rating = searchResponse.personalRating?.times(100) ?: searchResponse.rating + this.score = searchResponse.personalRating ?: searchResponse.score this.tags = searchResponse.tags } if (searchResponse is DataStoreHelper.BookmarkedData) { this.plot = searchResponse.plot - this.rating = searchResponse.rating + this.score = searchResponse.score this.tags = searchResponse.tags } } @@ -2743,7 +2678,7 @@ class ResultViewModel2 : ViewModel() { setKey( DOWNLOAD_HEADER_CACHE, mainId.toString(), - VideoDownloadHelper.DownloadHeaderCached( + DownloadObjects.DownloadHeaderCached( apiName = apiName, url = validUrl, type = loadResponse.type, 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 ad5d89d18e5..4231819dd25 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,10 +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.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 @@ -13,93 +14,56 @@ import com.lagradost.cloudstream3.utils.setText typealias SelectData = Pair -class SelectAdaptor(val callback: (Any) -> Unit) : RecyclerView.Adapter() { - private val selection: MutableList = mutableListOf() +class SelectAdaptor(val callback: (Any) -> Unit) : + NoStateAdapter(diffCallback = BaseDiffCallback(itemSame = { a, b -> + a.second == b.second + }, contentSame = { a, b -> + a == b + })) { private var selectedIndex: Int = -1 - 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 onCreateContent(parent: ViewGroup): ViewHolderState { + return ViewHolderState( + ResultSelectionBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false + ) ) } - override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { - when (holder) { - is SelectViewHolder -> { - holder.bind(selection[position], position == selectedIndex, callback) + 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 onViewDetachedFromWindow(holder: RecyclerView.ViewHolder) { - if(holder.itemView.hasFocus()) { + override fun onViewDetachedFromWindow(holder: ViewHolderState) { + 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 c888e717bb5..6c5c64ff8bc 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 @@ -4,11 +4,14 @@ import android.util.Log import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel +import com.lagradost.cloudstream3.Score import com.lagradost.cloudstream3.amap import com.lagradost.cloudstream3.mvvm.Resource import com.lagradost.cloudstream3.mvvm.logError -import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.SyncApis +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 @@ -31,7 +34,7 @@ class SyncViewModel : ViewModel() { const val TAG = "SYNCVM" } - private val repos = SyncApis + private val repos = AccountManager.syncApis private val _metaResponse: MutableLiveData?> = MutableLiveData(null) @@ -65,7 +68,7 @@ class SyncViewModel : ViewModel() { it.name, it.idPrefix, syncs.containsKey(it.idPrefix), - it.hasAccount(), + it.authUser() != null, it.icon, ) } @@ -157,7 +160,7 @@ class SyncViewModel : ViewModel() { } } - fun setScore(score: Int) { + fun setScore(score: Score?) { Log.i(TAG, "setScore = $score") val user = userData.value if (user is Resource.Success) { @@ -181,7 +184,7 @@ class SyncViewModel : ViewModel() { val user = userData.value if (user is Resource.Success) { syncs.forEach { (prefix, id) -> - repos.firstOrNull { it.idPrefix == prefix }?.score(id, user.value) + repos.firstOrNull { it.idPrefix == prefix }?.updateStatus(id, user.value) } } updateUserData() @@ -203,17 +206,10 @@ class SyncViewModel : ViewModel() { ioSafe { syncs.amap { (prefix, id) -> repos.firstOrNull { it.idPrefix == prefix }?.let { repo -> - if (repo.hasAccount()) { - val result = repo.getStatus(id) - if (result is Resource.Success) { - update(result.value)?.let { newData -> - Log.i(TAG, "modifyData ${repo.name} => $newData") - repo.score(id, newData) - } - } else if (result is Resource.Failure) { - Log.e(TAG, "modifyData getStatus error ${result.errorString}") - } - } + val result = + update(repo.status(id).getOrNull() ?: return@let null) ?: return@let null + Log.i(TAG, "modifyData ${repo.name} => $result") + repo.updateStatus(id, result) } } } @@ -221,22 +217,17 @@ class SyncViewModel : ViewModel() { fun updateUserData() = ioSafe { Log.i(TAG, "updateUserData") _userDataResponse.postValue(Resource.Loading()) - var lastError: Resource = Resource.Failure(false, "No data") - syncs.forEach { (prefix, id) -> - repos.firstOrNull { it.idPrefix == prefix }?.let { repo -> - if (repo.hasAccount()) { - val result = repo.getStatus(id) - if (result is Resource.Success) { - _userDataResponse.postValue(result) - return@ioSafe - } else if (result is Resource.Failure) { - Log.e(TAG, "updateUserData error ${result.errorString}") - lastError = result - } - } - } + + val status = syncs.firstNotNullOfOrNull { (prefix, id) -> + repos.firstOrNull { it.idPrefix == prefix } + ?.status(id)?.getOrNull() + } + + if (status == null) { + _userDataResponse.postValue(Resource.Failure(false, "No data")) + } else { + _userDataResponse.postValue(Resource.Success(status)) } - _userDataResponse.postValue(lastError) } private fun updateMetadata() = ioSafe { @@ -261,19 +252,20 @@ class SyncViewModel : ViewModel() { current.forEach { (prefix, id) -> repos.firstOrNull { it.idPrefix == prefix }?.let { repo -> - if (!repo.requiresLogin || repo.hasAccount()) { - Log.i(TAG, "updateMetadata loading ${repo.idPrefix}") - val result = repo.getResult(id) - if (result is Resource.Success) { - _metaResponse.postValue(result) - return@ioSafe - } else if (result is Resource.Failure) { - Log.e( - TAG, - "updateMetadata error $id at ${repo.idPrefix} ${result.errorString}" - ) - lastError = result - } + Log.i(TAG, "updateMetadata loading ${repo.idPrefix}") + val result = repo.load(id) + val resultValue = result.getOrNull() + val resultError = result.exceptionOrNull() + if (resultValue != null) { + _metaResponse.postValue(Resource.Success(resultValue)) + return@ioSafe + } else if (resultError != null) { + + /*Log.e( + TAG, + "updateMetadata error $id at ${repo.idPrefix} ${result.errorString}" + )*/ + lastError = throwAbleToResource(resultError) } } } @@ -285,6 +277,7 @@ 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 f318401c092..7b63b6edec6 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,13 +31,28 @@ class SearchClickCallback( ) class SearchAdapter( - private val cardList: MutableList, private val resView: AutofitRecyclerView, + private val isHorizontal:Boolean = false, private val clickCallback: (SearchClickCallback) -> Unit, -) : RecyclerView.Adapter() { +) : 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) } + } + var hasNext: Boolean = false - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { + 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 { val inflater = LayoutInflater.from(parent.context) val layout = @@ -49,84 +64,36 @@ class SearchAdapter( inflater, parent, false - ) //R.layout.search_result_grid_expanded else R.layout.search_result_grid - - - - return CardViewHolder( - layout, - clickCallback, - resView - ) + ) + return ViewHolderState(layout) } - override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { - when (holder) { - is CardViewHolder -> { - holder.bind(cardList[position], position) + override fun onClearView(holder: ViewHolderState) { + clearImage( + when (val binding = holder.view) { + is SearchResultGridExpandedBinding -> binding.imageView + is SearchResultGridBinding -> binding.imageView + else -> null } - } - } - - 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) { + override fun onBindContent(holder: ViewHolderState, item: SearchResponse, position: Int) { + val imageView = when (val binding = holder.view) { is SearchResultGridExpandedBinding -> binding.imageView is SearchResultGridBinding -> binding.imageView else -> null } - fun bind(card: SearchResponse, position: Int) { - if (!compactView) { - cardView?.apply { - layoutParams = FrameLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - coverHeight - ) - } + 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 } - - 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 1922e4fae92..5f5b064b543 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,11 +3,9 @@ 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 @@ -20,20 +18,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.RecyclerView +import androidx.recyclerview.widget.LinearLayoutManager import androidx.activity.result.contract.ActivityResultContracts +import androidx.core.view.doOnLayout import com.google.android.material.bottomsheet.BottomSheetBehavior import com.google.android.material.bottomsheet.BottomSheetDialog import com.google.android.material.button.MaterialButton 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 @@ -49,16 +47,21 @@ 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 @@ -67,18 +70,23 @@ 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.fixPaddingStatusbar +import com.lagradost.cloudstream3.utils.UIHelper.fixSystemBarsPadding 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 : Fragment() { +class SearchFragment : BaseFragment( + BaseFragment.BindingCreator.Bind(FragmentSearchBinding::bind) +) { companion object { fun List.filterSearchResponse(): List { return this.filter { response -> @@ -97,14 +105,13 @@ class SearchFragment : Fragment() { 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 -> @@ -118,6 +125,9 @@ class SearchFragment : Fragment() { } } + 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?, @@ -127,37 +137,13 @@ class SearchFragment : Fragment() { WindowManager.LayoutParams.SOFT_INPUT_STATE_VISIBLE ) bottomSheetDialog?.ownShow() - - - 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() + return super.onCreateView(inflater, container, savedInstanceState) } override fun onDestroyView() { hideKeyboard() bottomSheetDialog?.ownHide() - binding = null + activity?.detachBackPressedCallback("SearchFragment") super.onDestroyView() } @@ -182,7 +168,7 @@ class SearchFragment : Fragment() { fun search(query: String?) { if (query == null) return // don't resume state from prev search - (binding?.searchMasterRecycler?.adapter as? BaseAdapter<*,*>)?.clear() + (binding?.searchMasterRecycler?.adapter as? BaseAdapter<*, *>)?.clearState() context?.let { ctx -> val default = enumValues().sorted().filter { it != TvType.NSFW } .map { it.ordinal.toString() }.toSet() @@ -231,42 +217,59 @@ class SearchFragment : Fragment() { } } + override fun fixLayout(view: View) { + fixSystemBarsPadding( + view, + padBottom = isLandscape(), + padLeft = isLayout(TV or EMULATOR) + ) - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) + // Fix grid + currentSpan = view.context.getSpanCount() + binding?.searchAutofitResults?.spanCount = currentSpan + HomeFragment.configEvent.invoke() + } - fixPaddingStatusbar(binding?.searchRoot) - fixGrid() + override fun onBindingCreated( + binding: FragmentSearchBinding, + savedInstanceState: Bundle? + ) { reloadRepos() - - binding?.apply { - val adapter: RecyclerView.Adapter = + binding.apply { + val adapter = SearchAdapter( - ArrayList(), searchAutofitResults, ) { callback -> SearchHelper.handleSearchClickCallback(callback) } - searchRoot.findViewById(androidx.appcompat.R.id.search_src_text)?.tag = "tv_no_focus_tag" + searchRoot.findViewById(androidx.appcompat.R.id.search_src_text)?.tag = + "tv_no_focus_tag" + searchAutofitResults.setRecycledViewPool(SearchAdapter.sharedPool) 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) } @@ -274,21 +277,11 @@ class SearchFragment : Fragment() { } val searchExitIcon = - 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) + binding.mainSearch.findViewById(androidx.appcompat.R.id.search_close_btn) 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() @@ -300,11 +293,12 @@ class SearchFragment : Fragment() { 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 -> @@ -373,7 +367,10 @@ class SearchFragment : Fragment() { if (selectedSearchTypes.toSet() != list.toSet()) { selectedSearchTypes.clear() selectedSearchTypes.addAll(list) - updateChips(binding?.tvtypesChipsScroll?.tvtypesChips, selectedSearchTypes) + updateChips( + binding.tvtypesChipsScroll.tvtypesChips, + selectedSearchTypes + ) } } @@ -399,8 +396,8 @@ class SearchFragment : Fragment() { 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()) @@ -410,19 +407,31 @@ class SearchFragment : Fragment() { 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(TV)) { - binding?.searchFilter?.isFocusable = true - binding?.searchFilter?.isFocusableInTouchMode = true + if (!isLayout(PHONE)) { + 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) } @@ -435,76 +444,49 @@ class SearchFragment : Fragment() { if (showHistory) { searchViewModel.clearSearch() searchViewModel.updateHistory() + searchViewModel.clearSuggestions() + } else { + // Fetch suggestions when user is typing (if enabled) + if (isSearchSuggestionsEnabled) { + searchViewModel.fetchSuggestions(newText) + } } - binding?.apply { - searchHistoryHolder.isVisible = showHistory + binding.apply { + searchHistoryRecycler.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 -> - if (data.isNotEmpty()) { - (binding?.searchAutofitResults?.adapter as? SearchAdapter)?.updateList(data) + val list = data.list + if (list.isNotEmpty()) { + (binding.searchAutofitResults.adapter as? SearchAdapter)?.submitList( + list + ) } } 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 } } } @@ -514,20 +496,33 @@ class SearchFragment : Fragment() { try { // https://stackoverflow.com/questions/6866238/concurrent-modification-exception-adding-to-an-arraylist listLock.lock() - (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 pinnedOrder = DataStoreHelper.pinnedProviders.reversedArray() + + val sortedList = list.toList().sortedWith(compareBy { (providerName, _) -> + val index = pinnedOrder.indexOf(providerName) + if (index == -1) Int.MAX_VALUE else index + }) + + (binding.searchMasterRecycler.adapter as? ParentItemAdapter)?.apply { + val newItems = sortedList.map { (providerName, providerData) -> + val dataList = providerData.list val dataListFiltered = context?.filterSearchResultByFilmQuality(dataList) ?: dataList - val ongoingList = HomePageList( - ongoing.apiName, + + val homePageList = HomePageList( + providerName, dataListFiltered ) - ongoingList + + HomeViewModel.ExpandableHomepageList( + homePageList, + providerData.currentPage, + providerData.hasNext + ) } - updateList(newItems) + submitList(newItems) //notifyDataSetChanged() } } catch (e: Exception) { @@ -547,52 +542,123 @@ class SearchFragment : Fragment() { //main_search.onActionViewExpanded()*/ val masterAdapter = - ParentItemAdapter(fragment = this, id = "masterAdapter".hashCode(), { callback -> + ParentItemAdapter(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(mutableListOf()) { click -> + val historyAdapter = SearchHistoryAdaptor { 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??? } } } - binding?.apply { + val suggestionAdapter = SearchSuggestionAdapter { callback -> + when (callback.clickAction) { + SEARCH_SUGGESTION_CLICK -> { + // Search directly + binding.mainSearch.setQuery(callback.suggestion, true) + searchViewModel.clearSuggestions() + } + SEARCH_SUGGESTION_FILL -> { + // Fill the search box without searching + binding.mainSearch.setQuery(callback.suggestion, false) + } + SEARCH_SUGGESTION_CLEAR -> { + // Clear suggestions (from footer button) + searchViewModel.clearSuggestions() + } + } + } + + binding.apply { searchHistoryRecycler.adapter = historyAdapter searchHistoryRecycler.setLinearListLayout(isHorizontal = false, nextRight = FOCUS_SELF) //searchHistoryRecycler.layoutManager = GridLayoutManager(context, 1) + // Setup suggestions RecyclerView + searchSuggestionsRecycler.adapter = suggestionAdapter + searchSuggestionsRecycler.layoutManager = LinearLayoutManager(context) + + searchMasterRecycler.setRecycledViewPool(ParentItemAdapter.sharedPool) searchMasterRecycler.adapter = masterAdapter //searchMasterRecycler.setLinearListLayout(isHorizontal = false, nextRight = FOCUS_SELF) 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 - mainSearch.setQuery(query, true) + + // Queries are dropped if you are submitted before layout finishes + mainSearch.doOnLayout { + 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) @@ -600,18 +666,37 @@ class SearchFragment : Fragment() { } } + 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) + } + } - // 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()*/ - } + // Observe search suggestions + observe(searchViewModel.searchSuggestions) { suggestions -> + val hasSuggestions = suggestions.isNotEmpty() + binding.searchSuggestionsRecycler.isVisible = hasSuggestions + (binding.searchSuggestionsRecycler.adapter as? SearchSuggestionAdapter?)?.submitList(suggestions) + + // On non-phone layouts, redirect focus and handle back button + if (!isLayout(PHONE)) { + if (hasSuggestions) { + binding.tvtypesChipsScroll.tvtypesChips.root.nextFocusDownId = R.id.search_suggestions_recycler + // Attach back button callback to clear suggestions + activity?.attachBackPressedCallback("SearchFragment") { + searchViewModel.clearSuggestions() + } + } else { + // Reset to default focus target (history) + binding.tvtypesChipsScroll.tvtypesChips.root.nextFocusDownId = R.id.search_history_recycler + // Detach back button callback when no suggestions + activity?.detachBackPressedCallback("SearchFragment") + } + } + } + searchViewModel.updateHistory() + } } diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchHelper.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchHelper.kt index 01ec17b63f4..449a04bf81f 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.VideoDownloadHelper +import com.lagradost.cloudstream3.utils.downloader.DownloadObjects object SearchHelper { fun handleSearchClickCallback(callback: SearchClickCallback) { @@ -31,14 +31,14 @@ object SearchHelper { handleDownloadClick( DownloadClickEvent( DOWNLOAD_ACTION_PLAY_FILE, - VideoDownloadHelper.DownloadEpisodeCached( + DownloadObjects.DownloadEpisodeCached( name = card.name, poster = card.posterUrl, episode = card.episode ?: 0, season = card.season, id = id, parentId = card.parentId ?: return, - rating = null, + score = null, description = null, cacheTime = System.currentTimeMillis(), ) 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 4ef5fa698b2..4868abb3d08 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,11 +2,17 @@ package com.lagradost.cloudstream3.ui.search import android.view.LayoutInflater import android.view.ViewGroup -import androidx.recyclerview.widget.DiffUtil -import androidx.recyclerview.widget.RecyclerView +import androidx.core.view.isGone import com.fasterxml.jackson.annotation.JsonProperty import com.lagradost.cloudstream3.TvType +import com.lagradost.cloudstream3.databinding.SearchHistoryFooterBinding import com.lagradost.cloudstream3.databinding.SearchHistoryItemBinding +import com.lagradost.cloudstream3.ui.BaseDiffCallback +import com.lagradost.cloudstream3.ui.NoStateAdapter +import com.lagradost.cloudstream3.ui.ViewHolderState +import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR +import com.lagradost.cloudstream3.ui.settings.Globals.TV +import com.lagradost.cloudstream3.ui.settings.Globals.isLayout data class SearchHistoryItem( @JsonProperty("searchedAt") val searchedAt: Long, @@ -16,84 +22,73 @@ 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, -) : RecyclerView.Adapter() { - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { - return CardViewHolder( +) : NoStateAdapter(diffCallback = BaseDiffCallback(itemSame = { a,b -> + a.searchedAt == b.searchedAt && a.searchText == b.searchText +})) { + + // Add footer for all layouts + override val footers = 1 + + override fun submitList(list: Collection?, commitCallback: Runnable?) { + super.submitList(list, commitCallback) + // Notify footer to rebind when list changes to update visibility + if (footers > 0) { + notifyItemChanged(itemCount - 1) + } + } + + override fun onCreateContent(parent: ViewGroup): ViewHolderState { + return ViewHolderState( SearchHistoryItemBinding.inflate(LayoutInflater.from(parent.context), parent, false), - clickCallback, ) } - override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { - when (holder) { - is CardViewHolder -> { - holder.bind(cardList[position]) + 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 getItemCount(): Int { - return cardList.size - } - - fun updateList(newList: List) { - val diffResult = DiffUtil.calculateDiff( - SearchHistoryDiffCallback(this.cardList, newList) + + override fun onCreateFooter(parent: ViewGroup): ViewHolderState { + return ViewHolderState( + SearchHistoryFooterBinding.inflate(LayoutInflater.from(parent.context), parent, false) ) - - 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)) - } + + 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)) } } } } - -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 a0e9113320d..fd99b8d4b06 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,6 +2,7 @@ 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 @@ -10,8 +11,6 @@ import androidx.cardview.widget.CardView import androidx.core.view.isVisible import androidx.palette.graphics.Palette import androidx.preference.PreferenceManager -import coil3.request.error -import coil3.toBitmap import com.lagradost.cloudstream3.AnimeSearchResponse import com.lagradost.cloudstream3.DubStatus import com.lagradost.cloudstream3.LiveSearchResponse @@ -23,11 +22,12 @@ 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.createPaletteAsync +import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute import com.lagradost.cloudstream3.utils.getImageFromDrawable object SearchResultBuilder { @@ -50,7 +50,7 @@ object SearchResultBuilder { itemView: View, nextFocusUp: Int? = null, nextFocusDown: Int? = null, - colorCallback : ((Palette) -> Unit)? = null + colorCallback: ((Palette) -> Unit)? = null ) { val cardView: ImageView = itemView.findViewById(R.id.imageView) val cardText: TextView? = itemView.findViewById(R.id.imageText) @@ -67,6 +67,7 @@ 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 @@ -76,20 +77,27 @@ 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 - - if(card is SyncAPI.LibraryItem) { - val showRating = (card.personalRating ?: 0) != 0 + val showRatingView = + showCache[textQuality?.context?.getString(R.string.show_rating_key)] ?: false + if (card is SyncAPI.LibraryItem) { + val ratingText = card.personalRating?.toStringNull(0.1, 10, 1) + val showRating = !ratingText.isNullOrBlank() + rating?.isVisible = showRating + if (showRating) { + rating?.text = ratingText + } + } else if (showRatingView) { + val ratingText = card.score?.toStringNull(0.1, 10, 1) + val showRating = !ratingText.isNullOrBlank() rating?.isVisible = showRating if (showRating) { - // We want to show 8.5 but not 8.0 hence the replace - val ratingText = ((card.personalRating ?: 0).toDouble() / 10).toString() - .replace(".0", "") - rating?.text = ratingText } } @@ -124,19 +132,12 @@ object SearchResultBuilder { cardText?.text = card.name cardText?.isVisible = showTitle cardView.isVisible = true - 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) - }) - }*/ - } - + 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) + fun click(view: View?) { clickCallback.invoke( SearchClickCallback( @@ -174,7 +175,7 @@ object SearchResultBuilder { bg.isFocusable = false bg.isFocusableInTouchMode = false - if(!isLayout(TV)) { + if (!isLayout(TV)) { bg.setOnClickListener { click(it) } @@ -184,7 +185,7 @@ object SearchResultBuilder { } } // - // + // // itemView.setOnClickListener { @@ -218,9 +219,9 @@ object SearchResultBuilder { */ if (isLayout(TV)) { - // bg.isFocusable = true - // bg.isFocusableInTouchMode = true - // bg.touchscreenBlocksFocus = false + // bg.isFocusable = true + // bg.isFocusableInTouchMode = true + // bg.touchscreenBlocksFocus = false itemView.isFocusableInTouchMode = true itemView.isFocusable = true } @@ -249,6 +250,7 @@ object SearchResultBuilder { } } } + is DataStoreHelper.ResumeWatchingResult -> { val pos = card.watchPos?.fixVisual() if (pos != null) { @@ -256,14 +258,15 @@ object SearchResultBuilder { bar?.progress = (pos.position / 1000).toInt() bar?.visibility = View.VISIBLE } - playImg?.visibility = View.VISIBLE - - if (card.type?.isMovieType() == false) { - cardText?.text = - cardText?.context?.getNameFull(card.name, card.episode, card.season) + if (card.type?.isMovieType() == false && showEpisodeText) { + episodeText?.context?.getShortSeasonText(card.episode, card.season)?.let {text-> + episodeText.text = text + episodeText.isVisible = true + } } } + is AnimeSearchResponse -> { val dubStatus = card.dubStatus if (!dubStatus.isNullOrEmpty()) { @@ -299,5 +302,29 @@ 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 new file mode 100644 index 00000000000..74d5e7b08a3 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchSuggestionAdapter.kt @@ -0,0 +1,85 @@ +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 new file mode 100644 index 00000000000..8dbd7817898 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchSuggestionApi.kt @@ -0,0 +1,74 @@ +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 839b9d3f838..27db8d1ae5e 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,46 +5,65 @@ import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.lagradost.cloudstream3.APIHolder.apis -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.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.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 OnGoingSearch( - val apiName: String, - val data: Resource> + +data class ExpandableSearchList( + var list: List, var currentPage: Int, var hasNext: Boolean, ) 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 = synchronized(apis) { apis.map { APIRepository(it) } } fun clearSearch() { - _searchResponse.postValue(Resource.Success(ArrayList())) - _currentSearch.postValue(emptyList()) + _searchResponse.postValue(Resource.Success(ExpandableSearchList(emptyList(), 0, false))) + _currentSearch.postValue(emptyMap()) + expandableSearches.clear() } + 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 @@ -63,15 +82,119 @@ class SearchViewModel : ViewModel() { onGoingSearch = search(query, providersActive, ignoreSettings, isQuickSearch) } - fun updateHistory() = viewModelScope.launch { - ioSafe { - val items = getKeys("$currentAccount/$SEARCH_HISTORY_KEY")?.mapNotNull { - getKey(it) - }?.sortedByDescending { it.searchedAt } ?: emptyList() - _currentHistory.postValue(items) + 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 + } + + 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( query: String, providersActive: Set, @@ -100,43 +223,30 @@ class SearchViewModel : ViewModel() { } _searchResponse.postValue(Resource.Loading()) + _currentSearch.postValue(emptyMap()) + expandableSearches.clear() - - _currentSearch.postValue(ArrayList()) + lastQuery = query 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) + val search = if (isQuickSearch) a.quickSearch(query) else a.search(query, 1) if (currentSearchIndex != currentIndex) return@amap - currentList.add(OnGoingSearch(a.name, search)) - _currentSearch.postValue(currentList) + if (search is Resource.Success) { + val searchValue = search.value + expandableSearches[a.name] = + ExpandableSearchList(searchValue.items, 1, searchValue.hasNext) + } + + _currentSearch.postValue(expandableSearches) } if (currentSearchIndex != currentIndex) return@withContext // this should prevent rewrite of existing data bug - _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++ - } + _currentSearch.postValue(expandableSearches) + val list = bundleSearch(expandableSearches) _searchResponse.postValue(Resource.Success(list)) } diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/search/SyncSearchViewModel.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/search/SyncSearchViewModel.kt index 71077e91f7c..938b870bb89 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/search/SyncSearchViewModel.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/search/SyncSearchViewModel.kt @@ -1,5 +1,6 @@ package com.lagradost.cloudstream3.ui.search +import com.lagradost.cloudstream3.Score import com.lagradost.cloudstream3.SearchQuality import com.lagradost.cloudstream3.SearchResponse import com.lagradost.cloudstream3.TvType @@ -15,5 +16,6 @@ class SyncSearchViewModel { override var id: Int?, override var quality: SearchQuality? = null, override var posterHeaders: Map? = null, + override var score: Score? = null, ) : SearchResponse } \ No newline at end of file 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 ba53f96f1fb..be8b4180c2b 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,65 +1,56 @@ 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.AuthAPI +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: AuthAPI.LoginInfo) +class AccountClickCallback(val action: Int, val view: View, val card: AuthData) class AccountAdapter( - private val cardList: List, private val clickCallback: (AccountClickCallback) -> Unit ) : - RecyclerView.Adapter() { - - 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 + NoStateAdapter( + diffCallback = BaseDiffCallback(itemSame = { a, b -> + a.user.id == b.user.id + }) + ) { + + override fun onCreateContent(parent: ViewGroup): ViewHolderState { + return ViewHolderState( + AccountSingleBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false + ) ) } - override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { - when (holder) { - is CardViewHolder -> { - holder.bind(cardList[position]) - } - } - } - - override fun getItemCount(): Int { - return cardList.size - } - - override fun getItemId(position: Int): Long { - return cardList[position].accountIndex.toLong() + override fun onClearView(holder: ViewHolderState) { + val binding = holder.view as? AccountSingleBinding ?: return + clearImage(binding.accountProfilePicture) } - class CardViewHolder(val binding: AccountSingleBinding?, private val clickCallback: (AccountClickCallback) -> Unit) : - RecyclerView.ViewHolder(binding?.root!!) { - - @SuppressLint("StringFormatInvalid") - fun bind(card: AuthAPI.LoginInfo) { - // just in case name is null account index will show, should never happened - binding?.apply { - accountName.text = card.name ?: "%s %d".format( - binding.accountName.context.getString(R.string.account), - card.accountIndex - ) - accountProfilePicture.isVisible = true - accountProfilePicture.loadImage(card.profilePicture) - - itemView.setOnClickListener { - clickCallback.invoke(AccountClickCallback(0, itemView, card)) - } + 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 + ) + + root.setOnClickListener { + clickCallback.invoke(AccountClickCallback(0, root, item)) } } } 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 aa513d87a64..93e469a4d64 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,6 +3,7 @@ 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 @@ -44,6 +45,11 @@ 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 7fcfefb7b0c..36599064683 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,25 +2,30 @@ 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( - 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) +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 + ) + ) } - override fun onBindViewHolder(holder: LogViewHolder, position: Int) { - holder.binding.logText.text = logs[position] + override fun onBindContent(holder: ViewHolderState, item: String, position: Int) { + (holder.view as? ItemLogcatBinding)?.apply { + logText.text = item + } } - - 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 ebcc61b67a5..8d96a6b140e 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsAccount.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsAccount.kt @@ -1,5 +1,6 @@ package com.lagradost.cloudstream3.ui.settings +import android.annotation.SuppressLint import android.graphics.Bitmap import android.os.Bundle import android.os.CountDownTimer @@ -13,31 +14,36 @@ 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.AcraApplication.Companion.openBrowser +import com.lagradost.cloudstream3.CloudStreamApp.Companion.openBrowser import com.lagradost.cloudstream3.CommonActivity.onDialogDismissedEvent import com.lagradost.cloudstream3.CommonActivity.showToast +import com.lagradost.cloudstream3.ErrorLoadingException import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.databinding.AccountManagmentBinding import com.lagradost.cloudstream3.databinding.AccountSwitchBinding import com.lagradost.cloudstream3.databinding.AddAccountInputBinding import com.lagradost.cloudstream3.databinding.DeviceAuthBinding import com.lagradost.cloudstream3.mvvm.logError -import com.lagradost.cloudstream3.syncproviders.AccountManager 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.AuthAPI -import com.lagradost.cloudstream3.syncproviders.InAppAuthAPI -import com.lagradost.cloudstream3.syncproviders.OAuth2API +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.PHONE import com.lagradost.cloudstream3.ui.settings.Globals.TV +import com.lagradost.cloudstream3.ui.settings.Globals.PHONE import com.lagradost.cloudstream3.ui.settings.Globals.isLayout import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.getPref import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.hideOn @@ -63,13 +69,15 @@ import com.lagradost.cloudstream3.utils.setText import com.lagradost.cloudstream3.utils.txt import qrcode.QRCode -class SettingsAccount : PreferenceFragmentCompat(), BiometricCallback { +class SettingsAccount : BasePreferenceFragmentCompat(), BiometricCallback { companion object { /** Used by nginx plugin too */ + @SuppressLint("StringFormatInvalid") fun showLoginInfo( activity: FragmentActivity?, - api: AccountManager, - info: AuthAPI.LoginInfo + api: AuthRepo, + info: AuthUser?, + index: Int, ) { if (activity == null) return val binding: AccountManagmentBinding = @@ -79,17 +87,25 @@ class SettingsAccount : PreferenceFragmentCompat(), BiometricCallback { .setView(binding.root) val dialog = builder.show() - binding.accountMainProfilePictureHolder.isVisible = !info.profilePicture.isNullOrEmpty() - binding.accountMainProfilePicture.loadImage(info.profilePicture) - + binding.accountMainProfilePictureHolder.isVisible = + !info?.profilePicture.isNullOrEmpty() + binding.accountMainProfilePicture.loadImage(info?.profilePicture) + binding.accountLogout.isVisible = info != null binding.accountLogout.setOnClickListener { - api.logOut() + if (info != null) { + ioSafe { api.logout(info) } + } dialog.dismissSafe(activity) } - (info.name ?: activity.getString(R.string.no_data)).let { - dialog.findViewById(R.id.account_name)?.text = it + dialog.findViewById(R.id.account_name)?.text = if (info != null) { + info.name ?: "%s %d".format( + activity.getString(R.string.account), + index + 1 + ) + } else { + activity.getString(R.string.no_account) } binding.accountSite.text = api.name @@ -103,8 +119,8 @@ class SettingsAccount : PreferenceFragmentCompat(), BiometricCallback { } } - private fun showAccountSwitch(activity: FragmentActivity, api: AccountManager) { - val accounts = api.getAccounts() ?: return + private fun showAccountSwitch(activity: FragmentActivity, api: AuthRepo) { + val accounts = api.accounts val binding: AccountSwitchBinding = AccountSwitchBinding.inflate(activity.layoutInflater, null, false) @@ -118,248 +134,272 @@ class SettingsAccount : PreferenceFragmentCompat(), BiometricCallback { dialog?.dismissSafe(activity) } - val ogIndex = api.accountIndex - - val items = ArrayList() - - for (index in accounts) { - api.accountIndex = index - val accountInfo = api.loginInfo() - if (accountInfo != null) { - items.add(accountInfo) - } + binding.accountNone.setOnClickListener { + api.accountId = -1 + dialog?.dismissSafe(activity) } - api.accountIndex = ogIndex - val adapter = AccountAdapter(items) { + + val adapter = AccountAdapter { dialog?.dismissSafe(activity) - api.changeAccount(it.card.accountIndex) + api.accountId = it.card.user.id + }.apply { + submitList(accounts.toList()) } val list = dialog.findViewById(R.id.account_list) list?.adapter = adapter } + @UiThread - fun addAccount(activity: FragmentActivity?, api: AccountManager) { - try { - when (api) { - is OAuth2API -> { - if (isLayout(PHONE) || !api.supportDeviceAuth) { - api.authenticate(activity) - } else if (api.supportDeviceAuth && activity != null) { - - val binding: DeviceAuthBinding = - DeviceAuthBinding.inflate(activity.layoutInflater, null, false) - - val builder = - AlertDialog.Builder(activity) - .setView(binding.root) - - builder.apply { - setNegativeButton(R.string.cancel) { _, _ -> } - setPositiveButton(R.string.auth_locally) { _, _ -> - api.authenticate(activity) - } - } + fun showPin(activity: FragmentActivity, api: AuthRepo) { + val binding: DeviceAuthBinding = + DeviceAuthBinding.inflate(activity.layoutInflater, null, false) - val dialog = builder.create() + val builder = + AlertDialog.Builder(activity) + .setView(binding.root) - ioSafe { - try { - val pinCodeData = api.getDevicePin() - if (pinCodeData == null) { - showToast(R.string.device_pin_error_message) - api.authenticate(activity) - return@ioSafe - } - - /*val logoBytes = ContextCompat.getDrawable( - activity, - R.drawable.cloud_2_solid - )?.toBitmapOrNull()?.let { bitmap -> - val csLogo = ByteArrayOutputStream() - bitmap.compress(Bitmap.CompressFormat.PNG, 100, csLogo) - csLogo.toByteArray() - }*/ - - val qrCodeImage = QRCode.ofRoundedSquares() - .withColor(activity.colorFromAttribute(R.attr.textColor)) - .withBackgroundColor(activity.colorFromAttribute(R.attr.primaryBlackBackground)) - //.withLogo(logoBytes, 200.toPx, 200.toPx) //For later if logo needed anytime - .build(pinCodeData.verificationUrl) - .render().nativeImage() as Bitmap - - activity.runOnUiThread { - dialog.show() - binding.apply { - devicePinCode.setText(txt(pinCodeData.userCode)) - deviceAuthMessage.setText( - txt( - R.string.device_pin_url_message, - pinCodeData.verificationUrl - ) - ) - deviceAuthQrcode.loadImage(qrCodeImage) - } - - val expirationMillis = - pinCodeData.expiresIn.times(1000).toLong() - - object : CountDownTimer(expirationMillis, 1000) { - - override fun onTick(millisUntilFinished: Long) { - val secondsUntilFinished = - millisUntilFinished.div(1000).toInt() - - binding.deviceAuthValidationCounter.setText( - txt( - R.string.device_pin_counter_text, - secondsUntilFinished.div(60), - secondsUntilFinished.rem(60) - ) - ) - - ioSafe { - if (secondsUntilFinished.rem(pinCodeData.interval) == 0 && api.handleDeviceAuth(pinCodeData)) { - showToast( - txt( - R.string.authenticated_user, - api.name - ) - ) - dialog.dismissSafe(activity) - cancel() - } - } - } - - override fun onFinish() { - showToast(R.string.device_pin_expired_message) - dialog.dismissSafe(activity) - } - - }.start() - } - } catch (e: Exception) { - logError(e) - } - } - } + builder.apply { + setNegativeButton(R.string.cancel) { _, _ -> } + if (api.hasOAuth2) { + setPositiveButton(R.string.auth_locally) { _, _ -> + api.openOAuth2PageWithToast() } + } + } - is InAppAuthAPI -> { - if (activity == null) return - val binding: AddAccountInputBinding = - AddAccountInputBinding.inflate(activity.layoutInflater, null, false) - val builder = - AlertDialog.Builder(activity, R.style.AlertDialogCustom) - .setView(binding.root) - val dialog = builder.show() - - val visibilityMap = listOf( - binding.loginEmailInput to api.requiresEmail, - binding.loginPasswordInput to api.requiresPassword, - binding.loginServerInput to api.requiresServer, - binding.loginUsernameInput to api.requiresUsername - ) + val dialog = builder.create() - if (isLayout(TV or EMULATOR)) { - visibilityMap.forEach { (input, isVisible) -> - input.isVisible = isVisible - - // Band-aid for weird FireTV behavior causing crashes because keyboard covers the screen - input.setOnEditorActionListener { textView, actionId, _ -> - if (actionId == EditorInfo.IME_ACTION_NEXT) { - val view = textView.focusSearch(FOCUS_DOWN) - return@setOnEditorActionListener view?.requestFocus( - FOCUS_DOWN - ) == true - } - return@setOnEditorActionListener true - } - } - } else { - visibilityMap.forEach { (input, isVisible) -> - input.isVisible = isVisible - } - } - - binding.loginEmailInput.isVisible = api.requiresEmail - binding.loginPasswordInput.isVisible = api.requiresPassword - binding.loginServerInput.isVisible = api.requiresServer - binding.loginUsernameInput.isVisible = api.requiresUsername - binding.createAccount.isGone = api.createAccountUrl.isNullOrBlank() - binding.createAccount.setOnClickListener { - openBrowser( - api.createAccountUrl ?: return@setOnClickListener, - activity + ioSafe { + val pinCodeData = try { + api.pinRequest() + } catch (e: ErrorLoadingException) { + if (e.message != null) { + showToast(e.message) + null + } else { + throw e + } + } catch (t: Throwable) { + logError(t) + null + } + if (pinCodeData == null) { + if (api.hasOAuth2) { + showToast(R.string.device_pin_error_message) + api.openOAuth2PageWithToast() + } else { + showToast( + txt( + R.string.authenticated_user_fail, + api.name ) - dialog.dismissSafe() - } - - val displayedItems = listOf( - binding.loginUsernameInput, - binding.loginEmailInput, - binding.loginServerInput, - binding.loginPasswordInput - ).filter { it.isVisible } - - displayedItems.foldRight(displayedItems.firstOrNull()) { item, previous -> - item.id.let { previous?.nextFocusDownId = it } - previous?.id?.let { item.nextFocusUpId = it } - item - } + ) + } + return@ioSafe + } - displayedItems.firstOrNull()?.let { - binding.createAccount.nextFocusDownId = it.id - it.nextFocusUpId = binding.createAccount.id - } - binding.applyBtt.id.let { - displayedItems.lastOrNull()?.nextFocusDownId = it - } + /*val logoBytes = ContextCompat.getDrawable( + activity, + R.drawable.cloud_2_solid + )?.toBitmapOrNull()?.let { bitmap -> + val csLogo = ByteArrayOutputStream() + bitmap.compress(Bitmap.CompressFormat.PNG, 100, csLogo) + csLogo.toByteArray() + }*/ + + val qrCodeImage = QRCode.ofRoundedSquares() + .withColor(activity.colorFromAttribute(R.attr.textColor)) + .withBackgroundColor(activity.colorFromAttribute(R.attr.primaryBlackBackground)) + //.withLogo(logoBytes, 200.toPx, 200.toPx) //For later if logo needed anytime + .build(pinCodeData.verificationUrl) + .render().nativeImage() as Bitmap + + activity.runOnUiThread { + dialog.show() + binding.apply { + devicePinCode.setText(txt(pinCodeData.userCode)) + deviceAuthMessage.setText( + txt( + R.string.device_pin_url_message, + pinCodeData.verificationUrl + ) + ) + deviceAuthQrcode.loadImage(qrCodeImage) + } - binding.text1.text = api.name + val expirationMillis = + pinCodeData.expiresIn.times(1000).toLong() - if (api.storesPasswordInPlainText) { - api.getLatestLoginData()?.let { data -> - binding.loginEmailInput.setText(data.email ?: "") - binding.loginServerInput.setText(data.server ?: "") - binding.loginUsernameInput.setText(data.username ?: "") - binding.loginPasswordInput.setText(data.password ?: "") - } - } + object : CountDownTimer(expirationMillis, 1000) { + override fun onTick(millisUntilFinished: Long) { + val secondsUntilFinished = + millisUntilFinished.div(1000).toInt() - binding.applyBtt.setOnClickListener { - val loginData = InAppAuthAPI.LoginData( - username = if (api.requiresUsername) binding.loginUsernameInput.text?.toString() else null, - password = if (api.requiresPassword) binding.loginPasswordInput.text?.toString() else null, - email = if (api.requiresEmail) binding.loginEmailInput.text?.toString() else null, - server = if (api.requiresServer) binding.loginServerInput.text?.toString() else null, + binding.deviceAuthValidationCounter.setText( + txt( + R.string.device_pin_counter_text, + secondsUntilFinished.div(60), + secondsUntilFinished.rem(60) + ) ) + ioSafe { - try { + if (secondsUntilFinished.rem(pinCodeData.interval) == 0 && api.login( + pinCodeData + ) + ) { showToast( txt( - if (api.login(loginData)) R.string.authenticated_user else R.string.authenticated_user_fail, + R.string.authenticated_user, api.name ) ) - } catch (e: Exception) { - logError(e) + dialog.dismissSafe(activity) + cancel() } } - dialog.dismissSafe(activity) } - binding.cancelBtt.setOnClickListener { + + override fun onFinish() { + showToast(R.string.device_pin_expired_message) dialog.dismissSafe(activity) } + }.start() + } + } + } + + + fun showAppLogin(activity: FragmentActivity, api: AuthRepo) { + + val binding: AddAccountInputBinding = + AddAccountInputBinding.inflate(activity.layoutInflater, null, false) + val builder = + AlertDialog.Builder(activity, R.style.AlertDialogCustom) + .setView(binding.root) + val dialog = builder.show() + val req = + api.inAppLoginRequirement ?: throw ErrorLoadingException("Missing LoginRequirement") + val visibilityMap = listOf( + binding.loginEmailInput to req.email, + binding.loginPasswordInput to req.password, + binding.loginServerInput to req.server, + binding.loginUsernameInput to req.username + ) + + if (isLayout(TV or EMULATOR)) { + visibilityMap.forEach { (input, isVisible) -> + input.isVisible = isVisible + + // Band-aid for weird FireTV behavior causing crashes because keyboard covers the screen + input.setOnEditorActionListener { textView, actionId, _ -> + if (actionId == EditorInfo.IME_ACTION_NEXT) { + val view = textView.focusSearch(FOCUS_DOWN) + return@setOnEditorActionListener view?.requestFocus( + FOCUS_DOWN + ) == true + } + return@setOnEditorActionListener true } + } + } else { + visibilityMap.forEach { (input, isVisible) -> + input.isVisible = isVisible + } + } + + binding.createAccount.isGone = api.createAccountUrl.isNullOrBlank() + binding.createAccount.setOnClickListener { + openBrowser( + api.createAccountUrl ?: return@setOnClickListener, + activity + ) + dialog.dismissSafe() + } + + val displayedItems = listOf( + binding.loginUsernameInput, + binding.loginEmailInput, + binding.loginServerInput, + binding.loginPasswordInput + ).filter { it.isVisible } + + displayedItems.foldRight(displayedItems.firstOrNull()) { item, previous -> + item.id.let { previous?.nextFocusDownId = it } + previous?.id?.let { item.nextFocusUpId = it } + item + } + + displayedItems.firstOrNull()?.let { + binding.createAccount.nextFocusDownId = it.id + it.nextFocusUpId = binding.createAccount.id + } + binding.applyBtt.id.let { + displayedItems.lastOrNull()?.nextFocusDownId = it + } - else -> { - throw NotImplementedError("You are trying to add an account that has an unknown login method") + binding.text1.text = api.name + + binding.applyBtt.setOnClickListener { + val loginData = AuthLoginResponse( + username = if (req.username) binding.loginUsernameInput.text?.toString() else null, + password = if (req.password) binding.loginPasswordInput.text?.toString() else null, + email = if (req.email) binding.loginEmailInput.text?.toString() else null, + server = if (req.server) binding.loginServerInput.text?.toString() else null, + ) + ioSafe { + try { + if (api.login(loginData)) { + showToast( + txt( + R.string.authenticated_user, + api.name + ) + ) + dialog.dismissSafe(activity) + } else { + showToast( + txt( + R.string.authenticated_user_fail, + api.name + ) + ) + } + } catch (t: Throwable) { + if (t is ErrorLoadingException && t.message != null) { + showToast(t.message) + return@ioSafe + } + showToast( + txt( + R.string.authenticated_user_fail, + api.name + ) + ) } } - } catch (e: Exception) { - logError(e) + } + binding.cancelBtt.setOnClickListener { + dialog.dismissSafe(activity) + } + } + + @UiThread + fun addAccount(activity: FragmentActivity, api: AuthRepo) { + try { + if (api.hasPin && !isLayout(PHONE)) { + showPin(activity, api) + } else if (api.hasOAuth2) { + api.openOAuth2PageWithToast() + } else if (api.hasInApp) { + showAppLogin(activity, api) + } else { + throw NotImplementedError("The api ${api.name} has no login") + } + } catch (t: Throwable) { + showToast(txt(R.string.authenticated_user_fail, api.name)) + logError(t) } } } @@ -378,7 +418,7 @@ class SettingsAccount : PreferenceFragmentCompat(), BiometricCallback { } override fun onAuthenticationSuccess() { - if (isAuthEnabled(context?: return)) { + if (isAuthEnabled(context ?: return)) { updateAuthPreference(true) BackupUtils.backup(activity) activity?.showBottomDialogText( @@ -409,10 +449,10 @@ class SettingsAccount : PreferenceFragmentCompat(), BiometricCallback { if (deviceHasPasswordPinLock(ctx)) { startBiometricAuthentication( - activity?: return@setOnPreferenceClickListener false, + activity ?: return@setOnPreferenceClickListener false, R.string.biometric_authentication_title, false - ) + ) promptInfo?.let { authCallback = this biometricPrompt?.authenticate(it) @@ -424,20 +464,24 @@ class SettingsAccount : PreferenceFragmentCompat(), BiometricCallback { val syncApis = listOf( - R.string.mal_key to malApi, - R.string.anilist_key to aniListApi, - R.string.simkl_key to simklApi, - R.string.opensubtitles_key to openSubtitlesApi, - R.string.subdl_key to subDlApi, + 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) { getPref(key)?.apply { title = api.name setOnPreferenceClickListener { - val info = api.loginInfo() - if (info != null) { - showLoginInfo(activity, api, info) + val activity = activity ?: return@setOnPreferenceClickListener false + val info = api.authUser() + val index = api.accounts.indexOfFirst { account -> account.user.id == info?.id } + if (api.accounts.isNotEmpty()) { + showLoginInfo(activity, api, info, index) } else { addAccount(activity, api) } 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 f256a31e79e..e41109b5982 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,9 +2,7 @@ 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 @@ -18,17 +16,21 @@ 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.AccountManager.Companion.accountManagers +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 @@ -40,7 +42,9 @@ import java.util.Date import java.util.Locale import java.util.TimeZone -class SettingsFragment : Fragment() { +class SettingsFragment : BaseFragment( + BaseFragment.BindingCreator.Inflate(MainSettingsBinding::inflate) +) { companion object { fun PreferenceFragmentCompat?.getPref(id: Int): Preference? { if (this == null) return null @@ -122,7 +126,6 @@ class SettingsFragment : Fragment() { } } } - UIHelper.fixPaddingStatusbar(settingsToolbar) } fun Fragment?.setUpToolbar(@StringRes title: Int) { @@ -135,11 +138,20 @@ class SettingsFragment : Fragment() { setNavigationIcon(R.drawable.ic_baseline_arrow_back_24) children.firstOrNull { it is ImageView }?.tag = getString(R.string.tv_no_focus_tag) setNavigationOnClickListener { - activity?.onBackPressedDispatcher?.onBackPressed() + safe { activity?.onBackPressedDispatcher?.onBackPressed() } } } } - UIHelper.fixPaddingStatusbar(settingsToolbar) + } + + fun Fragment.setSystemBarsPadding() { + view?.let { + fixSystemBarsPadding( + it, + padLeft = isLayout(TV or EMULATOR), + padBottom = isLandscape() + ) + } } fun getFolderSize(dir: File): Long { @@ -157,24 +169,15 @@ class SettingsFragment : Fragment() { } } - override fun onDestroyView() { - binding = null - super.onDestroyView() + override fun fixLayout(view: View) { + fixSystemBarsPadding( + view, + padBottom = isLandscape(), + padLeft = isLayout(TV or EMULATOR) + ) } - 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?) { + override fun onBindingCreated(binding: MainSettingsBinding) { fun navigate(id: Int) { activity?.navigate(id, Bundle()) } @@ -183,26 +186,25 @@ class SettingsFragment : Fragment() { showToast(activity,"${VideoDownloadManager.downloadStatusEvent.size} : ${VideoDownloadManager.downloadProgressEvent.size}") **/ - fun hasProfilePictureFromAccountManagers(accountManagers: List): Boolean { + fun hasProfilePictureFromAccountManagers(accountManagers: Array): Boolean { for (syncApi in accountManagers) { - val login = syncApi.loginInfo() + 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 } // display local account information if not syncing - if (!hasProfilePictureFromAccountManagers(accountManagers)) { + if (!hasProfilePictureFromAccountManagers(AccountManager.allApis)) { val activity = activity ?: return val currentAccount = try { DataStoreHelper.accounts.firstOrNull { @@ -214,11 +216,11 @@ class SettingsFragment : Fragment() { 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, @@ -245,16 +247,18 @@ class SettingsFragment : Fragment() { } } - val appVersion = getString(R.string.app_version) - val commitInfo = getString(R.string.commit_hash) + val appVersion = BuildConfig.VERSION_NAME + val commitHash = activity?.currentCommitHash() ?: "" val buildTimestamp = SimpleDateFormat.getDateTimeInstance(DateFormat.LONG, DateFormat.LONG, Locale.getDefault() ).apply { timeZone = TimeZone.getTimeZone("UTC") }.format(Date(BuildConfig.BUILD_DATE)).replace("UTC", "") - binding?.buildDate?.text = buildTimestamp - binding?.appVersionInfo?.setOnLongClickListener { - clipboardHelper(txt(R.string.extension_version), "$appVersion $commitInfo $buildTimestamp") + binding.appVersion.text = appVersion + binding.buildDate.text = buildTimestamp + binding.commitHash.text = commitHash + binding.appVersionInfo.setOnLongClickListener { + clipboardHelper(txt(R.string.extension_version), "$appVersion $commitHash $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 e82481ffa68..dbf2ff1dc53 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsGeneral.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsGeneral.kt @@ -2,18 +2,19 @@ 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.preference.PreferenceFragmentCompat +import androidx.core.content.edit +import androidx.core.os.ConfigurationCompat +import androidx.fragment.app.Fragment import androidx.preference.PreferenceManager import com.fasterxml.jackson.annotation.JsonProperty import com.lagradost.cloudstream3.APIHolder.allProviders -import com.lagradost.cloudstream3.AcraApplication -import com.lagradost.cloudstream3.AcraApplication.Companion.getKey -import com.lagradost.cloudstream3.AcraApplication.Companion.setKey +import com.lagradost.cloudstream3.CloudStreamApp +import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey +import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey import com.lagradost.cloudstream3.CommonActivity import com.lagradost.cloudstream3.CommonActivity.showToast import com.lagradost.cloudstream3.MainActivity @@ -24,6 +25,7 @@ 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 @@ -43,90 +45,99 @@ 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.VideoDownloadManager -import com.lagradost.cloudstream3.utils.VideoDownloadManager.getBasePath +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 // Change local language settings in the app. fun getCurrentLocale(context: Context): String { - 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" - } + val conf = context.resources.configuration + return ConfigurationCompat.getLocales(conf).get(0)?.toLanguageTag() ?: "en" } -// 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 +/** + * 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 +*/ val appLanguages = arrayListOf( /* begin language list */ - 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"), + 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"), /* end language list */ -).sortedBy { it.second.lowercase() } //ye, we go alphabetical, so ppl don't put their lang on top +).sortedBy { it.first.lowercase(Locale.ROOT) } // 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" -class SettingsGeneral : PreferenceFragmentCompat() { + return "$flag\u00a0${this.first}" // \u00a0 non-breaking space +} + +class SettingsGeneral : BasePreferenceFragmentCompat() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) setUpToolbar(R.string.category_general) @@ -145,16 +156,23 @@ class SettingsGeneral : PreferenceFragmentCompat() { val lang: String, ) - private val pathPicker = getChooseFolderLauncher { 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() + 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) + } + override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { hideKeyboard() setPreferencesFromResource(R.xml.settings_general, rootKey) @@ -166,22 +184,20 @@ class SettingsGeneral : PreferenceFragmentCompat() { } getPref(R.string.locale_key)?.setOnPreferenceClickListener { pref -> - val tempLangs = appLanguages.toMutableList() val current = getCurrentLocale(pref.context) - 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) + val languageTagsIETF = appLanguages.map { it.second } + val languageNames = appLanguages.map { it.nameNextToFlagEmoji() } + val currentIndex = languageTagsIETF.indexOf(current) activity?.showDialog( - languageNames, index, getString(R.string.app_language), true, { } - ) { languageIndex -> + languageNames, currentIndex, getString(R.string.app_language), true, { } + ) { selectedLangIndex -> try { - val code = languageCodes[languageIndex] - CommonActivity.setLocale(activity, code) - settingsManager.edit().putString(getString(R.string.locale_key), code).apply() + val langTagIETF = languageTagsIETF[selectedLangIndex] + CommonActivity.setLocale(activity, langTagIETF) + settingsManager.edit { + putString(getString(R.string.locale_key), langTagIETF) + } activity?.recreate() } catch (e: Exception) { logError(e) @@ -227,7 +243,7 @@ class SettingsGeneral : PreferenceFragmentCompat() { 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() || realLang.length != 2) { + if (url.isNullOrBlank() || name.isNullOrBlank()) { showToast(R.string.error_invalid_data, Toast.LENGTH_SHORT) return@setOnClickListener } @@ -312,8 +328,8 @@ class SettingsGeneral : PreferenceFragmentCompat() { getString(R.string.dns_pref), true, {}) { - settingsManager.edit().putInt(getString(R.string.dns_pref), prefValues[it]).apply() - (context ?: AcraApplication.context)?.let { ctx -> app.initClient(ctx) } + settingsManager.edit { putInt(getString(R.string.dns_pref), prefValues[it]) } + (context ?: CloudStreamApp.context)?.let { ctx -> app.initClient(ctx) } } return@setOnPreferenceClickListener true } @@ -321,7 +337,7 @@ class SettingsGeneral : PreferenceFragmentCompat() { fun getDownloadDirs(): List { return safe { context?.let { ctx -> - val defaultDir = VideoDownloadManager.getDefaultDir(ctx)?.filePath() + val defaultDir = DownloadFileManagement.getDefaultDir(ctx)?.filePath() val first = listOf(defaultDir) (try { @@ -337,18 +353,24 @@ class SettingsGeneral : PreferenceFragmentCompat() { } ?: emptyList() } - settingsManager.edit().putBoolean(getString(R.string.jsdelivr_proxy_key), getKey(getString(R.string.jsdelivr_proxy_key), false) ?: false).apply() + settingsManager.edit { putBoolean(getString(R.string.jsdelivr_proxy_key), getKey(getString(R.string.jsdelivr_proxy_key), false) ?: false) } 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 -> VideoDownloadManager.getDefaultDir(ctx)?.filePath() } + ?: context?.let { ctx -> DownloadFileManagement.getDefaultDir(ctx)?.filePath() } activity?.showBottomDialog( dirs + listOf(getString(R.string.custom)), @@ -367,10 +389,10 @@ class SettingsGeneral : PreferenceFragmentCompat() { // 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]) - .apply() + settingsManager.edit { + putString(getString(R.string.download_path_key), dirs[it]) + putString(getString(R.string.download_path_key_visual), dirs[it]) + } } } return@setOnPreferenceClickListener true @@ -393,10 +415,12 @@ class SettingsGeneral : PreferenceFragmentCompat() { if (beneneCount%20 == 0) { activity?.navigate(R.id.action_navigation_settings_general_to_easterEggMonkeFragment) } - settingsManager.edit().putInt( - getString(R.string.benene_count), - beneneCount - ).apply() + settingsManager.edit { + putInt( + getString(R.string.benene_count), + beneneCount + ) + } 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 5c6acdd9b85..0a0fb33c8aa 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,11 +3,13 @@ package com.lagradost.cloudstream3.ui.settings import android.os.Bundle import android.text.format.Formatter.formatShortFileSize import android.view.View -import androidx.preference.PreferenceFragmentCompat +import androidx.core.content.edit 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 @@ -20,18 +22,21 @@ 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 : PreferenceFragmentCompat() { +class SettingsPlayer : BasePreferenceFragmentCompat() { 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) @@ -47,7 +52,7 @@ class SettingsPlayer : PreferenceFragmentCompat() { ), TV or EMULATOR ) - + getPref(R.string.preview_seekbar_key)?.hideOn(TV) getPref(R.string.pref_category_android_tv_key)?.hideOn(PHONE) @@ -63,10 +68,11 @@ class SettingsPlayer : PreferenceFragmentCompat() { prefValues.indexOf(currentPrefSize), getString(R.string.video_buffer_length_settings), true, - {}) { - settingsManager.edit() - .putInt(getString(R.string.video_buffer_length_key), prefValues[it]) - .apply() + {} + ) { + settingsManager.edit { + putInt(getString(R.string.video_buffer_length_key), prefValues[it]) + } } return@setOnPreferenceClickListener true } @@ -81,10 +87,11 @@ class SettingsPlayer : PreferenceFragmentCompat() { prefValues.indexOf(current), getString(R.string.limit_title), true, - {}) { - settingsManager.edit() - .putInt(getString(R.string.prefer_limit_title_key), prefValues[it]) - .apply() + {} + ) { + settingsManager.edit { + putInt(getString(R.string.prefer_limit_title_key), prefValues[it]) + } } return@setOnPreferenceClickListener true } @@ -99,30 +106,48 @@ class SettingsPlayer : PreferenceFragmentCompat() { prefValues.indexOf(current), getString(R.string.software_decoding), true, - {}) { - settingsManager.edit() - .putInt(getString(R.string.software_decoding_key), prefValues[it]) - .apply() + {} + ) { + settingsManager.edit { + putInt(getString(R.string.software_decoding_key), prefValues[it]) + } } return@setOnPreferenceClickListener true } - 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) + getPref(R.string.prefer_limit_show_player_info)?.setOnPreferenceClickListener { + val ctx = context ?: return@setOnPreferenceClickListener false - activity?.showBottomDialog( + 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( prefNames.toList(), - prefValues.indexOf(current), + selectedIndices, getString(R.string.limit_title_rez), - true, - {}) { - settingsManager.edit() - .putInt(getString(R.string.prefer_limit_title_rez_key), prefValues[it]) - .apply() + {} + ) { selected -> + settingsManager.edit { + for ((index, key) in keys.withIndex()) { + putBoolean(key, selected.contains(index)) + } + } } - return@setOnPreferenceClickListener true + + true } getPref(R.string.hide_player_control_names_key)?.hideOn(TV) @@ -144,9 +169,11 @@ class SettingsPlayer : PreferenceFragmentCompat() { prefValues.indexOf(currentQuality), getString(R.string.watch_quality_pref), true, - {}) { - settingsManager.edit().putInt(getString(R.string.quality_pref_key), prefValues[it]) - .apply() + {} + ) { + settingsManager.edit { + putInt(getString(R.string.quality_pref_key), prefValues[it]) + } } return@setOnPreferenceClickListener true } @@ -168,9 +195,11 @@ class SettingsPlayer : PreferenceFragmentCompat() { prefValues.indexOf(currentQuality), getString(R.string.watch_quality_pref_data), true, - {}) { - settingsManager.edit().putInt(getString(R.string.quality_pref_mobile_data_key), prefValues[it]) - .apply() + {} + ) { + settingsManager.edit { + putInt(getString(R.string.quality_pref_mobile_data_key), prefValues[it]) + } } return@setOnPreferenceClickListener true } @@ -185,15 +214,19 @@ class SettingsPlayer : PreferenceFragmentCompat() { 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]).apply() + {} + ) { + settingsManager.edit { + putString(getString(R.string.player_default_key), prefValues[it]) + } } return@setOnPreferenceClickListener true } @@ -208,6 +241,21 @@ class SettingsPlayer : PreferenceFragmentCompat() { 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) @@ -220,10 +268,11 @@ class SettingsPlayer : PreferenceFragmentCompat() { prefValues.indexOf(currentPrefSize), getString(R.string.video_buffer_disk_settings), true, - {}) { - settingsManager.edit() - .putInt(getString(R.string.video_buffer_disk_key), prefValues[it]) - .apply() + {} + ) { + settingsManager.edit { + putInt(getString(R.string.video_buffer_disk_key), prefValues[it]) + } } return@setOnPreferenceClickListener true } @@ -239,10 +288,11 @@ class SettingsPlayer : PreferenceFragmentCompat() { prefValues.indexOf(currentPrefSize), getString(R.string.video_buffer_size_settings), true, - {}) { - settingsManager.edit() - .putInt(getString(R.string.video_buffer_size_key), prefValues[it]) - .apply() + {} + ) { + settingsManager.edit { + putInt(getString(R.string.video_buffer_size_key), prefValues[it]) + } } return@setOnPreferenceClickListener true } @@ -250,20 +300,20 @@ class SettingsPlayer : PreferenceFragmentCompat() { getPref(R.string.video_buffer_clear_key)?.let { pref -> val cacheDir = context?.cacheDir ?: return@let - fun updateSummery() { + fun updateSummary() { try { - pref.summary = formatShortFileSize(view?.context, getFolderSize(cacheDir)) + pref.summary = formatShortFileSize(pref.context, getFolderSize(cacheDir)) } catch (e: Exception) { logError(e) } } - updateSummery() + updateSummary() pref.setOnPreferenceClickListener { try { cacheDir.deleteRecursively() - updateSummery() + updateSummary() } 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 cb7d25fd7d5..076f17a0aaf 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,12 +2,13 @@ package com.lagradost.cloudstream3.ui.settings import android.os.Bundle import android.view.View -import androidx.navigation.NavOptions +import androidx.core.content.edit import androidx.navigation.fragment.findNavController -import androidx.preference.PreferenceFragmentCompat +import androidx.navigation.NavOptions 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 @@ -16,10 +17,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 +import com.lagradost.cloudstream3.utils.SubtitleHelper.getNameNextToFlagEmoji import com.lagradost.cloudstream3.utils.UIHelper.hideKeyboard -class SettingsProviders : PreferenceFragmentCompat() { +class SettingsProviders : BasePreferenceFragmentCompat() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) setUpToolbar(R.string.category_providers) @@ -46,13 +47,15 @@ class SettingsProviders : PreferenceFragmentCompat() { names, currentList, getString(R.string.display_subbed_dubbed_settings), - {}) { selectedList -> + {} + ) { selectedList -> APIRepository.dubStatusActive = selectedList.map { dublist[it] }.toHashSet() - - settingsManager.edit().putStringSet( - this.getString(R.string.display_sub_key), - selectedList.map { names[it] }.toMutableSet() - ).apply() + settingsManager.edit { + putStringSet( + getString(R.string.display_sub_key), + selectedList.map { names[it] }.toMutableSet() + ) + } } } @@ -91,50 +94,46 @@ class SettingsProviders : PreferenceFragmentCompat() { names, currentList, getString(R.string.preferred_media_settings), - {}) { selectedList -> - settingsManager.edit().putStringSet( - this.getString(R.string.prefer_media_type_key), - selectedList.map { it.toString() }.toMutableSet() - ).apply() + {} + ) { selectedList -> + settingsManager.edit { + putStringSet( + getString(R.string.prefer_media_type_key), + selectedList.map { it.toString() }.toMutableSet() + ) + } DataStoreHelper.currentHomePage = null - //(context ?: AcraApplication.context)?.let { ctx -> app.initClient(ctx) } + //(context ?: CloudStreamApp.context)?.let { ctx -> app.initClient(ctx) } } return@setOnPreferenceClickListener true } getPref(R.string.provider_lang_key)?.setOnPreferenceClickListener { - activity?.getApiProviderLangSettings()?.let { current -> - val languages = synchronized(APIHolder.apis) { - APIHolder.apis.map { it.lang }.toSet() - .sortedBy { SubtitleHelper.fromTwoLettersToLanguage(it) } + AllLanguagesName + activity?.getApiProviderLangSettings()?.let { currentLangTags -> + val languagesTagName = synchronized(APIHolder.apis) { + listOf( Pair(AllLanguagesName, getString(R.string.all_languages_preference)) ) + + APIHolder.apis.map { Pair(it.lang, getNameNextToFlagEmoji(it.lang) ?: it.lang) } + .toSet().sortedBy { it.second.substringAfter("\u00a0").lowercase() } // name ignoring flag emoji } - val 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) - } + val currentIndexList = currentLangTags.map { langTag -> + languagesTagName.indexOfFirst { lang -> lang.first == langTag } } activity?.showMultiDialog( - names.map { it.second }, - currentList, + languagesTagName.map { it.second }, + currentIndexList, getString(R.string.provider_lang_settings), - {}) { selectedList -> - settingsManager.edit().putStringSet( - this.getString(R.string.provider_lang_key), - selectedList.map { names[it].first }.toMutableSet() - ).apply() - //APIRepository.providersActive = it.context.getApiSettings() + {} + ) { selectedList -> + settingsManager.edit { + putStringSet( + getString(R.string.provider_lang_key), + selectedList.map { languagesTagName[it].first }.toSet() + ) + } + // 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 6446ae75d93..f4c522bf981 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,18 +3,22 @@ package com.lagradost.cloudstream3.ui.settings import android.os.Build import android.os.Bundle import android.view.View -import androidx.preference.PreferenceFragmentCompat +import androidx.core.content.edit import androidx.preference.PreferenceManager import androidx.preference.SeekBarPreference -import com.lagradost.cloudstream3.AcraApplication.Companion.getActivity +import com.lagradost.cloudstream3.CloudStreamApp.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 @@ -27,7 +31,7 @@ import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showMultiDialog import com.lagradost.cloudstream3.utils.UIHelper.hideKeyboard import com.lagradost.cloudstream3.utils.UIHelper.toPx -class SettingsUI : PreferenceFragmentCompat() { +class SettingsUI : BasePreferenceFragmentCompat() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) setUpToolbar(R.string.category_ui) @@ -40,14 +44,27 @@ class SettingsUI : PreferenceFragmentCompat() { setPreferencesFromResource(R.xml.settings_ui, rootKey) val settingsManager = PreferenceManager.getDefaultSharedPreferences(requireContext()) - getPref(R.string.random_button_key)?.hideOn(EMULATOR or TV) - - (getPref(R.string.overscan_key)?.hideOn(PHONE or EMULATOR) as? SeekBarPreference)?.setOnPreferenceChangeListener { perf, newValue -> + (getPref(R.string.overscan_key)?.hideOn(PHONE or EMULATOR) as? SeekBarPreference)?.setOnPreferenceChangeListener { pref, newValue -> val padding = (newValue as? Int)?.toPx ?: return@setOnPreferenceChangeListener true - (perf.context.getActivity() as? MainActivity)?.binding?.homeRoot?.setPadding(padding, padding, padding, padding) + (pref.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) @@ -63,12 +80,13 @@ class SettingsUI : PreferenceFragmentCompat() { prefNames.toList(), prefValues, getString(R.string.poster_ui_settings), - {}) { list -> - val edit = settingsManager.edit() - for ((i, key) in keys.withIndex()) { - edit.putBoolean(key, list.contains(i)) + {} + ) { list -> + settingsManager.edit { + for ((i, key) in keys.withIndex()) { + putBoolean(key, list.contains(i)) + } } - edit.apply() SearchResultBuilder.updateCache(it.context) } @@ -90,9 +108,9 @@ class SettingsUI : PreferenceFragmentCompat() { dismissCallback = {}, callback = { try { - settingsManager.edit() - .putInt(getString(R.string.app_layout_key), prefValues[it]) - .apply() + settingsManager.edit { + putInt(getString(R.string.app_layout_key), prefValues[it]) + } context?.updateTv() activity?.recreate() } catch (e: Exception) { @@ -132,11 +150,12 @@ class SettingsUI : PreferenceFragmentCompat() { prefValues.indexOf(currentLayout), getString(R.string.app_theme_settings), true, - {}) { + {} + ) { try { - settingsManager.edit() - .putString(getString(R.string.app_theme_key), prefValues[it]) - .apply() + settingsManager.edit { + putString(getString(R.string.app_theme_key), prefValues[it]) + } activity?.recreate() } catch (e: Exception) { logError(e) @@ -169,11 +188,12 @@ class SettingsUI : PreferenceFragmentCompat() { prefValues.indexOf(currentLayout), getString(R.string.primary_color_settings), true, - {}) { + {} + ) { try { - settingsManager.edit() - .putString(getString(R.string.primary_color_key), prefValues[it]) - .apply() + settingsManager.edit { + putString(getString(R.string.primary_color_key), prefValues[it]) + } activity?.recreate() } catch (e: Exception) { logError(e) @@ -195,11 +215,14 @@ class SettingsUI : PreferenceFragmentCompat() { names, currentList, getString(R.string.pref_filter_search_quality), - {}) { selectedList -> - settingsManager.edit().putStringSet( - this.getString(R.string.pref_filter_search_quality_key), - selectedList.map { it.toString() }.toMutableSet() - ).apply() + {} + ) { selectedList -> + settingsManager.edit { + putStringSet( + getString(R.string.pref_filter_search_quality_key), + selectedList.map { it.toString() }.toMutableSet() + ) + } } return@setOnPreferenceClickListener true @@ -217,9 +240,9 @@ class SettingsUI : PreferenceFragmentCompat() { showApply = true, dismissCallback = {}, callback = { selectedOption -> - settingsManager.edit() - .putInt(getString(R.string.confirm_exit_key), prefValues[selectedOption]) - .apply() + settingsManager.edit { + putInt(getString(R.string.confirm_exit_key), prefValues[selectedOption]) + } } ) 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 bacca67ec2a..c04215594e1 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsUpdates.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsUpdates.kt @@ -5,12 +5,13 @@ 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 @@ -20,8 +21,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 @@ -31,13 +32,14 @@ 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.Companion.runAutoUpdate +import com.lagradost.cloudstream3.utils.InAppUpdater.installPreReleaseIfNeeded +import com.lagradost.cloudstream3.utils.InAppUpdater.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.VideoDownloadManager +import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager import com.lagradost.cloudstream3.utils.txt import java.io.BufferedReader import java.io.InputStreamReader @@ -47,7 +49,7 @@ import java.text.SimpleDateFormat import java.util.Date import java.util.Locale -class SettingsUpdates : PreferenceFragmentCompat() { +class SettingsUpdates : BasePreferenceFragmentCompat() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) setUpToolbar(R.string.category_updates) @@ -56,16 +58,17 @@ class SettingsUpdates : PreferenceFragmentCompat() { } private val pathPicker = getChooseFolderLauncher { uri, path -> - val context = context ?: AcraApplication.context ?: return@getChooseFolderLauncher + if(uri == null) return@getChooseFolderLauncher + + val context = context ?: CloudStreamApp.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) - .apply() + PreferenceManager.getDefaultSharedPreferences(context).edit { + putString(getString(R.string.backup_path_key), uri.toString()) + putString(getString(R.string.backup_dir_key), it) + } } } - @Suppress("DEPRECATION_ERROR") override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { hideKeyboard() setPreferencesFromResource(R.xml.settings_updates, rootKey) @@ -86,11 +89,13 @@ class SettingsUpdates : PreferenceFragmentCompat() { prefValues.indexOf(current), getString(R.string.backup_frequency), true, - {}) { index -> - settingsManager.edit() - .putInt(getString(R.string.automatic_backup_key), prefValues[index]).apply() + {} + ) { index -> + settingsManager.edit { + putInt(getString(R.string.automatic_backup_key), prefValues[index]) + } BackupWorkManager.enqueuePeriodicWork( - context ?: AcraApplication.context, + context ?: CloudStreamApp.context, prefValues[index].toLong() ) } @@ -117,7 +122,8 @@ class SettingsUpdates : PreferenceFragmentCompat() { dirs.indexOf(currentDir), getString(R.string.backup_path_title), true, - {}) { + {} + ) { // Last = custom if (it == dirs.size) { try { @@ -129,10 +135,10 @@ class SettingsUpdates : PreferenceFragmentCompat() { // 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]) - .apply() + settingsManager.edit { + putString(getString(R.string.backup_path_key), dirs[it]) + putString(getString(R.string.backup_dir_key), dirs[it]) + } } } return@setOnPreferenceClickListener true @@ -157,7 +163,7 @@ class SettingsUpdates : PreferenceFragmentCompat() { logError(e) // kinda ironic } - val adapter = LogcatAdapter(logList) + val adapter = LogcatAdapter().apply { submitList(logList) } binding.logcatRecyclerView.layoutManager = LinearLayoutManager(pref.context) binding.logcatRecyclerView.adapter = adapter @@ -201,19 +207,21 @@ class SettingsUpdates : PreferenceFragmentCompat() { val prefNames = resources.getStringArray(R.array.apk_installer_pref) val prefValues = resources.getIntArray(R.array.apk_installer_values) + // Use legacy installer as default until we make the new installer completely reliable val currentInstaller = - settingsManager.getInt(getString(R.string.apk_installer_key), 0) + settingsManager.getInt(getString(R.string.apk_installer_key), 1) activity?.showBottomDialog( prefNames.toList(), prefValues.indexOf(currentInstaller), getString(R.string.apk_installer_settings), true, - {}) { num -> + {} + ) { num -> try { - settingsManager.edit() - .putInt(getString(R.string.apk_installer_key), prefValues[num]) - .apply() + settingsManager.edit { + putInt(getString(R.string.apk_installer_key), prefValues[num]) + } } catch (e: Exception) { logError(e) } @@ -221,18 +229,29 @@ class SettingsUpdates : PreferenceFragmentCompat() { return@setOnPreferenceClickListener true } - 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 - ) + 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 + ) + } } } + 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 { @@ -247,10 +266,12 @@ class SettingsUpdates : PreferenceFragmentCompat() { 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]).apply() - (context ?: AcraApplication.context)?.let { ctx -> app.initClient(ctx) } + {} + ) { num -> + settingsManager.edit { + putInt(getString(R.string.auto_download_plugins_key), prefValues[num]) + } + (context ?: CloudStreamApp.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 bd1e219d046..af0d3dfe756 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/ExtensionsFragment.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/ExtensionsFragment.kt @@ -4,10 +4,8 @@ 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 @@ -15,7 +13,6 @@ 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 @@ -26,11 +23,12 @@ 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 @@ -38,23 +36,13 @@ 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 : Fragment() { - var binding: FragmentExtensionsBinding? = null - override fun onDestroyView() { - binding = null - super.onDestroyView() - } +class ExtensionsFragment : BaseFragment( + BaseFragment.BindingCreator.Inflate(FragmentExtensionsBinding::inflate) +) { - 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 val extensionViewModel: ExtensionsViewModel by activityViewModels() private fun View.setLayoutWidth(weight: Int) { val param = LinearLayout.LayoutParams( @@ -65,8 +53,6 @@ class ExtensionsFragment : Fragment() { this.layoutParams = param } - private val extensionViewModel: ExtensionsViewModel by activityViewModels() - override fun onResume() { super.onResume() afterRepositoryLoadedEvent += ::reloadRepositories @@ -82,24 +68,25 @@ class ExtensionsFragment : Fragment() { extensionViewModel.loadRepositories() } - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - //context?.fixPaddingStatusbar(extensions_root) + override fun fixLayout(view: View) { + setSystemBarsPadding() + } + 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, @@ -113,10 +100,10 @@ class ExtensionsFragment : Fragment() { 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 } } } @@ -132,13 +119,14 @@ class ExtensionsFragment : Fragment() { }, { repo -> // Prompt user before deleting repo main { - val builder = AlertDialog.Builder(context ?: view.context) + val uiContext = context ?: binding.root.context + val builder = AlertDialog.Builder(uiContext) val dialogClickListener = DialogInterface.OnClickListener { _, which -> when (which) { DialogInterface.BUTTON_POSITIVE -> { ioSafe { - RepositoryManager.removeRepository(view.context, repo) + RepositoryManager.removeRepository(uiContext.applicationContext, repo) extensionViewModel.loadStats() extensionViewModel.loadRepositories() } @@ -149,9 +137,7 @@ class ExtensionsFragment : Fragment() { } builder.setTitle(R.string.delete_repository) - .setMessage( - context?.getString(R.string.delete_repository_plugins) - ) + .setMessage(uiContext.getString(R.string.delete_repository_plugins)) .setPositiveButton(R.string.delete, dialogClickListener) .setNegativeButton(R.string.cancel, dialogClickListener) .show().setDefaultFocus() @@ -160,37 +146,15 @@ class ExtensionsFragment : Fragment() { } observe(extensionViewModel.repositories) { - binding?.repoRecyclerView?.isVisible = it.isNotEmpty() - binding?.blankRepoScreen?.isVisible = it.isEmpty() - (binding?.repoRecyclerView?.adapter as? RepoAdapter)?.updateList(it) + binding.repoRecyclerView.isVisible = it.isNotEmpty() + binding.blankRepoScreen.isVisible = it.isEmpty() + (binding.repoRecyclerView.adapter as? RepoAdapter)?.submitList(it.toList()) } - /*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 } @@ -210,7 +174,7 @@ class ExtensionsFragment : Fragment() { } } - binding?.pluginStorageAppbar?.setOnClickListener { + binding.pluginStorageAppbar.setOnClickListener { findNavController().navigate( R.id.navigation_settings_extensions_to_navigation_settings_plugins, PluginsFragment.newInstance( @@ -230,24 +194,24 @@ class ExtensionsFragment : Fragment() { 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 { copy -> - binding.repoUrlInput.setText(copy) + )?.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) + } } -// 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 = binding.repoUrlInput.text?.toString() - ?.let { it1 -> RepositoryManager.parseRepoUrl(it1) } + val url = urlInput?.let { it1 -> RepositoryManager.parseRepoUrl(it1) } if (url.isNullOrBlank()) { main { showToast(R.string.error_invalid_data, Toast.LENGTH_SHORT) @@ -263,8 +227,7 @@ class ExtensionsFragment : Fragment() { val fixedName = if (!name.isNullOrBlank()) name else repository.name - - val newRepo = RepositoryData(fixedName, url) + val newRepo = RepositoryData(repository.iconUrl,fixedName, url) RepositoryManager.addRepository(newRepo) extensionViewModel.loadStats() extensionViewModel.loadRepositories() @@ -288,7 +251,7 @@ class ExtensionsFragment : Fragment() { } val isTv = isLayout(TV) - binding?.apply { + binding.apply { addRepoButton.isGone = isTv addRepoButtonImageviewHolder.isVisible = isTv @@ -301,4 +264,4 @@ class ExtensionsFragment : Fragment() { } 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 ebe9fc88865..482251b7831 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.AcraApplication.Companion.getKey +import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.amap import com.lagradost.cloudstream3.mvvm.debugAssert @@ -17,9 +17,12 @@ import com.lagradost.cloudstream3.utils.txt import com.lagradost.cloudstream3.utils.Coroutines.ioSafe data class RepositoryData( + @JsonProperty("iconUrl") val iconUrl: String?, @JsonProperty("name") val name: String, @JsonProperty("url") val url: String -) +){ + constructor(name: String,url: String):this(null,name,url) +} const val REPOSITORIES_KEY = "REPOSITORIES_KEY" 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 15228b260d6..d0f9ff565da 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,29 +8,26 @@ import android.view.ViewGroup import androidx.appcompat.app.AppCompatActivity import androidx.core.view.isGone import androidx.core.view.isVisible -import androidx.recyclerview.widget.DiffUtil -import androidx.recyclerview.widget.RecyclerView -import com.lagradost.cloudstream3.AcraApplication.Companion.getActivity +import androidx.viewbinding.ViewBinding +import com.lagradost.cloudstream3.CloudStreamApp.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.plugins.VotingApi.getVotes -import com.lagradost.cloudstream3.utils.setText -import com.lagradost.cloudstream3.utils.txt +import com.lagradost.cloudstream3.ui.BaseDiffCallback +import com.lagradost.cloudstream3.ui.NoStateAdapter +import com.lagradost.cloudstream3.ui.ViewHolderState +import com.lagradost.cloudstream3.ui.newSharedPool import com.lagradost.cloudstream3.ui.settings.Globals.TV import com.lagradost.cloudstream3.ui.settings.Globals.isLayout import com.lagradost.cloudstream3.utils.AppContextUtils.html -import com.lagradost.cloudstream3.utils.Coroutines.ioSafe -import com.lagradost.cloudstream3.utils.Coroutines.main import com.lagradost.cloudstream3.utils.ImageLoader.loadImage -import com.lagradost.cloudstream3.utils.SubtitleHelper.fromTwoLettersToLanguage -import com.lagradost.cloudstream3.utils.SubtitleHelper.getFlagFromIso +import com.lagradost.cloudstream3.utils.SubtitleHelper.getNameNextToFlagEmoji import com.lagradost.cloudstream3.utils.UIHelper.toPx import com.lagradost.cloudstream3.utils.getImageFromDrawable -import org.junit.Assert -import org.junit.Test +import com.lagradost.cloudstream3.utils.setText +import com.lagradost.cloudstream3.utils.txt import java.text.DecimalFormat import kotlin.math.floor import kotlin.math.log10 @@ -41,60 +38,171 @@ 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 -) : - 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 +) : 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 val inflated = LayoutInflater.from(parent.context).inflate(layout, parent, false) - return PluginViewHolder( + return RepositoryViewHolderState( RepositoryItemBinding.bind(inflated) // may crash ) } - override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { - when (holder) { - is PluginViewHolder -> { - holder.bind(plugins[position]) + override fun onClearView(holder: ViewHolderState) { + if (holder is RepositoryViewHolderState) { + holder.recycleCount += 1 + } + when (val binding = holder.view) { + is RepositoryItemBinding -> { + clearImage(binding.entryIcon) } } } - override fun getItemCount(): Int { - return plugins.size - } + @SuppressLint("SetTextI18n") + override fun onBindContent(holder: ViewHolderState, item: PluginViewData, position: Int) { + val binding = holder.view as? RepositoryItemBinding ?: return + val itemView = holder.itemView + + 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 + } - fun updateList(newList: List) { - val diffResult = DiffUtil.calculateDiff( - PluginDiffCallback(this.plugins, newList) + val url = metadata.iconUrl?.replace( + "%size%", + "$iconSize" + )?.replace( + "%exact_size%", + "$iconSizeExact" ) - plugins.clear() - plugins.addAll(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)) } + } + + binding.extVersion.isVisible = true + binding.extVersion.text = "v${metadata.version}" - diffResult.dispatchUpdatesTo(this) - } + if (metadata.language.isNullOrBlank()) { + binding.langIcon.isVisible = false + } else { + binding.langIcon.isVisible = true + binding.langIcon.text = getNameNextToFlagEmoji(metadata.language) ?: metadata.language + } - /* - private var storedPlugins: Array = reloadStoredPlugins() + //val oldRecycleCount = (holder as? RepositoryViewHolderState)?.recycleCount - private fun reloadStoredPlugins(): Array { - return PluginManager.getPluginsOnline().also { storedPlugins = it } - }*/ + binding.extVotes.isVisible = false - // 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) + // 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 } - super.onViewRecycled(holder) + + 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() } 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 @@ -103,14 +211,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 { @@ -131,136 +239,4 @@ 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 80be3cf4b22..0dcbece6cc3 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,33 +1,36 @@ 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.google.android.material.bottomsheet.BottomSheetDialogFragment -import com.lagradost.cloudstream3.AcraApplication.Companion.openBrowser -import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.CloudStreamApp.Companion.openBrowser 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.fromTwoLettersToLanguage -import com.lagradost.cloudstream3.utils.SubtitleHelper.getFlagFromIso +import com.lagradost.cloudstream3.utils.SubtitleHelper.getNameNextToFlagEmoji 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) : BottomSheetDialogFragment() { +class PluginDetailsFragment(val data: PluginViewData) : BaseBottomSheetDialogFragment( + BaseFragment.BindingCreator.Inflate(FragmentPluginDetailsBinding::inflate) +) { companion object { private tailrec fun findClosestBase2(target: Int, current: Int = 16, max: Int = 512): Int { @@ -42,26 +45,17 @@ class PluginDetailsFragment(val data: PluginViewData) : BottomSheetDialogFragmen } } - override fun onDestroyView() { - binding = null - super.onDestroyView() - } - - 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 fixLayout(view: View) { + fixSystemBarsPadding( + view, + padBottom = isLandscape(), + padLeft = isLayout(TV or EMULATOR) + ) } - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) + override fun onBindingCreated(binding: FragmentPluginDetailsBinding) { 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) } @@ -85,9 +79,9 @@ class PluginDetailsFragment(val data: PluginViewData) : BottomSheetDialogFragmen ", " ) pluginLang.text = if (metadata.language == null) - getString(R.string.no_data) - else - "${getFlagFromIso(metadata.language)} ${fromTwoLettersToLanguage(metadata.language)}" + getString(R.string.no_data) + else + getNameNextToFlagEmoji(metadata.language) ?: 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 4878049b4cf..534ffa62a43 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,70 +1,62 @@ 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.R -import com.lagradost.cloudstream3.TvType 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.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 +import com.lagradost.cloudstream3.utils.SubtitleHelper.getNameNextToFlagEmoji 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 : 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) - } +class PluginsFragment : BaseFragment( + BaseFragment.BindingCreator.Inflate(FragmentPluginsBinding::inflate) +) { + + private val pluginViewModel: PluginsViewModel by activityViewModels() override fun onDestroyView() { - binding = null + pluginViewModel.clear() // clear for the next observe super.onDestroyView() } - private val pluginViewModel: PluginsViewModel by activityViewModels() - var binding: FragmentPluginsBinding? = null - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) + override fun fixLayout(view: View) { + setSystemBarsPadding() + } + override fun onBindingCreated(binding: FragmentPluginsBinding) { // Since the ViewModel is getting reused the tvTypes must be cleared between uses pluginViewModel.tvTypes.clear() - pluginViewModel.languages = listOf() - pluginViewModel.search(null) + pluginViewModel.selectedLanguages = listOf() + pluginViewModel.clear() // Filter by language set on preferred media activity?.let { val providerLangs = it.getApiProviderLangSettings().toList() if (!providerLangs.contains(AllLanguagesName)) { - pluginViewModel.languages = mutableListOf("none") + providerLangs - //Log.i("DevDebug", "providerLang => ${pluginViewModel.languages.toJson()}") + pluginViewModel.selectedLanguages = mutableListOf("none") + providerLangs } } @@ -72,16 +64,16 @@ class PluginsFragment : Fragment() { 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) { - activity?.onBackPressedDispatcher?.onBackPressed() + dispatchBackPressed() return } setToolBarScrollFlags() setUpToolbar(name) - binding?.settingsToolbar?.apply { + binding.settingsToolbar.apply { setOnMenuItemClickListener { menuItem -> when (menuItem?.itemId) { R.id.download_all -> { @@ -89,24 +81,35 @@ class PluginsFragment : Fragment() { } R.id.lang_filter -> { - 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" + val languagesTagName = pluginViewModel.pluginLanguages + .map { langTag -> + Pair( + langTag, + getNameNextToFlagEmoji(langTag) ?: langTag + ) } - val selectedList = - pluginViewModel.languages.map { languageCodes.indexOf(it) } + .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 } + } activity?.showMultiDialog( - languageNames, - selectedList, + languagesTagName.map { it.second }, + currentIndexList, getString(R.string.provider_lang_settings), - {}) { newList -> - pluginViewModel.languages = newList.map { languageCodes[it] } + {} + ) { selectedList -> + pluginViewModel.selectedLanguages = + selectedList.map { languagesTagName[it].first } pluginViewModel.updateFilteredPlugins() } } @@ -124,7 +127,7 @@ class PluginsFragment : Fragment() { if (searchView?.isIconified == false) { searchView.isIconified = true } else { - activity?.onBackPressedDispatcher?.onBackPressed() + dispatchBackPressed() } } searchView?.setOnQueryTextFocusChangeListener { _, hasFocus -> @@ -149,46 +152,46 @@ class PluginsFragment : Fragment() { // Because onActionViewCollapsed doesn't wanna work we need this workaround :( - binding?.pluginRecyclerView?.setLinearListLayout( - isHorizontal = false, - nextDown = FOCUS_SELF, - nextRight = FOCUS_SELF, - ) - - binding?.pluginRecyclerView?.adapter = - PluginAdapter { - pluginViewModel.handlePluginAction(activity, url, it, isLocal) - } + binding.pluginRecyclerView.apply { + setLinearListLayout( + isHorizontal = false, + nextDown = FOCUS_SELF, + nextRight = FOCUS_SELF, + ) + setRecycledViewPool(PluginAdapter.sharedPool) + 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)?.updateList(list) - - if (scrollToTop) - binding?.pluginRecyclerView?.scrollToPosition(0) + (binding.pluginRecyclerView.adapter as? PluginAdapter)?.submitList(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 a6f9148985c..dfc61eba54c 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/PluginsViewModel.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/PluginsViewModel.kt @@ -26,6 +26,7 @@ import com.lagradost.cloudstream3.utils.Coroutines.runOnMainThread 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. @@ -36,13 +37,28 @@ 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 languages = listOf() + var selectedLanguages = listOf() private var currentQuery: String? = null companion object { @@ -112,6 +128,7 @@ class PluginsViewModel : ViewModel() { PluginManager.downloadPlugin( activity, metadata.url, + metadata.fileHash, metadata.internalName, repo, metadata.status != PROVIDER_STATUS_DOWN @@ -163,6 +180,7 @@ class PluginsViewModel : ViewModel() { PluginManager.downloadPlugin( activity, metadata.url, + metadata.fileHash, metadata.internalName, repo, isEnabled @@ -213,12 +231,12 @@ class PluginsViewModel : ViewModel() { } private fun List.filterLang(): List { - if (languages.isEmpty()) return this + if (selectedLanguages.isEmpty()) return this // do not filter return this.filter { if (it.plugin.second.language == null) { - return@filter languages.contains("none") + return@filter selectedLanguages.contains("none") } - languages.contains(it.plugin.second.language) + selectedLanguages.contains(it.plugin.second.language?.lowercase()) } } @@ -227,7 +245,12 @@ class PluginsViewModel : ViewModel() { // Return list to base state if no query this.sortedBy { it.plugin.second.name } } else { - this.sortedBy { -FuzzySearch.partialRatio(it.plugin.second.name.lowercase(), query.lowercase()) } + this.sortedBy { + -FuzzySearch.partialRatio( + it.plugin.second.name.lowercase(), + query.lowercase() + ) + } } } @@ -237,6 +260,13 @@ 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 9d633314413..0f9bf5f58c9 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,17 +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.utils.txt +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.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, @@ -20,10 +22,11 @@ class RepoAdapter( val imageClickCallback: RepoAdapter.(RepositoryData) -> Unit, /** In setup mode the trash icons will be replaced with download icons */ ) : - RecyclerView.Adapter() { - private val repositories: MutableList = mutableListOf() + NoStateAdapter(diffCallback = BaseDiffCallback(itemSame = { a, b -> + a.url == b.url + })) { - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { + override fun onCreateContent(parent: ViewGroup): ViewHolderState { val layout = if (isLayout(TV)) RepositoryItemTvBinding.inflate( LayoutInflater.from(parent.context), parent, @@ -32,109 +35,97 @@ 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 onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { - when (holder) { - is RepoViewHolder -> { - holder.bind(repositories[position]) - } + override fun onClearView(holder: ViewHolderState) { + when (val binding = holder.view) { + is RepositoryItemBinding -> clearImage(binding.entryIcon) + is RepositoryItemTvBinding -> clearImage(binding.entryIcon) } } - override fun getItemCount(): Int { - return repositories.size - } - - fun updateList(newList: Array) { - val diffResult = DiffUtil.calculateDiff( - RepoDiffCallback(this.repositories, newList) - ) - - repositories.clear() - repositories.addAll(newList) - - diffResult.dispatchUpdatesTo(this) - } - - 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) - } + 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) + } - actionButton.setOnClickListener { - imageClickCallback(repositoryData) - } + actionButton.setOnClickListener { + imageClickCallback(item) + } - repositoryItemRoot.setOnClickListener { - clickCallback(repositoryData) + 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 + ) + ) } - mainText.text = repositoryData.name - subText.text = repositoryData.url + } 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) - } + 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) - } + actionButton.setOnClickListener { + imageClickCallback(item) + } - repositoryItemRoot.setOnClickListener { - clickCallback(repositoryData) - } + repositoryItemRoot.setOnClickListener { + clickCallback(item) + } - repositoryItemRoot.setOnLongClickListener { - val shareableRepoData = "${repositoryData.name} : \n ${repositoryData.url}" - clipboardHelper(txt(R.string.repo_copy_label), shareableRepoData) - true - } + repositoryItemRoot.setOnLongClickListener { + val shareableRepoData = + "${item.name}$SHAREABLE_REPO_SEPARATOR\n ${item.url}" + clipboardHelper(txt(R.string.repo_copy_label), shareableRepoData) + true + } - mainText.text = repositoryData.name - subText.text = repositoryData.url + 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 + ) + ) + } + } else { + entryIcon.loadImage(R.drawable.ic_github_logo) } } } } } -} - -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] + companion object { + const val SHAREABLE_REPO_SEPARATOR = " : " + } } \ 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 921ac0674b7..4ec005a094d 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/testing/TestFragment.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/testing/TestFragment.kt @@ -1,41 +1,35 @@ 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 : Fragment() { +class TestFragment : BaseFragment( + BaseFragment.BindingCreator.Inflate(FragmentTestingBinding::inflate) +) { private val testViewModel: TestViewModel by activityViewModels() - var binding: FragmentTestingBinding? = null - override fun onDestroyView() { - binding = null - super.onDestroyView() + override fun fixLayout(view: View) { + setSystemBarsPadding() } - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + override fun onBindingCreated(binding: FragmentTestingBinding) { setUpToolbar(R.string.category_provider_test) setToolBarScrollFlags() - super.onViewCreated(view, savedInstanceState) - binding?.apply { - providerTestRecyclerView.adapter = TestResultAdapter( - mutableListOf() - ) + binding.apply { + providerTestRecyclerView.adapter = TestResultAdapter() testViewModel.init() if (testViewModel.isRunningTest) { @@ -46,10 +40,10 @@ class TestFragment : Fragment() { providerTest.setProgress(passed, failed, total) } - observeNullable(testViewModel.providerResults) { + observe(testViewModel.providerResults) { safe { val newItems = it.sortedBy { api -> api.first.name } - (providerTestRecyclerView.adapter as? TestResultAdapter)?.updateList( + (providerTestRecyclerView.adapter as? TestResultAdapter)?.submitList( newItems ) } @@ -96,13 +90,4 @@ class TestFragment : Fragment() { } } } - - 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 bad58a0e74e..c53ff1fcf8a 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,7 +7,6 @@ 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 @@ -15,103 +14,117 @@ 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.utils.AppContextUtils +import com.lagradost.cloudstream3.ui.BaseDiffCallback +import com.lagradost.cloudstream3.ui.NoStateAdapter +import com.lagradost.cloudstream3.ui.ViewHolderState 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(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), - ) +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 onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { - when (holder) { - is ProviderTestViewHolder -> { - val item = items[position] - holder.bind(item.first, item.second) - } - } + override fun onClearView(holder: ViewHolderState) { + val binding = holder.view as? ProviderTestItemBinding ?: return + clearImage(binding.actionButton) } - 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 + override fun onCreateContent(parent: ViewGroup): ViewHolderState { + return ViewHolderState( + ProviderTestItemBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false + ) + ) + } - private fun String.lastLine(): String? { - return this.lines().lastOrNull { it.isNotBlank() } - } + override fun onBindContent( + holder: ViewHolderState, + item: Pair, + position: Int + ) { + val binding = holder.view as? ProviderTestItemBinding ?: return + val (api, result) = item - fun bind(api: MainAPI, result: TestingUtils.TestResultProvider) { - languageText.text = getFlagFromIso(api.lang) - providerTitle.text = api.name + val itemView = holder.itemView - 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 - } + 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_failed to R.color.colorTestFail + R.string.test_passed to R.color.colorTestPass } + } else { + R.string.test_failed to R.color.colorTestFail + } - statusText.setText(resultText) - statusText.setTextColor(ContextCompat.getColor(itemView.context, resultColor)) - - 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" } ?: "") - - failDescription.text = messages?.lastLine() ?: resultLog.lastLine() - - 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) { _, _ -> } - - 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) - } + statusText.setText(resultText) + statusText.setTextColor(ContextCompat.getColor(itemView.context, resultColor)) + + 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" } ?: "") + + failDescription.text = messages?.lastLine() ?: resultLog.lastLine() + + 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) { _, _ -> } + + 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 eea495a267e..65ed47a545a 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,6 +9,7 @@ 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 @@ -59,10 +60,9 @@ class TestView @JvmOverloads constructor( playPauseButton = findViewById(R.id.tests_play_pause) attrs?.let { - val typedArray = context.obtainStyledAttributes(it, R.styleable.TestView) - val headerText = typedArray.getString(R.styleable.TestView_header_text) - mainSectionHeader?.text = headerText - typedArray.recycle() + context.withStyledAttributes(it, R.styleable.TestView) { + mainSectionHeader?.text = getString(R.styleable.TestView_header_text) + } } playPauseButton?.setOnClickListener { 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 9e126b7a6a5..dfc93117481 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/utils/DirectoryPicker.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/utils/DirectoryPicker.kt @@ -4,14 +4,17 @@ import android.content.Intent import android.net.Uri import androidx.activity.result.contract.ActivityResultContracts import androidx.fragment.app.Fragment -import com.lagradost.cloudstream3.AcraApplication +import com.lagradost.cloudstream3.CloudStreamApp import com.lagradost.safefile.SafeFile fun Fragment.getChooseFolderLauncher(dirSelected: (uri: Uri?, path: String?) -> Unit) = registerForActivityResult(ActivityResultContracts.OpenDocumentTree()) { uri -> // It lies, it can be null if file manager quits. - if (uri == null) return@registerForActivityResult - val context = context ?: AcraApplication.context ?: return@registerForActivityResult + if(uri == null) { + dirSelected(null, null) + return@registerForActivityResult + } + val context = context ?: CloudStreamApp.context ?: return@registerForActivityResult // RW perms for the path val flags = Intent.FLAG_GRANT_READ_URI_PERMISSION or 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 0dccd5cc42b..501ee0eef7b 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,11 +1,8 @@ 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 @@ -14,13 +11,15 @@ 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.fixPaddingStatusbar +import com.lagradost.cloudstream3.utils.UIHelper.fixSystemBarsPadding - -class SetupFragmentExtensions : Fragment() { +class SetupFragmentExtensions : BaseFragment( + BaseFragment.BindingCreator.Inflate(FragmentSetupExtensionsBinding::inflate) +) { companion object { const val SETUP_EXTENSION_BUNDLE_IS_SETUP = "isSetup" @@ -34,24 +33,6 @@ class SetupFragmentExtensions : Fragment() { } } - 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 @@ -62,18 +43,21 @@ class SetupFragmentExtensions : Fragment() { 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 { updateList(repositories) } + }).apply { submitList(repositories.toList()) } } // else { // list_repositories?.setOnClickListener { @@ -84,19 +68,12 @@ class SetupFragmentExtensions : Fragment() { } } - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - fixPaddingStatusbar(binding?.setupRoot) + override fun onBindingCreated(binding: FragmentSetupExtensionsBinding) { 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) } @@ -123,6 +100,4 @@ class SetupFragmentExtensions : Fragment() { } } } - - -} \ 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 f57d4f159bd..e96a662c370 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,61 +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.ContextCompat -import androidx.fragment.app.Fragment +import androidx.core.content.edit import androidx.navigation.fragment.findNavController import androidx.preference.PreferenceManager 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.utils.SubtitleHelper -import com.lagradost.cloudstream3.utils.UIHelper.fixPaddingStatusbar +import com.lagradost.cloudstream3.ui.settings.nameNextToFlagEmoji +import com.lagradost.cloudstream3.utils.UIHelper.fixSystemBarsPadding const val HAS_DONE_SETUP_KEY = "HAS_DONE_SETUP" -class SetupFragmentLanguage : Fragment() { - var binding: FragmentSetupLanguageBinding? = null +class SetupFragmentLanguage : BaseFragment( + BaseFragment.BindingCreator.Inflate(FragmentSetupLanguageBinding::inflate) +) { - override fun onDestroyView() { - binding = null - super.onDestroyView() + override fun fixLayout(view: View) { + fixSystemBarsPadding(view) } - 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) - + override fun onBindingCreated(binding: FragmentSetupLanguageBinding) { // 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 { @@ -67,24 +51,21 @@ class SetupFragmentLanguage : Fragment() { } val current = getCurrentLocale(ctx) - 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) + val languageTagsIETF = appLanguages.map { it.second } + val languageNames = appLanguages.map { it.nameNextToFlagEmoji() } + val currentIndex = languageTagsIETF.indexOf(current) arrayAdapter.addAll(languageNames) listview1.adapter = arrayAdapter listview1.choiceMode = AbsListView.CHOICE_MODE_SINGLE - listview1.setItemChecked(index, true) + listview1.setItemChecked(currentIndex, true) - listview1.setOnItemClickListener { _, _, position, _ -> - val code = languageCodes[position] - CommonActivity.setLocale(activity, code) - settingsManager.edit().putString(getString(R.string.locale_key), code) - .apply() - activity?.recreate() + listview1.setOnItemClickListener { _, _, selectedLangIndex, _ -> + val langTagIETF = languageTagsIETF[selectedLangIndex] + CommonActivity.setLocale(activity, langTagIETF) + settingsManager.edit { + putString(getString(R.string.locale_key), langTagIETF) + } } nextBtt.setOnClickListener { @@ -103,10 +84,10 @@ class SetupFragmentLanguage : Fragment() { } skipBtt.setOnClickListener { + setKey(HAS_DONE_SETUP_KEY, true) 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 85eabefa4b3..4a8e784a145 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,45 +1,27 @@ 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.fragment.app.Fragment +import androidx.core.content.edit import androidx.navigation.fragment.findNavController import androidx.preference.PreferenceManager -import com.lagradost.cloudstream3.AcraApplication.Companion.setKey +import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.databinding.FragmentSetupLayoutBinding import com.lagradost.cloudstream3.mvvm.safe -import com.lagradost.cloudstream3.utils.UIHelper.fixPaddingStatusbar -import org.acra.ACRA +import com.lagradost.cloudstream3.ui.BaseFragment +import com.lagradost.cloudstream3.utils.UIHelper.fixSystemBarsPadding +class SetupFragmentLayout : BaseFragment( + BaseFragment.BindingCreator.Inflate(FragmentSetupLayoutBinding::inflate) +) { -class SetupFragmentLayout : Fragment() { - - var binding: FragmentSetupLayoutBinding? = null - - override fun onDestroyView() { - binding = null - super.onDestroyView() + override fun fixLayout(view: View) { + fixSystemBarsPadding(view) } - 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) - + override fun onBindingCreated(binding: FragmentSetupLayoutBinding) { safe { val ctx = context ?: return@safe @@ -55,7 +37,7 @@ class SetupFragmentLayout : Fragment() { 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( @@ -63,28 +45,11 @@ class SetupFragmentLayout : Fragment() { ) listview1.setOnItemClickListener { _, _, position, _ -> - settingsManager.edit() - .putInt(getString(R.string.app_layout_key), prefValues[position]) - .apply() + settingsManager.edit { + putInt(getString(R.string.app_layout_key), prefValues[position]) + } 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) @@ -97,4 +62,4 @@ class SetupFragmentLayout : Fragment() { } } } -} \ 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 9db967dcb5c..8da121daa98 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,47 +1,30 @@ 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.fixPaddingStatusbar +import com.lagradost.cloudstream3.utils.UIHelper.fixSystemBarsPadding +class SetupFragmentMedia : BaseFragment( + BaseFragment.BindingCreator.Inflate(FragmentSetupMediaBinding::inflate) +) { -class SetupFragmentMedia : Fragment() { - var binding: FragmentSetupMediaBinding? = null - - override fun onDestroyView() { - binding = null - super.onDestroyView() - } - - 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 fixLayout(view: View) { + fixSystemBarsPadding(view) } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) + override fun onBindingCreated(binding: FragmentSetupMediaBinding) { safe { - fixPaddingStatusbar(binding?.setupRoot) - val ctx = context ?: return@safe val settingsManager = PreferenceManager.getDefaultSharedPreferences(ctx) @@ -52,7 +35,7 @@ class SetupFragmentMedia : Fragment() { val selected = mutableListOf() arrayAdapter.addAll(names) - binding?.apply { + binding.apply { listview1.let { it.adapter = arrayAdapter it.choiceMode = AbsListView.CHOICE_MODE_MULTIPLE @@ -71,9 +54,9 @@ class SetupFragmentMedia : Fragment() { val itemVal = TvType.valueOf(item) itemVal.ordinal.toString() }.toSet() - settingsManager.edit() - .putStringSet(getString(R.string.prefer_media_type_key), prefValues) - .apply() + settingsManager.edit { + putStringSet(getString(R.string.prefer_media_type_key), prefValues) + } // Regenerate set homepage DataStoreHelper.currentHomePage = null @@ -90,4 +73,4 @@ class SetupFragmentMedia : Fragment() { } } } -} \ 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 353e735e905..3c4a09adea8 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,47 +1,31 @@ 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.APIHolder import com.lagradost.cloudstream3.AllLanguagesName -import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.APIHolder 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 -import com.lagradost.cloudstream3.utils.UIHelper.fixPaddingStatusbar +import com.lagradost.cloudstream3.utils.SubtitleHelper.getNameNextToFlagEmoji +import com.lagradost.cloudstream3.utils.UIHelper.fixSystemBarsPadding -class SetupFragmentProviderLanguage : Fragment() { - var binding: FragmentSetupProviderLanguagesBinding? = null +class SetupFragmentProviderLanguage : BaseFragment( + BaseFragment.BindingCreator.Inflate(FragmentSetupProviderLanguagesBinding::inflate) +) { - override fun onDestroyView() { - binding = null - super.onDestroyView() + override fun fixLayout(view: View) { + fixSystemBarsPadding(view) } - 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) - + override fun onBindingCreated(binding: FragmentSetupProviderLanguagesBinding) { safe { val ctx = context ?: return@safe @@ -50,51 +34,47 @@ class SetupFragmentProviderLanguage : Fragment() { val arrayAdapter = ArrayAdapter(ctx, R.layout.sort_bottom_single_choice) - val current = ctx.getApiProviderLangSettings() - val langs = synchronized(APIHolder.apis) { APIHolder.apis.map { it.lang }.toSet() - .sortedBy { SubtitleHelper.fromTwoLettersToLanguage(it) } + AllLanguagesName} + val currentLangTags = ctx.getApiProviderLangSettings() + + val languagesTagName = synchronized(APIHolder.apis) { + listOf( Pair(AllLanguagesName, getString(R.string.all_languages_preference)) ) + + APIHolder.apis.map { Pair(it.lang, getNameNextToFlagEmoji(it.lang) ?: it.lang) } + .toSet().sortedBy { it.second.substringAfter("\u00a0").lowercase() } // name ignoring flag emoji + } - val 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 } - 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(languagesTagName.map { it.second }) + binding.apply { + listview1.adapter = arrayAdapter + listview1.choiceMode = AbsListView.CHOICE_MODE_MULTIPLE + currentIndexList.forEach { + listview1.setItemChecked(it, true) } - } - arrayAdapter.addAll(languageNames) - binding?.apply { - listview1.adapter = arrayAdapter - listview1.choiceMode = AbsListView.CHOICE_MODE_MULTIPLE - currentList.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() + ) + } + } - listview1.setOnItemClickListener { _, _, _, _ -> - val currentLanguages = mutableListOf() - listview1.checkedItemPositions?.forEach { key, value -> - if (value) currentLanguages.add(langs[key]) + nextBtt.setOnClickListener { + findNavController().navigate(R.id.navigation_setup_provider_languages_to_navigation_setup_media) } - 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() + } } - - 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 c76a218e547..f9b1cb1fe88 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,13 +7,12 @@ 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.fragment.app.Fragment +import androidx.annotation.OptIn 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 @@ -21,19 +20,21 @@ 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.AcraApplication.Companion.getKey +import com.lagradost.cloudstream3.CloudStreamApp.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.fixPaddingStatusbar +import com.lagradost.cloudstream3.utils.UIHelper.fixSystemBarsPadding import com.lagradost.cloudstream3.utils.UIHelper.hideSystemUI import com.lagradost.cloudstream3.utils.UIHelper.navigate import com.lagradost.cloudstream3.utils.UIHelper.popCurrentPage @@ -50,8 +51,10 @@ data class SaveChromeCaptionStyle( @JsonProperty("fontScale") var fontScale: Float = 1.05f, @JsonProperty("windowColor") var windowColor: Int = Color.TRANSPARENT, ) -@androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class) -class ChromecastSubtitlesFragment : Fragment() { + +class ChromecastSubtitlesFragment : BaseFragment( + BaseFragment.BindingCreator.Inflate(ChromecastSubtitleSettingsBinding::inflate) +) { companion object { val applyStyleEvent = Event() @@ -142,23 +145,6 @@ class ChromecastSubtitlesFragment : Fragment() { //subtitle_text?.setStyle(fromSaveToStyle(state)) } - var binding : ChromecastSubtitleSettingsBinding? = null - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle?, - ): View { - val localBinding = ChromecastSubtitleSettingsBinding.inflate(inflater, container, false) - binding = localBinding - return localBinding.root//inflater.inflate(R.layout.chromecast_subtitle_settings, container, false) - } - - override fun onDestroyView() { - binding = null - super.onDestroyView() - } - private lateinit var state: SaveChromeCaptionStyle private var hide: Boolean = true @@ -167,26 +153,29 @@ class ChromecastSubtitlesFragment : Fragment() { onColorSelectedEvent -= ::onColorSelected } - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) + override fun fixLayout(view: View) { + fixSystemBarsPadding( + view, + padBottom = isLandscape(), + padLeft = isLayout(TV or EMULATOR) + ) + } + + override fun onBindingCreated(binding: ChromecastSubtitleSettingsBinding) { hide = arguments?.getBoolean("hide") ?: true onColorSelectedEvent += ::onColorSelected onDialogDismissedEvent += ::onDialogDismissed - fixPaddingStatusbar(binding?.subsRoot) - state = getCurrentSavedStyle() updateState() val isTvSettings = isLayout(TV or EMULATOR) - fun View.setFocusableInTv() { this.isFocusableInTouchMode = isTvSettings } fun View.setup(id: Int) { setFocusableInTv() - this.setOnClickListener { activity?.let { ColorPickerDialog.newBuilder() @@ -204,20 +193,19 @@ class ChromecastSubtitlesFragment : Fragment() { } } - binding?.apply { + binding.apply { subsTextColor.setup(0) subsOutlineColor.setup(1) subsBackgroundColor.setup(2) } - val dismissCallback = { if (hide) activity?.hideSystemUI() } - binding?.subsEdgeType?.setFocusableInTv() - binding?.subsEdgeType?.setOnClickListener { textView -> + binding.subsEdgeType.setFocusableInTv() + binding.subsEdgeType.setOnClickListener { textView -> val edgeTypes = listOf( Pair( EDGE_TYPE_NONE, @@ -254,15 +242,15 @@ class ChromecastSubtitlesFragment : Fragment() { } } - binding?.subsEdgeType?.setOnLongClickListener { + binding.subsEdgeType.setOnLongClickListener { state.edgeType = defaultState.edgeType updateState() showToast(R.string.subs_default_reset_toast, Toast.LENGTH_SHORT) return@setOnLongClickListener true } - binding?.subsFontSize?.setFocusableInTv() - binding?.subsFontSize?.setOnClickListener { textView -> + binding.subsFontSize.setFocusableInTv() + binding.subsFontSize.setOnClickListener { textView -> val fontSizes = listOf( Pair(0.75f, "75%"), Pair(0.80f, "80%"), @@ -295,17 +283,15 @@ class ChromecastSubtitlesFragment : Fragment() { } } - binding?.subsFontSize?.setOnLongClickListener { _ -> + 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", @@ -329,24 +315,30 @@ class ChromecastSubtitlesFragment : Fragment() { updateState() } } - binding?.subsFont?.setOnLongClickListener { _ -> + 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() } - binding?.subtitleText?.apply { + + setSubtitleCues(binding) + } + + @OptIn(UnstableApi::class) + private fun setSubtitleCues(binding: ChromecastSubtitleSettingsBinding) { + 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 28c2cdc486f..5f716cca3f1 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,16 +12,14 @@ 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 @@ -29,23 +27,29 @@ 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.AcraApplication.Companion.getKey -import com.lagradost.cloudstream3.AcraApplication.Companion.setKey +import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey +import com.lagradost.cloudstream3.CloudStreamApp.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 -import com.lagradost.cloudstream3.utils.UIHelper.fixPaddingStatusbar +import com.lagradost.cloudstream3.utils.SubtitleHelper.languages +import com.lagradost.cloudstream3.utils.UIHelper.fixSystemBarsPadding import com.lagradost.cloudstream3.utils.UIHelper.hideSystemUI import com.lagradost.cloudstream3.utils.UIHelper.navigate import com.lagradost.cloudstream3.utils.UIHelper.popCurrentPage @@ -56,10 +60,11 @@ 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 @OptIn(UnstableApi::class) constructor( +data class SaveCaptionStyle( @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 @@ -81,28 +86,40 @@ data class SaveCaptionStyle @OptIn(UnstableApi::class) constructor( @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(androidx.media3.common.util.UnstableApi::class) -class SubtitlesFragment : DialogFragment() { +@OptIn(UnstableApi::class) +class SubtitlesFragment : BaseDialogFragment( + BaseFragment.BindingCreator.Inflate(SubtitleSettingsBinding::inflate) +) { companion object { val applyStyleEvent = Event() private val captionRegex = Regex("""(-\s?|)[\[({][\S\s]*?[])}]\s*""") - fun setSubtitleViewStyle(view: SubtitleView?, data: SaveCaptionStyle) { + fun setSubtitleViewStyle( + view: SubtitleView?, + data: SaveCaptionStyle, + applyElevation: Boolean + ) { if (view == null) return val ctx = view.context ?: return val style = ctx.fromSaveToStyle(data) view.setStyle(style) - view.setPadding( - view.paddingLeft, data.elevation.toPx, view.paddingRight, view.paddingBottom - ) + + if (applyElevation) { + 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 view.setFixedTextSize(TypedValue.COMPLEX_UNIT_SP, size) + view.setBottomPaddingFraction(0.0f) /*if (size != null) { view.setFixedTextSize(TypedValue.COMPLEX_UNIT_SP, size) } else { @@ -191,7 +208,8 @@ class SubtitlesFragment : DialogFragment() { } } - return this + // 6. set alignment + return this.setSubtitleAlignment(style.alignment) } private fun Context.fromSaveToStyle(data: SaveCaptionStyle): CaptionStyleCompat { @@ -275,11 +293,11 @@ class SubtitlesFragment : DialogFragment() { return TypedValue.applyDimension(unit, size, metrics).toInt() } - fun getDownloadSubsLanguageISO639_1(): List { + fun getDownloadSubsLanguageTagIETF(): List { return getKey(SUBTITLE_DOWNLOAD_KEY) ?: listOf("en") } - fun getAutoSelectLanguageISO639_1(): String { + fun getAutoSelectLanguageTagIETF(): String { return getKey(SUBTITLE_AUTO_SELECT_KEY) ?: "en" } } @@ -311,7 +329,7 @@ class SubtitlesFragment : DialogFragment() { 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) + setSubtitleViewStyle(binding?.subtitleText, state, false) binding?.subtitleText?.setCues( listOf( @@ -336,23 +354,6 @@ class SubtitlesFragment : DialogFragment() { 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 @@ -363,30 +364,35 @@ class SubtitlesFragment : DialogFragment() { override fun onStart() { super.onStart() - dialog?.window?.setWindowAnimations(R.style.DialogFullscreen) + dialog?.window?.setWindowAnimations(R.style.DialogFullscreenPlayer) } override fun getTheme(): Int { - return R.style.DialogFullscreen + return R.style.DialogFullscreenPlayer } - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) + var systemBarsAddPadding = isLayout(TV or EMULATOR) + override fun fixLayout(view: View) { + fixSystemBarsPadding( + view, + padBottom = systemBarsAddPadding || isLandscape(), + padLeft = systemBarsAddPadding + ) + } + + override fun onBindingCreated(binding: SubtitleSettingsBinding) { 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 } @@ -410,7 +416,7 @@ class SubtitlesFragment : DialogFragment() { return@setOnLongClickListener true } } - binding?.apply { + binding.apply { subsTextColor.setup(0) subsOutlineColor.setup(1) subsBackgroundColor.setup(2) @@ -426,7 +432,7 @@ class SubtitlesFragment : DialogFragment() { // 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..30).map { x -> + ) + (1..40).map { x -> val i = x * 10 i to "${i}dp" } @@ -482,6 +488,33 @@ class SubtitlesFragment : DialogFragment() { 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( @@ -605,10 +638,9 @@ class SubtitlesFragment : DialogFragment() { subtitlesFilterSubLang.setOnCheckedChangeListener { _, b -> context?.let { ctx -> - PreferenceManager.getDefaultSharedPreferences(ctx) - .edit() - .putBoolean(getString(R.string.filter_sub_lang_key), b) - .apply() + PreferenceManager.getDefaultSharedPreferences(ctx).edit { + putBoolean(getString(R.string.filter_sub_lang_key), b) + } } } @@ -670,28 +702,29 @@ class SubtitlesFragment : DialogFragment() { subsAutoSelectLanguage.setFocusableInTv() subsAutoSelectLanguage.setOnClickListener { textView -> - val langMap = arrayListOf( - SubtitleHelper.Language639( - textView.context.getString(R.string.none), - textView.context.getString(R.string.none), - "", - "", - "", - "", - "" - ), - ) - langMap.addAll(SubtitleHelper.languages) + 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 lang639_1 = langMap.map { it.ISO_639_1 } activity?.showDialog( - langMap.map { it.languageName }, - lang639_1.indexOf(getAutoSelectLanguageISO639_1()), + langNames, + langTagsIETF.indexOf(getAutoSelectLanguageTagIETF()), (textView as TextView).text.toString(), true, dismissCallback ) { index -> - setKey(SUBTITLE_AUTO_SELECT_KEY, lang639_1[index]) + setKey(SUBTITLE_AUTO_SELECT_KEY, langTagsIETF[index]) } } @@ -703,18 +736,26 @@ class SubtitlesFragment : DialogFragment() { subsDownloadLanguages.setFocusableInTv() subsDownloadLanguages.setOnClickListener { textView -> - 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 } + 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 } activity?.showMultiDialog( - langMap.map { it.languageName }, - keyMap, + langNames, + selectedLanguages, (textView as TextView).text.toString(), dismissCallback ) { indexList -> - setKey(SUBTITLE_DOWNLOAD_KEY, indexList.map { lang639_1[it] }.toList()) + setKey(SUBTITLE_DOWNLOAD_KEY, indexList.map { langTagsIETF[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 deleted file mode 100644 index 820a01f9f40..00000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/AniSkip.kt +++ /dev/null @@ -1,139 +0,0 @@ -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 28b194531ce..1377ccd08ad 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/AppContextUtils.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/AppContextUtils.kt @@ -6,7 +6,6 @@ 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 @@ -19,13 +18,9 @@ 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 @@ -37,6 +32,7 @@ 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 @@ -44,7 +40,6 @@ 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 @@ -59,8 +54,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 @@ -90,25 +85,18 @@ 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) - } +object AppContextUtils { fun RecyclerView.isRecyclerScrollable(): Boolean { val layoutManager = this.layoutManager as? LinearLayoutManager? @@ -159,12 +147,12 @@ object AppContextUtils { text.toSpanned() } } - + /** Get channel ID by name */ @SuppressLint("RestrictedApi") private fun buildWatchNextProgramUri( context: Context, card: DataStoreHelper.ResumeWatchingResult, - resumeWatching: VideoDownloadHelper.ResumeWatching? + resumeWatching: DownloadObjects.ResumeWatching? ): WatchNextProgram { val isSeries = card.type?.isMovieType() == false val title = if (isSeries) { @@ -182,10 +170,10 @@ object AppContextUtils { ) .setWatchNextType(TvContractCompat.WatchNextPrograms.WATCH_NEXT_TYPE_CONTINUE) .setTitle(title) - .setPosterArtUri(Uri.parse(card.posterUrl)) - .setIntentUri(Uri.parse(card.id?.let { + .setPosterArtUri(card.posterUrl?.toUri()) + .setIntentUri((card.id?.let { "$APP_STRING_RESUME_WATCHING://$it" - } ?: card.url)) + } ?: card.url).toUri()) .setInternalProviderId(card.url) .setLastEngagementTimeUtcMillis( resumeWatching?.updateTime ?: System.currentTimeMillis() @@ -331,7 +319,7 @@ object AppContextUtils { val context = this continueWatchingLock.withLock { // A way to get all last watched timestamps - val timeStampHashMap = HashMap() + val timeStampHashMap = HashMap() getAllResumeStateIds()?.forEach { id -> val lastWatched = getLastWatched(id) ?: return@forEach timeStampHashMap[lastWatched.parentId] = lastWatched @@ -376,25 +364,6 @@ object AppContextUtils { } } - @SuppressLint("Range") - fun getVideoContentUri(context: Context, videoFilePath: String): Uri? { - val cursor = context.contentResolver.query( - MediaStore.Video.Media.EXTERNAL_CONTENT_URI, arrayOf(MediaStore.Video.Media._ID), - MediaStore.Video.Media.DATA + "=? ", arrayOf(videoFilePath), null - ) - return if (cursor != null && cursor.moveToFirst()) { - val id = cursor.getInt(cursor.getColumnIndex(MediaStore.MediaColumns._ID)) - cursor.close() - Uri.withAppendedPath(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, "" + id) - } else { - val values = ContentValues() - values.put(MediaStore.Video.Media.DATA, videoFilePath) - context.contentResolver.insert( - MediaStore.Video.Media.EXTERNAL_CONTENT_URI, values - ) - } - } - fun sortSubs(subs: Set): List { return subs.sortedBy { it.name } } @@ -480,6 +449,14 @@ 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 @@ -562,6 +539,7 @@ object AppContextUtils { val repo = RepositoryManager.parseRepository(url) ?: return@ioSafe RepositoryManager.addRepository( RepositoryData( + repo.iconUrl ?: "", repo.name, url ) @@ -577,45 +555,6 @@ 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, @@ -672,7 +611,7 @@ object AppContextUtils { ) = (this.getActivity() ?: activity)?.runOnUiThread { try { val intent = Intent(Intent.ACTION_VIEW) - intent.data = Uri.parse(url) + intent.data = url.toUri() 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 @@ -756,6 +695,18 @@ 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()) @@ -911,100 +862,6 @@ object AppContextUtils { } } - // Copied from https://github.com/videolan/vlc-android/blob/master/application/vlc-android/src/org/videolan/vlc/util/FileUtils.kt - @SuppressLint("Range") - fun Context.getUri(data: Uri?): Uri? { - var uri = data - val ctx = this - if (data != null && data.scheme == "content") { - // Mail-based apps - download the stream to a temporary file and play it - if ("com.fsck.k9.attachmentprovider" == data.host || "gmail-ls" == data.host) { - var inputStream: InputStream? = null - var os: OutputStream? = null - var cursor: Cursor? = null - try { - cursor = ctx.contentResolver.query( - data, - arrayOf(MediaStore.MediaColumns.DISPLAY_NAME), null, null, null - ) - if (cursor != null && cursor.moveToFirst()) { - val filename = - cursor.getString(cursor.getColumnIndex(MediaStore.MediaColumns.DISPLAY_NAME)) - .replace("/", "") - inputStream = ctx.contentResolver.openInputStream(data) - if (inputStream == null) return data - os = - FileOutputStream(Environment.getExternalStorageDirectory().path + "/Download/" + filename) - val buffer = ByteArray(1024) - var bytesRead = inputStream.read(buffer) - while (bytesRead >= 0) { - os.write(buffer, 0, bytesRead) - bytesRead = inputStream.read(buffer) - } - uri = - Uri.fromFile(File(Environment.getExternalStorageDirectory().path + "/Download/" + filename)) - } - } catch (e: Exception) { - return null - } finally { - inputStream?.close() - os?.close() - cursor?.close() - } - } else if (data.authority == "media") { - uri = this.contentResolver.query( - data, - arrayOf(MediaStore.Video.Media.DATA), null, null, null - )?.use { - val columnIndex = it.getColumnIndexOrThrow(MediaStore.Video.Media.DATA) - if (it.moveToFirst()) Uri.fromFile(File(it.getString(columnIndex))) - ?: data else data - } - //uri = MediaUtils.getContentMediaUri(data) - /*} else if (data.authority == ctx.getString(R.string.tv_provider_authority)) { - println("TV AUTHORITY") - //val medialibrary = Medialibrary.getInstance() - //val media = medialibrary.getMedia(data.lastPathSegment!!.toLong()) - uri = null//media.uri*/ - } else { - val inputPFD: ParcelFileDescriptor? - try { - inputPFD = ctx.contentResolver.openFileDescriptor(data, "r") - if (inputPFD == null) return data - uri = Uri.parse("fd://" + inputPFD.fd) - // Cursor returnCursor = - // getContentResolver().query(data, null, null, null, null); - // if (returnCursor != null) { - // if (returnCursor.getCount() > 0) { - // int nameIndex = returnCursor.getColumnIndex(OpenableColumns.DISPLAY_NAME); - // if (nameIndex > -1) { - // returnCursor.moveToFirst(); - // title = returnCursor.getString(nameIndex); - // } - // } - // returnCursor.close(); - // } - } catch (e: FileNotFoundException) { - Log.e("TAG", "${e.message} for $data", e) - return null - } catch (e: IllegalArgumentException) { - Log.e("TAG", "${e.message} for $data", e) - return null - } catch (e: IllegalStateException) { - Log.e("TAG", "${e.message} for $data", e) - return null - } catch (e: NullPointerException) { - Log.e("TAG", "${e.message} for $data", e) - return null - } catch (e: SecurityException) { - Log.e("TAG", "${e.message} for $data", e) - return null - } - }// Media or MMS URI - } - return uri - } - fun Context.isUsingMobileData(): Boolean { val connectionManager = getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { 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 9f77597b084..10736e13e5f 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/BackPressedCallbackHelper.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/BackPressedCallbackHelper.kt @@ -2,31 +2,61 @@ 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>() - fun ComponentActivity.attachBackPressedCallback(id: String, callback: () -> Unit) { - val callbackMap = backPressedCallbacks.getOrPut(this) { mutableMapOf() } + 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 + ) { + 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() { - callback.invoke() + CallbackHelper(activityRef, this).callback() } } - callbackMap[id] = newCallback + callbackMap[id] = newCallback onBackPressedDispatcher.addCallback(this, newCallback) } + fun ComponentActivity.disableBackPressedCallback(id : String) { + backPressedCallbacks[this]?.get(id)?.isEnabled = false + } + + fun ComponentActivity.enableBackPressedCallback(id : String) { + backPressedCallbacks[this]?.get(id)?.isEnabled = true + } + fun ComponentActivity.detachBackPressedCallback(id: String) { val callbackMap = backPressedCallbacks[this] ?: return - callbackMap[id]?.let { callback -> - callback.isEnabled = false + callback.remove() callbackMap.remove(id) } @@ -34,4 +64,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 9bc00a99203..88cb7481c9a 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/BackupUtils.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/BackupUtils.kt @@ -1,6 +1,5 @@ package com.lagradost.cloudstream3.utils -import android.annotation.SuppressLint import android.content.Context import android.net.Uri import android.widget.Toast @@ -12,23 +11,16 @@ import androidx.fragment.app.FragmentActivity import androidx.preference.PreferenceManager import com.fasterxml.jackson.annotation.JsonProperty import com.fasterxml.jackson.module.kotlin.readValue -import com.lagradost.cloudstream3.AcraApplication.Companion.getActivity +import com.lagradost.cloudstream3.CloudStreamApp.Companion.getActivity import com.lagradost.cloudstream3.CommonActivity.showToast import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.plugins.PLUGINS_KEY 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.AniListApi.Companion.ANILIST_TOKEN_KEY -import com.lagradost.cloudstream3.syncproviders.providers.AniListApi.Companion.ANILIST_UNIXTIME_KEY -import com.lagradost.cloudstream3.syncproviders.providers.AniListApi.Companion.ANILIST_USER_KEY import com.lagradost.cloudstream3.syncproviders.providers.MALApi.Companion.MAL_CACHED_LIST -import com.lagradost.cloudstream3.syncproviders.providers.MALApi.Companion.MAL_REFRESH_TOKEN_KEY -import com.lagradost.cloudstream3.syncproviders.providers.MALApi.Companion.MAL_TOKEN_KEY -import com.lagradost.cloudstream3.syncproviders.providers.MALApi.Companion.MAL_UNIXTIME_KEY -import com.lagradost.cloudstream3.syncproviders.providers.MALApi.Companion.MAL_USER_KEY -import com.lagradost.cloudstream3.syncproviders.providers.OpenSubtitlesApi.Companion.OPEN_SUBTITLES_USER_KEY -import com.lagradost.cloudstream3.syncproviders.providers.SubDlApi.Companion.SUBDL_SUBTITLES_USER_KEY +import com.lagradost.cloudstream3.syncproviders.providers.KitsuApi.Companion.KITSU_CACHED_LIST import com.lagradost.cloudstream3.utils.Coroutines.ioSafe import com.lagradost.cloudstream3.utils.Coroutines.main import com.lagradost.cloudstream3.utils.DataStore.getDefaultSharedPrefs @@ -36,19 +28,22 @@ 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.VideoDownloadManager.StreamData -import com.lagradost.cloudstream3.utils.VideoDownloadManager.getBasePath -import com.lagradost.cloudstream3.utils.VideoDownloadManager.setupStream +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.safefile.MediaFileContentType import com.lagradost.safefile.SafeFile import okhttp3.internal.closeQuietly -import java.io.File import java.io.IOException import java.io.OutputStream import java.io.PrintWriter import java.lang.System.currentTimeMillis import java.text.SimpleDateFormat import java.util.Date +import java.util.Locale object BackupUtils { @@ -56,24 +51,18 @@ object BackupUtils { * No sensitive or breaking data in the backup * */ private val nonTransferableKeys = listOf( - // When sharing backup we do not want to transfer what is essentially the password - ANILIST_TOKEN_KEY, ANILIST_CACHED_LIST, - ANILIST_UNIXTIME_KEY, - ANILIST_USER_KEY, - MAL_TOKEN_KEY, - MAL_REFRESH_TOKEN_KEY, MAL_CACHED_LIST, - MAL_UNIXTIME_KEY, - MAL_USER_KEY, + KITSU_CACHED_LIST, // The plugins themselves are not backed up PLUGINS_KEY, PLUGINS_KEY_LOCAL, - OPEN_SUBTITLES_USER_KEY, - SUBDL_SUBTITLES_USER_KEY, + 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 @@ -81,7 +70,44 @@ object BackupUtils { "download_path_key", "download_path_key_visual", "backup_path_key", - "backup_dir_path_key" + "backup_dir_path_key", + + // When sharing backup we do not want to transfer what is essentially the password + // Note that this is deprecated, and can be removed after all tokens have expired + "anilist_token", + "anilist_user", + "mal_user", + "mal_token", + "mal_refresh_token", + "mal_unixtime", + "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 */ @@ -162,9 +188,13 @@ 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 @@ -177,7 +207,7 @@ object BackupUtils { return@ioSafe } - val date = SimpleDateFormat("yyyy_MM_dd_HH_mm").format(Date(currentTimeMillis())) + val date = SimpleDateFormat("yyyy_MM_dd_HH_mm", Locale.getDefault()).format(Date(currentTimeMillis())) val displayName = "CS3_Backup_${date}" val backupFile = getBackup(context) val stream = setupBackupStream(context, displayName) @@ -207,7 +237,7 @@ object BackupUtils { } @Throws(IOException::class) - private fun setupBackupStream(context: Context, name: String, ext: String = "txt"): StreamData { + private fun setupBackupStream(context: Context, name: String, ext: String = "txt"): DownloadObjects.StreamData { return setupStream( baseFile = getCurrentBackupDir(context).first ?: getDefaultBackupDir(context) ?: throw IOException("Bad config"), @@ -289,7 +319,7 @@ object BackupUtils { } /** - * Copy of [VideoDownloadManager.basePathToFile], [VideoDownloadManager.getDefaultDir] and [VideoDownloadManager.getBasePath] + * Copy of [com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager.basePathToFile], [com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager.getDefaultDir] and [com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager.getBasePath] * modded for backup specific paths * */ @@ -299,7 +329,8 @@ object BackupUtils { fun getCurrentBackupDir(context: Context): Pair { val settingsManager = PreferenceManager.getDefaultSharedPreferences(context) - val basePathSetting = settingsManager.getString(context.getString(R.string.backup_path_key), null) + val basePathSetting = + settingsManager.getString(context.getString(R.string.backup_path_key), null) return baseBackupPathToFile(context, basePathSetting) to basePathSetting } 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 1d9cf5f46ee..bce8f09dced 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/BiometricAuthenticator.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/BiometricAuthenticator.kt @@ -1,5 +1,6 @@ package com.lagradost.cloudstream3.utils +import android.annotation.SuppressLint import android.app.Activity import android.app.KeyguardManager import android.content.Context @@ -100,31 +101,51 @@ 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 + } + } - 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 + 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 + } } - } 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 + + 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 + } } } 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 d83731658b7..b48c8d40a69 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 android.net.Uri +import androidx.core.net.toUri 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(Uri.parse(srcPoster))) + movieMetadata.addImage(WebImage(srcPoster.toUri())) } 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 new file mode 100644 index 00000000000..def41d7a07a --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/ConsistentLiveData.kt @@ -0,0 +1,47 @@ +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 b5192aae287..0a1db85fadb 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/DataStore.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/DataStore.kt @@ -6,21 +6,24 @@ import androidx.preference.PreferenceManager import com.fasterxml.jackson.databind.DeserializationFeature import com.fasterxml.jackson.databind.json.JsonMapper import com.fasterxml.jackson.module.kotlin.kotlinModule -import com.lagradost.cloudstream3.AcraApplication.Companion.getKeyClass -import com.lagradost.cloudstream3.AcraApplication.Companion.removeKey -import com.lagradost.cloudstream3.AcraApplication.Companion.setKeyClass +import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKeyClass +import com.lagradost.cloudstream3.CloudStreamApp.Companion.removeKey +import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKeyClass import com.lagradost.cloudstream3.mvvm.logError import kotlin.reflect.KClass import kotlin.reflect.KProperty +import androidx.core.content.edit +/** Used to display metadata about downloads and resume watching */ const val DOWNLOAD_HEADER_CACHE = "download_header_cache" +const val DOWNLOAD_HEADER_CACHE_BACKUP = "BACKUP_download_header_cache" //const val WATCH_HEADER_CACHE = "watch_header_cache" const val DOWNLOAD_EPISODE_CACHE = "download_episode_cache" +const val DOWNLOAD_EPISODE_CACHE_BACKUP = "BACKUP_download_episode_cache" const val VIDEO_PLAYER_BRIGHTNESS = "video_player_alpha_key" const val USER_SELECTED_HOMEPAGE_API = "home_api_used" const val USER_PROVIDER_API = "user_custom_sites" - const val PREFERENCES_NAME = "rebuild_preference" // TODO degelgate by value for get & set @@ -29,6 +32,7 @@ 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 @@ -52,10 +56,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) @@ -70,7 +74,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 } @@ -95,13 +99,15 @@ object DataStore { return getPreferences(this) } + fun getFolderName(folder: String, path: String): String { 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) } @@ -110,7 +116,9 @@ object DataStore { } fun Context.getKeys(folder: String): List { - return this.getSharedPrefs().all.keys.filter { it.startsWith(folder) } + // 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) } } fun Context.removeKey(folder: String, path: String) { @@ -130,9 +138,9 @@ object DataStore { try { val prefs = getSharedPrefs() if (prefs.contains(path)) { - val editor: SharedPreferences.Editor = prefs.edit() - editor.remove(path) - editor.apply() + prefs.edit { + remove(path) + } } } catch (e: Exception) { logError(e) @@ -141,17 +149,24 @@ object DataStore { fun Context.removeKeys(folder: String): Int { val keys = getKeys("$folder/") - keys.forEach { value -> - removeKey(value) + try { + getSharedPrefs().edit { + keys.forEach { value -> + remove(value) + } + } + return keys.size + } catch (e: Exception) { + logError(e) + return 0 } - return keys.size } fun Context.setKey(path: String, value: T) { try { - val editor: SharedPreferences.Editor = getSharedPrefs().edit() - editor.putString(path, mapper.writeValueAsString(value)) - editor.apply() + getSharedPrefs().edit { + putString(path, mapper.writeValueAsString(value)) + } } catch (e: Exception) { logError(e) } 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 650502dcaba..19caead21ee 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/DataStoreHelper.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/DataStoreHelper.kt @@ -3,18 +3,20 @@ 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.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.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.CommonActivity.showToast import com.lagradost.cloudstream3.DubStatus import com.lagradost.cloudstream3.EpisodeResponse import com.lagradost.cloudstream3.MainActivity import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.Score import com.lagradost.cloudstream3.SearchQuality import com.lagradost.cloudstream3.SearchResponse import com.lagradost.cloudstream3.TvType @@ -22,9 +24,13 @@ 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 @@ -44,7 +50,7 @@ const val RESULT_EPISODE = "result_episode" const val RESULT_SEASON = "result_season" const val RESULT_DUB = "result_dub" const val KEY_RESULT_SORT = "result_sort" - +const val USER_PINNED_PROVIDERS = "user_pinned_providers" //key for pinned user set class UserPreferenceDelegate( private val key: String, private val default: T //, private val klass: KClass @@ -52,7 +58,7 @@ class UserPreferenceDelegate( private val klass: KClass = default::class private val realKey get() = "${DataStoreHelper.currentAccount}/$key" operator fun getValue(self: Any?, property: KProperty<*>) = - AcraApplication.getKeyClass(realKey, klass.java) ?: default + getKeyClass(realKey, klass.java) ?: default operator fun setValue( self: Any?, @@ -62,7 +68,7 @@ class UserPreferenceDelegate( if (t == null) { removeKey(realKey) } else { - AcraApplication.setKeyClass(realKey, t) + setKeyClass(realKey, t) } } } @@ -79,50 +85,64 @@ object DataStoreHelper { R.drawable.profile_bg_teal ) - private var searchPreferenceProvidersStrings : List by UserPreferenceDelegate( + private var searchPreferenceProvidersStrings: List by UserPreferenceDelegate( /** java moment right here, as listOf()::class.java != List(0) { "" }::class.java */ "search_pref_providers", List(0) { "" } ) - private fun serializeTv(data : List) : List = data.map { it.name } + private fun serializeTv(data: List): List = data.map { it.name } - private fun deserializeTv(data : List) : List { + private fun deserializeTv(data: List): List { return data.mapNotNull { listName -> TvType.values().firstOrNull { it.name == listName } } } - var searchPreferenceProviders : List + var searchPreferenceProviders: List get() { val ret = searchPreferenceProvidersStrings return ret.ifEmpty { context?.filterProviderByPreferredMedia()?.map { it.name } ?: emptyList() } - } set(value) { + } + set(value) { searchPreferenceProvidersStrings = value } - private var searchPreferenceTagsStrings : List by UserPreferenceDelegate("search_pref_tags", listOf(TvType.Movie, TvType.TvSeries).map { it.name }) - var searchPreferenceTags : List + private var searchPreferenceTagsStrings: List by UserPreferenceDelegate( + "search_pref_tags", + listOf(TvType.Movie, TvType.TvSeries).map { it.name }) + var searchPreferenceTags: List get() = deserializeTv(searchPreferenceTagsStrings) set(value) { searchPreferenceTagsStrings = serializeTv(value) } - private var homePreferenceStrings : List by UserPreferenceDelegate("home_pref_homepage", listOf(TvType.Movie, TvType.TvSeries).map { it.name }) - var homePreference : List + private var homePreferenceStrings: List by UserPreferenceDelegate( + "home_pref_homepage", + listOf(TvType.Movie, TvType.TvSeries).map { it.name }) + var homePreference: List get() = deserializeTv(homePreferenceStrings) set(value) { homePreferenceStrings = serializeTv(value) } - var homeBookmarkedList : IntArray by UserPreferenceDelegate("home_bookmarked_last_list", IntArray(0)) - var playBackSpeed : Float by UserPreferenceDelegate("playback_speed", 1.0f) - var resizeMode : Int by UserPreferenceDelegate("resize_mode", 0) - var librarySortingMode : Int by UserPreferenceDelegate("library_sorting_mode", ListSorting.AlphabeticalA.ordinal) - private var _resultsSortingMode : Int by UserPreferenceDelegate("results_sorting_mode", EpisodeSortType.NUMBER_ASC.ordinal) - var resultsSortingMode : EpisodeSortType + var homeBookmarkedList: IntArray by UserPreferenceDelegate( + "home_bookmarked_last_list", + IntArray(0) + ) + var playBackSpeed: Float by UserPreferenceDelegate("playback_speed", 1.0f) + var resizeMode: Int by UserPreferenceDelegate("resize_mode", 0) + var librarySortingMode: Int by UserPreferenceDelegate( + "library_sorting_mode", + ListSorting.AlphabeticalA.ordinal + ) + private var _resultsSortingMode: Int by UserPreferenceDelegate( + "results_sorting_mode", + EpisodeSortType.NUMBER_ASC.ordinal + ) + var resultsSortingMode: EpisodeSortType get() = EpisodeSortType.entries.getOrNull(_resultsSortingMode) ?: EpisodeSortType.NUMBER_ASC set(value) { _resultsSortingMode = value.ordinal @@ -140,7 +160,10 @@ object DataStoreHelper { @JsonProperty("lockPin") val lockPin: String? = null, ) { - val image get() = customImage?.let { UiImage.Image(it) } ?: profileImages.getOrNull(defaultImageIndex)?.let { UiImage.Drawable(it) } ?: UiImage.Drawable(profileImages.first()) + val image + get() = customImage?.let { UiImage.Image(it) } ?: profileImages.getOrNull( + defaultImageIndex + )?.let { UiImage.Drawable(it) } ?: UiImage.Drawable(profileImages.first()) } const val TAG = "data_store_helper" @@ -167,6 +190,7 @@ object DataStoreHelper { val homepage = currentHomePage selectedKeyIndex = account.keyIndex + AccountManager.updateAccountIds() showToast(context?.getString(R.string.logged_account, account.name) ?: account.name) MainActivity.bookmarksUpdatedEvent(true) MainActivity.reloadLibraryEvent(true) @@ -222,7 +246,8 @@ object DataStoreHelper { return this } - fun Int.toYear() : Date = GregorianCalendar.getInstance().also { it.set(Calendar.YEAR, this) }.time + fun Int.toYear(): Date = + GregorianCalendar.getInstance().also { it.set(Calendar.YEAR, this) }.time /** * Used to display notifications on new episodes and posters in library. @@ -239,10 +264,24 @@ object DataStoreHelper { @JsonProperty("syncData") open val syncData: Map?, @JsonProperty("quality") override var quality: SearchQuality?, @JsonProperty("posterHeaders") override var posterHeaders: Map?, - @JsonProperty("plot") open val plot : String? = null, - @JsonProperty("rating") open val rating : Int? = null, - @JsonProperty("tags") open val tags : List? = null, - ) : SearchResponse + @JsonProperty("plot") open val plot: String? = null, + @JsonProperty("score") override var score: Score? = null, + @JsonProperty("tags") open val tags: List? = null, + ) : SearchResponse { + @JsonProperty("rating", access = JsonProperty.Access.WRITE_ONLY) + @Deprecated( + "`rating` is the old scoring system, use score instead", + replaceWith = ReplaceWith("score"), + level = DeprecationLevel.ERROR + ) + var rating: Int? = null + set(value) { + if (value != null) { + @Suppress("DEPRECATION_ERROR") + score = Score.fromOld(value) + } + } + } data class SubscribedData( @JsonProperty("subscribedTime") val subscribedTime: Long, @@ -259,9 +298,24 @@ object DataStoreHelper { override var quality: SearchQuality? = null, override var posterHeaders: Map? = null, override val plot: String? = null, - override val rating: Int? = null, + override var score: Score? = null, override val tags: List? = null, - ) : LibrarySearchResponse(id, latestUpdatedTime, name, url, apiName, type, posterUrl, year, syncData, quality, posterHeaders, plot,rating,tags) { + ) : LibrarySearchResponse( + id, + latestUpdatedTime, + name, + url, + apiName, + type, + posterUrl, + year, + syncData, + quality, + posterHeaders, + plot, + score, + tags + ) { fun toLibraryItem(): SyncAPI.LibraryItem? { return SyncAPI.LibraryItem( name, @@ -271,7 +325,16 @@ object DataStoreHelper { null, null, latestUpdatedTime, - apiName, type, posterUrl, posterHeaders, quality, year?.toYear(), this.id, plot = this.plot, rating = this.rating, tags = this.tags + apiName, + type, + posterUrl, + posterHeaders, + quality, + year?.toYear(), + this.id, + plot = this.plot, + score = this.score, + tags = this.tags ) } } @@ -290,9 +353,22 @@ object DataStoreHelper { override var quality: SearchQuality? = null, override var posterHeaders: Map? = null, override val plot: String? = null, - override val rating: Int? = null, + override var score: Score? = null, override val tags: List? = null, - ) : LibrarySearchResponse(id, latestUpdatedTime, name, url, apiName, type, posterUrl, year, syncData, quality, posterHeaders, plot) { + ) : LibrarySearchResponse( + id, + latestUpdatedTime, + name, + url, + apiName, + type, + posterUrl, + year, + syncData, + quality, + posterHeaders, + plot + ) { fun toLibraryItem(id: String): SyncAPI.LibraryItem { return SyncAPI.LibraryItem( name, @@ -302,7 +378,16 @@ object DataStoreHelper { null, null, latestUpdatedTime, - apiName, type, posterUrl, posterHeaders, quality, year?.toYear(), this.id, plot = this.plot, rating = this.rating, tags = this.tags + apiName, + type, + posterUrl, + posterHeaders, + quality, + year?.toYear(), + this.id, + plot = this.plot, + score = this.score, + tags = this.tags ) } } @@ -321,9 +406,22 @@ object DataStoreHelper { override var quality: SearchQuality? = null, override var posterHeaders: Map? = null, override val plot: String? = null, - override val rating: Int? = null, + override var score: Score? = null, override val tags: List? = null, - ) : LibrarySearchResponse(id, latestUpdatedTime, name, url, apiName, type, posterUrl, year, syncData, quality, posterHeaders,plot) { + ) : LibrarySearchResponse( + id, + latestUpdatedTime, + name, + url, + apiName, + type, + posterUrl, + year, + syncData, + quality, + posterHeaders, + plot + ) { fun toLibraryItem(): SyncAPI.LibraryItem? { return SyncAPI.LibraryItem( name, @@ -333,7 +431,16 @@ object DataStoreHelper { null, null, latestUpdatedTime, - apiName, type, posterUrl, posterHeaders, quality, year?.toYear(), this.id, plot = this.plot, rating = this.rating, tags = this.tags + apiName, + type, + posterUrl, + posterHeaders, + quality, + year?.toYear(), + this.id, + plot = this.plot, + score = this.score, + tags = this.tags ) } } @@ -352,6 +459,7 @@ object DataStoreHelper { @JsonProperty("isFromDownload") val isFromDownload: Boolean, @JsonProperty("quality") override var quality: SearchQuality? = null, @JsonProperty("posterHeaders") override var posterHeaders: Map? = null, + @JsonProperty("score") override var score: Score? = null, ) : SearchResponse /** @@ -422,7 +530,7 @@ object DataStoreHelper { setKey( "$currentAccount/$RESULT_RESUME_WATCHING", parentId.toString(), - VideoDownloadHelper.ResumeWatching( + DownloadObjects.ResumeWatching( parentId, episodeId, episode, @@ -443,7 +551,7 @@ object DataStoreHelper { removeKey("$currentAccount/$RESULT_RESUME_WATCHING", parentId.toString()) } - fun getLastWatched(id: Int?): VideoDownloadHelper.ResumeWatching? { + fun getLastWatched(id: Int?): DownloadObjects.ResumeWatching? { if (id == null) return null return getKey( "$currentAccount/$RESULT_RESUME_WATCHING", @@ -451,7 +559,7 @@ object DataStoreHelper { ) } - private fun getLastWatchedOld(id: Int?): VideoDownloadHelper.ResumeWatching? { + private fun getLastWatchedOld(id: Int?): DownloadObjects.ResumeWatching? { if (id == null) return null return getKey( "$currentAccount/$RESULT_RESUME_WATCHING_OLD", @@ -540,6 +648,62 @@ 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) @@ -562,7 +726,7 @@ object DataStoreHelper { } fun getDub(id: Int): DubStatus? { - return DubStatus.values() + return DubStatus.entries .getOrNull(getKey("$currentAccount/$RESULT_DUB", id.toString(), -1) ?: -1) } @@ -614,4 +778,9 @@ object DataStoreHelper { getKey("${idPrefix}_sync", id.toString()) } } + + 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 deleted file mode 100644 index 4eeb4e5da43..00000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/DownloadFileWorkManager.kt +++ /dev/null @@ -1,104 +0,0 @@ -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 a0dfe734e54..f66da4e5ff3 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/Event.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/Event.kt @@ -24,3 +24,28 @@ 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 14d1b055654..8456094d1e9 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/FillerEpisodeCheck.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/FillerEpisodeCheck.kt @@ -1,112 +1,166 @@ package com.lagradost.cloudstream3.utils -import com.lagradost.cloudstream3.app +import androidx.annotation.WorkerThread +import com.fasterxml.jackson.annotation.JsonProperty +import com.lagradost.cloudstream3.LoadResponse +import com.lagradost.cloudstream3.LoadResponse.Companion.getAniListId +import com.lagradost.cloudstream3.LoadResponse.Companion.getImdbId +import com.lagradost.cloudstream3.LoadResponse.Companion.getKitsuId +import com.lagradost.cloudstream3.LoadResponse.Companion.getMalId +import com.lagradost.cloudstream3.LoadResponse.Companion.getTMDbId +import com.lagradost.cloudstream3.TvType +import com.lagradost.cloudstream3.ui.result.getId import com.lagradost.cloudstream3.utils.Coroutines.main -import org.jsoup.Jsoup import java.lang.Thread.sleep import java.util.* import kotlin.concurrent.thread +import com.lagradost.cloudstream3.utils.AppUtils.parseJson +import java.io.InputStream +import kotlin.let object FillerEpisodeCheck { - private const val MAIN_URL = "https://www.animefillerlist.com" + fun String?.toClassDir(): String { + val q = this ?: "null" + val z = (6..10).random().calc() + return q + "cache" + z + } - var list: HashMap? = null - var cache: HashMap> = hashMapOf() + data class Show( + @JsonProperty("slug") + val slug: String, + @JsonProperty("title") + val title: String, + @JsonProperty("filler") + val filler: ArrayList, + @JsonProperty("mixedCanon") + val mixedCanon: ArrayList, + @JsonProperty("mangaCanon") + val mangaCanon: ArrayList, + @JsonProperty("animeCanon") + val animeCanon: ArrayList, + ) - private fun fixName(name: String): String { - return name.lowercase(Locale.ROOT)/*.replace(" ", "")*/.replace("-", " ") - .replace("[^a-zA-Z0-9 ]".toRegex(), "") - } + data class MappingRoot( + @JsonProperty("type") + val type: String?, + @JsonProperty("anidb_id") + val anidbId: Long?, + @JsonProperty("anilist_id") + val anilistId: Long?, + @JsonProperty("animecountdown_id") + val animecountdownId: Long?, + @JsonProperty("animenewsnetwork_id") + val animenewsnetworkId: Long?, + @JsonProperty("anime-planet_id") + val animePlanetId: String?, + @JsonProperty("anisearch_id") + val anisearchId: Long?, + @JsonProperty("imdb_id") + val imdbId: String?, + @JsonProperty("kitsu_id") + val kitsuId: Long?, + @JsonProperty("livechart_id") + val livechartId: Long?, + @JsonProperty("mal_id") + val malId: Long?, + @JsonProperty("simkl_id") + val simklId: Long?, + @JsonProperty("themoviedb_id") + val themoviedbId: Long?, + @JsonProperty("tvdb_id") + val tvdbId: Long?, + @JsonProperty("season") + val season: Season?, + ) - private suspend fun getFillerList(): Boolean { - if (list != null) return true - try { - val result = app.get("$MAIN_URL/shows").text - val documented = Jsoup.parse(result) - val localHTMLList = documented.select("div#ShowList > div.Group > ul > li > a") - val localList = HashMap() - for (i in localHTMLList) { - val name = i.text() - - if (name.lowercase(Locale.ROOT).contains("manga only")) continue - - val href = i.attr("href") - if (name.isNullOrEmpty() || href.isNullOrEmpty()) { - continue - } + data class Season( + @JsonProperty("tvdb") + val tvdb: Long?, + @JsonProperty("tmdb") + val tmdb: Long?, + ) - val values = "(.*) \\((.*)\\)".toRegex().matchEntire(name)?.groups - if (values != null) { - for (index in 1 until values.size) { - val localName = values[index]?.value ?: continue - localList[fixName(localName)] = href - } - } else { - localList[fixName(name)] = href - } - } - if (localList.size > 0) { - list = localList - return true - } - } catch (e: Exception) { - e.printStackTrace() + data class CombinedMedia( + @JsonProperty("mapping") + val mapping: MappingRoot?, + @JsonProperty("show") + val show: Show + ) + + data class Database( + val mal: HashMap = hashMapOf(), + val anilist: HashMap = hashMapOf(), + val kitsu: HashMap = hashMapOf(), + val tmdb: HashMap = hashMapOf(), + val imdb: HashMap = hashMapOf(), + val name: HashMap = hashMapOf(), + ) + + private var database: Database? = null + + private val strip = Regex("[ :\\-.!]") + + /** Makes names more uniform to make partial matches more still give a result */ + fun stripName(name: String): String = + name.replace(strip, "").lowercase() + + + @Synchronized + @Throws + @WorkerThread + fun loadJson(): Database { + database?.let { + return it } - return false - } + + /** The entire "database" is stored as a json file we can parse */ + val stream: InputStream = com.lagradost.AnimeDB.getDatabaseStream()!! + val text = stream.reader().readText() - fun String?.toClassDir(): String { - val q = this ?: "null" - val z = (6..10).random().calc() - return q + "cache" + z + val allMedia = parseJson>(text) + val pending = Database() + for (media in allMedia) { + val lowercase = stripName(media.show.title) + pending.name[lowercase] = media + val map = media.mapping ?: continue + + map.imdbId?.let { id -> pending.imdb[id] = media } + map.malId?.let { id -> pending.mal[id] = media } + map.anilistId?.let { id -> pending.anilist[id] = media } + map.kitsuId?.let { id -> pending.kitsu[id] = media } + map.season?.tmdb?.let { id -> pending.tmdb[id] = media } + } + database = pending + return pending } - suspend fun getFillerEpisodes(query: String): HashMap? { - try { - cache[query]?.let { - return it - } - if (!getFillerList()) return null - val localList = list ?: return null - - // Strips these from the name - val blackList = listOf( - "TV Dubbed", - "(Dub)", - "Subbed", - "(TV)", - "(Uncensored)", - "(Censored)", - "(\\d+)" // year - ) - val blackListRegex = - Regex( - """ (${ - blackList.joinToString(separator = "|").replace("(", "\\(") - .replace(")", "\\)") - })""" - ) - - val realQuery = - fixName(query.replace(blackListRegex, "")).replace("shippuuden", "shippuden") - if (!localList.containsKey(realQuery)) return null - val href = localList[realQuery]?.replace(MAIN_URL, "") ?: return null // JUST IN CASE - val result = app.get("$MAIN_URL$href").text - val documented = Jsoup.parse(result) ?: 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() + val loadCache: HashMap?> = hashMapOf() + + @Synchronized + @Throws + @WorkerThread + fun getFillerEpisodes(data: LoadResponse): HashSet? { + /** Only for anime */ + if (data.type != TvType.Anime) { return null } + /** Try to hit the cache for this entry, to avoid recreating the hashset */ + loadCache[data.getId()]?.let { cachedResponse -> + return cachedResponse + } + val db = loadJson() + + val media = + db.mal[data.getMalId()?.toLongOrNull()] + ?: db.anilist[data.getAniListId()?.toLongOrNull()] + ?: db.kitsu[data.getKitsuId()?.toLongOrNull()] + ?: db.imdb[data.getImdbId()] + ?: db.tmdb[data.getTMDbId()?.toLongOrNull()] + ?: db.name[stripName(data.name)] + + return media?.show?.filler?.toHashSet().also { response -> + loadCache[data.getId()] = response + } } private fun Int.calc(): Int { diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/GitInfo.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/GitInfo.kt new file mode 100644 index 00000000000..58ff44bb257 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/GitInfo.kt @@ -0,0 +1,20 @@ +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 a75d1b43748..9d5c75289c8 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/ImageModuleCoil.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/ImageModuleCoil.kt @@ -12,6 +12,7 @@ import coil3.ImageLoader import coil3.PlatformContext import coil3.SingletonImageLoader import coil3.disk.DiskCache +import coil3.dispose import coil3.load import coil3.memory.MemoryCache import coil3.network.NetworkHeaders @@ -85,10 +86,16 @@ object ImageLoader { builder: ImageRequest.Builder.() -> Unit = {} // for placeholder, error & transformations ) { // clear image to avoid loading & flickering issue at fast scrolling (e.g, an image recycler) - this.load(null) + this.dispose() if(imageData == null) return // Just in case + // setImageResource is better than coil3 on resources due to attr + if(imageData is Int) { + this.setImageResource(imageData) + return + } + // 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)) { 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 1f90b920d0c..6ed4d4afaff 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,11 +30,7 @@ fun drawableToBitmap(drawable: Drawable): Bitmap? { return when (drawable) { is BitmapDrawable -> drawable.bitmap else -> { - val bitmap = Bitmap.createBitmap( - drawable.intrinsicWidth, - drawable.intrinsicHeight, - Bitmap.Config.ARGB_8888 - ) + val bitmap = createBitmap(drawable.intrinsicWidth, drawable.intrinsicHeight) 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 8bce8f63964..8bcd1b88e70 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/InAppUpdater.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/InAppUpdater.kt @@ -3,392 +3,365 @@ 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.* +import com.lagradost.cloudstream3.BuildConfig 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.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.File 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" + + 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 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 + ) -class InAppUpdater { - companion object { - private const val GITHUB_USER_NAME = "recloudstream" - private const val GITHUB_REPO = "cloudstream" + 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() - private const val LOG_TAG = "InAppUpdater" + val found = foundList.lastOrNull() + val foundAsset = found?.assets?.getOrNull(0) + val foundVersion = foundAsset?.name?.let { versionRegex.find(it) } - // === 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 - ) + if (foundVersion == null) { + return Update(false, null, null, null, null) + } - 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 - ) + 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 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 + } - data class GithubTag( - @JsonProperty("object") val githubObject: GithubObject, + return Update( + shouldUpdate, + foundAsset.browserDownloadUrl, + foundVersion.groupValues[2], + found.body, + found.nodeId ) + } - 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.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 ) - 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) - } + val found = response.lastOrNull { rel -> + rel.prerelease || rel.tagName == "pre-release" } - 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 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 foundAsset = found?.assets?.filter { it -> + it.contentType == "application/vnd.android.package-archive" + }?.getOrNull(0) - 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) - } - } + if (foundAsset == null) { return Update(false, null, null, null, null) } - 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) + 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) + } - private val updateLock = Mutex() + fun Activity.installPreReleaseIfNeeded() = ioSafe { + val isInstalled = try { + packageManager.getPackageInfo(PRERELEASE_PACKAGE_NAME, 0) + true + } catch (_: NameNotFoundException) { + false + } - private suspend fun Activity.downloadUpdate(url: String): Boolean { - try { - Log.d(LOG_TAG, "Downloading update: $url") - val appUpdateName = "CloudStream" - val appUpdateSuffix = "apk" + if (isInstalled) { + showToast(R.string.prerelease_already_installed) + } else if (!runAutoUpdate(checkAutoUpdate = false, installPrerelease = true)) { + showToast(R.string.prerelease_install_failed) + } + } - // 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() + /** + * @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 + } - updateLock.withLock { - sink.writeAll(app.get(url).body.source()) - sink.close() - openApk(this, Uri.fromFile(downloadedFile)) - } - return true - } catch (e: Exception) { - return false - } + val update = getAppUpdate(installPrerelease) + if (!update.shouldUpdate || update.updateURL == null) { + 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) - } + // 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 } - /** - * @param checkAutoUpdate if the update check was launched automatically - **/ - suspend fun Activity.runAutoUpdate(checkAutoUpdate: Boolean = true): Boolean { - val settingsManager = PreferenceManager.getDefaultSharedPreferences(this) + runOnUiThread { + safe { + val currentVersion = packageName?.let { + packageManager.getPackageInfo(it, 0) + } - if (!checkAutoUpdate || settingsManager.getBoolean( - getString(R.string.auto_update_key), - true + val builder = AlertDialog.Builder(this, R.style.AlertDialogCustom) + builder.setTitle( + getString(R.string.new_update_format).format( + currentVersion?.versionName, update.updateVersion + ) ) - ) { - 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 - ) + 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 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), 1 + ) - val currentInstaller = - settingsManager.getInt( - getString(R.string.apk_installer_key), - 0 - ) - - when (currentInstaller) { - // New method - 0 -> { - val intent = PackageInstallerService.Companion.getIntent( - context, - update.updateURL + when (currentInstaller) { + // New method + 0 -> { + val intent = PackageInstallerService.Companion.getIntent( + this@runAutoUpdate, update.updateURL + ) + ContextCompat.startForegroundService( + this@runAutoUpdate, intent + ) + } + // Legacy + 1 -> { + ioSafe { + if (!downloadUpdate(update.updateURL)) { + runOnUiThread { + showToast( + R.string.download_failed, Toast.LENGTH_LONG ) - ContextCompat.startForegroundService(context, intent) - } - // Legacy - 1 -> { - ioSafe { - if (!downloadUpdate(update.updateURL)) - runOnUiThread { - showToast( - R.string.download_failed, - Toast.LENGTH_LONG - ) - } - } } } } + } + } + } - setNegativeButton(R.string.cancel) { _, _ -> } + setNegativeButton(R.string.cancel) { _, _ -> } - if (checkAutoUpdate) { - setNeutralButton(R.string.skip_update) { _, _ -> - settingsManager.edit().putString( - getString(R.string.skip_update_key), - update.updateNodeId ?: "" - ).apply() - } - } + if (checkAutoUpdate) { + setNeutralButton(R.string.skip_update) { _, _ -> + settingsManager.edit { + putString( + getString(R.string.skip_update_key), update.updateNodeId ?: "" + ) } - builder.show().setDefaultFocus() - } catch (e: Exception) { - logError(e) } } - return true } - return false + builder.show().setDefaultFocus() } - return false } + return true + } - private fun isMiUi(): Boolean { - return !TextUtils.isEmpty(getSystemProperty("ro.miui.ui.version.name")) - } + private fun isMiUi(): Boolean = !getSystemProperty("ro.miui.ui.version.name").isNullOrEmpty() - 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 - } + 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 4be0dd56c30..67851f629cc 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.AcraApplication.Companion.context +import com.lagradost.cloudstream3.CloudStreamApp.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 1e572fb7c3e..6580182bba8 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/PercentageCropImageView.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/PercentageCropImageView.kt @@ -4,16 +4,67 @@ 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) + + constructor(context: Context?, attrs: AttributeSet?) : super(context!!, attrs) { + initAttrs(context, attrs) + } + constructor( context: Context?, attrs: AttributeSet?, defStyle: Int - ) : super(context!!, attrs, defStyle) + ) : super(context!!, attrs, defStyle) { + initAttrs(context, attrs) + } var cropYCenterOffsetPct: Float get() = mCropYCenterOffsetPct!! @@ -43,12 +94,12 @@ class PercentageCropImageView : androidx.appcompat.widget.AppCompatImageView { var dy = 0f if (dWidth * vHeight > vWidth * dHeight) { val cropXCenterOffsetPct = - if (mCropXCenterOffsetPct != null) mCropXCenterOffsetPct!!.toFloat() else 0.5f + if (mCropXCenterOffsetPct != null) mCropXCenterOffsetPct!! else 0.5f scale = vHeight.toFloat() / dHeight.toFloat() dx = (vWidth - dWidth * scale) * cropXCenterOffsetPct } else { val cropYCenterOffsetPct = - if (mCropYCenterOffsetPct != null) mCropYCenterOffsetPct!!.toFloat() else 0f + if (mCropYCenterOffsetPct != null) mCropYCenterOffsetPct!! else 0f scale = vWidth.toFloat() / dWidth.toFloat() dy = (vHeight - dHeight * scale) * cropYCenterOffsetPct } @@ -80,6 +131,7 @@ 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(); @@ -91,4 +143,26 @@ 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 0d7a8abc4a1..e3c7d68dffd 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/PowerManagerAPI.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/PowerManagerAPI.kt @@ -3,12 +3,13 @@ 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 @@ -38,7 +39,6 @@ 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) - .apply() + settingsManager.edit { + putBoolean(getString(R.string.battery_optimisation_key), false) + } } .show() } catch (t: Throwable) { @@ -67,7 +67,7 @@ object BatteryOptimizationChecker { try { val intent = Intent().apply { action = Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS - data = Uri.parse("package:$PACKAGE_NAME") + data = "package:$PACKAGE_NAME".toUri() } 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 ea75ff62e4b..26c710103fa 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/SingleSelectionHelper.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/SingleSelectionHelper.kt @@ -22,6 +22,7 @@ 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 @@ -113,8 +114,12 @@ object SingleSelectionHelper { val textView = binding.text1 val applyButton = binding.applyBtt val cancelButton = binding.cancelBtt - val applyHolder = - binding.applyBttHolder + val applyHolder = binding.applyBttHolder + + if (isLayout(PHONE or EMULATOR) && dialog is BottomSheetDialog) { + binding.dragHandle.isVisible = true + listView.isNestedScrollingEnabled = true + } 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 66a6e156c76..c0068f91a83 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.VideoDownloadManager.getFolder -import com.lagradost.safefile.SafeFile +import com.lagradost.cloudstream3.utils.downloader.DownloadFileManagement.basePathToFile +import com.lagradost.cloudstream3.utils.downloader.DownloadObjects object SubtitleUtils { @@ -13,17 +13,21 @@ object SubtitleUtils { ".ttml", ".sbv", ".dfxp" ) - fun deleteMatchingSubtitles(context: Context, info: VideoDownloadManager.DownloadedFileInfo) { - val relative = info.relativePath - val display = info.displayName - val cleanDisplay = cleanDisplayName(display) - - 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()}") - } + fun deleteMatchingSubtitles(context: Context, info: DownloadObjects.DownloadedFileInfo) { + val cleanDisplay = cleanDisplayName(info.displayName) + + 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") } } } @@ -39,7 +43,7 @@ object SubtitleUtils { cleanDisplay: String ): Boolean { // Check if the file has a valid subtitle extension - val hasValidExtension = allowedExtensions.any { name.contains(it, ignoreCase = true) } + val hasValidExtension = allowedExtensions.any { name.endsWith(it, ignoreCase = true) } // We can't have the exact same file as a subtitle val isNotDisplayName = !name.equals(display, ignoreCase = true) @@ -53,4 +57,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/TestingUtils.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/TestingUtils.kt index 049f92fb4cc..91c8a2fc1fb 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/TestingUtils.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/TestingUtils.kt @@ -112,7 +112,7 @@ object TestingUtils { val searchResults = testQueries.firstNotNullOfOrNull { query -> try { logger.log("Searching for: $query") - api.search(query).takeIf { !it.isNullOrEmpty() } + api.search(query, 1)?.items?.takeIf { it.isNotEmpty() } } catch (e: Throwable) { if (e is NotImplementedError) { Assert.fail("Provider has not implemented search()") diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/TvChannelUtils.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/TvChannelUtils.kt new file mode 100644 index 00000000000..feecbe312df --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/TvChannelUtils.kt @@ -0,0 +1,164 @@ +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 de5314fe022..c12674816dc 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/UIHelper.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/UIHelper.kt @@ -13,7 +13,12 @@ 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 @@ -23,7 +28,6 @@ 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 @@ -31,6 +35,7 @@ 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 @@ -38,6 +43,7 @@ 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 @@ -47,6 +53,11 @@ 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 @@ -57,19 +68,21 @@ 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.AcraApplication.Companion.context -import com.lagradost.cloudstream3.CommonActivity +import com.lagradost.cloudstream3.CloudStreamApp.Companion.context 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 +import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.enableBackPressedCallback object UIHelper { val Int.toPx: Int get() = (this * Resources.getSystem().displayMetrics.density).toInt() @@ -88,7 +101,12 @@ object UIHelper { || Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) } - fun populateChips(view: ChipGroup?, tags: List, @StyleRes style : Int = R.style.ChipFilled) { + fun populateChips( + view: ChipGroup?, + tags: List, + @StyleRes style: Int = R.style.ChipFilled, + @AttrRes textColor: Int? = R.attr.white, + ) { if (view == null) return view.removeAllViews() val context = view.context ?: return @@ -108,7 +126,9 @@ object UIHelper { chip.isCheckable = false chip.isFocusable = false chip.isClickable = false - chip.setTextColor(context.colorFromAttribute(R.attr.white)) + textColor?.let { + chip.setTextColor(context.colorFromAttribute(it)) + } view.addView(chip) } } @@ -184,17 +204,15 @@ object UIHelper { listView.requestLayout() } - 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 + 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 return if (orientation == Configuration.ORIENTATION_LANDSCAPE) { spanCountLandscape - } else { - spanCountPortrait - } + } else spanCountPortrait } fun Fragment.hideKeyboard() { @@ -205,7 +223,7 @@ object UIHelper { } fun View?.setAppBarNoScrollFlagsOnTV() { - if (isLayout(Globals.TV or EMULATOR)) { + if (isLayout(TV or EMULATOR)) { this?.updateLayoutParams { scrollFlags = AppBarLayout.LayoutParams.SCROLL_FLAG_NO_SCROLL } @@ -241,10 +259,12 @@ object UIHelper { } // Open activities from an activity outside the nav graph - fun Context.openActivity(activity: Class<*>, args: Bundle? = null) { + fun Context.openActivity(activity: Class<*>, args: Bundle? = null, baseIntent: Intent? = null) { val tag = "NavComponent" try { - val intent = Intent(this, activity) + val intent = baseIntent ?: Intent() + intent.setClass(this, activity) + if (args != null) { intent.putExtras(args) } @@ -255,11 +275,15 @@ object UIHelper { } } - fun FragmentActivity.popCurrentPage() { + /** 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) { // 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) { + disableBackPressedCallback(fromBackPressedCallback) + } if (!supportFragmentManager.isStateSaved) { // Get the top fragment from the back stack Log.d("popFragment", "Destroying Fragment") @@ -275,23 +299,33 @@ object UIHelper { onBackPressedDispatcher.onBackPressed() } } + if (fromBackPressedCallback != null) { + enableBackPressedCallback(fromBackPressedCallback) + } } } @ColorInt fun Context.getResourceColor(@AttrRes resource: Int, alphaFactor: Float = 1f): Int { - val typedArray = obtainStyledAttributes(intArrayOf(resource)) - val color = typedArray.getColor(0, 0) - typedArray.recycle() + val color = colorFromAttribute(resource) + return if (alphaFactor < 1f) adjustAlpha(color, alphaFactor) else color + } - if (alphaFactor < 1f) { - val alpha = (color.alpha * alphaFactor).roundToInt() - return Color.argb(alpha, color.red, color.green, color.blue) + @ColorInt + fun Context.colorFromAttribute(@AttrRes attribute: Int): Int { + var color = 0 + withStyledAttributes(attrs = intArrayOf(attribute)) { + color = getColor(0, 0) } - 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) + } + var createPaletteAsyncCache: HashMap = hashMapOf() fun createPaletteAsync(url: String, bitmap: Bitmap, callback: (Palette) -> Unit) { createPaletteAsyncCache[url]?.let { palette -> @@ -306,52 +340,48 @@ 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) { + @Suppress("DEPRECATION") + window.decorView.systemUiVisibility = ( + View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY + // Set the content to appear under the system bars so that the + // content doesn't resize when the system bars hide and show. + or View.SYSTEM_UI_FLAG_LAYOUT_STABLE + or View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION + or View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN + // Hide the nav bar and status bar + or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION + or View.SYSTEM_UI_FLAG_FULLSCREEN + ) + } - 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 - // Set the content to appear under the system bars so that the - // content doesn't resize when the system bars hide and show. - or View.SYSTEM_UI_FLAG_LAYOUT_STABLE - or View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION - or View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN - // Hide the nav bar and status bar - or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION - or View.SYSTEM_UI_FLAG_FULLSCREEN - ) // FIXME this should be replaced - //} + 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) } fun Context.getStatusBarHeight(): Int { - if (isLayout(Globals.TV or EMULATOR)) { + if (isLayout(TV or EMULATOR)) { return 0 } @@ -363,17 +393,6 @@ 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 @@ -398,6 +417,84 @@ 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") @@ -413,84 +510,52 @@ object UIHelper { return settingsManager.getBoolean(getString(R.string.bottom_title_key), true) } - fun Activity.changeStatusBarState(hide: Boolean): Int { - return if (hide) { - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - window.insetsController?.hide(WindowInsets.Type.statusBars()) - - } else { - @Suppress("DEPRECATION") - window.setFlags( - WindowManager.LayoutParams.FLAG_FULLSCREEN, - WindowManager.LayoutParams.FLAG_FULLSCREEN - ) - } - 0 - } else { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - window.insetsController?.show(WindowInsets.Type.statusBars()) + fun Activity.changeStatusBarState(hide: Boolean) { + try { + if (hide) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + val controller = WindowCompat.getInsetsController(window, window.decorView) + controller.hide(WindowInsetsCompat.Type.statusBars()) + } else { + @Suppress("DEPRECATION") + window.setFlags( + WindowManager.LayoutParams.FLAG_FULLSCREEN, + WindowManager.LayoutParams.FLAG_FULLSCREEN + ) + } } else { - @Suppress("DEPRECATION") - window.clearFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + val controller = WindowCompat.getInsetsController(window, window.decorView) + controller.show(WindowInsetsCompat.Type.statusBars()) + } else { + @Suppress("DEPRECATION") + window.clearFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN) + } } - - this.getStatusBarHeight() + } catch (t: Throwable) { + logError(t) } } // 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) // FIXME this should be replaced - //} + (View.SYSTEM_UI_FLAG_LAYOUT_STABLE or View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN) 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 @@ -525,7 +590,13 @@ object UIHelper { onMenuItemClick: MenuItem.() -> Unit, ): PopupMenu { val ctw = ContextThemeWrapper(context, R.style.PopupMenu) - val popup = PopupMenu(ctw, this, Gravity.NO_GRAVITY, androidx.appcompat.R.attr.actionOverflowMenuStyle, 0) + val popup = PopupMenu( + ctw, + this, + Gravity.NO_GRAVITY, + androidx.appcompat.R.attr.actionOverflowMenuStyle, + 0 + ) items.forEach { (id, stringRes) -> popup.menu.add(0, id, 0, stringRes) @@ -549,7 +620,13 @@ object UIHelper { onMenuItemClick: MenuItem.() -> Unit, ): PopupMenu { val ctw = ContextThemeWrapper(context, R.style.PopupMenu) - val popup = PopupMenu(ctw, this, Gravity.NO_GRAVITY, androidx.appcompat.R.attr.actionOverflowMenuStyle, 0) + val popup = PopupMenu( + ctw, + this, + Gravity.NO_GRAVITY, + androidx.appcompat.R.attr.actionOverflowMenuStyle, + 0 + ) items.forEach { (id, string) -> popup.menu.add(0, id, 0, string) @@ -565,4 +642,39 @@ 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 deleted file mode 100644 index 30f66f835bb..00000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/VideoDownloadHelper.kt +++ /dev/null @@ -1,40 +0,0 @@ -package com.lagradost.cloudstream3.utils - -import com.fasterxml.jackson.annotation.JsonProperty -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("rating") val rating: Int?, - @JsonProperty("description") val description: String?, - @JsonProperty("cacheTime") val cacheTime: Long, - override val id: Int, - ): DownloadCached(id) - - 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/DownloadFileManagement.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/downloader/DownloadFileManagement.kt new file mode 100644 index 00000000000..898c30a1ca9 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/downloader/DownloadFileManagement.kt @@ -0,0 +1,132 @@ +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/VideoDownloadManager.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/downloader/DownloadManager.kt similarity index 65% rename from app/src/main/java/com/lagradost/cloudstream3/utils/VideoDownloadManager.kt rename to app/src/main/java/com/lagradost/cloudstream3/utils/downloader/DownloadManager.kt index fcb2a25d3a0..d209d544bd7 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/VideoDownloadManager.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/downloader/DownloadManager.kt @@ -1,40 +1,30 @@ -package com.lagradost.cloudstream3.utils +package com.lagradost.cloudstream3.utils.downloader + 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.* +import android.content.Context +import android.content.Intent 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 @@ -42,14 +32,58 @@ 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.safefile.MediaFileContentType +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.SafeFile import com.lagradost.safefile.closeQuietly import kotlinx.coroutines.CancellationException @@ -60,23 +94,24 @@ 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 { - private fun maxConcurrentDownloads(context: Context): Int = + fun maxConcurrentDownloads(context: Context): Int = PreferenceManager.getDefaultSharedPreferences(context) ?.getInt(context.getString(R.string.download_parallel_key), 3) ?: 3 @@ -84,8 +119,11 @@ object VideoDownloadManager { PreferenceManager.getDefaultSharedPreferences(context) ?.getInt(context.getString(R.string.download_concurrent_key), 3) ?: 3 - private var currentDownloads = mutableListOf() + private val _currentDownloads: MutableStateFlow> = MutableStateFlow(emptySet()) + val currentDownloads: StateFlow> = _currentDownloads + 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" @@ -129,56 +167,6 @@ 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 = @@ -195,91 +183,60 @@ 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" + const val KEY_RESUME_PACKAGES = "download_resume_2" const val KEY_DOWNLOAD_INFO = "download_info" - private const val KEY_RESUME_QUEUE_PACKAGES = "download_q_resume" + + /** 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" val downloadStatus = HashMap() val downloadStatusEvent = Event>() val downloadDeleteEvent = Event() val downloadEvent = Event>() val downloadProgressEvent = Event>() - val downloadQueue = LinkedList() +// val downloadQueue = LinkedList() - private var hasCreatedNotChanel = false + private var hasCreatedNotChannel = false private fun Context.createNotificationChannel() { - 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] - } + hasCreatedNotChannel = true - 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() - } + this.createNotificationChannel( + DOWNLOAD_CHANNEL_ID, + DOWNLOAD_CHANNEL_NAME, + DOWNLOAD_CHANNEL_DESCRIPT + ) + } - bitmap?.let { - cachedBitmaps[url] = it + 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) } - - return bitmap - } catch (e: Exception) { - logError(e) - return null } } + /** * @param hlsProgress will together with hlsTotal display another notification if used, to lessen the confusion about estimated size. * */ @SuppressLint("StringFormatInvalid") - private suspend fun createNotification( + private suspend fun createDownloadNotification( context: Context, source: String?, linkName: String?, @@ -295,7 +252,6 @@ 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) @@ -362,7 +318,7 @@ object VideoDownloadManager { val mbFormat = "%.1f MB" if (hlsProgress != null && hlsTotal != null) { - progressPercentage = hlsProgress.toLong() * 100 / hlsTotal + progressPercentage = hlsProgress * 100 / hlsTotal progressMbString = hlsProgress.toString() totalMbString = hlsTotal.toString() suffix = " - $mbFormat".format(progress / 1000000f) @@ -378,10 +334,15 @@ object VideoDownloadManager { " ($mbFormat/s)".format(bytesPerSecond.toFloat() / 1000000f) } else "" + val remainingTime = + if (state == DownloadType.IsDownloading) { + getEstimatedTimeLeft(context, bytesPerSecond, progress, total) + } else "" + val bigText = when (state) { DownloadType.IsDownloading, DownloadType.IsPaused -> { - (if (linkName == null) "" else "$linkName\n") + "$rowTwo\n$progressPercentage % ($progressMbString/$totalMbString)$suffix$mbPerSecondString" + (if (linkName == null) "" else "$linkName\n") + "$rowTwo\n$progressPercentage % ($progressMbString/$totalMbString)$suffix$mbPerSecondString $remainingTime" } DownloadType.IsPending -> { @@ -439,7 +400,7 @@ object VideoDownloadManager { builder.setContentText(txt) } - if ((state == DownloadType.IsDownloading || state == DownloadType.IsPaused) && SDK_INT >= Build.VERSION_CODES.O) { + if ((state == DownloadType.IsDownloading || state == DownloadType.IsPaused || state == DownloadType.IsPending) && SDK_INT >= Build.VERSION_CODES.O) { val actionTypes: MutableList = ArrayList() // INIT if (state == DownloadType.IsDownloading) { @@ -451,6 +412,9 @@ 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()) { @@ -489,7 +453,7 @@ object VideoDownloadManager { } } - if (!hasCreatedNotChanel) { + if (!hasCreatedNotChannel) { context.createNotificationChannel() } @@ -504,7 +468,7 @@ object VideoDownloadManager { ) { return null } - notify(ep.id, notification) + notify(DOWNLOAD_NOTIFICATION_TAG, ep.id, notification) } return notification } catch (e: Exception) { @@ -513,69 +477,6 @@ 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( @@ -597,7 +498,7 @@ object VideoDownloadManager { /** * Sets up the appropriate file and creates a data stream from the file. - * Used for initializing downloads. + * Used for initializing downloads and backups. * */ @Throws(IOException::class) fun setupStream( @@ -630,6 +531,7 @@ 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, @@ -641,7 +543,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 @@ -659,13 +561,17 @@ 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 */ @@ -686,8 +592,6 @@ object VideoDownloadManager { DownloadActionType.Stop -> { type = DownloadType.IsStopped - removeKey(KEY_RESUME_PACKAGES, event.first.toString()) - saveQueue() stopListener?.invoke() stopListener = null } @@ -702,11 +606,32 @@ 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( - totalBytes = approxTotalBytes, + linkHash = linkHash, + totalBytes = totalBytesValue, extraInfo = if (isHLS) hlsWrittenProgress.toString() else null ) ) @@ -849,34 +774,12 @@ 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 chunck i starts and ends, + /** This specifies where chunk i starts and ends, * bytes=${chuckStartByte[ i ]}-${chuckStartByte[ i+1 ] -1} * where out of bounds => bytes=${chuckStartByte[ i ]}- */ private val chuckStartByte: LongArray, @@ -963,11 +866,11 @@ object VideoDownloadManager { if (end == null) return true // we have download more or exactly what we needed if (start >= end) return true - } catch (e: IllegalStateException) { + } catch (_: IllegalStateException) { return false - } catch (e: CancellationException) { + } catch (_: CancellationException) { return false - } catch (t: Throwable) { + } catch (_: Throwable) { continue } } @@ -1086,38 +989,6 @@ 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( @@ -1145,6 +1016,8 @@ object VideoDownloadManager { bytesDownloaded = 0, createNotificationCallback = createNotificationCallback, id = parentId, + linkHash = link.url.hashCode(), + isHLS = false ) try { // get the file path @@ -1166,14 +1039,7 @@ object VideoDownloadManager { startByte = stream.startAt, headers = link.headers.appendAndDontOverride( mapOf( - "Accept-Encoding" to "identity", - "accept" to "*/*", "user-agent" to USER_AGENT, - "sec-ch-ua" to "\"Chromium\";v=\"91\", \" Not;A Brand\";v=\"99\"", - "sec-fetch-mode" to "navigate", - "sec-fetch-dest" to "video", - "sec-fetch-user" to "?1", - "sec-ch-ua-mobile" to "?0", ) ) ) @@ -1295,10 +1161,23 @@ object VideoDownloadManager { // this will take up the first available job and resolve while (true) { if (!isActive) return@launch + + var isTooFarAhead = false fileMutex.withLock { if (metadata.type == DownloadType.IsStopped || metadata.type == DownloadType.IsFailed ) return@launch + + // Limit RAM usage by throttling if too much data is downloaded but not yet written to disk + // 50MB limit + if (metadata.bytesDownloaded - metadata.bytesWritten > 50_000_000) { + isTooFarAhead = true + } + } + + if (isTooFarAhead) { + delay(500) + continue } // mutex just in case, we never want this to fail due to multithreading @@ -1334,7 +1213,7 @@ object VideoDownloadManager { if (!stream.exists) metadata.type = DownloadType.IsStopped if (metadata.type == DownloadType.IsFailed) { - return@withContext DOWNLOAD_FAILED + return@withContext metadata.failedStatus() } if (metadata.type == DownloadType.IsStopped) { @@ -1364,11 +1243,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 DOWNLOAD_FAILED + return@withContext metadata.failedStatus() } finally { fileStream?.closeQuietly() //requestStream?.closeQuietly() @@ -1390,7 +1269,9 @@ object VideoDownloadManager { val metadata = DownloadMetaData( createNotificationCallback = createNotificationCallback, - id = parentId + id = parentId, + linkHash = link.url.hashCode(), + isHLS = true ) var fileStream: OutputStream? = null try { @@ -1413,6 +1294,7 @@ object VideoDownloadManager { // push the metadata metadata.setResumeLength(stream.startAt) metadata.hlsProgress = startAt + metadata.hlsWrittenProgress = startAt metadata.type = DownloadType.IsPending metadata.setDownloadFileInfoTemplate( DownloadedFileInfo( @@ -1427,8 +1309,6 @@ object VideoDownloadManager { val m3u8 = M3u8Helper.M3u8Stream( link.url, link.quality, link.headers.appendAndDontOverride( mapOf( - "Accept-Encoding" to "identity", - "accept" to "*/*", "user-agent" to USER_AGENT, ) + if (link.referer.isNotBlank()) mapOf("referer" to link.referer) else emptyMap() ) @@ -1466,10 +1346,23 @@ object VideoDownloadManager { launch(Dispatchers.IO) { while (true) { if (!isActive) return@launch + + var isTooFarAhead = false fileMutex.withLock { if (metadata.type == DownloadType.IsStopped || metadata.type == DownloadType.IsFailed ) return@launch + + // Limit RAM usage by throttling if too much data is downloaded but not yet written to disk + // 50MB limit + if (metadata.bytesDownloaded - metadata.bytesWritten > 50_000_000) { + isTooFarAhead = true + } + } + + if (isTooFarAhead) { + delay(500) + continue } // mutex just in case, we never want this to fail due to multithreading @@ -1489,50 +1382,45 @@ object VideoDownloadManager { return@launch } - 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 + fileMutex.withLock { + 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) + 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) + // 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 - } + 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() + // 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) + 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 { - // may cause java.lang.IllegalStateException: Mutex is not locked because of cancelling - fileMutex.unlock() + 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 + } } } } @@ -1552,7 +1440,7 @@ object VideoDownloadManager { if (!stream.exists) metadata.type = DownloadType.IsStopped if (metadata.type == DownloadType.IsFailed) { - return@withContext DOWNLOAD_FAILED + return@withContext metadata.failedStatus() } if (metadata.type == DownloadType.IsStopped) { @@ -1568,7 +1456,7 @@ object VideoDownloadManager { } catch (t: Throwable) { logError(t) metadata.type = DownloadType.IsFailed - return@withContext DOWNLOAD_FAILED + return@withContext metadata.failedStatus() } finally { fileStream?.closeQuietly() metadata.close() @@ -1579,75 +1467,6 @@ 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?, @@ -1673,7 +1492,7 @@ object VideoDownloadManager { val callback: (CreateNotificationMetadata) -> Unit = { meta -> main { - createNotification( + createDownloadNotification( context, source, link.name, @@ -1727,100 +1546,17 @@ object VideoDownloadManager { ) } - else -> throw IllegalArgumentException("unsuported download type") + else -> throw IllegalArgumentException("Unsupported download type") } - } catch (t: Throwable) { + } catch (_: 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 - - 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( + fun getDownloadFileInfo( context: Context, id: Int, ): DownloadedFileInfoResult? { @@ -1830,8 +1566,7 @@ object VideoDownloadManager { val file = info.toFile(context) // only delete the key if the file is not found - if (file == null || !file.existsOrThrow()) { - //if (removeKeys) context.removeKey(KEY_DOWNLOAD_INFO, id.toString()) // TODO READD + if (file == null || file.exists() == false) { return null } @@ -1882,35 +1617,20 @@ 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) - 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) + + 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) + } return isFileDeleted } @@ -1919,119 +1639,453 @@ object VideoDownloadManager { return context.getKey(KEY_RESUME_PACKAGES, id.toString()) } - suspend fun downloadFromResume( - context: Context, - pkg: DownloadResumePackage, - notificationCallback: (Int, Notification) -> Unit, - setKey: Boolean = true + fun getDownloadQueuePackage(context: Context, id: Int): DownloadQueueWrapper? { + return context.getKey(KEY_RESUME_IN_QUEUE, id.toString()) + } + + 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, + ) + } + + class EpisodeDownloadInstance( + val context: Context, + val downloadQueueWrapper: DownloadQueueWrapper ) { - 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 + 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 } - } - 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) + // 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() } - } - /*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 + 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) + } } } - 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 - ) - } + 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 + } - /** 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 - ) - } + _currentDownloads.update { downloads -> + downloads + id + } - fun downloadCheckUsingWorker( - context: Context, - ) { - startWork(context, DOWNLOAD_CHECK) - } + 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) + ) - fun downloadFromResumeUsingWorker( - context: Context, - pkg: DownloadResumePackage, - ) { - val key = pkg.item.ep.id.toString() - setKey(WORK_KEY_PACKAGE, key, pkg) - startWork(context, key) - } + 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 + ) + } - // 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" + 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 + } + } + } - fun downloadEpisodeUsingWorker( - context: Context, - source: String?, - folder: String?, - ep: DownloadEpisodeMetadata, - links: List, - ) { - val info = DownloadInfo( - source, folder, ep, links - ) + 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 + } + } - val key = info.ep.id.toString() - setKey(WORK_KEY_INFO, key, info) - startWork(context, key) - } + 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(), + ) + ) - data class DownloadInfo( - @JsonProperty("source") val source: String?, - @JsonProperty("folder") val folder: String?, - @JsonProperty("ep") val ep: DownloadEpisodeMetadata, - @JsonProperty("links") val links: List - ) + 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), + ) + } + } } 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 new file mode 100644 index 00000000000..25a9fdf2a4f --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/downloader/DownloadObjects.kt @@ -0,0 +1,224 @@ +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 new file mode 100644 index 00000000000..f3866408833 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/downloader/DownloadQueueManager.kt @@ -0,0 +1,250 @@ +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 new file mode 100644 index 00000000000..9f2c31d9a37 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/downloader/DownloadUtils.kt @@ -0,0 +1,165 @@ +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/videoskip/AniSkip.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/videoskip/AniSkip.kt new file mode 100644 index 00000000000..0db90afeaef --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/videoskip/AniSkip.kt @@ -0,0 +1,68 @@ +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 new file mode 100644 index 00000000000..f9254576bb5 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/videoskip/AnimeSkip.kt @@ -0,0 +1,370 @@ +package com.lagradost.cloudstream3.utils.videoskip + +import com.fasterxml.jackson.annotation.JsonProperty +import com.lagradost.cloudstream3.AnimeLoadResponse +import com.lagradost.cloudstream3.ErrorLoadingException +import com.lagradost.cloudstream3.LoadResponse +import com.lagradost.cloudstream3.TvSeriesLoadResponse +import com.lagradost.cloudstream3.TvType +import com.lagradost.cloudstream3.app +import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.animeSkipApi +import com.lagradost.cloudstream3.syncproviders.AuthAPI +import com.lagradost.cloudstream3.syncproviders.AuthLoginRequirement +import com.lagradost.cloudstream3.syncproviders.AuthLoginResponse +import com.lagradost.cloudstream3.syncproviders.AuthToken +import com.lagradost.cloudstream3.syncproviders.AuthUser +import com.lagradost.cloudstream3.syncproviders.PlainAuthRepo +import com.lagradost.cloudstream3.ui.result.ResultEpisode +import com.lagradost.cloudstream3.utils.AppUtils.parseJson +import com.lagradost.cloudstream3.utils.AppUtils.toJson +import java.math.BigInteger +import java.util.concurrent.ConcurrentHashMap +import java.security.MessageDigest + +class AnimeSkipAuth : AuthAPI() { + override val name = "AnimeSkip" + override val inAppLoginRequirement: AuthLoginRequirement = + AuthLoginRequirement(password = true, username = true) + override val idPrefix = "anime-skip" + override val hasInApp = true + override val createAccountUrl = "https://anime-skip.com/account" + val baseClientId = "as1JgiMbW4wKfmTLWXS79iTDQFll76pk" + fun md5(input: String): String { + val md = MessageDigest.getInstance("MD5") + return BigInteger(1, md.digest(input.toByteArray())).toString(16).padStart(32, '0') + } + + data class LoginRoot( + @JsonProperty("data") + val data: LoginData, + ) + + data class LoginData( + @JsonProperty("login") + val login: Login, + ) + + data class Login( + @JsonProperty("authToken") + val authToken: String, + @JsonProperty("refreshToken") + val refreshToken: String, + @JsonProperty("account") + val account: Account, + ) + + data class ApiRoot( + @JsonProperty("data") + val data: ApiData, + ) + + data class ApiData( + @JsonProperty("myApiClients") + val myApiClients: List, + ) + + data class MyApiClient( + @JsonProperty("id") + val id: String, + ) + + data class Account( + @JsonProperty("profileUrl") + val profileUrl: String, + @JsonProperty("username") + val username: String, + @JsonProperty("email") + val email: String, + ) + + data class Payload( + @JsonProperty("profileUrl") + val profileUrl: String, + @JsonProperty("username") + val username: String, + @JsonProperty("email") + val email: String, + @JsonProperty("clientId") + val clientId: String, + ) + + override suspend fun user(token: AuthToken?): AuthUser? { + val payload = parseJson(token?.payload ?: return null) + return AuthUser( + name = payload.username, + id = payload.email.hashCode(), + profilePicture = payload.profileUrl + ) + } + + override suspend fun login(form: AuthLoginResponse): AuthToken? { + val hash = md5(form.password ?: return null) + val emailOrUserName = form.email ?: form.username ?: return null + + val loginQuery = """ + { + login(usernameEmail: "$emailOrUserName", passwordHash: "$hash") { + authToken + refreshToken + account { + profileUrl + username + email + } + } + } +""" + val loginRoot = app.post( + "https://api.anime-skip.com/graphql", + json = mapOf("query" to loginQuery), + headers = mapOf( + "Accept" to "*/*", + "content-type" to "application/json", + "X-Client-ID" to baseClientId + ) + ).parsed() + + val authToken = loginRoot.data.login.authToken + val refreshToken = loginRoot.data.login.refreshToken + val account = loginRoot.data.login.account + + val clientQuery = """ + { + myApiClients { + id + } + } + """.trimIndent() + + val apiRoot = app.post( + "https://api.anime-skip.com/graphql", + json = mapOf("query" to clientQuery), + headers = mapOf( + "Accept" to "*/*", + "content-type" to "application/json", + "Authorization" to "Bearer $authToken", + "X-Client-ID" to baseClientId + ) + ).parsed() + + val clientId = apiRoot.data.myApiClients.getOrNull(0)?.id + ?: throw ErrorLoadingException("No API token found") + + val payload = Payload( + profileUrl = account.profileUrl, + username = account.username, + email = account.email, + clientId = clientId, + ) + return AuthToken( + accessToken = authToken, + refreshToken = refreshToken, + payload = payload.toJson() + ) + } +} + +class AnimeSkip : SkipAPI() { + override val name: String = "AniSkip" + override val supportedTypes: Set = setOf(TvType.Anime, TvType.OVA) + + val auth = PlainAuthRepo(animeSkipApi) + //val clientId = "ZGfO0sMF3eCwLYf8yMSCJjlynwNGRXWE" + + companion object { + const val MIN_LENGTH: Int = 4 + + private val strip = Regex("[ :\\-.!]") + + /** Makes names more uniform to make partial matches more still give a result */ + fun stripName(name: String?): String? = + name?.replace(strip, "")?.lowercase() + + private val asciiRegex = Regex("[^a-zA-Z0-9 ]") + + /** Makes names more uniform to make partial matches more still give a result */ + fun asciiName(name: String?): String? = + name?.replace(asciiRegex, "")?.lowercase() + } + + data class Root( + @JsonProperty("data") + val data: Data, + ) + + data class Data( + @JsonProperty("searchShows") + val searchShows: List, + ) + + data class SearchShow( + @JsonProperty("name") + val name: String, + @JsonProperty("originalName") + val originalName: String?, + @JsonProperty("seasonCount") + val seasonCount: Long, + @JsonProperty("episodeCount") + val episodeCount: Long, + @JsonProperty("baseDuration") + val baseDuration: Double, + @JsonProperty("episodes") + val episodes: List, + ) + + data class Episode( + @JsonProperty("number") + val number: String?, + @JsonProperty("absoluteNumber") + val absoluteNumber: String?, + @JsonProperty("season") + val season: String?, + @JsonProperty("timestamps") + val timestamps: List, + ) + + data class Timestamp( + @JsonProperty("at") + val at: Double, + @JsonProperty("type") + val type: Type, + ) + + data class Type( + @JsonProperty("name") + val name: String, + ) + + val cache: ConcurrentHashMap = ConcurrentHashMap() + + override suspend fun stamps( + data: LoadResponse, + episode: ResultEpisode, + episodeDurationMs: Long + ): List? { + val clientId : String = parseJson( + auth.authData()?.token?.payload ?: return null + ).clientId + + when (data) { + is AnimeLoadResponse, is TvSeriesLoadResponse -> { + /** Require episode based anime */ + } + + else -> return null + } + + val query = """{ + searchShows(search: "${data.name}", limit: 1) { + name + originalName + seasonCount + episodeCount + episodes { + number + absoluteNumber + season + baseDuration + timestamps { + at + type { + name + } + } + } + } +}""" + val root = cache[data.name] ?: run { + app.post( + "https://api.anime-skip.com/graphql", + json = mapOf("query" to query), + headers = mapOf( + "Accept" to "*/*", + "content-type" to "application/json", + "X-Client-ID" to clientId + ) + ) + .parsed().data.also { root -> + cache[data.name] = root + } + } + val show = root.searchShows.firstOrNull { show -> + /** Match ascii */ + val ascii1 = asciiName(data.name) + val ascii2 = asciiName(show.name) + if (ascii1 == ascii2 && (ascii1?.length ?: 0) > MIN_LENGTH) { + return@firstOrNull true + } + + if (data !is AnimeLoadResponse) { + return@firstOrNull false + } + + /** Match original name */ + val strip1 = stripName(show.originalName) + val strip2 = stripName(data.japName) + + /** Match english name*/ + val ascii3 = stripName(data.engName) + (strip1 == strip2 && (strip1?.length ?: 0) > MIN_LENGTH) || + (ascii2 == ascii3 && (ascii2?.length ?: 0) > MIN_LENGTH) + } ?: return null + + val showEpisode = when (data) { + is AnimeLoadResponse -> { + val episodeNumber = episode.episode.toString() + /** For anime, match on number */ + show.episodes.firstOrNull { + it.absoluteNumber == episodeNumber + } ?: show.episodes.firstOrNull { + it.number == episodeNumber + } + } + + is TvSeriesLoadResponse -> { + /** For tv-series, match on season + number */ + val seasonNumber = episode.season?.toString() + val episodeNumber = episode.episode.toString() + val episodeIndex = episode.totalEpisodeIndex.toString() + + show.episodes.firstOrNull { + it.season == seasonNumber && it.number == episodeNumber + } ?: show.episodes.firstOrNull { + it.absoluteNumber == episodeIndex + } + } + + else -> null + } ?: return null + + val result = ArrayList() + var pending: SkipStamp? = null + for (stamp in showEpisode.timestamps) { + val startMS = (stamp.at * 1000.0).toLong() + pending?.let { pending -> + result.add(pending.copy(endMs = startMS)) + } + val type = when (stamp.type.name) { + "Intro", "New Intro" -> SkipType.Intro + "Credits" -> SkipType.Credits + "Preview" -> SkipType.Preview + "Recap" -> SkipType.Recap + "Mixed Credits" -> SkipType.MixedEnding + "Filler", "Transition", "Branding", "Canon", "Title Card" -> null + else -> null + } + if (type == null) { + pending = null + continue + } + pending = SkipStamp(type, startMS, 0L) + } + pending?.let { pending -> + result.add(pending.copy(endMs = episodeDurationMs)) + /** Base duration = fucked */ + } + + return result + } +} + diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/videoskip/IntroDbSkip.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/videoskip/IntroDbSkip.kt new file mode 100644 index 00000000000..869515f4390 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/videoskip/IntroDbSkip.kt @@ -0,0 +1,77 @@ +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 new file mode 100644 index 00000000000..60cc3ae1e23 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/videoskip/SkipAPI.kt @@ -0,0 +1,105 @@ +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 new file mode 100644 index 00000000000..cc2661cb096 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/videoskip/TheIntroDBSkip.kt @@ -0,0 +1,76 @@ +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 624370032e3..c18ad39c682 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/widget/FlowLayout.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/widget/FlowLayout.kt @@ -4,6 +4,7 @@ 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 @@ -19,9 +20,9 @@ class FlowLayout : ViewGroup { @SuppressLint("CustomViewStyleable") internal constructor(c: Context, attrs: AttributeSet?) : super(c, attrs) { - val t = c.obtainStyledAttributes(attrs, R.styleable.FlowLayout_Layout) - itemSpacing = t.getDimensionPixelSize(R.styleable.FlowLayout_Layout_itemSpacing, 0) - t.recycle() + c.withStyledAttributes(attrs, R.styleable.FlowLayout_Layout) { + itemSpacing = getDimensionPixelSize(R.styleable.FlowLayout_Layout_itemSpacing, 0) + } } override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { @@ -104,9 +105,9 @@ class FlowLayout : ViewGroup { @SuppressLint("CustomViewStyleable") internal constructor(c: Context, attrs: AttributeSet?) : super(c, attrs) { - val t = c.obtainStyledAttributes(attrs, R.styleable.FlowLayout_Layout) - spacing = 0//t.getDimensionPixelSize(R.styleable.FlowLayout_Layout_itemSpacing, 0); - t.recycle() + c.withStyledAttributes(attrs, R.styleable.FlowLayout_Layout) { + spacing = 0 + } } 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 new file mode 100644 index 00000000000..d2a6b6c4d62 --- /dev/null +++ b/app/src/main/res/color/black_button_ripple.xml @@ -0,0 +1,5 @@ + + + + + \ 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 new file mode 100644 index 00000000000..3042fd58896 --- /dev/null +++ b/app/src/main/res/color/item_select_color_tv.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/animeskip.xml b/app/src/main/res/drawable/animeskip.xml new file mode 100644 index 00000000000..8f1bb3105ed --- /dev/null +++ b/app/src/main/res/drawable/animeskip.xml @@ -0,0 +1,36 @@ + + + + + + + + + + + diff --git a/app/src/main/res/drawable/bg_color_both.xml b/app/src/main/res/drawable/bg_color_both.xml new file mode 100644 index 00000000000..bb71f8731a4 --- /dev/null +++ b/app/src/main/res/drawable/bg_color_both.xml @@ -0,0 +1,5 @@ + + + + + \ 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 new file mode 100644 index 00000000000..7c744f19f1d --- /dev/null +++ b/app/src/main/res/drawable/bg_color_bottom.xml @@ -0,0 +1,9 @@ + + + + + \ 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 new file mode 100644 index 00000000000..7cb4374526c --- /dev/null +++ b/app/src/main/res/drawable/bg_color_center.xml @@ -0,0 +1,5 @@ + + + + + \ 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 new file mode 100644 index 00000000000..45497d27218 --- /dev/null +++ b/app/src/main/res/drawable/bg_color_top.xml @@ -0,0 +1,9 @@ + + + + + \ 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 new file mode 100644 index 00000000000..de7a6704b5c --- /dev/null +++ b/app/src/main/res/drawable/bg_imdb_badge.xml @@ -0,0 +1,11 @@ + + + + + + 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 new file mode 100644 index 00000000000..b4701e42a73 --- /dev/null +++ b/app/src/main/res/drawable/bg_player_metadata_scrim_netflix.xml @@ -0,0 +1,7 @@ + + + \ 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 new file mode 100644 index 00000000000..81b400d925a --- /dev/null +++ b/app/src/main/res/drawable/bookmark_star_24px.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/clear_all_24px.xml b/app/src/main/res/drawable/clear_all_24px.xml new file mode 100644 index 00000000000..dbbc7dc9f21 --- /dev/null +++ b/app/src/main/res/drawable/clear_all_24px.xml @@ -0,0 +1,12 @@ + + + + + diff --git a/app/src/main/res/drawable/dashed_line_horizontal.xml b/app/src/main/res/drawable/dashed_line_horizontal.xml new file mode 100644 index 00000000000..737ff195947 --- /dev/null +++ b/app/src/main/res/drawable/dashed_line_horizontal.xml @@ -0,0 +1,10 @@ + + + + + diff --git a/app/src/main/res/drawable/go_back_30.xml b/app/src/main/res/drawable/go_back_30.xml index e57946b65e2..14999011662 100644 --- a/app/src/main/res/drawable/go_back_30.xml +++ b/app/src/main/res/drawable/go_back_30.xml @@ -1,6 +1,7 @@ - - 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 70db409b338..7dea8241e9e 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 bf421c227be..6aebfabdc7e 100644 --- a/app/src/main/res/drawable/ic_baseline_exit_24.xml +++ b/app/src/main/res/drawable/ic_baseline_exit_24.xml @@ -1,5 +1,13 @@ - - - - + + + + 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 6e130c3c948..66afaed2c7b 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,5 +1,12 @@ - - - - + + + + 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 new file mode 100644 index 00000000000..c46eb4b0cc4 --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_north_west_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_baseline_people_24.xml b/app/src/main/res/drawable/ic_baseline_people_24.xml new file mode 100644 index 00000000000..2e7c9b0703a --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_people_24.xml @@ -0,0 +1,15 @@ + + + + + + \ 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 new file mode 100644 index 00000000000..455006b3176 --- /dev/null +++ b/app/src/main/res/drawable/ic_offline_pin_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_refresh.xml b/app/src/main/res/drawable/ic_refresh.xml new file mode 100644 index 00000000000..e61dcf1ce75 --- /dev/null +++ b/app/src/main/res/drawable/ic_refresh.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/kid_star_24px.xml b/app/src/main/res/drawable/kid_star_24px.xml new file mode 100644 index 00000000000..2efe84195b8 --- /dev/null +++ b/app/src/main/res/drawable/kid_star_24px.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/metadata_overlay_icon.xml b/app/src/main/res/drawable/metadata_overlay_icon.xml new file mode 100644 index 00000000000..6d1b6510af1 --- /dev/null +++ b/app/src/main/res/drawable/metadata_overlay_icon.xml @@ -0,0 +1,36 @@ + + + + + + + diff --git a/app/src/main/res/drawable/netflix_download_batch.xml b/app/src/main/res/drawable/netflix_download_batch.xml new file mode 100644 index 00000000000..8ef633fd236 --- /dev/null +++ b/app/src/main/res/drawable/netflix_download_batch.xml @@ -0,0 +1,19 @@ + + + + + diff --git a/app/src/main/res/drawable/outline_big_15_gray.xml b/app/src/main/res/drawable/outline_big_15_gray.xml new file mode 100644 index 00000000000..b9450027971 --- /dev/null +++ b/app/src/main/res/drawable/outline_big_15_gray.xml @@ -0,0 +1,11 @@ + + + + + \ 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 new file mode 100644 index 00000000000..ebcdc0bf4ee --- /dev/null +++ b/app/src/main/res/drawable/outline_big_20_gray.xml @@ -0,0 +1,10 @@ + + + + + \ 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 new file mode 100644 index 00000000000..ea5f31a1f7e --- /dev/null +++ b/app/src/main/res/drawable/outline_big_25_gray.xml @@ -0,0 +1,11 @@ + + + + + \ 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 new file mode 100644 index 00000000000..ab18a1354d1 --- /dev/null +++ b/app/src/main/res/drawable/outline_big_35_gray.xml @@ -0,0 +1,10 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/outline_drawable_less_inset.xml b/app/src/main/res/drawable/outline_drawable_less_inset.xml new file mode 100644 index 00000000000..29096d867bb --- /dev/null +++ b/app/src/main/res/drawable/outline_drawable_less_inset.xml @@ -0,0 +1,5 @@ + + \ No newline at end of file diff --git a/app/src/main/res/drawable/pin_ic.xml b/app/src/main/res/drawable/pin_ic.xml new file mode 100644 index 00000000000..1425ff05a79 --- /dev/null +++ b/app/src/main/res/drawable/pin_ic.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/play_button.xml b/app/src/main/res/drawable/play_button.xml index 04886b6e500..ee3d47dfe97 100644 --- a/app/src/main/res/drawable/play_button.xml +++ b/app/src/main/res/drawable/play_button.xml @@ -1,25 +1,19 @@ + 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_2" + android:pathData="M 598.91 419.24 L 333.91 266.24 L 333.91 572.24 L 598.91 419.24 Z" + android:fillColor="#ffffff" + android:strokeWidth="1"/> diff --git a/app/src/main/res/drawable/play_button_transparent.xml b/app/src/main/res/drawable/play_button_transparent.xml new file mode 100644 index 00000000000..caa7041e608 --- /dev/null +++ b/app/src/main/res/drawable/play_button_transparent.xml @@ -0,0 +1,15 @@ + + + + diff --git a/app/src/main/res/drawable/player_gradient_tv.xml b/app/src/main/res/drawable/player_gradient_tv.xml index 79bb3af5fe3..8077b418fbe 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 2aa20b92f86..4cf33aba0e9 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 @@ - + - \ No newline at end of file + diff --git a/app/src/main/res/drawable/round_keyboard_arrow_up_24.xml b/app/src/main/res/drawable/round_keyboard_arrow_up_24.xml new file mode 100644 index 00000000000..d1360f9489f --- /dev/null +++ b/app/src/main/res/drawable/round_keyboard_arrow_up_24.xml @@ -0,0 +1,13 @@ + + + + + diff --git a/app/src/main/res/drawable/rounded_select_ripple.xml b/app/src/main/res/drawable/rounded_select_ripple.xml new file mode 100644 index 00000000000..5dd7559b39e --- /dev/null +++ b/app/src/main/res/drawable/rounded_select_ripple.xml @@ -0,0 +1,14 @@ + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/speedup.xml b/app/src/main/res/drawable/speedup.xml index 79ef428ac47..879ef852cec 100644 --- a/app/src/main/res/drawable/speedup.xml +++ b/app/src/main/res/drawable/speedup.xml @@ -1,12 +1,10 @@ - - - \ 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 a6cbb3115fe..12116eabc2a 100644 --- a/app/src/main/res/drawable/subdl_logo_big.xml +++ b/app/src/main/res/drawable/subdl_logo_big.xml @@ -1,10 +1,12 @@ - - - + android:viewportHeight="320"> + + + diff --git a/app/src/main/res/drawable/sun_7_24.xml b/app/src/main/res/drawable/sun_7_24.xml new file mode 100644 index 00000000000..26e3f43e88b --- /dev/null +++ b/app/src/main/res/drawable/sun_7_24.xml @@ -0,0 +1,111 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/video_outline.xml b/app/src/main/res/drawable/video_outline.xml new file mode 100644 index 00000000000..558c4ec3e98 --- /dev/null +++ b/app/src/main/res/drawable/video_outline.xml @@ -0,0 +1,4 @@ + + + + \ 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 new file mode 100644 index 00000000000..4710473d4fa --- /dev/null +++ b/app/src/main/res/layout-port/player_select_source_and_subs.xml @@ -0,0 +1,171 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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 new file mode 100644 index 00000000000..2cba9c869bb --- /dev/null +++ b/app/src/main/res/layout-port/player_select_source_priority.xml @@ -0,0 +1,126 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout-port/subtitle_offset.xml b/app/src/main/res/layout-port/subtitle_offset.xml new file mode 100644 index 00000000000..b6c4f61fd2d --- /dev/null +++ b/app/src/main/res/layout-port/subtitle_offset.xml @@ -0,0 +1,147 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/account_edit_dialog.xml b/app/src/main/res/layout/account_edit_dialog.xml index 9d39425a499..f52c8ea5196 100644 --- a/app/src/main/res/layout/account_edit_dialog.xml +++ b/app/src/main/res/layout/account_edit_dialog.xml @@ -37,6 +37,33 @@ android:layout_marginBottom="60dp" android:orientation="vertical"> + + + + + + + + android:buttonTint="?attr/textColor" + android:text="@string/lock_profile" + android:textColor="?attr/grayTextColor" /> - - - - + android:layout_marginTop="-60dp" + android:orientation="horizontal" + android:padding="10dp"> + android:text="@string/delete" /> + android:text="@string/sort_apply" /> + android:text="@string/sort_cancel" /> \ 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 f133d6c3f28..3cbfc72fb1e 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:animateLayoutChanges="true" - android:backgroundTint="?attr/primaryGrayBackground" - android:foreground="?attr/selectableItemBackground" android:layout_margin="10dp" + android:animateLayoutChanges="true" + android:backgroundTint="@color/primaryGrayBackground" android:focusable="true" + android:foreground="?attr/selectableItemBackground" app:cardCornerRadius="@dimen/rounded_image_radius" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintDimensionRatio="1" @@ -42,6 +42,7 @@ android:layout_margin="4dp" android:src="@drawable/video_locked" android:visibility="gone" + app:tint="@color/textColor" tools:visibility="visible" /> + android:textColor="@color/textColor" + android:textSize="16sp" + tools:text="Hello World!" /> \ 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 0adade19fc0..3f41a23c230 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:animateLayoutChanges="true" - android:backgroundTint="?attr/primaryGrayBackground" - android:foreground="?attr/selectableItemBackground" android:layout_margin="10dp" + android:animateLayoutChanges="true" + android:backgroundTint="@color/primaryGrayBackground" android:focusable="true" + android:foreground="?attr/selectableItemBackground" app:cardCornerRadius="@dimen/rounded_image_radius" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintDimensionRatio="1" @@ -42,6 +42,7 @@ 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" + app:tint="@color/textColor" /> + android:textColor="@color/textColor" + android:textSize="16sp" + tools:text="Hello World!" /> \ No newline at end of file diff --git a/app/src/main/res/layout/account_switch.xml b/app/src/main/res/layout/account_switch.xml index 5153f0e3565..ac6e41a60c1 100644 --- a/app/src/main/res/layout/account_switch.xml +++ b/app/src/main/res/layout/account_switch.xml @@ -16,6 +16,13 @@ android:layout_height="wrap_content" android:focusable="true"/> + + + - - + - + android:clickable="false" + android:focusable="false" + android:focusableInTouchMode="false" + android:importantForAccessibility="no" + android:src="@drawable/outline" + android:visibility="gone" /> \ 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 ea48a80f006..4f96b109e9b 100644 --- a/app/src/main/res/layout/add_account_input.xml +++ b/app/src/main/res/layout/add_account_input.xml @@ -80,6 +80,7 @@ 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" @@ -96,7 +97,7 @@ android:layout_height="wrap_content" android:autofillHints="password" android:hint="@string/example_password" - android:inputType="textVisiblePassword" + android:inputType="textPassword" 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 cb4224d10b5..a8bdf2a3872 100644 --- a/app/src/main/res/layout/add_repo_input.xml +++ b/app/src/main/res/layout/add_repo_input.xml @@ -81,6 +81,7 @@ 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 1c61f8b4da9..519b790da96 100644 --- a/app/src/main/res/layout/add_site_input.xml +++ b/app/src/main/res/layout/add_site_input.xml @@ -62,6 +62,7 @@ + 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" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:layout_rowWeight="1" + android:autofillHints="no" + android:inputType="text" + tools:text="nginx.com" + 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"> + android:layout_width="wrap_content" + android:layout_gravity="center_vertical|end" + android:text="@string/sort_apply" + android:id="@+id/apply_btt" + style="@style/WhiteButton" /> + android:layout_width="wrap_content" + android:layout_gravity="center_vertical|end" + android:text="@string/sort_cancel" + android:id="@+id/cancel_btt" + style="@style/BlackButton" /> diff --git a/app/src/main/res/layout/bottom_selection_dialog.xml b/app/src/main/res/layout/bottom_selection_dialog.xml index 0532f2506b1..55ca6562e4e 100644 --- a/app/src/main/res/layout/bottom_selection_dialog.xml +++ b/app/src/main/res/layout/bottom_selection_dialog.xml @@ -1,58 +1,65 @@ + + + android:layout_height="wrap_content" + android:layout_marginBottom="10dp"> + + - + tools:text="Test" /> + + android:id="@+id/listview1" + android:layout_width="match_parent" + android:layout_height="0dp" + android:layout_weight="1" + android:paddingTop="10dp" + android:dividerHeight="1dp" + android:requiresFadingEdge="vertical" + android:nextFocusRight="@id/cancel_btt" + android:nextFocusLeft="@id/apply_btt" + tools:listitem="@layout/sort_bottom_single_choice" /> + android:id="@+id/apply_btt_holder" + android:layout_width="match_parent" + android:layout_height="60dp" + android:gravity="center_vertical|end" + android:orientation="horizontal"> + android:id="@+id/apply_btt" + android:layout_width="wrap_content" + android:layout_gravity="center_vertical|end" + android:text="@string/sort_apply" + style="@style/WhiteButton" /> + android:id="@+id/cancel_btt" + android:layout_width="wrap_content" + android:layout_gravity="center_vertical|end" + android:text="@string/sort_cancel" + style="@style/BlackButton" /> diff --git a/app/src/main/res/layout/cast_item.xml b/app/src/main/res/layout/cast_item.xml index 99a9750b291..4f7bdf74d9f 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,38 +25,42 @@ android:layout_gravity="center_horizontal"> - + tools:src="@drawable/profile_bg_blue" /> + app:cardCornerRadius="35dp"> + diff --git a/app/src/main/res/layout/chromecast_subtitle_settings.xml b/app/src/main/res/layout/chromecast_subtitle_settings.xml index 4d3b50dfe4b..92d0bd35023 100644 --- a/app/src/main/res/layout/chromecast_subtitle_settings.xml +++ b/app/src/main/res/layout/chromecast_subtitle_settings.xml @@ -1,16 +1,21 @@ - + + + android:layout_height="match_parent"> - + android:layout_height="wrap_content" + android:orientation="vertical"> - - - - - + - - - - - - - - - - - - + + - + - - \ 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 518aaa4777d..c312e64e32a 100644 --- a/app/src/main/res/layout/confirm_exit_dialog.xml +++ b/app/src/main/res/layout/confirm_exit_dialog.xml @@ -5,9 +5,11 @@ android:orientation="vertical" android:paddingHorizontal="16dp" android:paddingVertical="8dp"> + + android:text="@string/dont_show_again" + android:textColor="?attr/grayTextColor" /> 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 06db9901755..f5d78e83507 100644 --- a/app/src/main/res/layout/custom_preference_category_material.xml +++ b/app/src/main/res/layout/custom_preference_category_material.xml @@ -19,9 +19,7 @@ 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" @@ -52,7 +50,6 @@ 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 0ab98c22be0..c6685ee29f2 100644 --- a/app/src/main/res/layout/custom_preference_material.xml +++ b/app/src/main/res/layout/custom_preference_material.xml @@ -21,9 +21,7 @@ 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" @@ -51,7 +49,6 @@ 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" @@ -67,9 +64,7 @@ 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 02c5ec1be36..132091e5f09 100644 --- a/app/src/main/res/layout/custom_preference_widget_seekbar.xml +++ b/app/src/main/res/layout/custom_preference_widget_seekbar.xml @@ -18,13 +18,12 @@ + android:ellipsize="marquee" + tools:ignore="LabelFor" /> @@ -99,9 +96,7 @@ 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" @@ -113,13 +108,11 @@ + - - + tools:ignore="UseCompoundDrawables" + android:padding="10dp"> + android:layout_marginEnd="10dp" + android:background="@drawable/search_background"> - - - - - - - - - + + + - + - - diff --git a/app/src/main/res/layout/download_child_episode.xml b/app/src/main/res/layout/download_child_episode.xml index e53e63d312f..cb9c13d5394 100644 --- a/app/src/main/res/layout/download_child_episode.xml +++ b/app/src/main/res/layout/download_child_episode.xml @@ -14,30 +14,44 @@ 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 388e862b2b9..e128f7cec08 100644 --- a/app/src/main/res/layout/empty_layout.xml +++ b/app/src/main/res/layout/empty_layout.xml @@ -1,18 +1,19 @@ - + - - \ 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 new file mode 100644 index 00000000000..8f82121bb05 --- /dev/null +++ b/app/src/main/res/layout/extra_brightness_overlay.xml @@ -0,0 +1,8 @@ + + \ 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 64ed1d7006d..0a7b42327ee 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="?attr/primaryGrayBackground"> @@ -32,8 +32,8 @@ android:contentDescription="@string/cancel" android:padding="8dp" android:layout_gravity="center_vertical" - android:nextFocusLeft="@id/nav_rail_view" - app:tint="@android:color/white" /> + android:nextFocusLeft="@id/navigation_downloads" + app:tint="?attr/white" />