diff --git a/.github/locales.py b/.github/locales.py index 6127d9d80..a74d72588 100644 --- a/.github/locales.py +++ b/.github/locales.py @@ -1,13 +1,14 @@ import re import glob import requests +import os import lxml.etree as ET # builtin library doesn't preserve comments SETTINGS_PATH = "app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsGeneral.kt" START_MARKER = "/* begin language list */" END_MARKER = "/* end language list */" -XML_NAME = "app/src/main/res/values-b+" +XML_NAME = "app/src/main/res/values-" ISO_MAP_URL = "https://raw.githubusercontent.com/haliaeetus/iso-639/master/data/iso_639-1.min.json" INDENT = " "*4 @@ -20,29 +21,29 @@ rest, after_src = rest.split(END_MARKER) # Load already added langs languages = {} -for lang in re.finditer(r'Pair\("(.*)", "(.*)"\)', rest): - name, iso = lang.groups() - languages[iso] = name +for lang in re.finditer(r'Triple\("(.*)", "(.*)", "(.*)"\)', rest): + flag, name, iso = lang.groups() + languages[iso] = (flag, name) # Add not yet added langs for folder in glob.glob(f"{XML_NAME}*"): - iso = folder[len(XML_NAME):].replace("+", "-") + iso = folder[len(XML_NAME):] if iso not in languages.keys(): - entry = iso_map.get(iso.lower(), {'nativeName':iso}) # fallback to iso code if not found - languages[iso] = entry['nativeName'].split(',')[0] # first name if there are multiple + entry = iso_map.get(iso.lower(),{'nativeName':iso}) + languages[iso] = ("", entry['nativeName'].split(',')[0]) -# Create pairs -pairs = [] -for iso in sorted(languages, key=lambda iso: languages[iso].lower()): # sort by language name - name = languages[iso] - pairs.append(f'{INDENT}Pair("{name}", "{iso}"),') +# Create triples +triples = [] +for iso in sorted(languages.keys()): + flag, name = languages[iso] + triples.append(f'{INDENT}Triple("{flag}", "{name}", "{iso}"),') # Update settings file open(SETTINGS_PATH, "w+",encoding='utf-8').write( before_src + START_MARKER + "\n" + - "\n".join(pairs) + + "\n".join(triples) + "\n" + END_MARKER + after_src @@ -61,5 +62,8 @@ for file in glob.glob(f"{XML_NAME}*/strings.xml"): with open(file, 'wb') as fp: fp.write(b'\n') tree.write(fp, encoding="utf-8", method="xml", pretty_print=True, xml_declaration=False) + # Remove trailing new line to be consistent with weblate + fp.seek(-1, os.SEEK_END) + fp.truncate() except ET.ParseError as ex: print(f"[{file}] {ex}") diff --git a/.github/workflows/build_to_archive.yml b/.github/workflows/build_to_archive.yml index 30bedcc1b..e84bb08b0 100644 --- a/.github/workflows/build_to_archive.yml +++ b/.github/workflows/build_to_archive.yml @@ -1,93 +1,78 @@ -name: Archive build - -on: - push: - branches: [ master ] - paths-ignore: - - '*.md' - - '*.json' - - '**/wcokey.txt' - workflow_dispatch: - -permissions: - contents: read - -concurrency: - group: "Archive-build" - cancel-in-progress: true - -jobs: - build: - runs-on: ubuntu-latest - steps: - - name: Generate access token - id: generate_token - uses: tibdex/github-app-token@v2 - with: - app_id: ${{ secrets.GH_APP_ID }} - private_key: ${{ secrets.GH_APP_KEY }} - repository: "recloudstream/secrets" - - - name: Generate access token (archive) - id: generate_archive_token - uses: tibdex/github-app-token@v2 - with: - app_id: ${{ secrets.GH_APP_ID }} - private_key: ${{ secrets.GH_APP_KEY }} - repository: "recloudstream/cloudstream-archive" - - - uses: actions/checkout@v6 - - - name: Set up JDK 17 - uses: actions/setup-java@v5 - with: - distribution: temurin - java-version: 17 - - - name: Grant execute permission for gradlew - run: chmod +x gradlew - - - name: Fetch keystore - id: fetch_keystore - run: | - TMP_KEYSTORE_FILE_PATH="${RUNNER_TEMP}"/keystore - mkdir -p "${TMP_KEYSTORE_FILE_PATH}" - curl -H "Authorization: token ${{ steps.generate_token.outputs.token }}" -o "${TMP_KEYSTORE_FILE_PATH}/prerelease_keystore.keystore" "https://raw.githubusercontent.com/recloudstream/secrets/master/keystore.jks" - curl -H "Authorization: token ${{ steps.generate_token.outputs.token }}" -o "keystore_password.txt" "https://raw.githubusercontent.com/recloudstream/secrets/master/keystore_password.txt" - KEY_PWD="$(cat keystore_password.txt)" - echo "::add-mask::${KEY_PWD}" - echo "key_pwd=$KEY_PWD" >> $GITHUB_OUTPUT - - - name: Setup Gradle - uses: gradle/actions/setup-gradle@v5 - with: - cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} - - - name: Run Gradle - run: ./gradlew assemblePrereleaseRelease - env: - SIGNING_KEY_ALIAS: "key0" - SIGNING_KEY_PASSWORD: ${{ steps.fetch_keystore.outputs.key_pwd }} - SIGNING_STORE_PASSWORD: ${{ steps.fetch_keystore.outputs.key_pwd }} - SIMKL_CLIENT_ID: ${{ secrets.SIMKL_CLIENT_ID }} - SIMKL_CLIENT_SECRET: ${{ secrets.SIMKL_CLIENT_SECRET }} - TRAKT_CLIENT_ID: ${{ secrets.TRAKT_CLIENT_ID }} - MDL_API_KEY: ${{ secrets.MDL_API_KEY }} - - - uses: actions/checkout@v6 - with: - 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" - - - name: Push archive - run: | - cd $GITHUB_WORKSPACE/archive - git config --local user.email "actions@github.com" - 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 +name: Archive build + +on: + push: + branches: [ master ] + paths-ignore: + - '*.md' + - '*.json' + - '**/wcokey.txt' + workflow_dispatch: + +concurrency: + group: "Archive-build" + cancel-in-progress: true + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Generate access token + id: generate_token + uses: tibdex/github-app-token@v2 + with: + app_id: ${{ secrets.GH_APP_ID }} + private_key: ${{ secrets.GH_APP_KEY }} + repository: "recloudstream/secrets" + - name: Generate access token (archive) + id: generate_archive_token + uses: tibdex/github-app-token@v2 + with: + app_id: ${{ secrets.GH_APP_ID }} + private_key: ${{ secrets.GH_APP_KEY }} + repository: "recloudstream/cloudstream-archive" + - uses: actions/checkout@v4 + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'adopt' + - name: Grant execute permission for gradlew + run: chmod +x gradlew + - name: Fetch keystore + id: fetch_keystore + run: | + TMP_KEYSTORE_FILE_PATH="${RUNNER_TEMP}"/keystore + mkdir -p "${TMP_KEYSTORE_FILE_PATH}" + curl -H "Authorization: token ${{ steps.generate_token.outputs.token }}" -o "${TMP_KEYSTORE_FILE_PATH}/prerelease_keystore.keystore" "https://raw.githubusercontent.com/recloudstream/secrets/master/keystore.jks" + curl -H "Authorization: token ${{ steps.generate_token.outputs.token }}" -o "keystore_password.txt" "https://raw.githubusercontent.com/recloudstream/secrets/master/keystore_password.txt" + KEY_PWD="$(cat keystore_password.txt)" + echo "::add-mask::${KEY_PWD}" + echo "key_pwd=$KEY_PWD" >> $GITHUB_OUTPUT + - name: Run Gradle + run: | + ./gradlew assemblePrerelease + env: + SIGNING_KEY_ALIAS: "key0" + SIGNING_KEY_PASSWORD: ${{ steps.fetch_keystore.outputs.key_pwd }} + SIGNING_STORE_PASSWORD: ${{ steps.fetch_keystore.outputs.key_pwd }} + SIMKL_CLIENT_ID: ${{ secrets.SIMKL_CLIENT_ID }} + SIMKL_CLIENT_SECRET: ${{ secrets.SIMKL_CLIENT_SECRET }} + - uses: actions/checkout@v4 + with: + repository: "recloudstream/cloudstream-archive" + token: ${{ steps.generate_archive_token.outputs.token }} + path: "archive" + + - name: Move build + run: | + cp app/build/outputs/apk/prerelease/release/*.apk "archive/$(git rev-parse --short HEAD).apk" + + - name: Push archive + run: | + cd $GITHUB_WORKSPACE/archive + git config --local user.email "actions@github.com" + 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 diff --git a/.github/workflows/generate_dokka.yml b/.github/workflows/generate_dokka.yml index d67b8a519..ec50743ae 100644 --- a/.github/workflows/generate_dokka.yml +++ b/.github/workflows/generate_dokka.yml @@ -1,18 +1,19 @@ name: Dokka +# https://docs.github.com/en/actions/learn-github-actions/workflow-syntax-for-github-actions#concurrency +concurrency: + group: "dokka" + cancel-in-progress: true + on: push: - branches: [ master ] + branches: + # choose your default branch + - master + - main paths-ignore: - '*.md' -permissions: - contents: read - -concurrency: - group: "dokka" - cancel-in-progress: true - jobs: build: runs-on: ubuntu-latest @@ -24,44 +25,41 @@ jobs: app_id: ${{ secrets.GH_APP_ID }} private_key: ${{ secrets.GH_APP_KEY }} repository: "recloudstream/dokka" - - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@master with: path: "src" - name: Checkout dokka - uses: actions/checkout@v6 + uses: actions/checkout@master with: repository: "recloudstream/dokka" path: "dokka" token: ${{ steps.generate_token.outputs.token }} - + - name: Clean old builds run: | cd $GITHUB_WORKSPACE/dokka/ - rm -rf "./app" - rm -rf "./library" + rm -rf "./-cloudstream" - - name: Set up JDK 17 - uses: actions/setup-java@v5 + - name: Setup JDK 17 + uses: actions/setup-java@v4 with: - distribution: temurin java-version: 17 + distribution: 'adopt' - - name: Setup Gradle - uses: gradle/actions/setup-gradle@v5 - with: - cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} + - name: Setup Android SDK + uses: android-actions/setup-android@v3 - name: Generate Dokka run: | cd $GITHUB_WORKSPACE/src/ chmod +x gradlew - ./gradlew docs:dokkaGeneratePublicationHtml + ./gradlew docs:dokkaHtml - name: Copy Dokka - run: cp -r $GITHUB_WORKSPACE/src/docs/build/dokka/html/* $GITHUB_WORKSPACE/dokka/ + run: | + cp -r $GITHUB_WORKSPACE/src/docs/build/dokka/html/* $GITHUB_WORKSPACE/dokka/ - name: Push builds run: | diff --git a/.github/workflows/issue_action.yml b/.github/workflows/issue_action.yml new file mode 100644 index 000000000..88ab3656c --- /dev/null +++ b/.github/workflows/issue_action.yml @@ -0,0 +1,88 @@ +name: Issue automatic actions + +on: + issues: + types: [opened] + +jobs: + issue-moderator: + runs-on: ubuntu-latest + steps: + - name: Generate access token + id: generate_token + uses: tibdex/github-app-token@v2 + with: + app_id: ${{ secrets.GH_APP_ID }} + private_key: ${{ secrets.GH_APP_KEY }} + - name: Similarity analysis + id: similarity + uses: actions-cool/issues-similarity-analysis@v1 + with: + token: ${{ steps.generate_token.outputs.token }} + filter-threshold: 0.60 + title-excludes: '' + comment-title: | + ### Your issue looks similar to these issues: + Please close if duplicate. + comment-body: '${index}. ${similarity} #${number}' + - name: Label if possible duplicate + if: steps.similarity.outputs.similar-issues-found =='true' + uses: actions/github-script@v7 + with: + github-token: ${{ steps.generate_token.outputs.token }} + script: | + github.rest.issues.addLabels({ + issue_number: context.issue.number, + owner: context.repo.owner, + repo: context.repo.repo, + labels: ["possible duplicate"] + }) + - uses: actions/checkout@v4 + - name: Automatically close issues that dont follow the issue template + uses: lucasbento/auto-close-issues@v1.0.2 + with: + github-token: ${{ steps.generate_token.outputs.token }} + issue-close-message: | + @${issue.user.login}: hello! :wave: + This issue is being automatically closed because it does not follow the issue template." + closed-issues-label: "invalid" + - name: Check if issue mentions a provider + id: provider_check + env: + GH_TEXT: "${{ github.event.issue.title }} ${{ github.event.issue.body }}" + run: | + wget --output-document check_issue.py "https://raw.githubusercontent.com/recloudstream/.github/master/.github/check_issue.py" + pip3 install httpx + RES="$(python3 ./check_issue.py)" + echo "name=${RES}" >> $GITHUB_OUTPUT + - name: Comment if issue mentions a provider + if: steps.provider_check.outputs.name != 'none' + uses: actions-cool/issues-helper@v3 + with: + actions: 'create-comment' + token: ${{ steps.generate_token.outputs.token }} + body: | + Hello ${{ github.event.issue.user.login }}. + Please do not report any provider bugs here. This repository does not contain any providers. Please find the appropriate repository and report your issue there or join the [discord](https://discord.gg/5Hus6fM). + + Found provider name: `${{ steps.provider_check.outputs.name }}` + - name: Label if mentions provider + if: steps.provider_check.outputs.name != 'none' + uses: actions/github-script@v7 + with: + github-token: ${{ steps.generate_token.outputs.token }} + script: | + github.rest.issues.addLabels({ + issue_number: context.issue.number, + owner: context.repo.owner, + repo: context.repo.repo, + labels: ["possible provider issue"] + }) + - name: Add eyes reaction to all issues + uses: actions-cool/emoji-helper@v1.0.0 + with: + type: 'issue' + token: ${{ steps.generate_token.outputs.token }} + emoji: 'eyes' + + diff --git a/.github/workflows/prerelease.yml b/.github/workflows/prerelease.yml index b5b17ba6a..f35cd58c5 100644 --- a/.github/workflows/prerelease.yml +++ b/.github/workflows/prerelease.yml @@ -8,13 +8,10 @@ on: - '*.json' - '**/wcokey.txt' -concurrency: +concurrency: group: "pre-release" cancel-in-progress: true -permissions: - contents: write - jobs: build: runs-on: ubuntu-latest @@ -26,18 +23,14 @@ jobs: app_id: ${{ secrets.GH_APP_ID }} private_key: ${{ secrets.GH_APP_KEY }} repository: "recloudstream/secrets" - - - uses: actions/checkout@v6 - + - uses: actions/checkout@v4 - name: Set up JDK 17 - uses: actions/setup-java@v5 + uses: actions/setup-java@v4 with: - distribution: temurin - java-version: 17 - + java-version: '17' + distribution: 'adopt' - name: Grant execute permission for gradlew run: chmod +x gradlew - - name: Fetch keystore id: fetch_keystore run: | @@ -48,25 +41,18 @@ jobs: KEY_PWD="$(cat keystore_password.txt)" echo "::add-mask::${KEY_PWD}" echo "key_pwd=$KEY_PWD" >> $GITHUB_OUTPUT - - - name: Setup Gradle - uses: gradle/actions/setup-gradle@v5 - with: - cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} - - name: Run Gradle - run: ./gradlew assemblePrereleaseRelease androidSourcesJar makeJar + run: | + ./gradlew assemblePrerelease build androidSourcesJar + ./gradlew makeJar # for classes.jar, has to be done after assemblePrerelease env: SIGNING_KEY_ALIAS: "key0" SIGNING_KEY_PASSWORD: ${{ steps.fetch_keystore.outputs.key_pwd }} SIGNING_STORE_PASSWORD: ${{ steps.fetch_keystore.outputs.key_pwd }} SIMKL_CLIENT_ID: ${{ secrets.SIMKL_CLIENT_ID }} SIMKL_CLIENT_SECRET: ${{ secrets.SIMKL_CLIENT_SECRET }} - TRAKT_CLIENT_ID: ${{ secrets.TRAKT_CLIENT_ID }} - MDL_API_KEY: ${{ secrets.MDL_API_KEY }} - - name: Create pre-release - uses: marvinpinto/action-automatic-releases@latest + uses: "marvinpinto/action-automatic-releases@latest" with: repo_token: "${{ secrets.GITHUB_TOKEN }}" automatic_release_tag: "pre-release" diff --git a/.github/workflows/pull_request.yml b/.github/workflows/pull_request.yml index 8f5c62866..7f6dd4123 100644 --- a/.github/workflows/pull_request.yml +++ b/.github/workflows/pull_request.yml @@ -2,35 +2,22 @@ name: Artifact Build on: [pull_request] -permissions: - contents: read - jobs: build: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 - + - uses: actions/checkout@v4 - name: Set up JDK 17 - uses: actions/setup-java@v5 + uses: actions/setup-java@v4 with: - distribution: temurin - java-version: 17 - + java-version: '17' + distribution: 'adopt' - name: Grant execute permission for gradlew run: chmod +x gradlew - - - name: Setup Gradle - uses: gradle/actions/setup-gradle@v5 - with: - cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} - cache-read-only: false - - name: Run Gradle - run: ./gradlew assemblePrereleaseDebug lint check - + run: ./gradlew assemblePrereleaseDebug - name: Upload Artifact - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@v4 with: name: pull-request-build path: "app/build/outputs/apk/prerelease/debug/*.apk" diff --git a/.github/workflows/update_locales.yml b/.github/workflows/update_locales.yml index 0a538d5d4..ce140e559 100644 --- a/.github/workflows/update_locales.yml +++ b/.github/workflows/update_locales.yml @@ -1,19 +1,17 @@ name: Fix locale issues on: + workflow_dispatch: push: - branches: [ master ] paths: - '**.xml' - workflow_dispatch: + branches: + - master -concurrency: +concurrency: group: "locale" cancel-in-progress: true -permissions: - contents: read - jobs: create: runs-on: ubuntu-latest @@ -25,17 +23,15 @@ jobs: app_id: ${{ secrets.GH_APP_ID }} private_key: ${{ secrets.GH_APP_KEY }} repository: "recloudstream/cloudstream" - - - uses: actions/checkout@v6 + - uses: actions/checkout@v4 with: token: ${{ steps.generate_token.outputs.token }} - - name: Install dependencies - run: pip3 install lxml requests - + run: | + pip3 install lxml - name: Edit files - run: python3 .github/locales.py - + run: | + python3 .github/locales.py - name: Commit to the repo run: | git config --local user.email "111277985+recloudstream[bot]@users.noreply.github.com" diff --git a/.gitignore b/.gitignore index 5fc9f0870..2ac6c9695 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ +*.iml +.gradle /local.properties /.idea/caches /.idea/misc.xml @@ -9,220 +11,6 @@ .DS_Store /build /captures -.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 +.cxx +local.properties diff --git a/.idea/.name b/.idea/.name new file mode 100644 index 000000000..1eb497a93 --- /dev/null +++ b/.idea/.name @@ -0,0 +1 @@ +CloudStream \ No newline at end of file diff --git a/.idea/codeStyles/Project.xml b/.idea/codeStyles/Project.xml new file mode 100644 index 000000000..7643783a8 --- /dev/null +++ b/.idea/codeStyles/Project.xml @@ -0,0 +1,123 @@ + + + + + + + + + + \ No newline at end of file diff --git a/.idea/codeStyles/codeStyleConfig.xml b/.idea/codeStyles/codeStyleConfig.xml new file mode 100644 index 000000000..79ee123c2 --- /dev/null +++ b/.idea/codeStyles/codeStyleConfig.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/.idea/compiler.xml b/.idea/compiler.xml new file mode 100644 index 000000000..b589d56e9 --- /dev/null +++ b/.idea/compiler.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.idea/discord.xml b/.idea/discord.xml new file mode 100644 index 000000000..d8e956166 --- /dev/null +++ b/.idea/discord.xml @@ -0,0 +1,7 @@ + + + + + \ No newline at end of file diff --git a/.idea/gradle.xml b/.idea/gradle.xml new file mode 100644 index 000000000..db202a929 --- /dev/null +++ b/.idea/gradle.xml @@ -0,0 +1,21 @@ + + + + + + + \ No newline at end of file diff --git a/.idea/jarRepositories.xml b/.idea/jarRepositories.xml new file mode 100644 index 000000000..333d49373 --- /dev/null +++ b/.idea/jarRepositories.xml @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/studiobot.xml b/.idea/studiobot.xml new file mode 100644 index 000000000..9298202cb --- /dev/null +++ b/.idea/studiobot.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 000000000..35eb1ddfb --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 000000000..7282979ad --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,6 @@ +{ + "githubPullRequests.ignoredPullRequestBranches": [ + "master" + ], + "java.configuration.updateBuildConfiguration": "interactive" +} \ No newline at end of file diff --git a/AI-POLICY.md b/AI-POLICY.md deleted file mode 100644 index 5409393fb..000000000 --- a/AI-POLICY.md +++ /dev/null @@ -1,11 +0,0 @@ -# AI Policy - -AI is a great tool. However, we want you to follow these rules regarding usage of AI in order to ensure the quality of both code and discussions. - -1. Always state any AI usage in pull requests and issues. - -2. Always test code before making a pull request. We do not want to test your AI generated code. - -3. Listen to humans over computers. Contributors to CloudStream know this codebase better than an AI. - -4. You should be able to explain and fix any code you submit. We do in-depth reviews and will reject low effort contributions. diff --git a/README.md b/README.md index c2492c5d8..8949304e9 100644 --- a/README.md +++ b/README.md @@ -1,46 +1,11 @@ # CloudStream -**⚠️ Warning: By default, this app doesn't provide any video sources; you have to install extensions to add functionality to the app.** +**⚠️ Warning: By default this app doesn't provide any video sources, you have to install extensions in order to add functionality to the app.** + [![Discord](https://invidget.switchblade.xyz/5Hus6fM)](https://discord.gg/5Hus6fM) - -## Table of Contents: -+ [About Us:](#about_us) -+ [Installation Steps:](#install_rules) -+ [Contributing:](#contributing) -+ [Issues:](#issues) - + [Bugs Reports:](#bug_report) - + [Enhancement:](#enhancment) -+ [Extension Development:](#extensions) -+ [Language Support:](#languages) -+ [Further Sources](#contact_and_sources) - - - - -## About us: - -**CloudStream is a media center that prioritizes and emphasizes complete freedom and flexibility for users and developers.** - -CloudStream is an extension-based multimedia player with tracking support. There are extensions to view videos from: - -+ [Librevox (audio-books)](https://librivox.org/) -+ [Youtube](https://www.youtube.com/) -+ [Twitch](https://www.twitch.tv/) -+ [iptv-org (A collection of publicly available IPTV (Internet Protocol television) channels from all over the world.)](https://github.com/iptv-org/iptv) -+ [nginx](https://nginx.org/) -+ And more... - - -**Please don't create illegal extensions or use any that host any copyrighted media.** For more details about our stance on the DMCA and EUCD, you can read about it on our organization: [reCloudStream](https://github.com/recloudstream) - -#### Important Copyright Note: - -Our documentation is unmaintained and open to contributions; therefore, apps and sources, extensions in recommended sources, and recommended apps are not officially moderated or endorsed by CloudStream; if you or another copyright owner identify an extension that breaches your copyright, please let us know. - - -#### Features: +### Features: + **AdFree**, No ads whatsoever + No tracking/analytics + Bookmarks @@ -48,64 +13,7 @@ Our documentation is unmaintained and open to contributions; therefore, apps and + Chromecast + Extension system for personal customization - - - -## Installation: - -Our documentation provides the steps to install and configure CloudStream for your streaming needs. - -[Getting Started With CloudStream:](https://recloudstream.github.io/csdocs/) - - - -## Contributing: -We **happily** accept any contributions to our project. To find out where you can start contributing towards the project, please look [at our issues tab](/cloudstream/issues) - - - - - -### Issues: -While we **actively** accept issues and pull requests, we do require you fill out an [template](https://github.com/recloudstream/cloudstream/issues/new/choose) for issues. These include the following: - - - -- [Bug Report Template: ](https://github.com/recloudstream/cloudstream/issues/new?assignees=&labels=bug&projects=&template=application-bug.yml) - - For bug reports, we want as much info as possible, including your downloaded version of CloudeStream, device and updated version (if possible, current API), - expected behavior of the program, and the actual behavior that the program did, most importantly we require clear, reproducible steps of the bug. If your bug can't be reproduced, it is unlikely we'll work on your issue. - - - -- [Feature Request Template: ](https://github.com/recloudstream/cloudstream/issues/new?assignees=&labels=enhancement&projects=&template=feature-request.yml) - - Before adding a feature request, please check to see if a feature request already has been requested. - - -### Extensions: - -**Further details on creating extensions for CloudStream are found in our documentation.** - -[Guide: For Extension Developers](https://recloudstream.github.io/csdocs/devs/gettingstarted/) - - - -## Further Sources: - -As well as providing clear install steps, our [website](https://dweb.link/ipns/cloudstream.on.fleek.co/) includes a wide variety of other tools, such as: -- [Troubleshooting](https://recloudstream.github.io/csdocs/troubleshooting/) -- [Further CloudStream Repositories](https://recloudstream.github.io/csdocs/repositories/) -- Set-Up for other devices, such as: - - [Android TV](https://recloudstream.github.io/csdocs/other-devices/tv/) - - [Windows](https://recloudstream.github.io/csdocs/other-devices/windows/) - - [Linux](https://recloudstream.github.io/csdocs/other-devices/linux/) -- And more... - - - ### Supported languages: - -Even if you can't contribute to the code or documentation, we always look for those who can contribute to translation and language support. Your contribution is exceptionally appreciated; you can check our translation from the figure below. - Translation status diff --git a/app/CMakeLists.txt b/app/CMakeLists.txt new file mode 100644 index 000000000..7f7fd14c1 --- /dev/null +++ b/app/CMakeLists.txt @@ -0,0 +1,6 @@ +# Set this to the minimum version your project supports. +cmake_minimum_required(VERSION 3.18) +project(CrashHandler) +find_library(log-lib log) +add_library(native-lib SHARED src/main/cpp/native-lib.cpp) +target_link_libraries(native-lib ${log-lib}) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 02c1f99e8..48a28e89b 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,96 +1,48 @@ 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 +import org.jetbrains.kotlin.gradle.plugin.mpp.pm20.util.archivesName +import org.jetbrains.kotlin.gradle.tasks.KotlinCompile +import java.io.ByteArrayOutputStream plugins { - alias(libs.plugins.android.application) - alias(libs.plugins.dokka) - alias(libs.plugins.kotlin.serialization) + id("com.android.application") + id("com.google.devtools.ksp") + id("kotlin-android") + id("org.jetbrains.dokka") } -val javaTarget = JvmTarget.fromTarget(libs.versions.jvmTarget.get()) +val tmpFilePath = System.getProperty("user.home") + "/work/_temp/keystore/" +val prereleaseStoreFile: File? = File(tmpFilePath).listFiles()?.first() -abstract class GenerateGitHashTask : DefaultTask() { - - @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")) +fun String.execute() = ByteArrayOutputStream().use { baot -> + if (project.exec { + workingDir = projectDir + commandLine = this@execute.split(Regex("\\s")) + standardOutput = baot + }.exitValue == 0) + String(baot.toByteArray()).trim() + else null } android { - @Suppress("UnstableApiUsage") testOptions { unitTests.isReturnDefaultValues = true } - // Looks like google likes to add metadata only they can read https://gitlab.com/IzzyOnDroid/repo/-/work_items/491 - dependenciesInfo { - // Disables dependency metadata when building APKs. - includeInApk = false - // Disables dependency metadata when building Android App Bundles. - includeInBundle = false + viewBinding { + enable = true } - androidComponents { - onVariants { variant -> - variant.sources.assets?.addGeneratedSourceDirectory( - generateGitHash, - GenerateGitHashTask::outputDir - ) + /* disable this for now + externalNativeBuild { + cmake { + path("CMakeLists.txt") } - } + }*/ signingConfigs { - // We just use SIGNING_KEY_ALIAS here since it won't change - // so won't kill the configuration cache. - if (System.getenv("SIGNING_KEY_ALIAS") != null) { + if (prereleaseStoreFile != null) { create("prerelease") { - val tmpFilePath = System.getProperty("user.home") + "/work/_temp/keystore/" - val prereleaseStoreFile: File? = File(tmpFilePath).listFiles()?.first() - - storeFile = prereleaseStoreFile?.let { file(it) } + storeFile = file(prereleaseStoreFile) storePassword = System.getenv("SIGNING_STORE_PASSWORD") keyAlias = System.getenv("SIGNING_KEY_ALIAS") keyPassword = System.getenv("SIGNING_KEY_PASSWORD") @@ -98,19 +50,23 @@ android { } } - compileSdk = libs.versions.compileSdk.get().toInt() + compileSdk = 34 + buildToolsVersion = "34.0.0" defaultConfig { applicationId = "com.lagradost.cloudstream3" - minSdk = libs.versions.minSdk.get().toInt() - targetSdk = libs.versions.targetSdk.get().toInt() - versionCode = libs.versions.versionCode.get().toInt() - versionName = libs.versions.versionName.get() + minSdk = 21 + targetSdk = 33 /* Android 14 is Fu*ked + ^ https://developer.android.com/about/versions/14/behavior-changes-14#safer-dynamic-code-loading*/ + versionCode = 64 + versionName = "4.4.0" - manifestPlaceholders["target_sdk_version"] = libs.versions.targetSdk.get() + resValue("string", "app_version", "${defaultConfig.versionName}${versionNameSuffix ?: ""}") + resValue("string", "commit_hash", "git rev-parse --short HEAD".execute() ?: "") + resValue("bool", "is_prerelease", "false") // Reads local.properties - val localProperties = gradleLocalProperties(rootDir, project.providers) + val localProperties = gradleLocalProperties(rootDir) buildConfigField( "long", @@ -128,6 +84,11 @@ android { "\"" + (System.getenv("SIMKL_CLIENT_SECRET") ?: localProperties["simkl.secret"]) + "\"" ) testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + + ksp { + arg("room.schemaLocation", "$projectDir/schemas") + arg("exportSchema", "true") + } } buildTypes { @@ -154,9 +115,12 @@ android { productFlavors { create("stable") { dimension = "state" + resValue("bool", "is_prerelease", "false") } create("prerelease") { dimension = "state" + resValue("bool", "is_prerelease", "true") + buildConfigField("boolean", "BETA", "true") applicationIdSuffix = ".prerelease" if (signingConfigs.names.contains("prerelease")) { signingConfig = signingConfigs.getByName("prerelease") @@ -170,33 +134,17 @@ android { compileOptions { isCoreLibraryDesugaringEnabled = true - sourceCompatibility = JavaVersion.toVersion(javaTarget.target) - 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())) - } + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 } 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" @@ -204,89 +152,105 @@ android { dependencies { // Testing - testImplementation(libs.junit) - testImplementation(libs.json) - androidTestImplementation(libs.core) - androidTestImplementation(libs.espresso.core) - androidTestImplementation(libs.ext.junit) - androidTestImplementation(libs.instancio.core) - androidTestImplementation(libs.junit.ktx) - androidTestImplementation(libs.kotlin.test) + testImplementation("junit:junit:4.13.2") + testImplementation("org.json:json:20240303") + androidTestImplementation("androidx.test:core") + implementation("androidx.test.ext:junit-ktx:1.2.1") + androidTestImplementation("androidx.test.ext:junit:1.2.1") + androidTestImplementation("androidx.test.espresso:espresso-core:3.6.1") // Android Core & Lifecycle - implementation(libs.core.ktx) - implementation(libs.activity.ktx) - implementation(libs.annotation) - implementation(libs.appcompat) - implementation(libs.fragment.ktx) - implementation(libs.bundles.lifecycle) - implementation(libs.bundles.navigation) - implementation(libs.kotlinx.collections.immutable) - implementation(libs.kotlinx.serialization.json) // JSON Parser + implementation("androidx.core:core-ktx:1.13.1") + implementation("androidx.appcompat:appcompat:1.7.0") + implementation("androidx.navigation:navigation-ui-ktx:2.7.7") + implementation("androidx.lifecycle:lifecycle-livedata-ktx:2.8.3") + implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.8.3") + implementation("androidx.navigation:navigation-fragment-ktx:2.7.7") // Design & UI - implementation(libs.preference.ktx) - implementation(libs.material) - implementation(libs.constraintlayout) + implementation("jp.wasabeef:glide-transformations:4.3.0") + implementation("androidx.preference:preference-ktx:1.2.1") + implementation("com.google.android.material:material:1.12.0") + implementation("androidx.constraintlayout:constraintlayout:2.1.4") + implementation("androidx.swiperefreshlayout:swiperefreshlayout:1.1.0") - // Coil Image Loading - implementation(libs.bundles.coil) + // Glide Module + ksp("com.github.bumptech.glide:ksp:4.16.0") + implementation("com.github.bumptech.glide:glide:4.16.0") + implementation("com.github.bumptech.glide:okhttp3-integration:4.16.0") + + // For KSP -> Official Annotation Processors are Not Yet Supported for KSP + ksp("dev.zacsweers.autoservice:auto-service-ksp:1.2.0") + implementation("com.google.guava:guava:33.2.1-android") + implementation("dev.zacsweers.autoservice:auto-service-ksp:1.2.0") // Media 3 (ExoPlayer) - implementation(libs.bundles.media3) - implementation(libs.video) - - // FFmpeg Decoding - implementation(libs.bundles.nextlib) - - // Anime-db for filler - implementation(libs.anime.db) + implementation("androidx.media3:media3-ui:1.4.1") + implementation("androidx.media3:media3-cast:1.4.1") + implementation("androidx.media3:media3-common:1.4.1") + implementation("androidx.media3:media3-session:1.4.1") + implementation("androidx.media3:media3-exoplayer:1.4.1") + implementation("com.google.android.mediahome:video:1.0.0") + implementation("androidx.media3:media3-exoplayer-hls:1.4.1") + implementation("androidx.media3:media3-exoplayer-dash:1.4.1") + implementation("androidx.media3:media3-datasource-okhttp:1.4.1") // PlayBack - implementation(libs.colorpicker) // Subtitle Color Picker - implementation(libs.newpipeextractor) // For Trailers - implementation(libs.juniversalchardet) // Subtitle Decoding + implementation("com.jaredrummler:colorpicker:1.1.0") // Subtitle Color Picker + implementation("com.github.recloudstream:media-ffmpeg:1.1.0") // Custom FF-MPEG Lib for Audio Codecs + implementation("com.github.teamnewpipe:NewPipeExtractor:v0.24.2") /* For Trailers + ^ Update to Latest Commits if Trailers Misbehave, github.com/TeamNewPipe/NewPipeExtractor/commits/dev */ + implementation("com.github.albfernandez:juniversalchardet:2.5.0") // Subtitle Decoding + + // Crash Reports (AcraApplication.kt) + implementation("ch.acra:acra-core:5.11.3") + implementation("ch.acra:acra-toast:5.11.3") // UI Stuff - implementation(libs.shimmer) // Shimmering Effect (Loading Skeleton) - implementation(libs.palette.ktx) // Palette for Images -> Colors - implementation(libs.tvprovider) - implementation(libs.overlappingpanels) // Gestures - implementation(libs.biometric) // Fingerprint Authentication - implementation(libs.previewseekbar.media3) // SeekBar Preview - implementation(libs.qrcode.kotlin) // QR Code for PIN Auth on TV + implementation("com.facebook.shimmer:shimmer:0.5.0") // Shimmering Effect (Loading Skeleton) + implementation("androidx.palette:palette-ktx:1.0.0") // Palette For Images -> Colors + implementation("androidx.tvprovider:tvprovider:1.0.0") + implementation("com.github.discord:OverlappingPanels:0.1.5") // Gestures + implementation("androidx.biometric:biometric:1.2.0-alpha05") // Fingerprint Authentication + implementation("com.github.rubensousa:previewseekbar-media3:1.1.1.0") // SeekBar Preview + implementation("io.github.g0dkar:qrcode-kotlin:4.2.0") // QR code for PIN Auth on TV // Extensions & Other Libs - implementation(libs.jsoup) // HTML Parser - implementation(libs.rhino) // Run JavaScript - implementation(libs.safefile) // To Prevent the URI File Fu*kery - coreLibraryDesugaring(libs.desugar.jdk.libs.nio) // NIO Flavor Needed for NewPipeExtractor - implementation(libs.conscrypt.android) // To Fix SSL Fu*kery on Android 9 - implementation(libs.jackson.module.kotlin) // JSON Parser - implementation(libs.zipline) - - // Deprecated; will be removed once extensions have time to migrate from using it - implementation("me.xdrop:fuzzywuzzy:1.4.0") - - // Torrent Support - implementation(libs.torrentserver) + implementation("org.mozilla:rhino:1.7.15") // run JavaScript + implementation("me.xdrop:fuzzywuzzy:1.4.0") // Library/Ext Searching with Levenshtein Distance + implementation("com.github.LagradOst:SafeFile:0.0.6") // To Prevent the URI File Fu*kery + implementation("org.conscrypt:conscrypt-android:2.5.2") // To Fix SSL Fu*kery on Android 9 + implementation("com.uwetrottmann.tmdb2:tmdb-java:2.11.0") // TMDB API v3 Wrapper Made with RetroFit + coreLibraryDesugaring("com.android.tools:desugar_jdk_libs_nio:2.0.4") //nio flavor needed for NewPipeExtractor + implementation("com.fasterxml.jackson.module:jackson-module-kotlin:2.13.1") /* JSON Parser + ^ Don't Bump Jackson above 2.13.1 , Crashes on Android TV's and FireSticks that have Min API + Level 25 or Less. */ // Downloading & Networking - implementation(libs.work.runtime.ktx) - implementation(libs.nicehttp) // HTTP Lib + implementation("androidx.work:work-runtime:2.9.0") + implementation("androidx.work:work-runtime-ktx:2.9.0") + implementation("com.github.Blatzar:NiceHttp:0.4.11") // HTTP Lib - implementation(project(":library")) + implementation(project(":library") { + // There does not seem to be a good way of getting the android flavor. + val isDebug = gradle.startParameter.taskRequests.any { task -> + task.args.any { arg -> + arg.contains("debug", true) + } + } + + this.extra.set("isDebug", isDebug) + }) } tasks.register("androidSourcesJar") { archiveClassifier.set("sources") - from(android.sourceSets.getByName("main").java.directories) // Full Sources + from(android.sourceSets.getByName("main").java.srcDirs) // Full Sources } tasks.register("copyJar") { - dependsOn("build", ":library:jvmJar") from( - "build/intermediates/compile_app_classes_jar/prereleaseDebug/bundlePrereleaseDebugClassesToCompileJar", + "build/intermediates/compile_app_classes_jar/prereleaseDebug", "../library/build/libs" ) into("build/app-classes") @@ -305,39 +269,12 @@ tasks.register("makeJar") { zipTree("build/app-classes/library-jvm.jar") ) destinationDirectory.set(layout.buildDirectory) - archiveBaseName = "classes" + archivesName = "classes" } -tasks.withType { - compilerOptions { - jvmTarget.set(javaTarget) - jvmDefault.set(JvmDefaultMode.ENABLE) - freeCompilerArgs.add("-Xannotation-default-target=param-property") - optIn.addAll( - "com.lagradost.cloudstream3.InternalAPI", - "com.lagradost.cloudstream3.Prerelease", - "kotlin.uuid.ExperimentalUuidApi", - ) +tasks.withType { + kotlinOptions { + jvmTarget = "1.8" + freeCompilerArgs = listOf("-Xjvm-default=all-compatibility") } -} - -dokka { - moduleName = "App" - dokkaSourceSets { - configureEach { - suppress = name != "prereleaseDebug" - analysisPlatform = KotlinPlatform.JVM - displayName = "JVM" - documentedVisibilities( - VisibilityModifier.Public, - VisibilityModifier.Protected - ) - - sourceLink { - localDirectory = file("..") - remoteUrl("https://github.com/recloudstream/cloudstream/tree/master") - remoteLineSuffix = "#L" - } - } - } -} +} \ No newline at end of file diff --git a/app/lint.xml b/app/lint.xml deleted file mode 100644 index b2f5e8f2b..000000000 --- a/app/lint.xml +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - - - - - - - diff --git a/app/src/androidTest/java/com/lagradost/cloudstream3/ExampleInstrumentedTest.kt b/app/src/androidTest/java/com/lagradost/cloudstream3/ExampleInstrumentedTest.kt index 4c5cdea5b..c7f02baff 100644 --- a/app/src/androidTest/java/com/lagradost/cloudstream3/ExampleInstrumentedTest.kt +++ b/app/src/androidTest/java/com/lagradost/cloudstream3/ExampleInstrumentedTest.kt @@ -7,7 +7,6 @@ import android.view.LayoutInflater import androidx.test.core.app.ActivityScenario import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.viewbinding.ViewBinding -import com.lagradost.cloudstream3.databinding.BottomResultviewPreviewBinding import com.lagradost.cloudstream3.databinding.FragmentHomeBinding import com.lagradost.cloudstream3.databinding.FragmentHomeTvBinding import com.lagradost.cloudstream3.databinding.FragmentLibraryBinding @@ -89,8 +88,6 @@ class ExampleInstrumentedTest { // testAllLayouts(activity,R.layout.activity_main, R.layout.activity_main_tv) //testAllLayouts(activity, R.layout.activity_main_tv) - testAllLayouts(activity, R.layout.bottom_resultview_preview,R.layout.bottom_resultview_preview_tv) - testAllLayouts(activity, R.layout.fragment_player,R.layout.fragment_player_tv) testAllLayouts(activity, R.layout.fragment_player,R.layout.fragment_player_tv) @@ -136,14 +133,14 @@ class ExampleInstrumentedTest { @Test @Throws(AssertionError::class) fun providerCorrectData() { - val langTagsIETF = SubtitleHelper.languages.map { it.IETF_tag } - Assert.assertFalse("IETFTagNames does not contain any languages", langTagsIETF.isNullOrEmpty()) + val isoNames = SubtitleHelper.languages.map { it.ISO_639_1 } + Assert.assertFalse("ISO does not contain any languages", isoNames.isNullOrEmpty()) for (api in getAllProviders()) { Assert.assertTrue("Api does not contain a mainUrl", api.mainUrl != "NONE") Assert.assertTrue("Api does not contain a name", api.name != "NONE") Assert.assertTrue( "Api ${api.name} does not contain a valid language code", - langTagsIETF.contains(api.lang) + isoNames.contains(api.lang) ) Assert.assertTrue( "Api ${api.name} does not contain any supported types", diff --git a/app/src/androidTest/java/com/lagradost/cloudstream3/SerializationClassTester.kt b/app/src/androidTest/java/com/lagradost/cloudstream3/SerializationClassTester.kt deleted file mode 100644 index 80c7b49b0..000000000 --- a/app/src/androidTest/java/com/lagradost/cloudstream3/SerializationClassTester.kt +++ /dev/null @@ -1,134 +0,0 @@ -package com.lagradost.cloudstream3 - -import androidx.test.ext.junit.runners.AndroidJUnit4 -import androidx.test.platform.app.InstrumentationRegistry -import com.lagradost.cloudstream3.utils.AppUtils.toJson -import dalvik.system.DexFile -import kotlinx.serialization.ExperimentalSerializationApi -import kotlinx.serialization.InternalSerializationApi -import kotlinx.serialization.KSerializer -import kotlinx.serialization.Serializable -import kotlinx.serialization.serializer -import kotlinx.serialization.serializerOrNull -import org.instancio.Instancio -import org.junit.Test -import org.junit.runner.RunWith -import kotlin.reflect.KClass -import kotlin.reflect.jvm.jvmName -import kotlin.test.assertEquals -import kotlin.test.assertNotNull - -@RunWith(AndroidJUnit4::class) -class SerializationClassTester { - // Same as app, or using app reference - val jacksonMapper = mapper - val kotlinxMapper = json - - @Test - fun isIdenticalSerialization() { - val serializableClasses = findSerializableClasses("com.lagradost") - println("Number of serializable classes: ${serializableClasses.size}") - - serializableClasses.forEach { kClass -> - val instance = Instancio.create(kClass.java) - - val jacksonJson = jacksonMapper.writeValueAsString(instance) - val kotlinxJson = serializeWithKotlinx(kClass, instance) - - assertEquals( - jacksonJson, - kotlinxJson, - """ - Serialization mismatch for: - ${kClass.qualifiedName} - - Jackson: - $jacksonJson - - Kotlinx: - $kotlinxJson - - """.trimIndent() - ) - println("Identical serialization for: ${kClass.jvmName}") - } - } - - @OptIn(InternalSerializationApi::class, ExperimentalSerializationApi::class) - @Test - fun isIdenticalDeserialization() { - val serializableClasses = findSerializableClasses("com.lagradost") - println("Number of serializable classes: ${serializableClasses.size}") - - serializableClasses.forEach { kClass -> - val instance = Instancio.create(kClass.java) - // Convert to JSON to get example JSON object - // We prefer jackson here because the app may have many jackson JSON strings in local storage - val originalJson = jacksonMapper.writeValueAsString(instance) - - // Create an object from the JSON using kotlinx - val serializer = - kClass.serializerOrNull() ?: kotlinxMapper.serializersModule.getContextual(kClass) - assertNotNull(serializer, "The class: ${kClass.jvmName} must be serializable!") - val kotlinxDecoded = kotlinxMapper.decodeFromString(serializer, originalJson) - - // Create an object from the JSON using jackson - val mapperDecoded = jacksonMapper.readValue(originalJson, kClass.java) - - - // Deep inspect both object using the mapper toJson function. - // This deep equality check can be performed using other methods, but this just works. - val jacksonJson = mapperDecoded.toJson() - val kotlinxJson = kotlinxDecoded.toJson() - - assertEquals( - jacksonJson, - kotlinxJson, - """ - Serialization mismatch for: - ${kClass.qualifiedName} - - Jackson: - $jacksonJson - - Kotlinx: - $kotlinxJson - - """.trimIndent() - ) - println("Identical deserialization for: ${kClass.jvmName}") - } - } - - // DEX files are the best solution to read all our classes dynamically. - // classgraph could be used instead, but it only gives results on the JVM, not Android. - @Suppress("DEPRECATION") - private fun findSerializableClasses(packageName: String): List> { - val context = InstrumentationRegistry - .getInstrumentation() - .targetContext - - val dexFile = DexFile(context.packageCodePath) - return dexFile.entries() - .toList() - .filter { it.startsWith(packageName) } - .mapNotNull { - runCatching { Class.forName(it).kotlin }.getOrNull() - }.filter { kClass -> - // Not possible to use .hasAnnotation() on newer Android versions. - kClass.java.annotations.any { - it is Serializable - } - } - } - - @OptIn(InternalSerializationApi::class) - @Suppress("UNCHECKED_CAST") - private fun serializeWithKotlinx( - kClass: KClass<*>, - value: Any - ): String { - val serializer = kClass.serializer() as KSerializer - return kotlinxMapper.encodeToString(serializer, value) - } -} diff --git a/app/src/androidTest/java/com/lagradost/cloudstream3/utils/serializers/SerializerTest.kt b/app/src/androidTest/java/com/lagradost/cloudstream3/utils/serializers/SerializerTest.kt deleted file mode 100644 index 15ad532f8..000000000 --- a/app/src/androidTest/java/com/lagradost/cloudstream3/utils/serializers/SerializerTest.kt +++ /dev/null @@ -1,157 +0,0 @@ -package com.lagradost.cloudstream3.utils.serializers - -import android.net.Uri -import com.lagradost.cloudstream3.utils.AppUtils.parseJson -import com.lagradost.cloudstream3.utils.AppUtils.toJson -import kotlinx.serialization.ExperimentalSerializationApi -import kotlinx.serialization.KeepGeneratedSerializer -import kotlinx.serialization.Serializable -import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse -import org.junit.Assert.assertNull -import org.junit.Assert.assertTrue -import org.junit.Test - -@OptIn(ExperimentalSerializationApi::class) -@KeepGeneratedSerializer -@Serializable(with = NonEmptyData.Serializer::class) -data class NonEmptyData( - val title: String = "", - val tags: List = emptyList(), - val meta: Map = emptyMap(), - val name: String = "hello", -) { - object Serializer : NonEmptySerializer(NonEmptyData.generatedSerializer()) -} - -@OptIn(ExperimentalSerializationApi::class) -@KeepGeneratedSerializer -@Serializable(with = WriteOnlyData.Serializer::class) -data class WriteOnlyData( - val fieldA: String = "", - val fieldB: String = "", -) { - object Serializer : WriteOnlySerializer( - WriteOnlyData.generatedSerializer(), - setOf("fieldB"), - ) -} - -@OptIn(ExperimentalSerializationApi::class) -@KeepGeneratedSerializer -@Serializable(with = MultiWriteOnly.Serializer::class) -data class MultiWriteOnly( - val fieldA: String = "", - val fieldB: String = "", - val fieldC: String = "", -) { - object Serializer : WriteOnlySerializer( - MultiWriteOnly.generatedSerializer(), - setOf("fieldB", "fieldC"), - ) -} - -@Serializable -data class UriData( - @Serializable(with = UriSerializer::class) - val uri: Uri = Uri.EMPTY, -) - -class SerializerTest { - - @Test - fun nonEmptySerializerOmitsEmptyStrings() { - val data = NonEmptyData(title = "", name = "hello") - val result = data.toJson() - assertFalse(result.contains("title")) - assertTrue(result.contains("name")) - } - - @Test - fun nonEmptySerializerOmitsEmptyLists() { - val data = NonEmptyData(tags = emptyList(), name = "hello") - val result = data.toJson() - assertFalse(result.contains("tags")) - } - - @Test - fun nonEmptySerializerOmitsEmptyMaps() { - val data = NonEmptyData(meta = emptyMap(), name = "hello") - val result = data.toJson() - assertFalse(result.contains("meta")) - } - - @Test - fun nonEmptySerializerKeepsNonEmptyFields() { - val data = NonEmptyData(title = "hello", tags = listOf("a"), meta = mapOf("k" to "v")) - val result = data.toJson() - assertTrue(result.contains("title")) - assertTrue(result.contains("tags")) - assertTrue(result.contains("meta")) - } - - @Test - fun nonEmptySerializerDoesNotAffectDeserialization() { - val input = """{"title":"hello","tags":["a"],"meta":{"k":"v"},"name":"world"}""" - val result = parseJson(input) - assertEquals("hello", result.title) - assertEquals(listOf("a"), result.tags) - assertEquals(mapOf("k" to "v"), result.meta) - assertEquals("world", result.name) - } - - @Test - fun writeOnlySerializerOmitsFieldOnSerialize() { - val data = WriteOnlyData(fieldA = "hello", fieldB = "secret") - val result = data.toJson() - assertTrue(result.contains("fieldA")) - assertFalse(result.contains("fieldB")) - } - - @Test - fun writeOnlySerializerDeserializesNormally() { - val input = """{"fieldA":"hello","fieldB":"secret"}""" - val result = parseJson(input) - assertEquals("hello", result.fieldA) - assertEquals("secret", result.fieldB) - } - - @Test - fun writeOnlySerializerDeserializesMissingAsDefault() { - val input = """{"fieldA":"hello"}""" - val result = parseJson(input) - assertEquals("hello", result.fieldA) - assertEquals("", result.fieldB) - } - - @Test - fun writeOnlySerializerHandlesMultipleKeys() { - val data = MultiWriteOnly(fieldA = "hello", fieldB = "secret1", fieldC = "secret2") - val result = data.toJson() - assertTrue(result.contains("fieldA")) - assertFalse(result.contains("fieldB")) - assertFalse(result.contains("fieldC")) - } - - @Test - fun uriSerializerSerializesUriToString() { - val data = UriData(uri = Uri.parse("https://example.com/path?query=1")) - val result = data.toJson() - assertTrue(result.contains("https://example.com/path?query=1")) - } - - @Test - fun uriSerializerDeserializesStringToUri() { - val input = """{"uri":"https://example.com/path?query=1"}""" - val result = parseJson(input) - assertEquals(Uri.parse("https://example.com/path?query=1"), result.uri) - } - - @Test - fun uriSerializerRoundtripsCorrectly() { - val data = UriData(uri = Uri.parse("https://example.com/path?query=1")) - val encoded = data.toJson() - val decoded = parseJson(encoded) - assertEquals(data.uri, decoded.uri) - } -} diff --git a/app/src/debug/res/drawable-v24/ic_banner_background.xml b/app/src/debug/res/drawable-v24/ic_banner_background.xml index caed023d5..7b05b7111 100644 --- a/app/src/debug/res/drawable-v24/ic_banner_background.xml +++ b/app/src/debug/res/drawable-v24/ic_banner_background.xml @@ -25,8 +25,9 @@ android:endY="245.72" android:endX="292.58" android:type="linear"> - - + + + @@ -39,8 +40,9 @@ android:endY="245.72" android:endX="248.76" android:type="linear"> - - + + + @@ -53,45 +55,46 @@ android:endY="245.69" android:endX="210.03" android:type="linear"> - - + + + + android:fillColor="#2e24ff"/> + android:fillColor="#2e24ff"/> + android:fillColor="#2e24ff"/> + android:fillColor="#2e24ff"/> + android:fillColor="#2e24ff"/> + android:fillColor="#5252ff"/> + android:fillColor="#5252ff"/> + android:fillColor="#5252ff"/> + android:fillColor="#5252ff"/> + android:fillColor="#5252ff"/> + android:fillColor="#5252ff"/> @@ -101,9 +104,9 @@ android:endY="252.3" android:endX="373.57" android:type="linear"> - - - + + + @@ -114,9 +117,9 @@ android:startX="400.11" android:endX="900" android:type="linear"> - - - + + + @@ -129,9 +132,9 @@ android:endY="252.3" android:endX="373.57" android:type="linear"> - - - + + + @@ -142,9 +145,9 @@ android:startX="700.11" android:endX="900.57" android:type="linear"> - - - + + + @@ -155,9 +158,9 @@ android:startX="400.11" android:endX="800.57" android:type="linear"> - - - + + + diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index ee4c978f2..a04504acd 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -7,62 +7,21 @@ + - - - + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + tools:targetApi="tiramisu"> + android:launchMode="singleTask"> @@ -125,55 +83,19 @@ - - - - - - - - - - - - - - - - - - - - - - - + android:supportsPictureInPicture="true"> @@ -200,14 +122,7 @@ - - - - - - - @@ -231,7 +146,7 @@ - + @@ -244,6 +159,25 @@ + + + + + + + + + + + + + + - - - - - +#include +#include + +#define TAG "CloudStream Crash Handler" +volatile sig_atomic_t gSignalStatus = 0; +void handleNativeCrash(int signal) { + gSignalStatus = signal; +} + +extern "C" JNIEXPORT void JNICALL +Java_com_lagradost_cloudstream3_NativeCrashHandler_initNativeCrashHandler(JNIEnv *env, jobject) { + #define REGISTER_SIGNAL(X) signal(X, handleNativeCrash); + REGISTER_SIGNAL(SIGSEGV) + #undef REGISTER_SIGNAL +} + +//extern "C" JNIEXPORT void JNICALL +//Java_com_lagradost_cloudstream3_NativeCrashHandler_triggerNativeCrash(JNIEnv *env, jobject thiz) { +// int *p = nullptr; +// *p = 0; +//} + +extern "C" JNIEXPORT int JNICALL +Java_com_lagradost_cloudstream3_NativeCrashHandler_getSignalStatus(JNIEnv *env, jobject) { + //__android_log_print(ANDROID_LOG_INFO, TAG, "Got signal status %d", gSignalStatus); + return gSignalStatus; +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/AcraApplication.kt b/app/src/main/java/com/lagradost/cloudstream3/AcraApplication.kt index bbe7d97de..d6f978fe5 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/AcraApplication.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/AcraApplication.kt @@ -1,78 +1,216 @@ package com.lagradost.cloudstream3 -/** - * Deprecated alias for CloudStreamApp for backwards compatibility with plugins. - * Use CloudStreamApp instead. - */ -@Deprecated( - message = "AcraApplication is deprecated, use CloudStreamApp instead", - replaceWith = ReplaceWith("com.lagradost.cloudstream3.CloudStreamApp"), - level = DeprecationLevel.WARNING -) -class AcraApplication { - companion object { +import android.app.Activity +import android.app.Application +import android.content.Context +import android.content.ContextWrapper +import android.content.Intent +import android.widget.Toast +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentActivity +import com.lagradost.api.setContext +import com.lagradost.cloudstream3.mvvm.normalSafeApiCall +import com.lagradost.cloudstream3.mvvm.suspendSafeApiCall +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 kotlinx.coroutines.runBlocking +import org.acra.ACRA +import org.acra.ReportField +import org.acra.config.CoreConfiguration +import org.acra.data.CrashReportData +import org.acra.data.StringFormat +import org.acra.ktx.initAcra +import org.acra.sender.ReportSender +import org.acra.sender.ReportSenderFactory +import java.io.File +import java.io.FileNotFoundException +import java.io.PrintStream +import java.lang.ref.WeakReference +import java.util.Locale +import kotlin.concurrent.thread +import kotlin.system.exitProcess - @Deprecated( - message = "AcraApplication is deprecated, use CloudStreamApp instead", - replaceWith = ReplaceWith("com.lagradost.cloudstream3.CloudStreamApp.context"), - level = DeprecationLevel.WARNING - ) - val context get() = CloudStreamApp.context +class CustomReportSender : ReportSender { + // Sends all your crashes to google forms + override fun send(context: Context, errorContent: CrashReportData) { + println("Sending report") + val url = + "https://docs.google.com/forms/d/e/1FAIpQLSfO4r353BJ79TTY_-t5KWSIJT2xfqcQWY81xjAA1-1N0U2eSg/formResponse" + val data = mapOf( + "entry.1993829403" to errorContent.toJSON() + ) - @Deprecated( - message = "AcraApplication is deprecated, use CloudStreamApp instead", - replaceWith = ReplaceWith("com.lagradost.cloudstream3.CloudStreamApp.removeKeys(folder)"), - level = DeprecationLevel.WARNING - ) - fun removeKeys(folder: String): Int? = - CloudStreamApp.removeKeys(folder) + thread { // to not run it on main thread + runBlocking { + suspendSafeApiCall { + app.post(url, data = data) + //println("Report response: $post") + } + } + } - @Deprecated( - message = "AcraApplication is deprecated, use CloudStreamApp instead", - replaceWith = ReplaceWith("com.lagradost.cloudstream3.CloudStreamApp.setKey(path, value)"), - level = DeprecationLevel.WARNING - ) - fun setKey(path: String, value: T) = - CloudStreamApp.setKey(path, value) - - @Deprecated( - message = "AcraApplication is deprecated, use CloudStreamApp instead", - replaceWith = ReplaceWith("com.lagradost.cloudstream3.CloudStreamApp.setKey(folder, path, value)"), - level = DeprecationLevel.WARNING - ) - fun setKey(folder: String, path: String, value: T) = - CloudStreamApp.setKey(folder, path, value) - - @Deprecated( - message = "AcraApplication is deprecated, use CloudStreamApp instead", - replaceWith = ReplaceWith("com.lagradost.cloudstream3.CloudStreamApp.getKey(path, defVal)"), - level = DeprecationLevel.WARNING - ) - inline fun getKey(path: String, defVal: T?): T? = - CloudStreamApp.getKey(path, defVal) - - @Deprecated( - message = "AcraApplication is deprecated, use CloudStreamApp instead", - replaceWith = ReplaceWith("com.lagradost.cloudstream3.CloudStreamApp.getKey(path)"), - level = DeprecationLevel.WARNING - ) - inline fun getKey(path: String): T? = - CloudStreamApp.getKey(path) - - @Deprecated( - message = "AcraApplication is deprecated, use CloudStreamApp instead", - replaceWith = ReplaceWith("com.lagradost.cloudstream3.CloudStreamApp.getKey(folder, path)"), - level = DeprecationLevel.WARNING - ) - inline fun getKey(folder: String, path: String): T? = - CloudStreamApp.getKey(folder, path) - - @Deprecated( - message = "AcraApplication is deprecated, use CloudStreamApp instead", - replaceWith = ReplaceWith("com.lagradost.cloudstream3.CloudStreamApp.getKey(folder, path, defVal)"), - level = DeprecationLevel.WARNING - ) - inline fun getKey(folder: String, path: String, defVal: T?): T? = - CloudStreamApp.getKey(folder, path, defVal) - } + runOnMainThread { // to run it on main looper + normalSafeApiCall { + Toast.makeText(context, R.string.acra_report_toast, Toast.LENGTH_SHORT).show() + } + } + } +} + +class CustomSenderFactory : ReportSenderFactory { + override fun create(context: Context, config: CoreConfiguration): ReportSender { + return CustomReportSender() + } + + override fun enabled(config: CoreConfiguration): Boolean { + return true + } +} + +class ExceptionHandler(val errorFile: File, val onError: (() -> Unit)) : + Thread.UncaughtExceptionHandler { + override fun uncaughtException(thread: Thread, error: Throwable) { + ACRA.errorReporter.handleException(error) + try { + PrintStream(errorFile).use { ps -> + ps.println("Currently loading extension: ${PluginManager.currentlyLoading ?: "none"}") + ps.println("Fatal exception on thread ${thread.name} (${thread.id})") + error.printStackTrace(ps) + } + } catch (ignored: FileNotFoundException) { + } + try { + onError.invoke() + } catch (ignored: Exception) { + } + exitProcess(1) + } + +} + +class AcraApplication : Application() { + + override fun onCreate() { + super.onCreate() + 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. + }*/ + } + } + + companion object { + var exceptionHandler: ExceptionHandler? = null + + /** Use to get activity from Context */ + tailrec fun Context.getActivity(): Activity? = this as? Activity + ?: (this as? ContextWrapper)?.baseContext?.getActivity() + + 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() + ) + } + } } diff --git a/app/src/main/java/com/lagradost/cloudstream3/CloudStreamApp.kt b/app/src/main/java/com/lagradost/cloudstream3/CloudStreamApp.kt deleted file mode 100644 index a9cd9c01e..000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/CloudStreamApp.kt +++ /dev/null @@ -1,181 +0,0 @@ -package com.lagradost.cloudstream3 - -import android.app.Activity -import android.app.Application -import android.content.Context -import android.content.ContextWrapper -import android.content.Intent -import android.os.Build -import android.widget.Toast -import androidx.fragment.app.Fragment -import androidx.fragment.app.FragmentActivity -import coil3.ImageLoader -import coil3.PlatformContext -import coil3.SingletonImageLoader -import com.lagradost.api.setContext -import com.lagradost.cloudstream3.BuildConfig -import com.lagradost.cloudstream3.mvvm.safe -import com.lagradost.cloudstream3.mvvm.safeAsync -import com.lagradost.cloudstream3.plugins.PluginManager -import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR -import com.lagradost.cloudstream3.ui.settings.Globals.TV -import com.lagradost.cloudstream3.ui.settings.Globals.isLayout -import com.lagradost.cloudstream3.utils.AppContextUtils.openBrowser -import com.lagradost.cloudstream3.utils.AppDebug -import com.lagradost.cloudstream3.utils.Coroutines.runOnMainThread -import com.lagradost.cloudstream3.utils.DataStore.getKey -import com.lagradost.cloudstream3.utils.DataStore.getKeys -import com.lagradost.cloudstream3.utils.DataStore.removeKey -import com.lagradost.cloudstream3.utils.DataStore.removeKeys -import com.lagradost.cloudstream3.utils.DataStore.setKey -import com.lagradost.cloudstream3.utils.ImageLoader.buildImageLoader -import kotlinx.coroutines.runBlocking -import java.io.File -import java.io.FileNotFoundException -import java.io.PrintStream -import java.lang.ref.WeakReference -import java.util.Locale -import kotlin.concurrent.thread -import kotlin.system.exitProcess - -class ExceptionHandler( - val errorFile: File, - val onError: (() -> Unit) -) : Thread.UncaughtExceptionHandler { - - override fun uncaughtException(thread: Thread, error: Throwable) { - try { - val threadId = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.BAKLAVA) { - thread.threadId() - } else { - @Suppress("DEPRECATION") - thread.id - } - - PrintStream(errorFile).use { ps -> - ps.println("Currently loading extension: ${PluginManager.currentlyLoading ?: "none"}") - ps.println("Fatal exception on thread ${thread.name} ($threadId)") - error.printStackTrace(ps) - } - } catch (_: FileNotFoundException) { - } - try { - onError() - } catch (_: Exception) { - } - exitProcess(1) - } -} - -class CloudStreamApp : Application(), SingletonImageLoader.Factory { - - override fun onCreate() { - super.onCreate() - // If we want to initialize Coil as early as possible, maybe when - // loading an image or GIF in a splash screen activity. - // buildImageLoader(applicationContext) - - ExceptionHandler(filesDir.resolve("last_error")) { - val intent = context!!.packageManager.getLaunchIntentForPackage(context!!.packageName) - startActivity(Intent.makeRestartActivityTask(intent!!.component)) - }.also { - exceptionHandler = it - Thread.setDefaultUncaughtExceptionHandler(it) - } - - AppDebug.isDebug = BuildConfig.DEBUG - } - - override fun attachBaseContext(base: Context?) { - super.attachBaseContext(base) - context = base - } - - override fun newImageLoader(context: PlatformContext): ImageLoader { - // Coil module will be initialized globally when first loadImage() is invoked. - return buildImageLoader(applicationContext) - } - - companion object { - var exceptionHandler: ExceptionHandler? = null - - /** Use to get Activity from Context. */ - tailrec fun Context.getActivity(): Activity? { - return when (this) { - is Activity -> this - is ContextWrapper -> baseContext.getActivity() - else -> null - } - } - - private var _context: WeakReference? = null - var context - get() = _context?.get() - private set(value) { - _context = WeakReference(value) - setContext(WeakReference(value)) - } - - fun getKeyClass(path: String, valueType: Class): T? { - return context?.getKey(path, valueType) - } - - fun setKeyClass(path: String, value: T) { - context?.setKey(path, value) - } - - fun removeKeys(folder: String): Int? { - return context?.removeKeys(folder) - } - - fun setKey(path: String, value: T) { - context?.setKey(path, value) - } - - fun setKey(folder: String, path: String, value: T) { - context?.setKey(folder, path, value) - } - - inline fun getKey(path: String, defVal: T?): T? { - return context?.getKey(path, defVal) - } - - inline fun getKey(path: String): T? { - return context?.getKey(path) - } - - inline fun getKey(folder: String, path: String): T? { - return context?.getKey(folder, path) - } - - inline fun getKey(folder: String, path: String, defVal: T?): T? { - return context?.getKey(folder, path, defVal) - } - - fun getKeys(folder: String): List? { - return context?.getKeys(folder) - } - - fun removeKey(folder: String, path: String) { - context?.removeKey(folder, path) - } - - fun removeKey(path: String) { - context?.removeKey(path) - } - - /** If fallbackWebView is true and a fragment is supplied then it will open a WebView with the URL if the browser fails. */ - fun openBrowser(url: String, fallbackWebView: Boolean = false, fragment: Fragment? = null) { - context?.openBrowser(url, fallbackWebView, fragment) - } - - /** Will fall back to WebView if in TV or emulator layout. */ - fun openBrowser(url: String, activity: FragmentActivity?) { - openBrowser( - url, - isLayout(TV or EMULATOR), - activity?.supportFragmentManager?.fragments?.lastOrNull() - ) - } - } -} diff --git a/app/src/main/java/com/lagradost/cloudstream3/CommonActivity.kt b/app/src/main/java/com/lagradost/cloudstream3/CommonActivity.kt index 4ce09bd44..50e6d8c98 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/CommonActivity.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/CommonActivity.kt @@ -1,16 +1,13 @@ package com.lagradost.cloudstream3 -import android.annotation.SuppressLint +import android.Manifest import android.app.Activity import android.app.PictureInPictureParams import android.content.Context import android.content.pm.PackageManager import android.content.res.Configuration import android.content.res.Resources -import android.Manifest import android.os.Build -import android.os.Handler -import android.os.Looper import android.util.DisplayMetrics import android.util.Log import android.view.Gravity @@ -27,41 +24,30 @@ import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.widget.SearchView import androidx.core.content.ContextCompat import androidx.core.view.children -import androidx.core.view.isNotEmpty import androidx.preference.PreferenceManager import com.google.android.gms.cast.framework.CastSession import com.google.android.material.chip.ChipGroup import com.google.android.material.navigationrail.NavigationRailView -import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey -import com.lagradost.cloudstream3.CloudStreamApp.Companion.removeKey +import com.lagradost.cloudstream3.AcraApplication.Companion.getKey +import com.lagradost.cloudstream3.AcraApplication.Companion.removeKey import com.lagradost.cloudstream3.actions.OpenInAppAction import com.lagradost.cloudstream3.actions.VideoClickActionHolder import com.lagradost.cloudstream3.databinding.ToastBinding import com.lagradost.cloudstream3.mvvm.logError -import com.lagradost.cloudstream3.syncproviders.AccountManager -import com.lagradost.cloudstream3.ui.home.HomeChildItemAdapter -import com.lagradost.cloudstream3.ui.home.ParentItemAdapter -import com.lagradost.cloudstream3.ui.player.PlayerPipHelper.isPIPPossible -import com.lagradost.cloudstream3.ui.player.Torrent -import com.lagradost.cloudstream3.ui.result.ActorAdaptor -import com.lagradost.cloudstream3.ui.result.EpisodeAdapter -import com.lagradost.cloudstream3.ui.result.ImageAdapter -import com.lagradost.cloudstream3.ui.search.SearchAdapter -import com.lagradost.cloudstream3.ui.settings.Globals.isLayout -import com.lagradost.cloudstream3.ui.settings.Globals.TV +import com.lagradost.cloudstream3.ui.player.PlayerEventType +import com.lagradost.cloudstream3.ui.result.UiText import com.lagradost.cloudstream3.ui.settings.Globals.updateTv -import com.lagradost.cloudstream3.ui.settings.extensions.PluginAdapter import com.lagradost.cloudstream3.utils.AppContextUtils.isRtl -import com.lagradost.cloudstream3.utils.Coroutines.ioSafe import com.lagradost.cloudstream3.utils.Event -import com.lagradost.cloudstream3.utils.UIHelper.showInputMethod +import com.lagradost.cloudstream3.utils.UIHelper +import com.lagradost.cloudstream3.utils.UIHelper.hasPIPPermission +import com.lagradost.cloudstream3.utils.UIHelper.shouldShowPIPMode import com.lagradost.cloudstream3.utils.UIHelper.toPx -import com.lagradost.cloudstream3.utils.UiText +import org.schabi.newpipe.extractor.NewPipe import java.lang.ref.WeakReference import java.util.Locale import kotlin.math.max import kotlin.math.min -import org.schabi.newpipe.extractor.NewPipe enum class FocusDirection { Start, @@ -101,24 +87,17 @@ object CommonActivity { get() { return min(displayMetrics.widthPixels, displayMetrics.heightPixels) } - val screenWidthWithOrientation: Int - get() { - return displayMetrics.widthPixels - } - val screenHeightWithOrientation: Int - get() { - return displayMetrics.heightPixels - } - var isPipDesired: Boolean = false + + var canEnterPipMode: Boolean = false + var canShowPipMode: Boolean = false var isInPIPMode: Boolean = false val onColorSelectedEvent = Event>() val onDialogDismissedEvent = Event() + var playerEventListener: ((PlayerEventType) -> Unit)? = null var keyEventListener: ((Pair) -> Boolean)? = null - var appliedTheme: Int = 0 - var appliedColor: Int = 0 private var currentToast: Toast? = null @@ -185,41 +164,27 @@ object CommonActivity { val toast = Toast(act) toast.duration = duration ?: Toast.LENGTH_SHORT toast.setGravity(Gravity.CENTER_HORIZONTAL or Gravity.BOTTOM, 0, 5.toPx) - @Suppress("DEPRECATION") - toast.view = - binding.root // FIXME Find an alternative using default Toasts since custom toasts are deprecated and won't appear with api30 set as minSDK version. + toast.view = binding.root //fixme Find an alternative using default Toasts since custom toasts are deprecated and won't appear with api30 set as minSDK version. currentToast = toast toast.show() - val handler = Handler(Looper.getMainLooper()) - val ref = WeakReference(toast) - - /* Clean up activity leak */ - handler.postDelayed({ - if (ref.get() == currentToast) { - currentToast = null - } - }, 10_000) - } catch (e: Exception) { logError(e) } } /** - * Set locale - * @param languageTag shall a IETF BCP 47 conformant tag. - * Check [com.lagradost.cloudstream3.utils.SubtitleHelper]. - * - * See locales on: - * https://github.com/unicode-org/cldr-json/blob/main/cldr-json/cldr-core/availableLocales.json - * https://www.iana.org/assignments/language-subtag-registry/language-subtag-registry - * https://android.googlesource.com/platform/frameworks/base/+/android-16.0.0_r2/core/res/res/values/locale_config.xml - * https://iso639-3.sil.org/code_tables/639/data/all - */ - fun setLocale(context: Context?, languageTag: String?) { - if (context == null || languageTag == null) return - val locale = Locale.forLanguageTag(languageTag) + * Not all languages can be fetched from locale with a code. + * This map allows sidestepping the default Locale(languageCode) + * when setting the app language. + **/ + val appLanguageExceptions = hashMapOf( + "zh-rTW" to Locale.TRADITIONAL_CHINESE + ) + + fun setLocale(context: Context?, languageCode: String?) { + if (context == null || languageCode == null) return + val locale = appLanguageExceptions[languageCode] ?: Locale(languageCode) val resources: Resources = context.resources val config = resources.configuration Locale.setDefault(locale) @@ -227,12 +192,7 @@ object CommonActivity { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) context.createConfigurationContext(config) - - @Suppress("DEPRECATION") - resources.updateConfiguration( - config, - resources.displayMetrics - ) // FIXME this should be replaced + resources.updateConfiguration(config, resources.displayMetrics) } fun Context.updateLocale() { @@ -243,27 +203,30 @@ 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 = - componentActivity.registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> - if (result.resultCode == AppCompatActivity.RESULT_OK) { - val actionUid = - getKey("last_click_action") ?: return@registerForActivityResult - Log.d(TAG, "Loading action $actionUid result handler") - val action = VideoClickActionHolder.getByUniqueId(actionUid) as? OpenInAppAction - ?: return@registerForActivityResult - action.onResultSafe(act, result.data) - removeKey("last_click_action") - removeKey("last_opened") - } + MainActivity.activityResultLauncher = componentActivity.registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> + if (result.resultCode == AppCompatActivity.RESULT_OK) { + val actionUid = getKey("last_click_action") ?: return@registerForActivityResult + Log.d(TAG, "Loading action $actionUid result handler") + val action = VideoClickActionHolder.getByUniqueId(actionUid) as? OpenInAppAction ?: return@registerForActivityResult + action.onResult(act, result.data) + removeKey("last_click_action") + removeKey("last_opened_id") } + } // Ask for notification permissions on Android 13 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && @@ -283,22 +246,17 @@ object CommonActivity { } } - /** Enters pip mode if it is both possible and desired to do so*/ private fun Activity.enterPIPMode() { - if (!isPipDesired || !this.isPIPPossible()) return - + if (!shouldShowPIPMode(canEnterPipMode) || !canShowPipMode) return try { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { try { enterPictureInPictureMode(PictureInPictureParams.Builder().build()) - } catch (_: Exception) { - // Use fallback just in case - @Suppress("DEPRECATION") + } catch (e: Exception) { enterPictureInPictureMode() } } else { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { - @Suppress("DEPRECATION") enterPictureInPictureMode() } } @@ -307,18 +265,17 @@ object CommonActivity { } } - fun onUserLeaveHint(act: Activity) { - // On Android 12 and later we use setAutoEnterEnabled() instead. - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) return - act.enterPIPMode() + fun onUserLeaveHint(act: Activity?) { + if (canEnterPipMode && canShowPipMode) { + act?.enterPIPMode() + } } fun updateTheme(act: Activity) { val settingsManager = PreferenceManager.getDefaultSharedPreferences(act) if (settingsManager - .getString(act.getString(R.string.app_theme_key), "AmoledLight") == "System" - && Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q - ) { + .getString(act.getString(R.string.app_theme_key), "AmoledLight") == "System" + && Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { loadThemes(act) } } @@ -350,10 +307,6 @@ object CommonActivity { "Monet" -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) R.style.MonetMode else R.style.AppTheme - "Dracula" -> R.style.DraculaMode - "Lavender" -> R.style.LavenderMode - "SilentBlue" -> R.style.SilentBlueMode - else -> R.style.AppTheme } @@ -386,13 +339,9 @@ object CommonActivity { else -> R.style.OverlayPrimaryColorNormal } - 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( R.style.LoadedStyle, true @@ -423,7 +372,8 @@ object CommonActivity { private fun View.hasContent(): Boolean { return isShown && when (this) { - is ViewGroup -> this.isNotEmpty() + //is RecyclerView -> this.childCount > 0 + is ViewGroup -> this.childCount > 0 else -> true } } @@ -453,7 +403,7 @@ object CommonActivity { // if cant focus but visible then break and let android decide // the exception if is the view is a parent and has children that wants focus val hasChildrenThatWantsFocus = (next as? ViewGroup)?.let { parent -> - parent.descendantFocusability == ViewGroup.FOCUS_AFTER_DESCENDANTS && parent.isNotEmpty() + parent.descendantFocusability == ViewGroup.FOCUS_AFTER_DESCENDANTS && parent.childCount > 0 } ?: false if (!next.isFocusable && shown && !hasChildrenThatWantsFocus) return null @@ -531,8 +481,84 @@ object CommonActivity { } - fun onKeyDown(act: Activity?, keyCode: Int, event: KeyEvent?): Boolean? { - return null + fun onKeyDown(act: Activity?, keyCode: Int, event: KeyEvent?) { + + // 149 keycode_numpad 5 + 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 -> { + PlayerEventType.NextEpisode + } + + KeyEvent.KEYCODE_MEDIA_PREVIOUS, KeyEvent.KEYCODE_BUTTON_L1, KeyEvent.KEYCODE_B -> { + 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 -> null + }?.let { playerEvent -> + playerEventListener?.invoke(playerEvent) + } + + //when (keyCode) { + // KeyEvent.KEYCODE_DPAD_CENTER -> { + // println("DPAD PRESSED") + // } + //} } /** overrides focus and custom key events */ @@ -569,7 +595,6 @@ object CommonActivity { else -> null } - // println("NEXT FOCUS : $nextView") if (nextView != null) { nextView.requestFocus() @@ -577,15 +602,10 @@ object CommonActivity { return true } - // TODO: Figure out why removing the check for SearchAutoComplete seems - // to break focus on TV as it shouldn't need to be used. - // Also handle KEYCODE_ENTER here because some remotes (e.g. LG Magic Remote) - // send KEYCODE_ENTER instead of KEYCODE_DPAD_CENTER when clicking the OK button. - @SuppressLint("RestrictedApi") - if ((keyCode == KeyEvent.KEYCODE_DPAD_CENTER || keyCode == KeyEvent.KEYCODE_ENTER) && + if (keyCode == KeyEvent.KEYCODE_DPAD_CENTER && (act.currentFocus is SearchView || act.currentFocus is SearchView.SearchAutoComplete) ) { - showInputMethod(act.currentFocus?.findFocus()) + UIHelper.showInputMethod(act.currentFocus?.findFocus()) } //println("Keycode: $keyCode") @@ -594,6 +614,7 @@ object CommonActivity { // "Got Keycode $keyCode | ${KeyEvent.keyCodeToString(keyCode)} \n ${event?.action}", // Toast.LENGTH_LONG //) + } // if someone else want to override the focus then don't handle the event as it is already diff --git a/app/src/main/java/com/lagradost/cloudstream3/HeaderDecorationBindingAdapter.kt b/app/src/main/java/com/lagradost/cloudstream3/HeaderDecorationBindingAdapter.kt new file mode 100644 index 000000000..045a7963a --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/HeaderDecorationBindingAdapter.kt @@ -0,0 +1,11 @@ +package com.lagradost.cloudstream3 + +import android.view.LayoutInflater +import androidx.annotation.LayoutRes +import androidx.recyclerview.widget.RecyclerView +import com.lagradost.cloudstream3.ui.HeaderViewDecoration + +fun setHeaderDecoration(view: RecyclerView, @LayoutRes headerViewRes: Int) { + val headerView = LayoutInflater.from(view.context).inflate(headerViewRes, null) + view.addItemDecoration(HeaderViewDecoration(headerView)) +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt b/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt index 90583011d..fa54545cf 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt @@ -1,38 +1,36 @@ package com.lagradost.cloudstream3 import android.animation.ValueAnimator -import android.annotation.SuppressLint -import android.app.Dialog +import android.content.ComponentName import android.content.Context import android.content.Intent -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.Build import android.os.Bundle import android.util.AttributeSet import android.util.Log -import android.view.Gravity import android.view.KeyEvent import android.view.Menu import android.view.MenuItem import android.view.View import android.view.ViewGroup import android.view.WindowManager -import android.widget.CheckBox import android.widget.ImageView import android.widget.LinearLayout import android.widget.Toast +import androidx.activity.OnBackPressedCallback import androidx.activity.result.ActivityResultLauncher import androidx.annotation.IdRes import androidx.annotation.MainThread import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AppCompatActivity import androidx.cardview.widget.CardView -import androidx.core.content.edit -import androidx.core.net.toUri +import androidx.constraintlayout.widget.ConstraintLayout +import androidx.core.view.GravityCompat 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,7 +48,6 @@ 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 @@ -64,9 +61,10 @@ import com.jaredrummler.android.colorpicker.ColorPickerDialogListener import com.lagradost.cloudstream3.APIHolder.allProviders import com.lagradost.cloudstream3.APIHolder.apis import com.lagradost.cloudstream3.APIHolder.initAll -import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey -import com.lagradost.cloudstream3.CloudStreamApp.Companion.removeKey -import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey +import com.lagradost.cloudstream3.AcraApplication.Companion.getKey +import com.lagradost.cloudstream3.AcraApplication.Companion.removeKey +import com.lagradost.cloudstream3.AcraApplication.Companion.setKey +import com.lagradost.cloudstream3.CommonActivity.activity import com.lagradost.cloudstream3.CommonActivity.loadThemes import com.lagradost.cloudstream3.CommonActivity.onColorSelectedEvent import com.lagradost.cloudstream3.CommonActivity.onDialogDismissedEvent @@ -76,34 +74,35 @@ import com.lagradost.cloudstream3.CommonActivity.setActivityInstance import com.lagradost.cloudstream3.CommonActivity.showToast import com.lagradost.cloudstream3.CommonActivity.updateLocale import com.lagradost.cloudstream3.CommonActivity.updateTheme -import com.lagradost.cloudstream3.actions.temp.fcast.FcastManager import com.lagradost.cloudstream3.databinding.ActivityMainBinding import com.lagradost.cloudstream3.databinding.ActivityMainTvBinding import com.lagradost.cloudstream3.databinding.BottomResultviewPreviewBinding import com.lagradost.cloudstream3.mvvm.Resource import com.lagradost.cloudstream3.mvvm.logError -import com.lagradost.cloudstream3.mvvm.safe +import com.lagradost.cloudstream3.mvvm.normalSafeApiCall import com.lagradost.cloudstream3.mvvm.observe import com.lagradost.cloudstream3.mvvm.observeNullable import com.lagradost.cloudstream3.network.initClient import com.lagradost.cloudstream3.plugins.PluginManager -import com.lagradost.cloudstream3.plugins.PluginManager.___DO_NOT_CALL_FROM_A_PLUGIN_loadAllOnlinePlugins +import com.lagradost.cloudstream3.plugins.PluginManager.loadAllOnlinePlugins 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.APP_STRING_SHARE +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.localListApi import com.lagradost.cloudstream3.syncproviders.SyncAPI import com.lagradost.cloudstream3.ui.APIRepository import com.lagradost.cloudstream3.ui.SyncWatchType import com.lagradost.cloudstream3.ui.WatchType import com.lagradost.cloudstream3.ui.account.AccountHelper.showAccountSelectLinear +import com.lagradost.cloudstream3.ui.account.AccountViewModel import com.lagradost.cloudstream3.ui.download.DOWNLOAD_NAVIGATE_TO import com.lagradost.cloudstream3.ui.home.HomeViewModel import com.lagradost.cloudstream3.ui.library.LibraryViewModel @@ -114,12 +113,15 @@ import com.lagradost.cloudstream3.ui.result.LinearListLayout import com.lagradost.cloudstream3.ui.result.ResultViewModel2 import com.lagradost.cloudstream3.ui.result.START_ACTION_RESUME_LATEST import com.lagradost.cloudstream3.ui.result.SyncViewModel +import com.lagradost.cloudstream3.ui.result.setImage +import com.lagradost.cloudstream3.ui.result.setText +import com.lagradost.cloudstream3.ui.result.setTextHtml +import com.lagradost.cloudstream3.ui.result.txt import com.lagradost.cloudstream3.ui.search.SearchFragment 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,27 +158,22 @@ import com.lagradost.cloudstream3.utils.DataStoreHelper import com.lagradost.cloudstream3.utils.DataStoreHelper.accounts import com.lagradost.cloudstream3.utils.DataStoreHelper.migrateResumeWatching import com.lagradost.cloudstream3.utils.Event -import com.lagradost.cloudstream3.utils.ImageLoader.loadImage -import com.lagradost.cloudstream3.utils.InAppUpdater.runAutoUpdate +import com.lagradost.cloudstream3.utils.InAppUpdater.Companion.runAutoUpdate import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showBottomDialog import com.lagradost.cloudstream3.utils.SnackbarHelper.showSnackbar -import com.lagradost.cloudstream3.utils.TvChannelUtils import com.lagradost.cloudstream3.utils.UIHelper.changeStatusBarState import com.lagradost.cloudstream3.utils.UIHelper.checkWrite +import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute import com.lagradost.cloudstream3.utils.UIHelper.dismissSafe -import com.lagradost.cloudstream3.utils.UIHelper.enableEdgeToEdgeCompat -import com.lagradost.cloudstream3.utils.UIHelper.fixSystemBarsPadding import com.lagradost.cloudstream3.utils.UIHelper.getResourceColor import com.lagradost.cloudstream3.utils.UIHelper.hideKeyboard import com.lagradost.cloudstream3.utils.UIHelper.navigate import com.lagradost.cloudstream3.utils.UIHelper.requestRW -import com.lagradost.cloudstream3.utils.UIHelper.setNavigationBarColorCompat +import com.lagradost.cloudstream3.utils.UIHelper.setImage import com.lagradost.cloudstream3.utils.UIHelper.toPx import com.lagradost.cloudstream3.utils.USER_PROVIDER_API import com.lagradost.cloudstream3.utils.USER_SELECTED_HOMEPAGE_API -import com.lagradost.cloudstream3.utils.setText -import com.lagradost.cloudstream3.utils.setTextHtml -import com.lagradost.cloudstream3.utils.txt +import com.lagradost.cloudstream3.actions.temp.fcast.FcastManager import com.lagradost.safefile.SafeFile import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock @@ -188,9 +185,6 @@ import java.nio.charset.Charset import kotlin.math.abs import kotlin.math.absoluteValue import kotlin.system.exitProcess -import com.lagradost.cloudstream3.utils.downloader.DownloadQueueManager -import kotlinx.coroutines.Job -import kotlinx.coroutines.cancel class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCallback { companion object { @@ -200,23 +194,7 @@ 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" /** * Transient files to delete on application exit. @@ -279,14 +257,13 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa fun handleAppIntentUrl( activity: FragmentActivity?, str: String?, - isWebview: Boolean, - extraArgs: Bundle? = null + isWebview: Boolean ): Boolean = with(activity) { // TODO MUCH BETTER HANDLING // Invalid URIs can crash - fun safeURI(uri: String) = safe { URI(uri) } + fun safeURI(uri: String) = normalSafeApiCall { URI(uri) } if (str != null && this != null) { if (str.startsWith("https://cs.repo")) { @@ -295,29 +272,28 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa loadRepository(realUrl) return true } else if (str.contains(APP_STRING)) { - for (api in AccountManager.allApis) { - if (api.isValidRedirectUrl(str)) { + for (api in OAuth2Apis) { + if (str.contains("/${api.redirectUrl}")) { ioSafe { Log.i(TAG, "handleAppIntent $str") - try { - val isSuccessful = api.login(str) - if (isSuccessful) { - Log.i(TAG, "authenticated ${api.name}") - } else { - Log.i(TAG, "failed to authenticate ${api.name}") + 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 } - 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 @@ -326,11 +302,7 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa // This specific intent is used for the gradle deployWithAdb // https://github.com/recloudstream/gradle/blob/master/src/main/kotlin/com/lagradost/cloudstream3/gradle/tasks/DeployWithAdbTask.kt#L46 if (str == "$APP_STRING:") { - ioSafe { - PluginManager.___DO_NOT_CALL_FROM_A_PLUGIN_hotReloadAllLocalPlugins( - activity - ) - } + PluginManager.hotReloadAllLocalPlugins(activity) } } else if (safeURI(str)?.scheme == APP_STRING_REPO) { val url = str.replaceFirst(APP_STRING_REPO, "https") @@ -352,7 +324,7 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa activity?.findViewById(R.id.nav_rail_view)?.selectedItemId = R.id.navigation_search } else if (safeURI(str)?.scheme == APP_STRING_PLAYER) { - val uri = str.toUri() + val uri = Uri.parse(str) val name = uri.getQueryParameter("name") val url = URLDecoder.decode(uri.authority, "UTF-8") @@ -362,8 +334,7 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa LinkGenerator( listOf(BasicLink(url, name)), extract = true, - id = url.hashCode() - ), 0 + ) ) ) } else if (safeURI(str)?.scheme == APP_STRING_RESUME_WATCHING) { @@ -379,68 +350,27 @@ 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) return true } else { - val apiName = extraArgs?.getString(API_NAME_EXTRA_KEY) - ?.takeIf { it.isNotBlank() } - // if provided, try to match the api name instead of the api url - // this is in order to also support providers that use JSON dataUrls - // for example - if (apiName != null) { - loadResult(str, apiName, "") - return true - } - - val matchedApi = apis.filter { str.startsWith(it.mainUrl) }.firstOrNull() - if (matchedApi != null) { - loadResult(str, matchedApi.name, "") - return true + synchronized(apis) { + for (api in apis) { + if (str.startsWith(api.mainUrl)) { + loadResult(str, api.name) + return true + } + } } } } } return false } - - - fun centerView(view: View?) { - if (view == null) return - try { - Log.v(TAG, "centerView: $view") - val r = Rect(0, 0, 0, 0) - view.getDrawingRect(r) - val x = r.centerX() - val y = r.centerY() - val dx = r.width() / 2 //screenWidth / 2 - val dy = screenHeight / 2 - val r2 = Rect(x - dx, y - dy, x + dx, y + dy) - view.requestRectangleOnScreen(r2, false) - // TvFocus.current =TvFocus.current.copy(y=y.toFloat()) - } catch (_: Throwable) { - } - } } - var lastPopup: SearchResponse? = null - var lastPopupJob: Job? = null fun loadPopup(result: SearchResponse, load: Boolean = true) { lastPopup = result val syncName = syncViewModel.syncName(result.apiName) @@ -456,8 +386,7 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa syncViewModel.clear() } - lastPopupJob?.cancel() - lastPopupJob = if (load) { + if (load) { viewModel.load( this, result.url, result.apiName, false, if (getApiDubstatusSettings() .contains(DubStatus.Dubbed) @@ -504,7 +433,6 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa R.id.navigation_downloads, R.id.navigation_settings, R.id.navigation_download_child, - R.id.navigation_download_queue, R.id.navigation_subtitles, R.id.navigation_chrome_subtitles, R.id.navigation_settings_player, @@ -519,7 +447,7 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa ).contains(destination.id) - /*val dontPush = listOf( + val dontPush = listOf( R.id.navigation_home, R.id.navigation_search, R.id.navigation_results_phone, @@ -550,19 +478,25 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa } layoutParams = params - }*/ + } + + val landscape = when (resources.configuration.orientation) { + Configuration.ORIENTATION_LANDSCAPE -> { + true + } + + Configuration.ORIENTATION_PORTRAIT -> { + isLayout(TV or EMULATOR) + } + + else -> { + false + } + } binding?.apply { - navRailView.isVisible = isNavVisible && isLandscape() - navView.isVisible = isNavVisible && !isLandscape() - navHostFragment.apply { - val marginPx = resources.getDimensionPixelSize(R.dimen.nav_rail_view_width) - layoutParams = - (navHostFragment.layoutParams as ViewGroup.MarginLayoutParams).apply { - marginStart = - if (isNavVisible && isLandscape() && isLayout(TV or EMULATOR)) marginPx else 0 - } - } + navRailView.isVisible = isNavVisible && landscape + navView.isVisible = isNavVisible && !landscape /** * We need to make sure if we return to a sub-fragment, @@ -570,15 +504,10 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa * highlight the wrong one in UI. */ when (destination.id) { - in listOf( - R.id.navigation_downloads, - R.id.navigation_download_child, - R.id.navigation_download_queue - ) -> { + in listOf(R.id.navigation_downloads, R.id.navigation_download_child) -> { navRailView.menu.findItem(R.id.navigation_downloads).isChecked = true navView.menu.findItem(R.id.navigation_downloads).isChecked = true } - in listOf( R.id.navigation_settings, R.id.navigation_subtitles, @@ -665,11 +594,18 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa } } - override fun dispatchKeyEvent(event: KeyEvent): Boolean = - CommonActivity.dispatchKeyEvent(this, event) ?: super.dispatchKeyEvent(event) + override fun dispatchKeyEvent(event: KeyEvent): Boolean { + val response = CommonActivity.dispatchKeyEvent(this, event) + if (response != null) + return response + return super.dispatchKeyEvent(event) + } - override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean = - CommonActivity.onKeyDown(this, keyCode, event) ?: super.onKeyDown(keyCode, event) + override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean { + CommonActivity.onKeyDown(this, keyCode, event) + + return super.onKeyDown(keyCode, event) + } override fun onUserLeaveHint() { @@ -677,34 +613,14 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa onUserLeaveHint(this) } - @SuppressLint("ApplySharedPref") // commit since the op needs to be synchronous - private fun showConfirmExitDialog(settingsManager: SharedPreferences) { - val confirmBeforeExit = settingsManager.getInt(getString(R.string.confirm_exit_key), -1) - - if (confirmBeforeExit == 1 || (confirmBeforeExit == -1 && isLayout(PHONE))) { - // finish() causes a bug on some TVs where player - // may keep playing after closing the app. - if (isLayout(TV)) exitProcess(0) else finish() - return - } - - val dialogView = layoutInflater.inflate(R.layout.confirm_exit_dialog, null) - val dontShowAgainCheck: CheckBox = dialogView.findViewById(R.id.checkboxDontShowAgain) + private fun showConfirmExitDialog() { val builder: AlertDialog.Builder = AlertDialog.Builder(this) - builder.setView(dialogView) - .setTitle(R.string.confirm_exit_dialog) - .setNegativeButton(R.string.no) { _, _ -> /*NO-OP*/ } - .setPositiveButton(R.string.yes) { _, _ -> - if (dontShowAgainCheck.isChecked) { - settingsManager.edit(commit = true) { - putInt(getString(R.string.confirm_exit_key), 1) - } - } - // finish() causes a bug on some TVs where player - // may keep playing after closing the app. - if (isLayout(TV)) exitProcess(0) else finish() - } - + builder.setTitle(R.string.confirm_exit_dialog) + builder.apply { + // Forceful exit since back button can actually go back to setup + setPositiveButton(R.string.yes) { _, _ -> exitProcess(0) } + setNegativeButton(R.string.no) { _, _ -> } + } builder.show().setDefaultFocus() } @@ -723,11 +639,10 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa broadcastIntent.setClass(this, VideoDownloadRestartReceiver::class.java) this.sendBroadcast(broadcastIntent) afterPluginsLoadedEvent -= ::onAllPluginsLoaded - detachBackPressedCallback("MainActivityDefault") super.onDestroy() } - override fun onNewIntent(intent: Intent) { + override fun onNewIntent(intent: Intent?) { handleAppIntent(intent) super.onNewIntent(intent) } @@ -736,55 +651,13 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa if (intent == null) return val str = intent.dataString loadCache() - - handleAppIntentUrl(this, str, false, intent.extras) + handleAppIntentUrl(this, str, false) } private fun NavDestination.matchDestination(@IdRes destId: Int): Boolean = hierarchy.any { it.id == destId } - private var lastNavTime = 0L private fun onNavDestinationSelected(item: MenuItem, navController: NavController): Boolean { - val currentTime = System.currentTimeMillis() - // safeDebounce: Check if a previous tap happened within the last 400ms - if (currentTime - lastNavTime < 400) return false - lastNavTime = currentTime - - val destinationId = item.itemId - - // 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) @@ -797,11 +670,11 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa saveState = true ) } + val options = builder.build() return try { - navController.navigate(destinationId, null, builder.build()) - navController.currentDestination?.matchDestination(destinationId) == true + navController.navigate(item.itemId, null, options) + navController.currentDestination?.matchDestination(item.itemId) == true } catch (e: IllegalArgumentException) { - Log.e("NavigationError", "Failed to navigate: ${e.message}") false } } @@ -810,21 +683,19 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa private fun onAllPluginsLoaded(success: Boolean = false) { ioSafe { pluginsLock.withLock { - allProviders.withLock { + synchronized(allProviders) { // Load cloned sites after plugins have been loaded since clones depend on plugins. try { getKey>(USER_PROVIDER_API)?.let { list -> list.forEach { custom -> allProviders.firstOrNull { it.javaClass.simpleName == custom.parentJavaClass } ?.let { - allProviders.add( - it.javaClass.getDeclaredConstructor().newInstance() - .apply { - name = custom.name - lang = custom.lang - mainUrl = custom.url.trimEnd('/') - canBeOverridden = false - }) + allProviders.add(it.javaClass.getDeclaredConstructor().newInstance().apply { + name = custom.name + lang = custom.lang + mainUrl = custom.url.trimEnd('/') + canBeOverridden = false + }) } } } @@ -843,6 +714,7 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa lateinit var viewModel: ResultViewModel2 lateinit var syncViewModel: SyncViewModel private var libraryViewModel: LibraryViewModel? = null + private var accountViewModel: AccountViewModel? = null /** kinda dirty, however it signals that we should use the watch status as sync or not*/ var isLocalList: Boolean = false @@ -856,37 +728,20 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa private fun hidePreviewPopupDialog() { bottomPreviewPopup.dismissSafe(this) - lastPopupJob?.cancel() - lastPopupJob = null bottomPreviewPopup = null bottomPreviewBinding = null } - private var bottomPreviewPopup: Dialog? = null + private var bottomPreviewPopup: BottomSheetDialog? = null private var bottomPreviewBinding: BottomResultviewPreviewBinding? = null private fun showPreviewPopupDialog(): BottomResultviewPreviewBinding { val ret = (bottomPreviewBinding ?: run { - - val builder: Dialog - val layout: Int - - if (isLayout(PHONE)) { - builder = - BottomSheetDialog(this) - layout = R.layout.bottom_resultview_preview - } else { - builder = - Dialog(this, R.style.DialogHalfFullscreen) - layout = R.layout.bottom_resultview_preview_tv - // No way to do this in styles :( - builder.window?.setGravity(Gravity.CENTER_VERTICAL or Gravity.END) - } - - val root = layoutInflater.inflate(layout, null, false) - val binding = BottomResultviewPreviewBinding.bind(root) - + val builder = + BottomSheetDialog(this) + val binding: BottomResultviewPreviewBinding = + BottomResultviewPreviewBinding.inflate(builder.layoutInflater, null, false) bottomPreviewBinding = binding - builder.setContentView(root) + builder.setContentView(binding.root) builder.setOnDismissListener { bottomPreviewPopup = null bottomPreviewBinding = null @@ -1177,14 +1032,34 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa } } - override fun onCreate(savedInstanceState: Bundle?) { - app.initClient(this, ignoreSSL = false) - @OptIn(UnsafeSSL::class) - insecureApp.initClient(this, ignoreSSL = true) + private fun centerView(view: View?) { + if (view == null) return + try { + Log.v(TAG, "centerView: $view") + val r = Rect(0, 0, 0, 0) + view.getDrawingRect(r) + val x = r.centerX() + val y = r.centerY() + val dx = r.width() / 2 //screenWidth / 2 + val dy = screenHeight / 2 + val r2 = Rect(x - dx, y - dy, x + dx, y + dy) + view.requestRectangleOnScreen(r2, false) + // TvFocus.current =TvFocus.current.copy(y=y.toFloat()) + } catch (_: Throwable) { + } + } + override fun onCreate(savedInstanceState: Bundle?) { + app.initClient(this) val settingsManager = PreferenceManager.getDefaultSharedPreferences(this) - setLastError(this) + val errorFile = filesDir.resolve("last_error") + if (errorFile.exists() && errorFile.isFile) { + lastError = errorFile.readText(Charset.defaultCharset()) + errorFile.delete() + } else { + lastError = null + } val settingsForProvider = SettingsJson() settingsForProvider.enableAdult = @@ -1193,14 +1068,11 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa MainAPI.settingsForProvider = settingsForProvider loadThemes(this) - enableEdgeToEdgeCompat() - setNavigationBarColorCompat(R.attr.primaryGrayBackground) updateLocale() super.onCreate(savedInstanceState) try { if (isCastApiAvailable()) { - CastContext.getSharedInstance(this) { it.run() } - .addOnSuccessListener { mSessionManager = it.sessionManager } + CastContext.getSharedInstance(this) {it.run()}.addOnSuccessListener { mSessionManager = it.sessionManager } } } catch (t: Throwable) { logError(t) @@ -1210,17 +1082,15 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa updateTv() // backup when we update the app, I don't trust myself to not boot lock users, might want to make this a setting? - safe { + normalSafeApiCall { val appVer = BuildConfig.VERSION_NAME val lastAppAutoBackup: String = getKey("VERSION_NAME") ?: "" if (appVer != lastAppAutoBackup) { setKey("VERSION_NAME", BuildConfig.VERSION_NAME) - if (lastAppAutoBackup.isEmpty()) return@safe - - safe { + normalSafeApiCall { backup(this) } - safe { + normalSafeApiCall { // Recompile oat on new version PluginManager.deleteAllOatFiles(this) } @@ -1248,7 +1118,7 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa if (isLayout(TV)) { // Put here any button you don't want focusing it to center the view val exceptionButtons = listOf( - //R.id.home_preview_play_btt, + R.id.home_preview_play_btt, R.id.home_preview_info_btt, R.id.home_preview_hidden_next_focus, R.id.home_preview_hidden_prev_focus, @@ -1280,26 +1150,6 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa null } - binding?.apply { - fixSystemBarsPadding( - navView, - heightResId = R.dimen.nav_view_height, - padTop = false, - overlayCutout = false - ) - - fixSystemBarsPadding( - navRailView, - widthResId = R.dimen.nav_rail_view_width, - padRight = false, - padTop = false - ) - } - - // overscan - val padding = settingsManager.getInt(getString(R.string.overscan_key), 0).toPx - binding?.homeRoot?.setPadding(padding, padding, padding, padding) - changeStatusBarState(isLayout(EMULATOR)) /** Biometric stuff for users without accounts **/ @@ -1341,7 +1191,7 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa ioSafe { SafeFile.check(this@MainActivity) } if (PluginManager.checkSafeModeFile()) { - safe { + normalSafeApiCall { showToast(R.string.safe_mode_file, Toast.LENGTH_LONG) } } else if (lastError == null) { @@ -1358,11 +1208,9 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa true ) ) { - PluginManager.___DO_NOT_CALL_FROM_A_PLUGIN_updateAllOnlinePluginsAndLoadThem( - this@MainActivity - ) + PluginManager.updateAllOnlinePluginsAndLoadThem(this@MainActivity) } else { - ___DO_NOT_CALL_FROM_A_PLUGIN_loadAllOnlinePlugins(this@MainActivity) + loadAllOnlinePlugins(this@MainActivity) } //Automatically download not existing plugins, using mode specified. @@ -1373,7 +1221,7 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa ) ) ?: AutoDownloadMode.Disable if (autoDownloadPlugin != AutoDownloadMode.Disable) { - PluginManager.___DO_NOT_CALL_FROM_A_PLUGIN_downloadNotExistingPluginsAndLoad( + PluginManager.downloadNotExistingPluginsAndLoad( this@MainActivity, autoDownloadPlugin ) @@ -1381,14 +1229,8 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa } ioSafe { - PluginManager.___DO_NOT_CALL_FROM_A_PLUGIN_loadAllLocalPlugins( - this@MainActivity, - false - ) + PluginManager.loadAllLocalPlugins(this@MainActivity, false) } - -// Add your channel creation here - } } else { val builder: AlertDialog.Builder = AlertDialog.Builder(this) @@ -1513,17 +1355,9 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa resultviewPreviewMetaRating.setText(d.ratingText) resultviewPreviewDescription.setTextHtml(d.plotText) - if (isLayout(PHONE)) { - resultviewPreviewPoster.loadImage( - d.posterImage ?: d.posterBackgroundImage, - headers = d.posterHeaders - ) - } else { - resultviewPreviewPoster.loadImage( - d.posterBackgroundImage ?: d.posterImage, - headers = d.posterHeaders - ) - } + resultviewPreviewPoster.setImage( + d.posterImage ?: d.posterBackgroundImage + ) setUserData(syncViewModel.userData.value) setWatchStatus(viewModel.watchStatus.value) @@ -1626,6 +1460,18 @@ 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 @@ -1653,7 +1499,9 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa ioSafe { initAll() // No duplicates (which can happen by registerMainAPI) - apis = allProviders.distinctBy { it } + apis = synchronized(allProviders) { + allProviders.distinctBy { it } + } } // val navView: BottomNavigationView = findViewById(R.id.nav_view) @@ -1673,11 +1521,16 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa } } - if (navDestination.matchDestination(R.id.navigation_home)) { - attachBackPressedCallback("MainActivity") { - showConfirmExitDialog(settingsManager) - } - } else detachBackPressedCallback("MainActivity") + if (isLayout(TV or EMULATOR)) { + if (navDestination.matchDestination(R.id.navigation_home)) { + attachBackPressedCallback { + showConfirmExitDialog() + window?.navigationBarColor = + colorFromAttribute(R.attr.primaryGrayBackground) + updateLocale() + } + } else detachBackPressedCallback() + } } //val navController = findNavController(R.id.nav_host_fragment) @@ -1703,27 +1556,17 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa navController ) } - } binding?.navRailView?.apply { - if (isLayout(PHONE)) { - itemRippleColor = rippleColor - itemActiveIndicatorColor = rippleColor - } else { - val rippleColor = ColorStateList.valueOf(getResourceColor(R.attr.textColor, 1.0f)) - val rippleColorTransparent = - ColorStateList.valueOf(getResourceColor(R.attr.textColor, 0.2f)) - itemSpacing = 12.toPx // expandedItemSpacing does not have an attr - itemRippleColor = rippleColorTransparent - itemActiveIndicatorColor = rippleColor - } + itemRippleColor = rippleColor + itemActiveIndicatorColor = rippleColor setupWithNavController(navController) - /*if (isLayout(TV or EMULATOR)) { + if (isLayout(TV or EMULATOR)) { background?.alpha = 200 } else { background?.alpha = 255 - }*/ + } setOnItemSelectedListener { item -> onNavDestinationSelected( @@ -1732,7 +1575,6 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa ) } - fun noFocus(view: View) { view.tag = view.context.getString(R.string.tv_no_focus_tag) (view as? ViewGroup)?.let { @@ -1758,7 +1600,7 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa observe(homeViewModel.currentAccount) { currentAccount -> if (currentAccount != null) { - navProfilePic?.loadImage( + navProfilePic?.setImage( currentAccount.image ) navProfileRoot.isVisible = true @@ -1771,104 +1613,6 @@ 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 -> @@ -1935,7 +1679,7 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa fun buildMediaQueueItem(video: String): MediaQueueItem { // val movieMetadata = MediaMetadata(MediaMetadata.MEDIA_TYPE_PHOTO) //movieMetadata.putString(MediaMetadata.KEY_TITLE, "CloudStream") - val mediaInfo = MediaInfo.Builder(video.toUri().toString()) + val mediaInfo = MediaInfo.Builder(Uri.parse(video).toString()) .setStreamType(MediaInfo.STREAM_TYPE_NONE) .setContentType(MimeTypes.IMAGE_JPEG) // .setMetadata(movieMetadata).build() @@ -1961,7 +1705,7 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa if (BuildConfig.DEBUG) { var providersAndroidManifestString = "Current androidmanifest should be:\n" - allProviders.withLock { + synchronized(allProviders) { for (api in allProviders) { providersAndroidManifestString += "(USER_SELECTED_HOMEPAGE_API)?.let { homepage -> DataStoreHelper.currentHomePage = homepage removeKey(USER_SELECTED_HOMEPAGE_API) @@ -2039,14 +1772,22 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa // } // } - attachBackPressedCallback("MainActivityDefault") { - setNavigationBarColorCompat(R.attr.primaryGrayBackground) - updateLocale() - runDefault() - } + onBackPressedDispatcher.addCallback( + this, + object : OnBackPressedCallback(true) { + override fun handleOnBackPressed() { + window?.navigationBarColor = colorFromAttribute(R.attr.primaryGrayBackground) + updateLocale() - // Start the download queue - DownloadQueueManager.init(this) + // If we don't disable we end up in a loop with default behavior calling + // this callback as well, so we disable it, run default behavior, + // then re-enable this callback so it can be used for next back press. + isEnabled = false + onBackPressedDispatcher.onBackPressed() + isEnabled = true + } + } + ) } /** Biometric stuff **/ diff --git a/app/src/main/java/com/lagradost/cloudstream3/actions/AlwaysAskAction.kt b/app/src/main/java/com/lagradost/cloudstream3/actions/AlwaysAskAction.kt deleted file mode 100644 index a3c4040b5..000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/actions/AlwaysAskAction.kt +++ /dev/null @@ -1,26 +0,0 @@ -package com.lagradost.cloudstream3.actions - -import android.content.Context -import com.lagradost.cloudstream3.R -import com.lagradost.cloudstream3.ui.result.LinkLoadingResult -import com.lagradost.cloudstream3.ui.result.ResultEpisode -import com.lagradost.cloudstream3.utils.txt - -class AlwaysAskAction : VideoClickAction() { - override val name = txt(R.string.player_settings_always_ask) - override val isPlayer = true - - // Only show in settings, not on a video - override fun shouldShow(context: Context?, video: ResultEpisode?): Boolean = video == null - - override suspend fun runAction( - context: Context?, - video: ResultEpisode, - result: LinkLoadingResult, - index: Int? - ) { - // This is handled specially in ResultViewModel2.kt by detecting the AlwaysAskAction - // and showing the player selection dialog instead of executing the action directly - throw NotImplementedError("AlwaysAskAction is handled specially by the calling code") - } -} diff --git a/app/src/main/java/com/lagradost/cloudstream3/actions/OpenInAppAction.kt b/app/src/main/java/com/lagradost/cloudstream3/actions/OpenInAppAction.kt index ac912cbeb..99c1ac38b 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/actions/OpenInAppAction.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/actions/OpenInAppAction.kt @@ -1,28 +1,34 @@ package com.lagradost.cloudstream3.actions import android.app.Activity +import android.content.ActivityNotFoundException import android.content.ComponentName import android.content.Context import android.content.Intent +import android.widget.Toast import androidx.core.content.FileProvider import androidx.core.net.toUri -import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey -import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey +import com.lagradost.cloudstream3.AcraApplication.Companion.getKey +import com.lagradost.cloudstream3.AcraApplication.Companion.setKey +import com.lagradost.cloudstream3.CommonActivity.showToast +import com.lagradost.cloudstream3.MainActivity.Companion.activityResultLauncher import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.ui.result.LinkLoadingResult import com.lagradost.cloudstream3.ui.result.ResultEpisode import com.lagradost.cloudstream3.ui.result.ResultFragment -import com.lagradost.cloudstream3.utils.UiText -import com.lagradost.cloudstream3.utils.txt +import com.lagradost.cloudstream3.ui.result.UiText +import com.lagradost.cloudstream3.ui.result.txt import com.lagradost.cloudstream3.utils.AppContextUtils.isAppInstalled import com.lagradost.cloudstream3.utils.DataStoreHelper +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch import java.io.File fun updateDurationAndPosition(position: Long, duration: Long) { if (position <= 0 || duration <= 0) return - val episode = getKey("last_opened") ?: return - DataStoreHelper.setViewPosAndResume(episode.id, position, duration, episode, null) + DataStoreHelper.setViewPos(getKey("last_opened_id"), position, duration) ResultFragment.updateUI() } @@ -33,13 +39,7 @@ fun updateDurationAndPosition(position: Long, duration: Long) { fun makeTempM3U8Intent( context: Context, intent: Intent, - result: LinkLoadingResult -) { - if (result.links.size == 1) { - intent.setDataAndType(result.links.first().url.toUri(), "video/*") - return - } - + result: LinkLoadingResult) { intent.apply { addFlags(Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION) addFlags(Intent.FLAG_GRANT_PREFIX_URI_PERMISSION) @@ -47,29 +47,37 @@ fun makeTempM3U8Intent( addFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION) } - val outputFile = File.createTempFile("mirrorlist", ".m3u8", context.cacheDir) - var text = "#EXTM3U\n#EXT-X-VERSION:3" + val outputDir = context.cacheDir - result.links.forEach { link -> - text += "\n#EXTINF:0,${link.name}\n${link.url}" + if (result.links.size == 1) { + intent.setDataAndType(result.links.first().url.toUri(), "video/*") + } else { + val outputFile = File.createTempFile("mirrorlist", ".m3u8", outputDir) + + var text = "#EXTM3U\n#EXT-X-VERSION:3" + + result.links.forEachIndexed { index, link -> + text += "\n#EXTINF:$index,${link.name}\n${link.url}" + } + + //With subtitles it doesn't work for no reason :( + /*for (sub in result.subs) { + val normalizedName = sub.name.replace("[^a-zA-Z0-9 ]".toRegex(), "") + text += "\n#EXT-X-MEDIA:TYPE=SUBTITLES,GROUP-ID=\"subs\",NAME=\"${normalizedName}\",DEFAULT=NO,AUTOSELECT=NO,FORCED=NO,LANGUAGE=\"${sub.languageCode}\",URI=\"${sub.url}\"" + }*/ + + text += "\n#EXT-X-ENDLIST" + + outputFile.writeText(text) + + intent.setDataAndType( + FileProvider.getUriForFile( + context, + context.applicationContext.packageName + ".provider", + outputFile + ), "application/x-mpegURL" + ) } - - //With subtitles it doesn't work for no reason :( - /*for (sub in result.subs) { - val normalizedName = sub.name.replace("[^a-zA-Z0-9 ]".toRegex(), "") - text += "\n#EXT-X-MEDIA:TYPE=SUBTITLES,GROUP-ID=\"subs\",NAME=\"${normalizedName}\",DEFAULT=NO,AUTOSELECT=NO,FORCED=NO,LANGUAGE=\"${sub.languageCode}\",URI=\"${sub.url}\"" - }*/ - - text += "\n#EXT-X-ENDLIST" - outputFile.writeText(text) - - intent.setDataAndType( - FileProvider.getUriForFile( - context, - context.applicationContext.packageName + ".provider", - outputFile - ), "application/x-mpegURL" - ) } abstract class OpenInAppAction( @@ -77,16 +85,15 @@ abstract class OpenInAppAction( open val packageName: String, private val intentClass: String? = null, private val action: String = Intent.ACTION_VIEW -) : VideoClickAction() { +): VideoClickAction() { override val name: UiText get() = txt(R.string.episode_action_play_in_format, appName) override val isPlayer = true - override fun shouldShow(context: Context?, video: ResultEpisode?) = - context?.isAppInstalled(packageName) != false + override fun shouldShow(context: Context?, video: ResultEpisode?) = context?.isAppInstalled(packageName) == true - override suspend fun runAction( + override fun runAction( context: Context?, video: ResultEpisode, result: LinkLoadingResult, @@ -99,37 +106,29 @@ abstract class OpenInAppAction( intent.component = ComponentName(packageName, intentClass) } putExtra(context, intent, video, result, index) - setKey("last_opened", video) - launchResult(intent) + setKey("last_opened_id", video.id) + try { + CoroutineScope(Dispatchers.IO).launch { + activityResultLauncher?.launch(intent) + } + } catch (_: ActivityNotFoundException) { + showToast(R.string.app_not_found_error, Toast.LENGTH_LONG) + } catch (t: Throwable) { + logError(t) + showToast(t.toString(), Toast.LENGTH_LONG) + } } /** * Before intent is sent, this function is called to put extra data into the intent. * @see VideoClickAction.runAction * */ - @Throws - abstract suspend fun putExtra( - context: Context, - intent: Intent, - video: ResultEpisode, - result: LinkLoadingResult, - index: Int? - ) + abstract fun putExtra(context: Context, intent: Intent, video: ResultEpisode, result: LinkLoadingResult, index: Int?) /** * This function is called when the app is opened again after the intent was sent. * You can use it to for example update duration and position. * @see updateDurationAndPosition */ - @Throws abstract fun onResult(activity: Activity, intent: Intent?) - - /** Safe version of onResult, we don't trust extension devs to not crash the app */ - fun onResultSafe(activity: Activity, intent: Intent?) { - try { - onResult(activity, intent) - } catch (t: Throwable) { - logError(t) - } - } } \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/actions/VideoClickAction.kt b/app/src/main/java/com/lagradost/cloudstream3/actions/VideoClickAction.kt index a864b5fb7..f66ed74d9 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/actions/VideoClickAction.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/actions/VideoClickAction.kt @@ -1,77 +1,32 @@ package com.lagradost.cloudstream3.actions import android.app.Activity -import android.content.ActivityNotFoundException import android.content.Context -import android.content.Intent -import android.os.Bundle -import android.widget.Toast -import androidx.core.app.ActivityOptionsCompat import com.lagradost.api.Log -import com.lagradost.cloudstream3.CommonActivity -import com.lagradost.cloudstream3.ErrorLoadingException -import com.lagradost.cloudstream3.MainActivity -import com.lagradost.cloudstream3.R -import com.lagradost.cloudstream3.actions.temp.BiglyBTPackage import com.lagradost.cloudstream3.actions.temp.CopyClipboardAction -import com.lagradost.cloudstream3.actions.temp.JustPlayerPackage -import com.lagradost.cloudstream3.actions.temp.LibreTorrentPackage -import com.lagradost.cloudstream3.actions.temp.MpvExPackage import com.lagradost.cloudstream3.actions.temp.MpvKtPackage import com.lagradost.cloudstream3.actions.temp.MpvKtPreviewPackage import com.lagradost.cloudstream3.actions.temp.MpvPackage -import com.lagradost.cloudstream3.actions.temp.MpvRxPackage import com.lagradost.cloudstream3.actions.temp.MpvYTDLPackage -import com.lagradost.cloudstream3.actions.temp.NextPlayerPackage -import com.lagradost.cloudstream3.actions.temp.OnlyPlayer import com.lagradost.cloudstream3.actions.temp.PlayInBrowserAction -import com.lagradost.cloudstream3.actions.temp.PlayMirrorAction import com.lagradost.cloudstream3.actions.temp.ViewM3U8Action -import com.lagradost.cloudstream3.actions.temp.VlcNightlyPackage import com.lagradost.cloudstream3.actions.temp.VlcPackage import com.lagradost.cloudstream3.actions.temp.WebVideoCastPackage import com.lagradost.cloudstream3.actions.temp.fcast.FcastAction -import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.ui.result.LinkLoadingResult import com.lagradost.cloudstream3.ui.result.ResultEpisode -import com.lagradost.cloudstream3.utils.Coroutines.atomicListOf -import com.lagradost.cloudstream3.utils.Coroutines.ioSafe +import com.lagradost.cloudstream3.ui.result.UiText +import com.lagradost.cloudstream3.utils.Coroutines.threadSafeListOf import com.lagradost.cloudstream3.utils.ExtractorLinkType -import com.lagradost.cloudstream3.utils.UiText -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import java.util.concurrent.Callable -import java.util.concurrent.FutureTask import kotlin.reflect.jvm.jvmName object VideoClickActionHolder { - val allVideoClickActions = atomicListOf( - // Default - PlayInBrowserAction(), - CopyClipboardAction(), - ViewM3U8Action(), - PlayMirrorAction(), - // main support external apps - VlcPackage(), - MpvPackage(), - MpvExPackage(), - NextPlayerPackage(), - JustPlayerPackage(), - FcastAction(), - LibreTorrentPackage(), - BiglyBTPackage(), - // forks/backup apps - VlcNightlyPackage(), - WebVideoCastPackage(), - MpvYTDLPackage(), - MpvKtPackage(), - MpvKtPreviewPackage(), - OnlyPlayer(), - MpvRxPackage(), - // Always Ask option - AlwaysAskAction(), - // added by plugins - // ... + val allVideoClickActions = threadSafeListOf( + PlayInBrowserAction(), CopyClipboardAction(), + VlcPackage(), ViewM3U8Action(), + MpvPackage(), MpvYTDLPackage(), + WebVideoCastPackage(), MpvKtPackage(), MpvKtPreviewPackage(), + FcastAction() ) init { @@ -83,7 +38,7 @@ object VideoClickActionHolder { fun makeOptionMap(activity: Activity?, video: ResultEpisode) = allVideoClickActions // We need to have index before filtering .mapIndexed { id, it -> it to id + ACTION_ID_OFFSET } - .filter { it.first.shouldShowSafe(activity, video) } + .filter { it.first.shouldShow(activity, video) } .map { it.first.name to it.second } @@ -99,7 +54,7 @@ object VideoClickActionHolder { ?.second } - fun getPlayers(activity: Activity? = null) = allVideoClickActions.filter { it.isPlayer && it.shouldShowSafe(activity, null) } + fun getPlayers(activity: Activity? = null) = allVideoClickActions.filter { it.isPlayer && it.shouldShow(activity, null) } } abstract class VideoClickAction { @@ -117,66 +72,10 @@ abstract class VideoClickAction { /** Determines which plugin a given provider is from. This is the full path to the plugin. */ var sourcePlugin: String? = null - /** Even if VideoClickAction should not run any UI code, startActivity requires it, - * this is a wrapper for runOnUiThread in a suspended safe context that bubble up exceptions */ - @Throws - suspend fun uiThread(callable : Callable) : T? { - val future = FutureTask{ - try { - Result.success(callable.call()) - } catch (t : Throwable) { - Result.failure(t) - } - } - CommonActivity.activity?.runOnUiThread(future) ?: throw ErrorLoadingException("No UI Activity, this should never happened") - val result = withContext(Dispatchers.IO) { - return@withContext future.get() - } - return result.getOrThrow() - } - - /** Internally uses activityResultLauncher, - * use this when the activity has a result like watched position */ - @Throws - suspend fun launchResult(intent : Intent?, options : ActivityOptionsCompat? = null) { - if (intent == null) { - return - } - - uiThread { - MainActivity.activityResultLauncher?.launch(intent,options) - } - } - - /** Internally uses startActivity, use this when you don't - * have any result that needs to be stored when exiting the activity */ - @Throws - suspend fun launch(intent : Intent?, bundle : Bundle? = null) { - if (intent == null) { - return - } - - uiThread { - CommonActivity.activity?.startActivity(intent, bundle) - } - } - fun uniqueId() = "$sourcePlugin:${this::class.jvmName}" - @Throws abstract fun shouldShow(context: Context?, video: ResultEpisode?): Boolean - /** Safe version of shouldShow, as we don't trust extension devs to handle exceptions, - * however no dev *should* throw in shouldShow */ - fun shouldShowSafe(context: Context?, video: ResultEpisode?): Boolean { - return try { - shouldShow(context,video) - } catch (t : Throwable) { - logError(t) - false - } - } - /** * This function is called when the action is clicked. * @param context The current activity @@ -184,22 +83,5 @@ abstract class VideoClickAction { * @param result The result of the link loading, contains video & subtitle links * @param index if oneSource is true, this is the index of the selected source */ - @Throws - abstract suspend fun runAction(context: Context?, video: ResultEpisode, result: LinkLoadingResult, index: Int?) - - /** Safe version of runAction, as we don't trust extension devs to handle exceptions */ - fun runActionSafe(context: Context?, video: ResultEpisode, result: LinkLoadingResult, index: Int?) = ioSafe { - try { - runAction(context, video, result, index) - } catch (_ : NotImplementedError) { - CommonActivity.showToast("runAction has not been implemented for ${name.asStringNull(context)}, please contact the extension developer of $sourcePlugin", Toast.LENGTH_LONG) - } catch (error : ErrorLoadingException) { - CommonActivity.showToast(error.message, Toast.LENGTH_LONG) - } catch (_: ActivityNotFoundException) { - CommonActivity.showToast(R.string.app_not_found_error, Toast.LENGTH_LONG) - } catch (t : Throwable) { - logError(t) - CommonActivity.showToast(t.toString(), Toast.LENGTH_LONG) - } - } -} + abstract fun runAction(context: Context?, video: ResultEpisode, result: LinkLoadingResult, index: Int?) +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/actions/temp/Aria2Package.kt b/app/src/main/java/com/lagradost/cloudstream3/actions/temp/Aria2Package.kt deleted file mode 100644 index a7401c2ff..000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/actions/temp/Aria2Package.kt +++ /dev/null @@ -1,30 +0,0 @@ -package com.lagradost.cloudstream3.actions.temp - -import android.app.Activity -import android.content.Context -import android.content.Intent -import com.lagradost.cloudstream3.actions.OpenInAppAction -import com.lagradost.cloudstream3.ui.result.LinkLoadingResult -import com.lagradost.cloudstream3.ui.result.ResultEpisode -import com.lagradost.cloudstream3.utils.txt - -/** https://github.com/devgianlu/Aria2Android */ -@Suppress("unused") -class Aria2Package : OpenInAppAction( - appName = txt("Aria2"), - packageName = "com.gianlu.aria2android", - intentClass = "com.gianlu.aria2android.MainActivity" -) { - override val oneSource: Boolean = true - override suspend fun putExtra( - context: Context, - intent: Intent, - video: ResultEpisode, - result: LinkLoadingResult, - index: Int? - ) { - throw NotImplementedError("Aria2Android is missing getIntent, and onNewIntent, meaning it cant handle intents") - } - - override fun onResult(activity: Activity, intent: Intent?) = Unit -} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/actions/temp/BiglyBTPackage.kt b/app/src/main/java/com/lagradost/cloudstream3/actions/temp/BiglyBTPackage.kt deleted file mode 100644 index 3959bb9d3..000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/actions/temp/BiglyBTPackage.kt +++ /dev/null @@ -1,36 +0,0 @@ -package com.lagradost.cloudstream3.actions.temp - -import android.app.Activity -import android.content.Context -import android.content.Intent -import androidx.core.net.toUri -import com.lagradost.cloudstream3.actions.OpenInAppAction -import com.lagradost.cloudstream3.ui.result.LinkLoadingResult -import com.lagradost.cloudstream3.ui.result.ResultEpisode -import com.lagradost.cloudstream3.utils.ExtractorLinkType -import com.lagradost.cloudstream3.utils.txt - -/** https://github.com/BiglySoftware/BiglyBT-Android */ -class BiglyBTPackage : OpenInAppAction( - appName = txt("BiglyBT"), - packageName = "com.biglybt.android.client", - intentClass = "com.biglybt.android.client.activity.IntentHandler" -) { - // Only torrents are supported by the app - override val sourceTypes: Set = - setOf(ExtractorLinkType.MAGNET, ExtractorLinkType.TORRENT) - - override val oneSource: Boolean = true - - override suspend fun putExtra( - context: Context, - intent: Intent, - video: ResultEpisode, - result: LinkLoadingResult, - index: Int? - ) { - intent.data = result.links[index!!].url.toUri() - } - - override fun onResult(activity: Activity, intent: Intent?) = Unit -} \ No newline at end of file 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 deleted file mode 100644 index d414b6117..000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/actions/temp/CloudStreamPackage.kt +++ /dev/null @@ -1,162 +0,0 @@ -package com.lagradost.cloudstream3.actions.temp - -import android.app.Activity -import android.content.Context -import android.content.Intent -import android.net.Uri -import com.fasterxml.jackson.annotation.JsonProperty -import com.lagradost.cloudstream3.actions.OpenInAppAction -import com.lagradost.cloudstream3.BuildConfig -import com.lagradost.cloudstream3.ui.player.ExtractorUri -import com.lagradost.cloudstream3.ui.player.SubtitleData -import com.lagradost.cloudstream3.ui.player.SubtitleOrigin -import com.lagradost.cloudstream3.ui.result.LinkLoadingResult -import com.lagradost.cloudstream3.ui.result.ResultEpisode -import com.lagradost.cloudstream3.utils.AppUtils.toJson -import com.lagradost.cloudstream3.utils.DataStoreHelper.getViewPos -import com.lagradost.cloudstream3.utils.DrmExtractorLink -import com.lagradost.cloudstream3.utils.ExtractorLink -import com.lagradost.cloudstream3.utils.ExtractorLinkPlayList -import com.lagradost.cloudstream3.utils.ExtractorLinkType -import com.lagradost.cloudstream3.utils.newExtractorLink -import com.lagradost.cloudstream3.utils.Qualities -import com.lagradost.cloudstream3.utils.SubtitleHelper.fromCodeToLangTagIETF -import com.lagradost.cloudstream3.utils.SubtitleHelper.fromLanguageToTagIETF -import com.lagradost.cloudstream3.utils.txt - -/** - * If you want to support CloudStream 3 as an external player, then this shows how to play any video link - * For basic interactions, just `intent.data = uri` works - * - * However for more advanced use, CloudStream 3 also supports playlists of MinimalVideoLink and MinimalSubtitleLink with a `String[]` of JSON - * These are passed as LINKS_EXTRA and SUBTITLE_EXTRA respectively - */ -@Suppress("Unused") -class CloudStreamPackage : OpenInAppAction( - appName = txt("CloudStream"), - packageName = BuildConfig.APPLICATION_ID, //"com.lagradost.cloudstream3" or "com.lagradost.cloudstream3.prerelease" - intentClass = "com.lagradost.cloudstream3.ui.player.DownloadedPlayerActivity" -) { - override val oneSource: Boolean = false - - companion object { - const val SUBTITLE_EXTRA: String = "subs" // Json of an array of MinimalVideoLink - const val LINKS_EXTRA: String = "links" // Json of an array of MinimalSubtitleLink - const val TITLE_EXTRA: String = "title" // Unused (String) - const val ID_EXTRA: String = - "id" // Identification number for the video(s), used to store start time (Int) - const val POSITION_EXTRA: String = "pos" // Start time in MS (Long) - const val DURATION_EXTRA: String = "dur" // Duration time in MS (Long) - } - - data class MinimalVideoLink( - @JsonProperty("uri") - val uri: Uri?, - @JsonProperty("url") - val url: String?, - @JsonProperty("mimeType") - val mimeType: String = "video/mp4", - @JsonProperty("name") - val name: String?, - @JsonProperty("headers") - var headers: Map = mapOf(), - @JsonProperty("quality") - val quality: Int?, - ) { - companion object { - fun fromExtractor(link: ExtractorLink): MinimalVideoLink = MinimalVideoLink( - uri = null, - url = link.url, - name = link.name, - mimeType = link.type.getMimeType(), - headers = if (link.referer.isBlank()) emptyMap() else mapOf("referer" to link.referer) + link.headers, - quality = link.quality - ) - } - - suspend fun toExtractorLink(): Pair = - url?.let { url -> - newExtractorLink( - source = "NONE", - name = name ?: "Unknown", - url = url, - type = ExtractorLinkType.entries.firstOrNull { ty -> ty.getMimeType() == mimeType } - ?: ExtractorLinkType.VIDEO) { - - this@newExtractorLink.headers = - this@MinimalVideoLink.headers - - this@newExtractorLink.quality = - this@MinimalVideoLink.quality ?: Qualities.Unknown.value - } - } to uri?.let { uri -> - ExtractorUri( - uri = uri, - name = name ?: "Unknown", - ) - } - } - - - data class MinimalSubtitleLink( - @JsonProperty("url") - val url: String, - @JsonProperty("mimeType") - val mimeType: String = "text/vtt", - @JsonProperty("name") - val name: String?, - @JsonProperty("headers") - var headers: Map = mapOf(), - ) { - companion object { - fun fromSubtitle(sub: SubtitleData): MinimalSubtitleLink = MinimalSubtitleLink( - url = sub.url, - mimeType = sub.mimeType, - name = sub.originalName, - headers = sub.headers, - ) - } - - fun toSubtitleData(): SubtitleData = SubtitleData( - url = url, - nameSuffix = "", - mimeType = mimeType, - originalName = name ?: "Unknown", - headers = headers, - origin = SubtitleOrigin.URL, - languageCode = fromCodeToLangTagIETF(name) ?: - fromLanguageToTagIETF(name, true) ?: - name, - ) - } - - override suspend fun putExtra( - context: Context, - intent: Intent, - video: ResultEpisode, - result: LinkLoadingResult, - index: Int? - ) { - intent.apply { - val position = getViewPos(video.id)?.position - if (position != null) - putExtra(POSITION_EXTRA, position) - - putExtra(ID_EXTRA, video.id) - putExtra(TITLE_EXTRA, video.name) - putExtra( - SUBTITLE_EXTRA, - result.subs.map { MinimalSubtitleLink.fromSubtitle(it).toJson() }.toTypedArray() - ) - putExtra( - LINKS_EXTRA, - result.links.filter { it !is ExtractorLinkPlayList && it !is DrmExtractorLink } - .map { MinimalVideoLink.fromExtractor(it).toJson() }.toTypedArray() - ) - } - } - - override fun onResult(activity: Activity, intent: Intent?) { - // No results yet - } -} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/actions/temp/CopyClipboardAction.kt b/app/src/main/java/com/lagradost/cloudstream3/actions/temp/CopyClipboardAction.kt index 7e89d7c8c..e054b5ce2 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/actions/temp/CopyClipboardAction.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/actions/temp/CopyClipboardAction.kt @@ -4,7 +4,7 @@ import android.content.Context import com.lagradost.cloudstream3.actions.VideoClickAction import com.lagradost.cloudstream3.ui.result.LinkLoadingResult import com.lagradost.cloudstream3.ui.result.ResultEpisode -import com.lagradost.cloudstream3.utils.txt +import com.lagradost.cloudstream3.ui.result.txt import com.lagradost.cloudstream3.utils.UIHelper.clipboardHelper class CopyClipboardAction: VideoClickAction() { @@ -14,7 +14,7 @@ class CopyClipboardAction: VideoClickAction() { override fun shouldShow(context: Context?, video: ResultEpisode?) = true - override suspend fun runAction( + override fun runAction( context: Context?, video: ResultEpisode, result: LinkLoadingResult, diff --git a/app/src/main/java/com/lagradost/cloudstream3/actions/temp/JustPlayerPackage.kt b/app/src/main/java/com/lagradost/cloudstream3/actions/temp/JustPlayerPackage.kt deleted file mode 100644 index 20eb843c7..000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/actions/temp/JustPlayerPackage.kt +++ /dev/null @@ -1,37 +0,0 @@ -package com.lagradost.cloudstream3.actions.temp - -import android.app.Activity -import android.content.Context -import android.content.Intent -import androidx.core.net.toUri -import com.lagradost.cloudstream3.actions.OpenInAppAction -import com.lagradost.cloudstream3.ui.result.LinkLoadingResult -import com.lagradost.cloudstream3.ui.result.ResultEpisode -import com.lagradost.cloudstream3.utils.ExtractorLinkType -import com.lagradost.cloudstream3.utils.txt - -/** https://github.com/moneytoo/Player/ */ -class JustPlayerPackage : OpenInAppAction( - appName = txt("JustPlayer"), - packageName = "com.brouken.player", - intentClass = "com.brouken.player.PlayerActivity" -) { - override val sourceTypes: Set = - setOf(ExtractorLinkType.VIDEO, ExtractorLinkType.M3U8, ExtractorLinkType.DASH) - - override val oneSource: Boolean = true - - override suspend fun putExtra( - context: Context, - intent: Intent, - video: ResultEpisode, - result: LinkLoadingResult, - index: Int? - ) { - // While JustPlayer has support for subs, it cant add both subs and links at the same time - // See https://github.com/moneytoo/Player/blob/49d80eb8de7a7bfc662393fdf114788fed1ebb2e/app/src/main/java/com/brouken/player/PlayerActivity.java#L794 - intent.data = result.links[index!!].url.toUri() - } - - override fun onResult(activity: Activity, intent: Intent?) = Unit -} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/actions/temp/LibreTorrentPackage.kt b/app/src/main/java/com/lagradost/cloudstream3/actions/temp/LibreTorrentPackage.kt deleted file mode 100644 index 11d1858c6..000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/actions/temp/LibreTorrentPackage.kt +++ /dev/null @@ -1,36 +0,0 @@ -package com.lagradost.cloudstream3.actions.temp - -import android.app.Activity -import android.content.Context -import android.content.Intent -import androidx.core.net.toUri -import com.lagradost.cloudstream3.actions.OpenInAppAction -import com.lagradost.cloudstream3.ui.result.LinkLoadingResult -import com.lagradost.cloudstream3.ui.result.ResultEpisode -import com.lagradost.cloudstream3.utils.ExtractorLinkType -import com.lagradost.cloudstream3.utils.txt - -/** https://github.com/proninyaroslav/libretorrent */ -class LibreTorrentPackage : OpenInAppAction( - appName = txt("LibreTorrent"), - packageName = "org.proninyaroslav.libretorrent", - intentClass = "org.proninyaroslav.libretorrent.ui.addtorrent.AddTorrentActivity" -) { - // Only torrents are supported by the app - override val sourceTypes: Set = - setOf(ExtractorLinkType.MAGNET, ExtractorLinkType.TORRENT) - - override val oneSource: Boolean = true - - override suspend fun putExtra( - context: Context, - intent: Intent, - video: ResultEpisode, - result: LinkLoadingResult, - index: Int? - ) { - intent.data = result.links[index!!].url.toUri() - } - - override fun onResult(activity: Activity, intent: Intent?) = Unit -} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/actions/temp/MpvKtPackage.kt b/app/src/main/java/com/lagradost/cloudstream3/actions/temp/MpvKtPackage.kt index faae39212..f5ded49b8 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,12 +3,13 @@ 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 import com.lagradost.cloudstream3.ui.result.LinkLoadingResult import com.lagradost.cloudstream3.ui.result.ResultEpisode -import com.lagradost.cloudstream3.utils.txt +import com.lagradost.cloudstream3.ui.result.txt import com.lagradost.cloudstream3.utils.DataStoreHelper.getViewPos import com.lagradost.cloudstream3.utils.ExtractorLinkType @@ -33,7 +34,7 @@ open class MpvKtPackage( ExtractorLinkType.M3U8 ) - override suspend fun putExtra( + override fun putExtra( context: Context, intent: Intent, video: ResultEpisode, @@ -44,7 +45,7 @@ open class MpvKtPackage( intent.apply { putExtra("subs", result.subs.map { it.url.toUri() }.toTypedArray()) - setDataAndType(link.url.toUri(), "video/*") + setDataAndType(Uri.parse(link.url), "video/*") // m3u8 plays, but changing sources feature is not available // makeTempM3U8Intent(activity, this, result) diff --git a/app/src/main/java/com/lagradost/cloudstream3/actions/temp/MpvPackage.kt b/app/src/main/java/com/lagradost/cloudstream3/actions/temp/MpvPackage.kt index cd49eb994..4c66d0450 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 @@ -10,16 +10,13 @@ import com.lagradost.cloudstream3.actions.makeTempM3U8Intent import com.lagradost.cloudstream3.actions.updateDurationAndPosition import com.lagradost.cloudstream3.ui.result.LinkLoadingResult import com.lagradost.cloudstream3.ui.result.ResultEpisode -import com.lagradost.cloudstream3.utils.txt +import com.lagradost.cloudstream3.ui.result.txt import com.lagradost.cloudstream3.utils.DataStoreHelper.getViewPos import com.lagradost.cloudstream3.utils.ExtractorLinkType // https://github.com/mpv-android/mpv-android/blob/0eb3cdc6f1632636b9c30d52ec50e4b017661980/app/src/main/java/is/xyz/mpv/MPVActivity.kt#L904 // https://mpv-android.github.io/mpv-android/intent.html -//https://github.com/marlboro-advance/mpvEx -class MpvExPackage: MpvPackage("mpvEx","app.marlboroadvance.mpvex","app.marlboroadvance.mpvex.ui.player.PlayerActivity") - class MpvYTDLPackage : MpvPackage("MPV YTDL", "is.xyz.mpv.ytdl") { override val sourceTypes = setOf( ExtractorLinkType.VIDEO, @@ -28,13 +25,13 @@ class MpvYTDLPackage : MpvPackage("MPV YTDL", "is.xyz.mpv.ytdl") { ) } -open class MpvPackage(appName: String = "MPV", packageName: String = "is.xyz.mpv",intentClass:String = "is.xyz.mpv.MPVActivity"): OpenInAppAction( +open class MpvPackage(appName: String = "MPV", packageName: String = "is.xyz.mpv"): OpenInAppAction( txt(appName), packageName, - intentClass + "is.xyz.mpv.MPVActivity" ) { - override val oneSource = true // mpv has poor playlist support on TV - override suspend fun putExtra( + + override fun putExtra( context: Context, intent: Intent, video: ResultEpisode, @@ -45,11 +42,7 @@ open class MpvPackage(appName: String = "MPV", packageName: String = "is.xyz.mpv putExtra("subs", result.subs.map { it.url.toUri() }.toTypedArray()) putExtra("title", video.name) - if (index != null) { - setDataAndType((result.links.getOrNull(index)?.url ?: return).toUri(), "video/*") - } else { - makeTempM3U8Intent(context, this, result) - } + makeTempM3U8Intent(context, this, result) val position = getViewPos(video.id)?.position if (position != null) diff --git a/app/src/main/java/com/lagradost/cloudstream3/actions/temp/MpvRxPackage.kt b/app/src/main/java/com/lagradost/cloudstream3/actions/temp/MpvRxPackage.kt deleted file mode 100644 index e8bb93a99..000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/actions/temp/MpvRxPackage.kt +++ /dev/null @@ -1,75 +0,0 @@ -package com.lagradost.cloudstream3.actions.temp - -import android.app.Activity -import android.content.Context -import android.content.Intent -import androidx.core.net.toUri -import com.lagradost.api.Log -import com.lagradost.cloudstream3.actions.OpenInAppAction -import com.lagradost.cloudstream3.actions.updateDurationAndPosition -import com.lagradost.cloudstream3.isEpisodeBased -import com.lagradost.cloudstream3.ui.result.LinkLoadingResult -import com.lagradost.cloudstream3.ui.result.ResultEpisode -import com.lagradost.cloudstream3.utils.DataStoreHelper.getViewPos -import com.lagradost.cloudstream3.utils.txt - -/** https://github.com/Riteshp2001/mpvRx - * - * https://github.com/Riteshp2001/mpvRx/blob/00e0c5e803ab53e5757426cbf2248448ba1f49bf/app/src/main/java/app/gyrolet/mpvrx/utils/media/MediaUtils.kt#L132 - * https://github.com/Riteshp2001/mpvRx/blob/00e0c5e803ab53e5757426cbf2248448ba1f49bf/app/src/main/java/app/gyrolet/mpvrx/utils/media/MediaUtils.kt#L56 - * */ -class MpvRxPackage : OpenInAppAction( - appName = txt("mpvRx"), - packageName = "app.gyrolet.mpvrx", - intentClass = "app.gyrolet.mpvrx.ui.player.PlayerActivity" -) { - override val oneSource = true - override suspend fun putExtra( - context: Context, - intent: Intent, - video: ResultEpisode, - result: LinkLoadingResult, - index: Int? - ) { - intent.apply { - putExtra("title", video.name) - val link = result.links[index!!] - val headers = link.headers - - setData(link.url.toUri()) - if (headers.isNotEmpty()) { - // PlayerActivity expects a flat array: [key1, value1, key2, value2, ...] - val flat = headers.entries.flatMap { listOf(it.key, it.value) }.toTypedArray() - intent.putExtra("headers", flat) - } - /*val subs = result.subs // disabled due to https://github.com/Riteshp2001/mpvRx/issues/146 - intent.putExtra("subs", subs.map { it.url.toUri() }.toTypedArray()) - intent.putExtra( - "subs.titles", - subs.map { it.name }.toTypedArray(), - ) - intent.putExtra( - "subs.langs", - subs.map { it.languageCode }.toTypedArray(), - ) - val selected = subs.firstOrNull { it.matchesLanguageCode("en") }?.url?.toUri() - intent.putExtra("subs.enable", selected?.let { arrayOf(it) } ?: arrayOf() )*/ - - if (video.tvType.isEpisodeBased()) { - video.season?.let { intent.putExtra("introdb_season", it) } - video.episode.let { intent.putExtra("introdb_episode", it) } - } - - val position = getViewPos(video.id)?.position - if (position != null) - putExtra("position", position.toInt()) - } - } - - override fun onResult(activity: Activity, intent: Intent?) { - val position = intent?.getIntExtra("position", -1) ?: -1 - val duration = intent?.getIntExtra("duration", -1) ?: -1 - Log.d("MPV", "Position: $position, Duration: $duration") - updateDurationAndPosition(position.toLong(), duration.toLong()) - } -} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/actions/temp/NextPlayerPackage.kt b/app/src/main/java/com/lagradost/cloudstream3/actions/temp/NextPlayerPackage.kt deleted file mode 100644 index 5d0923b81..000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/actions/temp/NextPlayerPackage.kt +++ /dev/null @@ -1,35 +0,0 @@ -package com.lagradost.cloudstream3.actions.temp - -import android.app.Activity -import android.content.Context -import android.content.Intent -import androidx.core.net.toUri -import com.lagradost.cloudstream3.actions.OpenInAppAction -import com.lagradost.cloudstream3.ui.result.LinkLoadingResult -import com.lagradost.cloudstream3.ui.result.ResultEpisode -import com.lagradost.cloudstream3.utils.ExtractorLinkType -import com.lagradost.cloudstream3.utils.txt - -/** https://github.com/anilbeesetti/nextplayer */ -class NextPlayerPackage : OpenInAppAction( - appName = txt("NextPlayer"), - packageName = "dev.anilbeesetti.nextplayer", - intentClass = "dev.anilbeesetti.nextplayer.feature.player.PlayerActivity" -) { - override val sourceTypes: Set = - setOf(ExtractorLinkType.VIDEO, ExtractorLinkType.M3U8, ExtractorLinkType.DASH) - - override val oneSource: Boolean = true - - override suspend fun putExtra( - context: Context, - intent: Intent, - video: ResultEpisode, - result: LinkLoadingResult, - index: Int? - ) { - intent.data = result.links[index!!].url.toUri() - } - - override fun onResult(activity: Activity, intent: Intent?) = Unit -} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/actions/temp/OnlyPlayer.kt b/app/src/main/java/com/lagradost/cloudstream3/actions/temp/OnlyPlayer.kt deleted file mode 100644 index 348be440a..000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/actions/temp/OnlyPlayer.kt +++ /dev/null @@ -1,44 +0,0 @@ -package com.lagradost.cloudstream3.actions.temp - -import android.app.Activity -import android.content.Context -import android.content.Intent -import android.os.Bundle -import androidx.core.net.toUri -import com.lagradost.cloudstream3.actions.OpenInAppAction -import com.lagradost.cloudstream3.ui.result.LinkLoadingResult -import com.lagradost.cloudstream3.ui.result.ResultEpisode -import com.lagradost.cloudstream3.utils.txt - -/** https://github.com/Kindness-Kismet/only_player/tree/main - * https://github.com/Kindness-Kismet/only_player/blob/main/feature/player/src/main/java/one/only/player/feature/player/PlayerActivity.kt */ -class OnlyPlayer : OpenInAppAction( - txt("Only Player"), - "one.only.player", - intentClass = "one.only.player.feature.player.PlayerActivity" -) { - override val oneSource = true - override suspend fun putExtra( - context: Context, - intent: Intent, - video: ResultEpisode, - result: LinkLoadingResult, - index: Int? - ) { - /** https://github.com/Kindness-Kismet/only_player/blob/d3f55049a2913fa762d31b311146073cc2da46cb/app/src/main/java/one/only/player/navigation/CloudNavGraph.kt#L39 */ - intent.apply { - val link = result.links[index!!] - setData(link.url.toUri()) - - putExtra("headers", Bundle().apply { - for ((key, value) in link.headers) { - putExtra(key, value) - } - }) - } - } - - override fun onResult(activity: Activity, intent: Intent?) { - /* onResult does not get called */ - } -} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/actions/temp/PlayInBrowserAction.kt b/app/src/main/java/com/lagradost/cloudstream3/actions/temp/PlayInBrowserAction.kt index bfd2926bf..de32bb4b3 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,12 +2,13 @@ package com.lagradost.cloudstream3.actions.temp import android.content.Context import android.content.Intent -import androidx.core.net.toUri +import android.net.Uri import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.actions.VideoClickAction +import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.ui.result.LinkLoadingResult import com.lagradost.cloudstream3.ui.result.ResultEpisode -import com.lagradost.cloudstream3.utils.txt +import com.lagradost.cloudstream3.ui.result.txt import com.lagradost.cloudstream3.utils.ExtractorLinkType class PlayInBrowserAction: VideoClickAction() { @@ -25,15 +26,19 @@ class PlayInBrowserAction: VideoClickAction() { override fun shouldShow(context: Context?, video: ResultEpisode?) = true - override suspend fun runAction( + override fun runAction( context: Context?, video: ResultEpisode, result: LinkLoadingResult, index: Int? ) { val link = result.links.getOrNull(index ?: 0) ?: return - val i = Intent(Intent.ACTION_VIEW) - i.data = link.url.toUri() - launch(i) + try { + val i = Intent(Intent.ACTION_VIEW) + i.data = Uri.parse(link.url) + context?.startActivity(i) + } catch (e: Exception) { + logError(e) + } } } \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/actions/temp/PlayMirrorAction.kt b/app/src/main/java/com/lagradost/cloudstream3/actions/temp/PlayMirrorAction.kt deleted file mode 100644 index 56512377b..000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/actions/temp/PlayMirrorAction.kt +++ /dev/null @@ -1,65 +0,0 @@ -package com.lagradost.cloudstream3.actions.temp - -import android.app.Activity -import android.content.Context -import com.lagradost.cloudstream3.R -import com.lagradost.cloudstream3.actions.VideoClickAction -import com.lagradost.cloudstream3.ui.player.ExtractorUri -import com.lagradost.cloudstream3.ui.player.GeneratorPlayer -import com.lagradost.cloudstream3.ui.player.LOADTYPE_INAPP -import com.lagradost.cloudstream3.ui.player.SubtitleData -import com.lagradost.cloudstream3.ui.player.VideoGenerator -import com.lagradost.cloudstream3.ui.result.LinkLoadingResult -import com.lagradost.cloudstream3.ui.result.ResultEpisode -import com.lagradost.cloudstream3.utils.ExtractorLink -import com.lagradost.cloudstream3.utils.ExtractorLinkType -import com.lagradost.cloudstream3.utils.UIHelper.navigate -import com.lagradost.cloudstream3.utils.txt - -class PlayMirrorAction : VideoClickAction() { - override val name = txt(R.string.episode_action_play_mirror) - - override val oneSource = true - - override val isPlayer = true - - override val sourceTypes: Set = LOADTYPE_INAPP - - override fun shouldShow(context: Context?, video: ResultEpisode?) = true - - override suspend fun runAction( - context: Context?, - video: ResultEpisode, - result: LinkLoadingResult, - index: Int? - ) { - //Implemented a generator to handle the single - val activity = context as? Activity ?: return - val link = index?.let { result.links[it] } - val generatorMirror = object : VideoGenerator(listOf(video)) { - override val hasCache: Boolean = false - override val canSkipLoading: Boolean = false - override fun getId(index: Int): Int = video.id - - override suspend fun generateLinks( - clearCache: Boolean, - sourceTypes: Set, - callback: (Pair) -> Unit, - subtitleCallback: (SubtitleData) -> Unit, - offset: Int, - isCasting: Boolean - ): Boolean { - index?.let { callback(link to null) } - result.subs.forEach { subtitle -> subtitleCallback(subtitle) } - return true - } - } - - activity.navigate( - R.id.global_to_navigation_player, - GeneratorPlayer.newInstance( - generatorMirror, 0, result.syncData - ) - ) - } -} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/actions/temp/ViewM3U8Action.kt b/app/src/main/java/com/lagradost/cloudstream3/actions/temp/ViewM3U8Action.kt index 791566862..c14168e96 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/actions/temp/ViewM3U8Action.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/actions/temp/ViewM3U8Action.kt @@ -7,7 +7,7 @@ import com.lagradost.cloudstream3.actions.VideoClickAction import com.lagradost.cloudstream3.actions.makeTempM3U8Intent import com.lagradost.cloudstream3.ui.result.LinkLoadingResult import com.lagradost.cloudstream3.ui.result.ResultEpisode -import com.lagradost.cloudstream3.utils.txt +import com.lagradost.cloudstream3.ui.result.txt class ViewM3U8Action: VideoClickAction() { override val name = txt(R.string.episode_action_play_in_format, "m3u8 player") @@ -16,7 +16,7 @@ class ViewM3U8Action: VideoClickAction() { override fun shouldShow(context: Context?, video: ResultEpisode?) = true - override suspend fun runAction( + override fun runAction( context: Context?, video: ResultEpisode, result: LinkLoadingResult, @@ -25,6 +25,6 @@ class ViewM3U8Action: VideoClickAction() { if (context == null) return val i = Intent(Intent.ACTION_VIEW) makeTempM3U8Intent(context, i, result) - launch(i) + context.startActivity(i) } } \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/actions/temp/VlcPackage.kt b/app/src/main/java/com/lagradost/cloudstream3/actions/temp/VlcPackage.kt index 46b46a2c2..ecb37fdc6 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 @@ -4,27 +4,21 @@ import android.app.Activity import android.content.Context import android.content.Intent import android.os.Build -import androidx.core.net.toUri import com.lagradost.api.Log -import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey +import com.lagradost.cloudstream3.AcraApplication.Companion.getKey import com.lagradost.cloudstream3.actions.OpenInAppAction import com.lagradost.cloudstream3.actions.makeTempM3U8Intent import com.lagradost.cloudstream3.actions.updateDurationAndPosition import com.lagradost.cloudstream3.ui.result.LinkLoadingResult import com.lagradost.cloudstream3.ui.result.ResultEpisode -import com.lagradost.cloudstream3.utils.txt +import com.lagradost.cloudstream3.ui.result.txt import com.lagradost.cloudstream3.ui.subtitles.SUBTITLE_AUTO_SELECT_KEY import com.lagradost.cloudstream3.utils.DataStoreHelper.getViewPos // https://github.com/videolan/vlc-android/blob/3706c4be2da6800b3d26344fc04fab03ffa4b860/application/vlc-android/src/org/videolan/vlc/gui/video/VideoPlayerActivity.kt#L1898 // https://wiki.videolan.org/Android_Player_Intents/ -class VlcNightlyPackage : VlcPackage() { - override val packageName = "org.videolan.vlc.debug" - override val appName = txt("VLC Nightly") -} - -open class VlcPackage: OpenInAppAction( +class VlcPackage: OpenInAppAction( appName = txt("VLC"), packageName = "org.videolan.vlc", intentClass = if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) { @@ -38,21 +32,18 @@ open class VlcPackage: OpenInAppAction( Intent.ACTION_VIEW } ) { - // while VLC supports multi links, it has poor support, so we disable it for now - override val oneSource = true + override val oneSource = false - override suspend fun putExtra( + override fun putExtra( context: Context, intent: Intent, video: ResultEpisode, result: LinkLoadingResult, index: Int? ) { - if (index != null) { - intent.setDataAndType(result.links[index].url.toUri(), "video/*") - } else { - makeTempM3U8Intent(context, intent, result) - } + + makeTempM3U8Intent(context, intent, result) + val position = getViewPos(video.id)?.position ?: 0L intent.putExtra("from_start", false) @@ -60,7 +51,7 @@ open class VlcPackage: OpenInAppAction( intent.putExtra("secure_uri", true) intent.putExtra("title", video.name) - val subsLang = getKey(SUBTITLE_AUTO_SELECT_KEY) ?: "en" + val subsLang = getKey(SUBTITLE_AUTO_SELECT_KEY) ?: "en" result.subs.firstOrNull { subsLang == it.languageCode }?.let { diff --git a/app/src/main/java/com/lagradost/cloudstream3/actions/temp/WebVideoCastPackage.kt b/app/src/main/java/com/lagradost/cloudstream3/actions/temp/WebVideoCastPackage.kt index 963221bb3..f8419f63c 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,13 +3,14 @@ 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 import com.lagradost.cloudstream3.actions.OpenInAppAction import com.lagradost.cloudstream3.ui.result.LinkLoadingResult import com.lagradost.cloudstream3.ui.result.ResultEpisode -import com.lagradost.cloudstream3.utils.txt +import com.lagradost.cloudstream3.ui.result.txt import com.lagradost.cloudstream3.utils.ExtractorLinkType // https://www.webvideocaster.com/integrations @@ -27,7 +28,7 @@ class WebVideoCastPackage: OpenInAppAction( ExtractorLinkType.M3U8 ) - override suspend fun putExtra( + override fun putExtra( context: Context, intent: Intent, video: ResultEpisode, @@ -37,7 +38,7 @@ class WebVideoCastPackage: OpenInAppAction( val link = result.links[index ?: 0] intent.apply { - setDataAndType(link.url.toUri(), "video/*") + setDataAndType(Uri.parse(link.url), "video/*") val title = video.name ?: video.headerName diff --git a/app/src/main/java/com/lagradost/cloudstream3/actions/temp/fcast/FcastAction.kt b/app/src/main/java/com/lagradost/cloudstream3/actions/temp/fcast/FcastAction.kt index 1036a7055..c0f92e4df 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,13 +1,13 @@ package com.lagradost.cloudstream3.actions.temp.fcast import android.content.Context -import com.lagradost.cloudstream3.CloudStreamApp.Companion.getActivity +import com.lagradost.cloudstream3.AcraApplication.Companion.getActivity import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.USER_AGENT import com.lagradost.cloudstream3.actions.VideoClickAction import com.lagradost.cloudstream3.ui.result.LinkLoadingResult import com.lagradost.cloudstream3.ui.result.ResultEpisode -import com.lagradost.cloudstream3.utils.txt +import com.lagradost.cloudstream3.ui.result.txt import com.lagradost.cloudstream3.utils.DataStoreHelper.getViewPos import com.lagradost.cloudstream3.utils.ExtractorLink import com.lagradost.cloudstream3.utils.ExtractorLinkType @@ -26,7 +26,7 @@ class FcastAction: VideoClickAction() { override fun shouldShow(context: Context?, video: ResultEpisode?) = FcastManager.currentDevices.isNotEmpty() - override suspend fun runAction( + override fun runAction( context: Context?, video: ResultEpisode, result: LinkLoadingResult, @@ -34,16 +34,14 @@ class FcastAction: VideoClickAction() { ) { val link = result.links.getOrNull(index ?: 0) ?: return val devices = FcastManager.currentDevices.toList() - uiThread { - context?.getActivity()?.showBottomDialog( - devices.map { it.name }, - -1, - txt(R.string.player_settings_select_cast_device).asString(context), - false, - {}) { - val position = getViewPos(video.id)?.position - castTo(devices.getOrNull(it), link, position) - } + context?.getActivity()?.showBottomDialog( + devices.map { it.name }, + -1, + txt(R.string.player_settings_select_cast_device).asString(context), + false, + {}) { + val position = getViewPos(video.id)?.position + castTo(devices.getOrNull(it), link, position) } } @@ -55,7 +53,7 @@ class FcastAction: VideoClickAction() { session.sendMessage( Opcode.Play, PlayMessage( - link.type.getMimeType(), + "video/*", link.url, time = position?.let { it / 1000.0 }, headers = mapOf( @@ -66,4 +64,4 @@ class FcastAction: VideoClickAction() { ) } } -} +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/actions/temp/fcast/FcastManager.kt b/app/src/main/java/com/lagradost/cloudstream3/actions/temp/fcast/FcastManager.kt index e2cf4f002..78682ca1c 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 @@ -5,9 +5,7 @@ import android.net.nsd.NsdManager import android.net.nsd.NsdManager.ResolveListener import android.net.nsd.NsdServiceInfo import android.os.Build -import android.os.ext.SdkExtensions import android.util.Log -import com.lagradost.cloudstream3.mvvm.safe import com.lagradost.cloudstream3.utils.Coroutines.ioSafe class FcastManager { @@ -73,67 +71,24 @@ class FcastManager { } override fun onServiceFound(serviceInfo: NsdServiceInfo?) { - // Safe here as, java.lang.NoClassDefFoundError: Failed resolution of: Landroid/net/nsd/NsdManager$ServiceInfoCallback - safe { - if (serviceInfo == null) return@safe - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && SdkExtensions.getExtensionVersion( - Build.VERSION_CODES.TIRAMISU - ) >= 7 - ) { - nsdManager?.registerServiceInfoCallback( - serviceInfo, - Runnable::run, - object : NsdManager.ServiceInfoCallback { - override fun onServiceInfoCallbackRegistrationFailed(errorCode: Int) { - Log.e(tag, "Service registration failed: $errorCode") - } - - override fun onServiceUpdated(serviceInfo: NsdServiceInfo) { - Log.d( - tag, - "Service updated: ${serviceInfo.serviceName}," + - "Net: ${serviceInfo.hostAddresses.firstOrNull()?.hostAddress}" - ) - synchronized(_currentDevices) { - _currentDevices.removeIf { it.rawName == serviceInfo.serviceName } - _currentDevices.add(PublicDeviceInfo(serviceInfo)) - } - } - - override fun onServiceLost() { - Log.d(tag, "Service lost: ${serviceInfo.serviceName},") - synchronized(_currentDevices) { - _currentDevices.removeIf { it.rawName == serviceInfo.serviceName } - } - } - - override fun onServiceInfoCallbackUnregistered() {} - }) - } else { - @Suppress("DEPRECATION") - nsdManager?.resolveService(serviceInfo, object : ResolveListener { - override fun onResolveFailed( - serviceInfo: NsdServiceInfo?, - errorCode: Int - ) { - } - - override fun onServiceResolved(serviceInfo: NsdServiceInfo?) { - if (serviceInfo == null) return - - synchronized(_currentDevices) { - _currentDevices.add(PublicDeviceInfo(serviceInfo)) - } - - Log.d( - tag, - "Service found: ${serviceInfo.serviceName}, Net: ${serviceInfo.host.hostAddress}" - ) - } - }) + if (serviceInfo == null) return + nsdManager?.resolveService(serviceInfo, object : ResolveListener { + override fun onResolveFailed(serviceInfo: NsdServiceInfo?, errorCode: Int) { } - } + + override fun onServiceResolved(serviceInfo: NsdServiceInfo?) { + if (serviceInfo == null) return + + synchronized(_currentDevices) { + _currentDevices.add(PublicDeviceInfo(serviceInfo)) + } + + Log.d( + tag, + "Service found: ${serviceInfo.serviceName}, Net: ${serviceInfo.host.hostAddress}" + ) + } + }) } override fun onServiceLost(serviceInfo: NsdServiceInfo?) { @@ -180,16 +135,6 @@ class FcastManager { class PublicDeviceInfo(serviceInfo: NsdServiceInfo) { val rawName: String = serviceInfo.serviceName - val host: String? = if ( - Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && - SdkExtensions.getExtensionVersion( - Build.VERSION_CODES.TIRAMISU - ) >= 7 - ) { - serviceInfo.hostAddresses.firstOrNull()?.hostAddress - } else { - @Suppress("DEPRECATION") - serviceInfo.host.hostAddress - } + val host: String? = serviceInfo.host.hostAddress val name = rawName.replace("-", " ") + host?.let { " $it" } -} \ No newline at end of file +} diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/metaproviders/CrossTmdbProvider.kt b/app/src/main/java/com/lagradost/cloudstream3/metaproviders/CrossTmdbProvider.kt similarity index 83% rename from library/src/commonMain/kotlin/com/lagradost/cloudstream3/metaproviders/CrossTmdbProvider.kt rename to app/src/main/java/com/lagradost/cloudstream3/metaproviders/CrossTmdbProvider.kt index 7076e407f..5bbb4538b 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/metaproviders/CrossTmdbProvider.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/metaproviders/CrossTmdbProvider.kt @@ -1,18 +1,10 @@ package com.lagradost.cloudstream3.metaproviders import com.fasterxml.jackson.annotation.JsonProperty +import com.lagradost.cloudstream3.* import com.lagradost.cloudstream3.APIHolder.apis import com.lagradost.cloudstream3.APIHolder.getApiFromNameNull -import com.lagradost.cloudstream3.ErrorLoadingException -import com.lagradost.cloudstream3.LoadResponse -import com.lagradost.cloudstream3.MovieLoadResponse -import com.lagradost.cloudstream3.MovieSearchResponse -import com.lagradost.cloudstream3.SearchResponseList -import com.lagradost.cloudstream3.SubtitleFile -import com.lagradost.cloudstream3.TvType -import com.lagradost.cloudstream3.amap import com.lagradost.cloudstream3.mvvm.logError -import com.lagradost.cloudstream3.toNewSearchResponseList import com.lagradost.cloudstream3.utils.AppUtils.toJson import com.lagradost.cloudstream3.utils.AppUtils.tryParseJson import com.lagradost.cloudstream3.utils.ExtractorLink @@ -30,9 +22,11 @@ class CrossTmdbProvider : TmdbProvider() { } private val validApis - get() = apis.filter { it.lang == this.lang && it::class != this::class } + get() = + synchronized(apis) { apis.filter { it.lang == this.lang && it::class.java != this::class.java } } //.distinctBy { it.uniqueId } + data class CrossMetaData( @JsonProperty("isSuccess") val isSuccess: Boolean, @JsonProperty("movies") val movies: List>? = null, @@ -61,12 +55,8 @@ class CrossTmdbProvider : TmdbProvider() { return false } - override suspend fun search(query: String, page: Int): SearchResponseList? { - // TODO REMOVE - return super.search(query, page) - ?.items - ?.filterIsInstance() - ?.toNewSearchResponseList() + override suspend fun search(query: String): List? { + return super.search(query)?.filterIsInstance() // TODO REMOVE } override suspend fun load(url: String): LoadResponse? { @@ -119,4 +109,4 @@ class CrossTmdbProvider : TmdbProvider() { return base } -} +} \ No newline at end of file diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/metaproviders/SyncRedirector.kt b/app/src/main/java/com/lagradost/cloudstream3/metaproviders/SyncRedirector.kt similarity index 94% rename from library/src/commonMain/kotlin/com/lagradost/cloudstream3/metaproviders/SyncRedirector.kt rename to app/src/main/java/com/lagradost/cloudstream3/metaproviders/SyncRedirector.kt index 2a8524e00..bc646a8d2 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/metaproviders/SyncRedirector.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/metaproviders/SyncRedirector.kt @@ -1,7 +1,7 @@ package com.lagradost.cloudstream3.metaproviders import com.lagradost.cloudstream3.MainAPI -import com.lagradost.cloudstream3.mvvm.safeAsync +import com.lagradost.cloudstream3.mvvm.suspendSafeApiCall import com.lagradost.cloudstream3.syncproviders.SyncIdName object SyncRedirector { @@ -44,7 +44,7 @@ object SyncRedirector { return syncIds.firstNotNullOfOrNull { (syncName, syncRegex) -> if (providerApi.supportedSyncNames.contains(syncName)) { syncRegex.find(url)?.value?.let { - safeAsync { + suspendSafeApiCall { providerApi.getLoadUrl(syncName, it) } } diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/metaproviders/TmdbProvider.kt b/app/src/main/java/com/lagradost/cloudstream3/metaproviders/TmdbProvider.kt similarity index 68% rename from library/src/commonMain/kotlin/com/lagradost/cloudstream3/metaproviders/TmdbProvider.kt rename to app/src/main/java/com/lagradost/cloudstream3/metaproviders/TmdbProvider.kt index 89f935da3..c5b4d453d 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/metaproviders/TmdbProvider.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/metaproviders/TmdbProvider.kt @@ -1,51 +1,17 @@ package com.lagradost.cloudstream3.metaproviders import com.fasterxml.jackson.annotation.JsonProperty -import com.lagradost.cloudstream3.Actor -import com.lagradost.cloudstream3.Episode -import com.lagradost.cloudstream3.ErrorLoadingException -import com.lagradost.cloudstream3.HomePageList -import com.lagradost.cloudstream3.HomePageResponse -import com.lagradost.cloudstream3.LoadResponse +import com.lagradost.cloudstream3.* import com.lagradost.cloudstream3.LoadResponse.Companion.addActors import com.lagradost.cloudstream3.LoadResponse.Companion.addImdbId import com.lagradost.cloudstream3.LoadResponse.Companion.addTrailer -import com.lagradost.cloudstream3.MainAPI -import com.lagradost.cloudstream3.MainPageRequest -import com.lagradost.cloudstream3.MovieLoadResponse -import com.lagradost.cloudstream3.MovieSearchResponse -import com.lagradost.cloudstream3.ProviderType -import com.lagradost.cloudstream3.Score -import com.lagradost.cloudstream3.SearchResponseList -import com.lagradost.cloudstream3.TvSeriesLoadResponse -import com.lagradost.cloudstream3.TvSeriesSearchResponse -import com.lagradost.cloudstream3.TvType -import com.lagradost.cloudstream3.newEpisode -import com.lagradost.cloudstream3.newHomePageResponse -import com.lagradost.cloudstream3.newMovieLoadResponse -import com.lagradost.cloudstream3.newMovieSearchResponse -import com.lagradost.cloudstream3.newTvSeriesLoadResponse -import com.lagradost.cloudstream3.newTvSeriesSearchResponse -import com.lagradost.cloudstream3.runAllAsync -import com.lagradost.cloudstream3.toNewSearchResponseList import com.lagradost.cloudstream3.utils.AppUtils.toJson import com.uwetrottmann.tmdb2.Tmdb -import com.uwetrottmann.tmdb2.entities.AppendToResponse -import com.uwetrottmann.tmdb2.entities.BaseMovie -import com.uwetrottmann.tmdb2.entities.BaseTvShow -import com.uwetrottmann.tmdb2.entities.CastMember -import com.uwetrottmann.tmdb2.entities.ContentRating -import com.uwetrottmann.tmdb2.entities.Movie -import com.uwetrottmann.tmdb2.entities.ReleaseDate -import com.uwetrottmann.tmdb2.entities.ReleaseDatesResult -import com.uwetrottmann.tmdb2.entities.TvSeason -import com.uwetrottmann.tmdb2.entities.TvShow -import com.uwetrottmann.tmdb2.entities.Videos +import com.uwetrottmann.tmdb2.entities.* import com.uwetrottmann.tmdb2.enumerations.AppendToResponseItem import com.uwetrottmann.tmdb2.enumerations.VideoType import retrofit2.awaitResponse -import retrofit2.Response -import java.util.Calendar +import java.util.* /** * episode and season starting from 1 @@ -88,39 +54,36 @@ open class TmdbProvider : MainAPI() { } private fun BaseTvShow.toSearchResponse(): TvSeriesSearchResponse { - return newTvSeriesSearchResponse( - name = this.name ?: this.original_name, - url = getUrl(id, true), - type = TvType.TvSeries, - fix = false - ) { - this.id = this@toSearchResponse.id - this.posterUrl = getImageUrl(poster_path) - this.score = Score.from10(vote_average) - this.year = first_air_date?.let { + return TvSeriesSearchResponse( + this.name ?: this.original_name, + getUrl(id, true), + apiName, + TvType.TvSeries, + getImageUrl(this.poster_path), + this.first_air_date?.let { Calendar.getInstance().apply { time = it }.get(Calendar.YEAR) - } - } + }, + null, + this.id + ) } private fun BaseMovie.toSearchResponse(): MovieSearchResponse { - return newMovieSearchResponse( - name = this.title ?: this.original_title, - url = getUrl(id, false), - type = TvType.Movie, - fix = false - ) { - this.id = this@toSearchResponse.id - this.posterUrl = getImageUrl(poster_path) - this.score = Score.from10(vote_average) - this.year = release_date?.let { + return MovieSearchResponse( + this.title ?: this.original_title, + getUrl(id, false), + apiName, + TvType.TvSeries, + getImageUrl(this.poster_path), + this.release_date?.let { Calendar.getInstance().apply { time = it }.get(Calendar.YEAR) - } - } + }, + this.id, + ) } private fun List?.toActors(): List>? { @@ -133,39 +96,39 @@ open class TmdbProvider : MainAPI() { } private suspend fun TvShow.toLoadResponse(): TvSeriesLoadResponse { - val tvSeasonsService = tmdb.tvSeasonsService() - val episodes = mutableListOf() - - val validSeasons = this.seasons?.filter { !disableSeasonZero || (it.season_number ?: 0) != 0 } ?: emptyList() - for (season in validSeasons) { - val seasonNumber = season.season_number ?: continue - - val response: Response = tmdb.tvSeasonsService() - .season(this.id, seasonNumber, "external_ids,images,episodes") - .awaitResponse() - - val fullSeason = response.body() ?: continue - - fullSeason.episodes?.forEach { episode -> - episodes += newEpisode( - TmdbLink( - episode.external_ids?.imdb_id ?: this.external_ids?.imdb_id, - this.id, - episode.episode_number, + val episodes = this.seasons?.filter { !disableSeasonZero || (it.season_number ?: 0) != 0 } + ?.mapNotNull { season -> + season.episodes?.map { episode -> + Episode( + TmdbLink( + episode.external_ids?.imdb_id ?: this.external_ids?.imdb_id, + this.id, + episode.episode_number, + episode.season_number, + this.name ?: this.original_name, + ).toJson(), + episode.name, episode.season_number, - this.name ?: this.original_name - ).toJson() - ) { - this.name = episode.name - this.season = episode.season_number - this.episode = episode.episode_number - this.score = Score.from10(episode.vote_average) - this.description = episode.overview - this.date = episode.air_date?.time - this.posterUrl = getImageUrl(episode.still_path) + episode.episode_number, + getImageUrl(episode.still_path), + episode.rating, + episode.overview, + episode.air_date?.time, + ) + } ?: (1..(season.episode_count ?: 1)).map { episodeNum -> + Episode( + episode = episodeNum, + data = TmdbLink( + this.external_ids?.imdb_id, + this.id, + episodeNum, + season.season_number, + this.name ?: this.original_name, + ).toJson(), + season = season.season_number + ) } - } - } + }?.flatten() ?: listOf() return newTvSeriesLoadResponse( this.name ?: this.original_name, @@ -181,13 +144,16 @@ open class TmdbProvider : MainAPI() { } plot = overview addImdbId(external_ids?.imdb_id) + tags = genres?.mapNotNull { it.name } duration = episode_run_time?.average()?.toInt() - score = Score.from10(vote_average) + rating = this@toLoadResponse.rating addTrailer(videos.toTrailers()) + recommendations = (this@toLoadResponse.recommendations ?: this@toLoadResponse.similar)?.results?.map { it.toSearchResponse() } addActors(credits?.cast?.toList().toActors()) + contentRating = fetchContentRating(id, "US") } } @@ -225,7 +191,7 @@ open class TmdbProvider : MainAPI() { addImdbId(external_ids?.imdb_id) tags = genres?.mapNotNull { it.name } duration = runtime - score = Score.from10(vote_average) + rating = this@toLoadResponse.rating addTrailer(videos.toTrailers()) recommendations = (this@toLoadResponse.recommendations @@ -236,15 +202,15 @@ open class TmdbProvider : MainAPI() { } } - override suspend fun getMainPage(page: Int, request: MainPageRequest): HomePageResponse { + override suspend fun getMainPage(page: Int, request : MainPageRequest): HomePageResponse { // SAME AS DISCOVER IT SEEMS -// val popularSeries = tmdb.tvService().popular(page, "en-US").execute().body()?.results?.map { +// val popularSeries = tmdb.tvService().popular(1, "en-US").execute().body()?.results?.map { // it.toSearchResponse() // } ?: listOf() // // val popularMovies = -// tmdb.moviesService().popular(page, "en-US", "840").execute().body()?.results?.map { +// tmdb.moviesService().popular(1, "en-US", "840").execute().body()?.results?.map { // it.toSearchResponse() // } ?: listOf() @@ -252,31 +218,31 @@ open class TmdbProvider : MainAPI() { var discoverSeries: List = listOf() var topMovies: List = listOf() var topSeries: List = listOf() - runAllAsync( + argamap( { - discoverMovies = tmdb.discoverMovie().page(page).build().awaitResponse().body()?.results?.map { + discoverMovies = tmdb.discoverMovie().build().awaitResponse().body()?.results?.map { it.toSearchResponse() } ?: listOf() }, { - discoverSeries = tmdb.discoverTv().page(page).build().awaitResponse().body()?.results?.map { + discoverSeries = tmdb.discoverTv().build().awaitResponse().body()?.results?.map { it.toSearchResponse() } ?: listOf() }, { // https://en.wikipedia.org/wiki/ISO_3166-1 topMovies = - tmdb.moviesService().topRated(page, "en-US", "US").awaitResponse() + tmdb.moviesService().topRated(1, "en-US", "US").awaitResponse() .body()?.results?.map { it.toSearchResponse() } ?: listOf() }, { topSeries = - tmdb.tvService().topRated(page, "en-US").awaitResponse().body()?.results?.map { + tmdb.tvService().topRated(1, "en-US").awaitResponse().body()?.results?.map { it.toSearchResponse() } ?: listOf() } ) - return newHomePageResponse( + return HomePageResponse( listOf( // HomePageList("Popular Series", popularSeries), // HomePageList("Popular Movies", popularMovies), @@ -396,27 +362,29 @@ open class TmdbProvider : MainAPI() { } else { loadFromTmdb(id)?.let { return it } if (isTvSeries) { - tmdb.tvService().externalIds(id).awaitResponse().body()?.imdb_id?.let { + tmdb.tvService().externalIds(id, "en-US").awaitResponse().body()?.imdb_id?.let { val fromImdb = loadFromImdb(it) val result = if (fromImdb == null) { val details = tmdb.tvService().tv(id, "en-US").awaitResponse().body() loadFromImdb(it, details?.seasons ?: listOf()) ?: loadFromTmdb(id, details?.seasons ?: listOf()) - } else fromImdb + } else { + fromImdb + } result } } else { - tmdb.moviesService().externalIds(id).awaitResponse() + tmdb.moviesService().externalIds(id, "en-US").awaitResponse() .body()?.imdb_id?.let { loadFromImdb(it) } } } } - override suspend fun search(query: String, page: Int): SearchResponseList? { - return tmdb.searchService().multi(query, page, "en-US", "US", includeAdult).awaitResponse() + override suspend fun search(query: String): List? { + return tmdb.searchService().multi(query, 1, "en-Us", "US", includeAdult).awaitResponse() .body()?.results?.mapNotNull { it.movie?.toSearchResponse() ?: it.tvShow?.toSearchResponse() - }?.toNewSearchResponseList() + } } -} \ No newline at end of file +} diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/metaproviders/TraktProvider.kt b/app/src/main/java/com/lagradost/cloudstream3/metaproviders/TraktProvider.kt similarity index 78% rename from library/src/commonMain/kotlin/com/lagradost/cloudstream3/metaproviders/TraktProvider.kt rename to app/src/main/java/com/lagradost/cloudstream3/metaproviders/TraktProvider.kt index 59dcd2711..addee9a02 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/metaproviders/TraktProvider.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/metaproviders/TraktProvider.kt @@ -1,8 +1,9 @@ package com.lagradost.cloudstream3.metaproviders +import android.net.Uri import com.fasterxml.jackson.annotation.JsonAlias import com.fasterxml.jackson.annotation.JsonProperty -import com.lagradost.api.BuildConfig +import com.lagradost.cloudstream3.APIHolder import com.lagradost.cloudstream3.APIHolder.unixTimeMS import com.lagradost.cloudstream3.Actor import com.lagradost.cloudstream3.ActorData @@ -16,24 +17,24 @@ import com.lagradost.cloudstream3.MainAPI import com.lagradost.cloudstream3.MainPageRequest import com.lagradost.cloudstream3.NextAiring import com.lagradost.cloudstream3.ProviderType -import com.lagradost.cloudstream3.Score import com.lagradost.cloudstream3.SearchResponse -import com.lagradost.cloudstream3.SearchResponseList import com.lagradost.cloudstream3.ShowStatus import com.lagradost.cloudstream3.TvType import com.lagradost.cloudstream3.addDate import com.lagradost.cloudstream3.app -import com.lagradost.cloudstream3.isUpcoming +import com.lagradost.cloudstream3.base64Decode import com.lagradost.cloudstream3.mainPageOf -import com.lagradost.cloudstream3.newEpisode +import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.newHomePageResponse import com.lagradost.cloudstream3.newMovieLoadResponse import com.lagradost.cloudstream3.newMovieSearchResponse -import com.lagradost.cloudstream3.newSearchResponseList import com.lagradost.cloudstream3.newTvSeriesLoadResponse import com.lagradost.cloudstream3.newTvSeriesSearchResponse import com.lagradost.cloudstream3.utils.AppUtils.parseJson import com.lagradost.cloudstream3.utils.AppUtils.toJson +import java.text.SimpleDateFormat +import java.util.Locale +import kotlin.math.roundToInt open class TraktProvider : MainAPI() { override var name = "Trakt" @@ -45,9 +46,9 @@ open class TraktProvider : MainAPI() { TvType.Anime, ) - private val traktApiUrl = "https://api.trakt.tv" - - val traktClientId: String = BuildConfig.TRAKT_CLIENT_ID + private val traktClientId = + base64Decode("N2YzODYwYWQzNGI4ZTZmOTdmN2I5MTA0ZWQzMzEwOGI0MmQ3MTdlMTM0MmM2NGMxMTg5NGE1MjUyYTQ3NjE3Zg==") + private val traktApiUrl = base64Decode("aHR0cHM6Ly9hcGl6LnRyYWt0LnR2") override val mainPage = mainPageOf( "$traktApiUrl/movies/trending" to "Trending Movies", //Most watched movies right now @@ -57,7 +58,8 @@ open class TraktProvider : MainAPI() { ) override suspend fun getMainPage(page: Int, request: MainPageRequest): HomePageResponse { - val apiResponse = getApi("${request.data}?extended=full,images&page=$page") + + val apiResponse = getApi("${request.data}?extended=cloud9,full&page=$page") val results = parseJson>(apiResponse).map { element -> element.toSearchResponse() @@ -70,76 +72,76 @@ open class TraktProvider : MainAPI() { val media = this.media ?: this val mediaType = if (media.ids?.tvdb == null) TvType.Movie else TvType.TvSeries val poster = media.images?.poster?.firstOrNull() - return if (mediaType == TvType.Movie) { - newMovieSearchResponse( - name = media.title ?: "", + + if (mediaType == TvType.Movie) { + return newMovieSearchResponse( + name = media.title!!, url = Data( type = mediaType, mediaDetails = media, ).toJson(), type = TvType.Movie, ) { - score = Score.from10(media.rating) posterUrl = fixPath(poster) } } else { - newTvSeriesSearchResponse( - name = media.title ?: "", + return newTvSeriesSearchResponse( + name = media.title!!, url = Data( type = mediaType, mediaDetails = media, ).toJson(), type = TvType.TvSeries, ) { - score = Score.from10(media.rating) this.posterUrl = fixPath(poster) } } } - override suspend fun search(query: String, page: Int): SearchResponseList? { + override suspend fun search(query: String): List? { val apiResponse = - getApi("$traktApiUrl/search/movie,show?extended=full,images&limit=20&page=$page&query=$query") + getApi("$traktApiUrl/search/movie,show?extended=cloud9,full&limit=20&page=1&query=$query") - return newSearchResponseList(parseJson>(apiResponse).map { element -> + val results = parseJson>(apiResponse).map { element -> element.toSearchResponse() - }) + } + + return results } override suspend fun load(url: String): LoadResponse { + val data = parseJson(url) val mediaDetails = data.mediaDetails val moviesOrShows = if (data.type == TvType.Movie) "movies" else "shows" - val posterUrl = fixPath(mediaDetails?.images?.poster?.firstOrNull()) - val backDropUrl = fixPath(mediaDetails?.images?.fanart?.firstOrNull()) - val logoUrl = fixPath(mediaDetails?.images?.logo?.firstOrNull()) + val posterUrl = mediaDetails?.images?.poster?.firstOrNull() + val backDropUrl = mediaDetails?.images?.fanart?.firstOrNull() val resActor = - getApi("$traktApiUrl/$moviesOrShows/${mediaDetails?.ids?.trakt}/people?extended=full,images") + getApi("$traktApiUrl/$moviesOrShows/${mediaDetails?.ids?.trakt}/people?extended=cloud9,full") val actors = parseJson(resActor).cast?.map { ActorData( Actor( name = it.person?.name!!, - image = fixPath(it.person.images?.headshot?.firstOrNull()) + image = getWidthImageUrl(it.person.images?.headshot?.firstOrNull(), "w500") ), roleString = it.character ) } val resRelated = - getApi("$traktApiUrl/$moviesOrShows/${mediaDetails?.ids?.trakt}/related?extended=full,images&limit=20") + getApi("$traktApiUrl/$moviesOrShows/${mediaDetails?.ids?.trakt}/related?extended=cloud9,full&limit=20") val relatedMedia = parseJson>(resRelated).map { it.toSearchResponse() } val isCartoon = mediaDetails?.genres?.contains("animation") == true || mediaDetails?.genres?.contains("anime") == true val isAnime = - isCartoon && (mediaDetails.language == "zh" || mediaDetails.language == "ja") + isCartoon && (mediaDetails?.language == "zh" || mediaDetails?.language == "ja") val isAsian = !isAnime && (mediaDetails?.language == "zh" || mediaDetails?.language == "ko") val isBollywood = mediaDetails?.country == "in" - val uniqueUrl = data.mediaDetails?.ids?.trakt?.toJson() ?: data.toJson() if (data.type == TvType.Movie) { @@ -169,21 +171,19 @@ open class TraktProvider : MainAPI() { dataUrl = linkData.toJson(), type = if (isAnime) TvType.AnimeMovie else TvType.Movie, ) { - this.uniqueUrl = uniqueUrl this.name = mediaDetails.title this.type = if (isAnime) TvType.AnimeMovie else TvType.Movie - this.posterUrl = posterUrl + this.posterUrl = getOriginalWidthImageUrl(posterUrl) this.year = mediaDetails.year this.plot = mediaDetails.overview - this.score = Score.from10(mediaDetails.rating) + this.rating = mediaDetails.rating?.times(1000)?.roundToInt() this.tags = mediaDetails.genres this.duration = mediaDetails.runtime this.recommendations = relatedMedia this.actors = actors this.comingSoon = isUpcoming(mediaDetails.released) //posterHeaders - this.backgroundPosterUrl = backDropUrl - this.logoUrl = logoUrl + this.backgroundPosterUrl = getOriginalWidthImageUrl(backDropUrl) this.contentRating = mediaDetails.certification addTrailer(mediaDetails.trailer) addImdbId(mediaDetails.ids?.imdb) @@ -192,7 +192,7 @@ open class TraktProvider : MainAPI() { } else { val resSeasons = - getApi("$traktApiUrl/shows/${mediaDetails?.ids?.trakt.toString()}/seasons?extended=full,images,episodes") + getApi("$traktApiUrl/shows/${mediaDetails?.ids?.trakt.toString()}/seasons?extended=cloud9,full,episodes") val episodes = mutableListOf() val seasons = parseJson>(resSeasons) var nextAir: NextAiring? = null @@ -228,16 +228,16 @@ open class TraktProvider : MainAPI() { ).toJson() episodes.add( - newEpisode(linkData.toJson()) { - this.name = episode.title - this.season = episode.season - this.episode = episode.number - this.description = episode.overview - this.runTime = episode.runtime - this.posterUrl = fixPath( episode.images?.screenshot?.firstOrNull()) - //this.rating = episode.rating?.times(10)?.roundToInt() - this.score = Score.from10(episode.rating) - + Episode( + data = linkData.toJson(), + name = episode.title, + season = episode.season, + episode = episode.number, + posterUrl = fixPath(episode.images?.screenshot?.firstOrNull()), + rating = episode.rating?.times(10)?.roundToInt(), + description = episode.overview, + runTime = episode.runtime + ).apply { this.addDate(episode.firstAired, "yyyy-MM-dd'T'HH:mm:ss.SSSXXX") if (nextAir == null && this.date != null && this.date!! > unixTimeMS && this.season != 0) { nextAir = NextAiring( @@ -257,15 +257,14 @@ open class TraktProvider : MainAPI() { type = if (isAnime) TvType.Anime else TvType.TvSeries, episodes = episodes ) { - this.uniqueUrl = uniqueUrl this.name = mediaDetails.title this.type = if (isAnime) TvType.Anime else TvType.TvSeries this.episodes = episodes - this.posterUrl = posterUrl + this.posterUrl = getOriginalWidthImageUrl(posterUrl) this.year = mediaDetails.year this.plot = mediaDetails.overview this.showStatus = getStatus(mediaDetails.status) - this.score = Score.from10(mediaDetails.rating) + this.rating = mediaDetails.rating?.times(1000)?.roundToInt() this.tags = mediaDetails.genres this.duration = mediaDetails.runtime this.recommendations = relatedMedia @@ -273,8 +272,7 @@ open class TraktProvider : MainAPI() { this.comingSoon = isUpcoming(mediaDetails.released) //posterHeaders this.nextAiring = nextAir - this.backgroundPosterUrl = backDropUrl - this.logoUrl = logoUrl + this.backgroundPosterUrl = getOriginalWidthImageUrl(backDropUrl) this.contentRating = mediaDetails.certification addTrailer(mediaDetails.trailer) addImdbId(mediaDetails.ids?.imdb) @@ -291,7 +289,18 @@ open class TraktProvider : MainAPI() { "trakt-api-version" to "2", "trakt-api-key" to traktClientId, ) - ).text + ).toString() + } + + private fun isUpcoming(dateString: String?): Boolean { + return try { + val format = SimpleDateFormat("yyyy-MM-dd", Locale.getDefault()) + val dateTime = dateString?.let { format.parse(it)?.time } ?: return false + unixTimeMS < dateTime + } catch (t: Throwable) { + logError(t) + false + } } private fun getStatus(t: String?): ShowStatus { @@ -307,6 +316,19 @@ open class TraktProvider : MainAPI() { return "https://$url" } + private fun getWidthImageUrl(path: String?, width: String): String? { + if (path == null) return null + if (!path.contains("image.tmdb.org")) return fixPath(path) + val fileName = Uri.parse(path).lastPathSegment ?: return null + return "https://image.tmdb.org/t/p/${width}/${fileName}" + } + + private fun getOriginalWidthImageUrl(path: String?): String? { + if (path == null) return null + if (!path.contains("image.tmdb.org")) return fixPath(path) + return getWidthImageUrl(path, "original") + } + data class Data( val type: TvType? = null, val mediaDetails: MediaDetails? = null, @@ -357,10 +379,10 @@ open class TraktProvider : MainAPI() { ) data class Images( - @JsonProperty("poster") val poster: List? = null, @JsonProperty("fanart") val fanart: List? = null, + @JsonProperty("poster") val poster: List? = null, @JsonProperty("logo") val logo: List? = null, - @JsonProperty("clearart") val clearArt: List? = null, + @JsonProperty("clearart") val clearart: List? = null, @JsonProperty("banner") val banner: List? = null, @JsonProperty("thumb") val thumb: List? = null, @JsonProperty("screenshot") val screenshot: List? = null, @@ -420,30 +442,30 @@ open class TraktProvider : MainAPI() { ) data class LinkData( - @JsonProperty("id") val id: Int? = null, - @JsonProperty("trakt_id") val traktId: Int? = null, - @JsonProperty("trakt_slug") val traktSlug: String? = null, - @JsonProperty("tmdb_id") val tmdbId: Int? = null, - @JsonProperty("imdb_id") val imdbId: String? = null, - @JsonProperty("tvdb_id") val tvdbId: Int? = null, - @JsonProperty("tvrage_id") val tvrageId: String? = null, - @JsonProperty("type") val type: String? = null, - @JsonProperty("season") val season: Int? = null, - @JsonProperty("episode") val episode: Int? = null, - @JsonProperty("ani_id") val aniId: String? = null, - @JsonProperty("anime_id") val animeId: String? = null, - @JsonProperty("title") val title: String? = null, - @JsonProperty("year") val year: Int? = null, - @JsonProperty("org_title") val orgTitle: String? = null, - @JsonProperty("is_anime") val isAnime: Boolean = false, - @JsonProperty("aired_year") val airedYear: Int? = null, - @JsonProperty("last_season") val lastSeason: Int? = null, - @JsonProperty("eps_title") val epsTitle: String? = null, - @JsonProperty("jp_title") val jpTitle: String? = null, - @JsonProperty("date") val date: String? = null, - @JsonProperty("aired_date") val airedDate: String? = null, - @JsonProperty("is_asian") val isAsian: Boolean = false, - @JsonProperty("is_bollywood") val isBollywood: Boolean = false, - @JsonProperty("is_cartoon") val isCartoon: Boolean = false, + val id: Int? = null, + val traktId: Int? = null, + val traktSlug: String? = null, + val tmdbId: Int? = null, + val imdbId: String? = null, + val tvdbId: Int? = null, + val tvrageId: String? = null, + val type: String? = null, + val season: Int? = null, + val episode: Int? = null, + val aniId: String? = null, + val animeId: String? = null, + val title: String? = null, + val year: Int? = null, + val orgTitle: String? = null, + val isAnime: Boolean = false, + val airedYear: Int? = null, + val lastSeason: Int? = null, + val epsTitle: String? = null, + val jpTitle: String? = null, + val date: String? = null, + val airedDate: String? = null, + val isAsian: Boolean = false, + val isBollywood: Boolean = false, + val isCartoon: Boolean = false, ) -} +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/mvvm/Lifecycle.kt b/app/src/main/java/com/lagradost/cloudstream3/mvvm/Lifecycle.kt index 482ec05fc..3df5197cd 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/mvvm/Lifecycle.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/mvvm/Lifecycle.kt @@ -1,68 +1,16 @@ package com.lagradost.cloudstream3.mvvm -import android.view.View -import androidx.activity.ComponentActivity -import androidx.core.view.doOnAttach import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LiveData -import androidx.lifecycle.findViewTreeLifecycleOwner -import androidx.viewbinding.ViewBinding -import com.lagradost.cloudstream3.ui.BaseFragment /** NOTE: Only one observer at a time per value */ -fun ComponentActivity.observe(liveData: LiveData, action: (T) -> Unit) { - observeNullable(liveData) { t -> t?.run(action) } -} - -/** NOTE: Only one observer at a time per value */ -fun ComponentActivity.observeNullable(liveData: LiveData, action: (T?) -> Unit) { +fun LifecycleOwner.observe(liveData: LiveData, action: (t: T) -> Unit) { liveData.removeObservers(this) - liveData.observe(this, action) + liveData.observe(this) { it?.let { t -> action(t) } } } /** NOTE: Only one observer at a time per value */ -fun BaseFragment.observe(liveData: LiveData, action: (T) -> Unit) { - observeNullable(liveData) { t -> t?.run(action) } +fun LifecycleOwner.observeNullable(liveData: LiveData, action: (t: T) -> Unit) { + liveData.removeObservers(this) + liveData.observe(this) { action(it) } } - -/** - * Attaches an observable to the root binding, instead of the fragment. This is more efficient as - * it will not call observe if the view is in the background. - * - * NOTE: Only one observer at a time per value - * */ -fun BaseFragment.observeNullable( - liveData: LiveData, action: (T?) -> Unit -) { - val root = this.binding?.root - if (root == null) { - liveData.removeObservers(this) - liveData.observe(this, action) - } else { - root.doOnAttach { view -> - // On attach should make findViewTreeLifecycleOwner non-null, but use "this" just in case - val owner: LifecycleOwner = view.findViewTreeLifecycleOwner() ?: this@observeNullable - liveData.removeObservers(owner) - liveData.observe(owner, action) - } - } -} - -/** NOTE: Only one observer at a time per value */ -fun View.observe(liveData: LiveData, action: (T) -> Unit) { - observeNullable(liveData) { t -> t?.run(action) } -} - -/** NOTE: Only one observer at a time per value */ -fun View.observeNullable(liveData: LiveData, action: (T?) -> Unit) { - doOnAttach { view -> - // On attach should make findViewTreeLifecycleOwner non-null - val owner: LifecycleOwner? = view.findViewTreeLifecycleOwner() - if(owner == null) { - debugException { "Expected non-null findViewTreeLifecycleOwner" } - return@doOnAttach - } - liveData.removeObservers(owner) - liveData.observe(owner, action) - } -} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/network/CloudflareKiller.kt b/app/src/main/java/com/lagradost/cloudstream3/network/CloudflareKiller.kt index 9efa88a37..85a9db5db 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/network/CloudflareKiller.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/network/CloudflareKiller.kt @@ -5,7 +5,7 @@ import android.webkit.CookieManager import androidx.annotation.AnyThread import com.lagradost.cloudstream3.app import com.lagradost.cloudstream3.mvvm.debugWarning -import com.lagradost.cloudstream3.mvvm.safe +import com.lagradost.cloudstream3.mvvm.normalSafeApiCall import com.lagradost.nicehttp.Requests.Companion.await import com.lagradost.nicehttp.cookies import kotlinx.coroutines.runBlocking @@ -32,7 +32,7 @@ class CloudflareKiller : Interceptor { init { // Needs to clear cookies between sessions to generate new cookies. - safe { + normalSafeApiCall { // This can throw an exception on unsupported devices :( CookieManager.getInstance().removeAllCookies(null) } @@ -77,7 +77,7 @@ class CloudflareKiller : Interceptor { } private fun getWebViewCookie(url: String): String? { - return safe { + return normalSafeApiCall { CookieManager.getInstance()?.getCookie(url) } } diff --git a/app/src/main/java/com/lagradost/cloudstream3/network/DohProviders.kt b/app/src/main/java/com/lagradost/cloudstream3/network/DohProviders.kt index 4127799e8..55e092513 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/network/DohProviders.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/network/DohProviders.kt @@ -84,24 +84,4 @@ fun OkHttpClient.Builder.addQuad9Dns() = ( "9.9.9.9", "149.112.112.112", ) - )) - -fun OkHttpClient.Builder.addDnsSbDns() = ( - addGenericDns( - "https://doh.dns.sb/dns-query", - //https://dns.sb/guide/ - listOf( - "185.222.222.222", - "45.11.45.11", - ) - )) - -fun OkHttpClient.Builder.addCanadianShieldDns() = ( - addGenericDns( - "https://private.canadianshield.cira.ca/dns-query", - //https://www.cira.ca/en/canadian-shield/configure/summary-cira-canadian-shield-dns-resolver-addresses/ - listOf( - "149.112.121.10", - "149.112.122.10", - ) - )) + )) \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/network/RequestsHelper.kt b/app/src/main/java/com/lagradost/cloudstream3/network/RequestsHelper.kt index 6234297d0..a1d84f6cd 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/network/RequestsHelper.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/network/RequestsHelper.kt @@ -2,10 +2,9 @@ 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 +import com.lagradost.cloudstream3.mvvm.normalSafeApiCall import com.lagradost.nicehttp.Requests import com.lagradost.nicehttp.ignoreAllSSLErrors import okhttp3.Cache @@ -16,38 +15,14 @@ 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) } - +fun Requests.initClient(context: Context): OkHttpClient { + normalSafeApiCall { Security.insertProviderAt(Conscrypt.newProvider(), 1) } val settingsManager = PreferenceManager.getDefaultSharedPreferences(context) val dns = settingsManager.getInt(context.getString(R.string.dns_pref), 0) - val baseClient = OkHttpClient.Builder() + baseClient = OkHttpClient.Builder() .followRedirects(true) .followSslRedirects(true) - .apply { - if (ignoreSSL) { - ignoreAllSSLErrors() - } - } + .ignoreAllSSLErrors() .cache( // Note that you need to add a ResponseInterceptor to make this 100% active. // The server response dictates if and when stuff should be cached. @@ -63,8 +38,6 @@ fun buildDefaultClient(context: Context, ignoreSSL: Boolean = false): OkHttpClie 4 -> addAdGuardDns() 5 -> addDNSWatchDns() 6 -> addQuad9Dns() - 7 -> addDnsSbDns() - 8 -> addCanadianShieldDns() } } // Needs to be build as otherwise the other builders will change this object @@ -72,6 +45,11 @@ fun buildDefaultClient(context: Context, ignoreSSL: Boolean = false): OkHttpClie return baseClient } +//val Request.cookies: Map +// get() { +// return this.headers.getCookies("Cookie") +// } + private val DEFAULT_HEADERS = mapOf("user-agent" to USER_AGENT) /** diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/plugins/CloudstreamPlugin.kt b/app/src/main/java/com/lagradost/cloudstream3/plugins/CloudstreamPlugin.kt similarity index 100% rename from library/src/commonMain/kotlin/com/lagradost/cloudstream3/plugins/CloudstreamPlugin.kt rename to app/src/main/java/com/lagradost/cloudstream3/plugins/CloudstreamPlugin.kt diff --git a/app/src/main/java/com/lagradost/cloudstream3/plugins/Plugin.kt b/app/src/main/java/com/lagradost/cloudstream3/plugins/Plugin.kt index e1496db06..e35ae24b9 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/plugins/Plugin.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/plugins/Plugin.kt @@ -2,20 +2,56 @@ package com.lagradost.cloudstream3.plugins import android.content.Context import android.content.res.Resources +import kotlin.Throws +import com.lagradost.cloudstream3.MainAPI +import com.lagradost.cloudstream3.APIHolder +import com.lagradost.cloudstream3.utils.ExtractorApi +import com.lagradost.cloudstream3.utils.extractorApis import android.util.Log +import com.fasterxml.jackson.annotation.JsonProperty import com.lagradost.cloudstream3.actions.VideoClickAction import com.lagradost.cloudstream3.actions.VideoClickActionHolder -import kotlin.Throws -abstract class Plugin : BasePlugin() { +const val PLUGIN_TAG = "PluginInstance" + +abstract class Plugin { /** * Called when your Plugin is loaded * @param context Context */ @Throws(Throwable::class) open fun load(context: Context) { - // If not overridden by an extension then try the cross-platform load() - load() + } + + /** + * Called when your Plugin is being unloaded + */ + @Throws(Throwable::class) + open fun beforeUnload() { + } + + /** + * Used to register providers instances of MainAPI + * @param element MainAPI provider you want to register + */ + fun registerMainAPI(element: MainAPI) { + Log.i(PLUGIN_TAG, "Adding ${element.name} (${element.mainUrl}) MainAPI") + element.sourcePlugin = this.filename + // Race condition causing which would case duplicates if not for distinctBy + synchronized(APIHolder.allProviders) { + APIHolder.allProviders.add(element) + } + APIHolder.addPluginMapping(element) + } + + /** + * Used to register extractor instances of ExtractorApi + * @param element ExtractorApi provider you want to register + */ + fun registerExtractorAPI(element: ExtractorApi) { + Log.i(PLUGIN_TAG, "Adding ${element.name} (${element.mainUrl}) ExtractorApi") + element.sourcePlugin = this.filename + extractorApis.add(element) } /** @@ -25,16 +61,35 @@ abstract class Plugin : BasePlugin() { fun registerVideoClickAction(element: VideoClickAction) { Log.i(PLUGIN_TAG, "Adding ${element.name} VideoClickAction") element.sourcePlugin = this.filename - VideoClickActionHolder.allVideoClickActions.add(element) + synchronized(VideoClickActionHolder.allVideoClickActions) { + VideoClickActionHolder.allVideoClickActions.add(element) + } + } + + class Manifest { + @JsonProperty("name") + var name: String? = null + @JsonProperty("pluginClassName") + var pluginClassName: String? = null + @JsonProperty("version") + var version: Int? = null + @JsonProperty("requiresResources") + var requiresResources: Boolean = false } /** * This will contain your resources if you specified requiresResources in gradle */ var resources: Resources? = null + /** Full file path to the plugin. */ + @Deprecated("Renamed to `filename` to follow conventions", replaceWith = ReplaceWith("filename")) + var __filename: String? + get() = filename + set(value) {filename = value} + var filename: String? = null /** * This will add a button in the settings allowing you to add custom settings */ var openSettings: ((context: Context) -> Unit)? = null -} +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/plugins/PluginManager.kt b/app/src/main/java/com/lagradost/cloudstream3/plugins/PluginManager.kt index debd3f0eb..8535592d4 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/plugins/PluginManager.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/plugins/PluginManager.kt @@ -1,10 +1,7 @@ package com.lagradost.cloudstream3.plugins import android.Manifest -import android.app.Activity -import android.app.Notification -import android.app.NotificationChannel -import android.app.NotificationManager +import android.app.* import android.content.Context import android.content.pm.PackageManager import android.content.res.AssetManager @@ -13,56 +10,45 @@ 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 import androidx.fragment.app.FragmentActivity import com.fasterxml.jackson.annotation.JsonProperty -import com.lagradost.cloudstream3.APIHolder +import com.google.gson.Gson +import com.lagradost.cloudstream3.* import com.lagradost.cloudstream3.APIHolder.removePluginMapping -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.AcraApplication.Companion.getActivity +import com.lagradost.cloudstream3.AcraApplication.Companion.getKey +import com.lagradost.cloudstream3.AcraApplication.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 -import com.lagradost.cloudstream3.TvType import com.lagradost.cloudstream3.actions.VideoClickAction import com.lagradost.cloudstream3.actions.VideoClickActionHolder -import com.lagradost.cloudstream3.amap import com.lagradost.cloudstream3.mvvm.debugPrint import com.lagradost.cloudstream3.mvvm.logError -import com.lagradost.cloudstream3.mvvm.safe +import com.lagradost.cloudstream3.mvvm.normalSafeApiCall import com.lagradost.cloudstream3.plugins.RepositoryManager.ONLINE_PLUGINS_FOLDER 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.result.UiText +import com.lagradost.cloudstream3.ui.result.txt import com.lagradost.cloudstream3.ui.settings.extensions.REPOSITORIES_KEY import com.lagradost.cloudstream3.ui.settings.extensions.RepositoryData import com.lagradost.cloudstream3.utils.AppContextUtils.getApiProviderLangSettings -import com.lagradost.cloudstream3.utils.AppUtils.parseJson import com.lagradost.cloudstream3.utils.Coroutines.main import com.lagradost.cloudstream3.utils.ExtractorApi import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute -import com.lagradost.cloudstream3.utils.UiText -import com.lagradost.cloudstream3.utils.downloader.DownloadFileManagement.sanitizeFilename +import com.lagradost.cloudstream3.utils.VideoDownloadManager.sanitizeFilename import com.lagradost.cloudstream3.utils.extractorApis -import com.lagradost.cloudstream3.utils.txt import dalvik.system.PathClassLoader import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import java.io.File import java.io.InputStreamReader +import java.util.* // Different keys for local and not since local can be removed at any time without app knowing, hence the local are getting rebuilt on every app start const val PLUGINS_KEY = "PLUGINS_KEY" @@ -80,7 +66,6 @@ data class PluginData( @JsonProperty("filePath") val filePath: String, @JsonProperty("version") val version: Int, ) { - @WorkerThread fun toSitePlugin(): SitePlugin { return SitePlugin( this.filePath, @@ -95,9 +80,7 @@ data class PluginData( null, null, null, - File(this.filePath).length(), - // No file hash for local plugins. Local plugins have no use for the hash, and it's expensive to compute. - null + File(this.filePath).length() ) } } @@ -151,7 +134,7 @@ object PluginManager { !it.filePath.contains(repositoryPath) } val file = File(repositoryPath) - safe { + normalSafeApiCall { if (file.exists()) file.deleteRecursively() } setKey(PLUGINS_KEY, plugins) @@ -188,21 +171,22 @@ object PluginManager { var currentlyLoading: String? = null // Maps filepath to plugin - val plugins: MutableMap = - LinkedHashMap() + val plugins: MutableMap = + LinkedHashMap() // Maps urls to plugin - val urlPlugins: MutableMap = - LinkedHashMap() + val urlPlugins: MutableMap = + LinkedHashMap() - private val classLoaders: MutableMap = - HashMap() + private val classLoaders: MutableMap = + HashMap() var loadedLocalPlugins = false private set var loadedOnlinePlugins = false private set + private val gson = Gson() private suspend fun maybeLoadPlugin(context: Context, file: File) { val name = file.name @@ -261,24 +245,16 @@ object PluginManager { * 2. If disabled do nothing * 3. If outdated download and load the plugin * 4. Else load the plugin normally - * - * DO NOT USE THIS IN A PLUGIN! It may case an infinite recursive loop lagging or crashing everyone's devices. - * If you use it from a plugin, do not expect a stable jvmName, SO DO NOT USE IT! - */ - @Suppress("FunctionName") - @InternalAPI - @Throws - suspend fun ___DO_NOT_CALL_FROM_A_PLUGIN_updateAllOnlinePluginsAndLoadThem(activity: Activity) { - assertNonRecursiveCallstack() - + **/ + fun updateAllOnlinePluginsAndLoadThem(activity: Activity) { // Load all plugins as fast as possible! - ___DO_NOT_CALL_FROM_A_PLUGIN_loadAllOnlinePlugins(activity) + loadAllOnlinePlugins(activity) afterPluginsLoadedEvent.invoke(false) val urls = (getKey>(REPOSITORIES_KEY) ?: emptyArray()) + PREBUILT_REPOSITORIES - val onlinePlugins = urls.toList().amap { + val onlinePlugins = urls.toList().apmap { getRepoPlugins(it.url)?.toList() ?: emptyList() }.flatten().distinctBy { it.second.url } @@ -299,7 +275,7 @@ object PluginManager { val updatedPlugins = mutableListOf() - outdatedPlugins.amap { pluginData -> + outdatedPlugins.apmap { pluginData -> if (pluginData.isDisabled) { //updatedPlugins.add(activity.getString(R.string.single_plugin_disabled, pluginData.onlineData.second.name)) unloadPlugin(pluginData.savedData.filePath) @@ -307,7 +283,6 @@ object PluginManager { downloadPlugin( activity, pluginData.onlineData.second.url, - pluginData.onlineData.second.fileHash, pluginData.savedData.internalName, File(pluginData.savedData.filePath), true @@ -339,23 +314,12 @@ object PluginManager { * 1. Gets all online data from online plugins repo * 2. Fetch all not downloaded plugins * 3. Download them and reload plugins - * - * DO NOT USE THIS IN A PLUGIN! It may case an infinite recursive loop lagging or crashing everyone's devices. - * If you use it from a plugin, do not expect a stable jvmName, SO DO NOT USE IT! - */ - @Suppress("FunctionName") - @InternalAPI - @Throws - suspend fun ___DO_NOT_CALL_FROM_A_PLUGIN_downloadNotExistingPluginsAndLoad( - activity: Activity, - mode: AutoDownloadMode - ) { - assertNonRecursiveCallstack() - + **/ + fun downloadNotExistingPluginsAndLoad(activity: Activity, mode: AutoDownloadMode) { val newDownloadPlugins = mutableListOf() val urls = (getKey>(REPOSITORIES_KEY) ?: emptyArray()) + PREBUILT_REPOSITORIES - val onlinePlugins = urls.toList().amap { + val onlinePlugins = urls.toList().apmap { getRepoPlugins(it.url)?.toList() ?: emptyList() }.flatten().distinctBy { it.second.url } @@ -415,11 +379,10 @@ object PluginManager { } //Log.i(TAG, "notDownloadedPlugins => ${notDownloadedPlugins.toJson()}") - notDownloadedPlugins.amap { pluginData -> + notDownloadedPlugins.apmap { pluginData -> downloadPlugin( activity, pluginData.onlineData.second.url, - pluginData.onlineData.second.fileHash, pluginData.savedData.internalName, pluginData.onlineData.first, !pluginData.isDisabled @@ -441,27 +404,12 @@ object PluginManager { Log.i(TAG, "Plugin download done!") } - @Throws - private fun assertNonRecursiveCallstack() { - if (Thread.currentThread().stackTrace.any { it.methodName == "loadPlugin" }) { - throw Error("You tried to call a function that will recursively call loadPlugin, this will cause crashes or memory leaks. Do not do this, there is better ways to implement the feature than reloading plugins. Are you sure you read the compile error or docs?") - } - } - /** * Use updateAllOnlinePluginsAndLoadThem - * - * DO NOT USE THIS IN A PLUGIN! It may case an infinite recursive loop lagging or crashing everyone's devices. - * If you use it from a plugin, do not expect a stable jvmName, SO DO NOT USE IT! - */ - @Suppress("FunctionName") - @InternalAPI - @Throws - suspend fun ___DO_NOT_CALL_FROM_A_PLUGIN_loadAllOnlinePlugins(context: Context) { - assertNonRecursiveCallstack() - + * */ + fun loadAllOnlinePlugins(context: Context) { // Load all plugins as fast as possible! - (getPluginsOnline()).toList().amap { pluginData -> + (getPluginsOnline()).toList().apmap { pluginData -> loadPlugin( context, File(pluginData.filePath), @@ -472,37 +420,21 @@ object PluginManager { /** * Reloads all local plugins and forces a page update, used for hot reloading with deployWithAdb - * - * DO NOT USE THIS IN A PLUGIN! It may case an infinite recursive loop lagging or crashing everyone's devices. - * If you use it from a plugin, do not expect a stable jvmName, SO DO NOT USE IT! - */ - @Suppress("FunctionName") - @InternalAPI - @Throws - suspend fun ___DO_NOT_CALL_FROM_A_PLUGIN_hotReloadAllLocalPlugins(activity: FragmentActivity?) { - assertNonRecursiveCallstack() - + **/ + fun hotReloadAllLocalPlugins(activity: FragmentActivity?) { Log.d(TAG, "Reloading all local plugins!") if (activity == null) return getPluginsLocal().forEach { unloadPlugin(it.filePath) } - ___DO_NOT_CALL_FROM_A_PLUGIN_loadAllLocalPlugins(activity, true) + loadAllLocalPlugins(activity, true) } /** * @param forceReload see afterPluginsLoadedEvent, basically a way to load all local plugins * and reload all pages even if they are previously valid - * - * DO NOT USE THIS IN A PLUGIN! It may case an infinite recursive loop lagging or crashing everyone's devices. - * If you use it from a plugin, do not expect a stable jvmName, SO DO NOT USE IT! - */ - @Suppress("FunctionName") - @InternalAPI - @Throws - suspend fun ___DO_NOT_CALL_FROM_A_PLUGIN_loadAllLocalPlugins(context: Context, forceReload: Boolean) { - assertNonRecursiveCallstack() - + **/ + fun loadAllLocalPlugins(context: Context, forceReload: Boolean) { val dir = File(LOCAL_PLUGINS_PATH) if (!dir.exists()) { @@ -516,64 +448,24 @@ object PluginManager { val sortedPlugins = dir.listFiles() // Always sort plugins alphabetically for reproducible results - Log.d(TAG, "Files in '${LOCAL_PLUGINS_PATH}' folder: ${sortedPlugins?.size}") + Log.d(TAG, "Files in '${LOCAL_PLUGINS_PATH}' folder: $sortedPlugins") - // Use app-specific external files directory and copy the file there. - // We have to do this because on Android 14+, it otherwise gives SecurityException - // due to dex files and setReadOnly seems to have no effect unless it it here. - val pluginDirectory = File(context.getExternalFilesDir(null), "plugins") - if (!pluginDirectory.exists()) { - pluginDirectory.mkdirs() // Ensure the plugins directory exists - } - - // Make sure all local plugins are fully refreshed. - removeKey(PLUGINS_KEY_LOCAL) - - sortedPlugins?.sortedBy { it.name }?.amap { file -> - try { - val destinationFile = File(pluginDirectory, file.name) - - // Only copy the file if the destination file doesn't exist or if it - // has been modified (check file length and modification time). - if (!destinationFile.exists() || - destinationFile.length() != file.length() || - destinationFile.lastModified() != file.lastModified() - ) { - - // Copy the file to the app-specific plugin directory - file.copyTo(destinationFile, overwrite = true) - - // After copying, set the destination file's modification time - // to match the source file. We do this for performance so that we - // can check the modification time and not make redundant writes. - destinationFile.setLastModified(file.lastModified()) - } - - // Load the plugin after it has been copied - maybeLoadPlugin(context, destinationFile) - } catch (t: Throwable) { - Log.e(TAG, "Failed to copy the file") - logError(t) - } + sortedPlugins?.sortedBy { it.name }?.apmap { file -> + maybeLoadPlugin(context, file) } loadedLocalPlugins = true 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 **/ fun checkSafeModeFile(): Boolean { - return safe { + return normalSafeApiCall { val folder = File(CLOUD_STREAM_FOLDER) - if (!folder.exists()) return@safe false + if (!folder.exists()) return@normalSafeApiCall false val files = folder.listFiles { _, name -> name.equals("safe", ignoreCase = true) } @@ -591,26 +483,26 @@ object PluginManager { Log.i(TAG, "Loading plugin: $data") return try { - // In case of Android 14+ then + // in case of android 14 then try { - // Set the file as read-only and log if it fails - if (!file.setReadOnly()) { - Log.e(TAG, "Failed to set read-only on plugin file: ${file.name}") - } + File(filePath).setReadOnly() } catch (t: Throwable) { - Log.e(TAG, "Failed to set dex as read-only") + Log.e(TAG, "Failed to set dex as readonly") logError(t) } val loader = PathClassLoader(filePath, context.classLoader) - var manifest: BasePlugin.Manifest + var manifest: Plugin.Manifest loader.getResourceAsStream("manifest.json").use { stream -> if (stream == null) { Log.e(TAG, "Failed to load plugin $fileName: No manifest found") return false } InputStreamReader(stream).use { reader -> - manifest = parseJson(reader.readText()) + manifest = gson.fromJson( + reader, + Plugin.Manifest::class.java + ) } } @@ -623,9 +515,9 @@ object PluginManager { @Suppress("UNCHECKED_CAST") val pluginClass: Class<*> = - loader.loadClass(manifest.pluginClassName) as Class - val pluginInstance: BasePlugin = - pluginClass.getDeclaredConstructor().newInstance() as BasePlugin + loader.loadClass(manifest.pluginClassName) as Class + val pluginInstance: Plugin = + pluginClass.getDeclaredConstructor().newInstance() as Plugin // Sets with the proper version setPluginData(data.copy(version = version)) @@ -645,33 +537,23 @@ object PluginManager { addAssetPath.invoke(assets, file.absolutePath) @Suppress("DEPRECATION") - (pluginInstance as? Plugin)?.resources = Resources( + pluginInstance.resources = Resources( assets, context.resources.displayMetrics, context.resources.configuration ) } - synchronized(plugins) { - plugins[filePath] = pluginInstance - } - synchronized(classLoaders) { - classLoaders[loader] = pluginInstance - } - synchronized(urlPlugins) { - urlPlugins[data.url ?: filePath] = pluginInstance - } - if (pluginInstance is Plugin) { - pluginInstance.load(context) - } else { - pluginInstance.load() - } + plugins[filePath] = pluginInstance + classLoaders[loader] = pluginInstance + urlPlugins[data.url ?: filePath] = pluginInstance + pluginInstance.load(context) Log.i(TAG, "Loaded plugin ${data.internalName} successfully") currentlyLoading = null true } catch (e: Throwable) { Log.e(TAG, "Failed to load $file: ${Log.getStackTraceString(e)}") showToast( - // context.getActivity(), // we are not always on the main thread + context.getActivity(), context.getString(R.string.plugin_load_fail).format(fileName), Toast.LENGTH_LONG ) @@ -695,33 +577,25 @@ object PluginManager { } // remove all registered apis - APIHolder.apis.filter { api -> api.sourcePlugin == plugin.filename }.forEach { - removePluginMapping(it) + synchronized(APIHolder.apis) { + APIHolder.apis.filter { api -> api.sourcePlugin == plugin.filename }.forEach { + removePluginMapping(it) + } + } + synchronized(APIHolder.allProviders) { + APIHolder.allProviders.removeIf { provider: MainAPI -> provider.sourcePlugin == plugin.filename } } - APIHolder.allProviders.withLock { - APIHolder.allProviders.removeAll { provider -> provider.sourcePlugin == plugin.filename } + extractorApis.removeIf { provider: ExtractorApi -> provider.sourcePlugin == plugin.filename } + + synchronized(VideoClickActionHolder.allVideoClickActions) { + VideoClickActionHolder.allVideoClickActions.removeIf { action: VideoClickAction -> action.sourcePlugin == plugin.filename } } - extractorApis.withLock { - extractorApis.removeAll { provider -> provider.sourcePlugin == plugin.filename } - } + classLoaders.values.removeIf { v -> v == plugin } - VideoClickActionHolder.allVideoClickActions.withLock { - VideoClickActionHolder.allVideoClickActions.removeAll { action -> action.sourcePlugin == plugin.filename } - } - - synchronized(classLoaders) { - classLoaders.values.removeIf { v -> v == plugin } - } - - synchronized(plugins) { - plugins.remove(absolutePath) - } - - synchronized(urlPlugins) { - urlPlugins.values.removeIf { v -> v == plugin } - } + plugins.remove(absolutePath) + urlPlugins.values.removeIf { v -> v == plugin } } /** @@ -751,27 +625,25 @@ object PluginManager { suspend fun downloadPlugin( activity: Activity, pluginUrl: String, - pluginHash: String?, internalName: String, repositoryUrl: String, loadPlugin: Boolean ): Boolean { val file = getPluginPath(activity, internalName, repositoryUrl) - return downloadPlugin(activity, pluginUrl, pluginHash, internalName, file, loadPlugin) + return downloadPlugin(activity, pluginUrl, internalName, file, loadPlugin) } suspend fun downloadPlugin( activity: Activity, pluginUrl: String, - pluginHash: String?, internalName: String, file: File, - loadPlugin: Boolean, + loadPlugin: Boolean ): Boolean { try { Log.d(TAG, "Downloading plugin: $pluginUrl to ${file.absolutePath}") // The plugin file needs to be salted with the repository url hash as to allow multiple repositories with the same internal plugin names - val newFile = downloadPluginToFile(activity, pluginUrl, file, pluginHash) ?: return false + val newFile = downloadPluginToFile(pluginUrl, file) ?: return false val data = PluginData( internalName, @@ -814,84 +686,6 @@ object PluginManager { } } - /** - * DO NOT USE THIS IN A PLUGIN! It may case an infinite recursive loop lagging or crashing everyone's devices. - * If you use it from a plugin, do not expect a stable jvmName, SO DO NOT USE IT! - */ - @Suppress("FunctionName") - @InternalAPI - @Throws - suspend fun ___DO_NOT_CALL_FROM_A_PLUGIN_manuallyReloadAndUpdatePlugins(activity: Activity) { - assertNonRecursiveCallstack() - - showToast(activity.getString(R.string.starting_plugin_update_manually), Toast.LENGTH_LONG) - - ___DO_NOT_CALL_FROM_A_PLUGIN_loadAllOnlinePlugins(activity) - afterPluginsLoadedEvent.invoke(false) - - val urls = (getKey>(REPOSITORIES_KEY) - ?: emptyArray()) + PREBUILT_REPOSITORIES - val onlinePlugins = urls.toList().amap { - getRepoPlugins(it.url)?.toList() ?: emptyList() - }.flatten().distinctBy { it.second.url } - - val allPlugins = getPluginsOnline().flatMap { savedData -> - onlinePlugins - .filter { it.second.internalName == savedData.internalName } - .mapNotNull { onlineData -> - OnlinePluginData(savedData, onlineData).takeIf { it.validOnlineData(activity) } - } - }.distinctBy { it.onlineData.second.url } - - val updatedPlugins = mutableListOf() - - allPlugins.amap { pluginData -> - if (pluginData.isDisabled) { - Log.e( - "PluginManager", - "Unloading disabled plugin: ${pluginData.onlineData.second.name}" - ) - unloadPlugin(pluginData.savedData.filePath) - } else { - val existingFile = File(pluginData.savedData.filePath) - if (existingFile.exists()) existingFile.delete() - - if (downloadPlugin( - activity, - pluginData.onlineData.second.url, - pluginData.onlineData.second.fileHash, - pluginData.savedData.internalName, - existingFile, - true - ) - ) { - updatedPlugins.add(pluginData.onlineData.second.name) - } - } - }.also { - main { - val message = if (updatedPlugins.isNotEmpty()) { - activity.getString(R.string.plugins_updated_manually, updatedPlugins.size) - } else { - activity.getString(R.string.no_plugins_updated_manually) - } - showToast(message, Toast.LENGTH_LONG) - - val notificationText = UiText.StringResource( - R.string.plugins_updated_manually, - listOf(updatedPlugins.size) - ) - createNotification(activity, notificationText, updatedPlugins) - - } - } - - loadedOnlinePlugins = true - afterPluginsLoadedEvent.invoke(false) - - Log.i("PluginManager", "Plugin update done!") - } - private fun Context.createNotificationChannel() { hasCreatedNotChanel = true // Create the NotificationChannel, but only on API 26+ because diff --git a/app/src/main/java/com/lagradost/cloudstream3/plugins/RepositoryManager.kt b/app/src/main/java/com/lagradost/cloudstream3/plugins/RepositoryManager.kt index 07d6aaa37..c6ec9df7f 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/plugins/RepositoryManager.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/plugins/RepositoryManager.kt @@ -1,17 +1,16 @@ package com.lagradost.cloudstream3.plugins import android.content.Context -import androidx.annotation.WorkerThread import com.fasterxml.jackson.annotation.JsonProperty -import com.lagradost.cloudstream3.CloudStreamApp.Companion.context -import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey -import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey +import com.lagradost.cloudstream3.AcraApplication.Companion.context +import com.lagradost.cloudstream3.AcraApplication.Companion.getKey +import com.lagradost.cloudstream3.AcraApplication.Companion.setKey import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.amap import com.lagradost.cloudstream3.app import com.lagradost.cloudstream3.mvvm.logError -import com.lagradost.cloudstream3.mvvm.safe -import com.lagradost.cloudstream3.mvvm.safeAsync +import com.lagradost.cloudstream3.mvvm.normalSafeApiCall +import com.lagradost.cloudstream3.mvvm.suspendSafeApiCall import com.lagradost.cloudstream3.plugins.PluginManager.getPluginSanitizedFileName import com.lagradost.cloudstream3.plugins.PluginManager.unloadPlugin import com.lagradost.cloudstream3.ui.settings.extensions.REPOSITORIES_KEY @@ -19,19 +18,16 @@ import com.lagradost.cloudstream3.ui.settings.extensions.RepositoryData import com.lagradost.cloudstream3.utils.AppUtils.tryParseJson import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock +import java.io.BufferedInputStream import java.io.File -import java.nio.file.AtomicMoveNotSupportedException -import java.nio.file.Files -import java.nio.file.StandardCopyOption -import java.security.MessageDigest -import java.util.concurrent.atomic.AtomicInteger +import java.io.InputStream +import java.io.OutputStream /** * Comes with the app, always available in the app, non removable. * */ data class Repository( - @JsonProperty("iconUrl") val iconUrl: String?, @JsonProperty("name") val name: String, @JsonProperty("description") val description: String?, @JsonProperty("manifestVersion") val manifestVersion: Int, @@ -65,12 +61,10 @@ data class SitePlugin( @JsonProperty("repositoryUrl") val repositoryUrl: String?, // These types are yet to be mapped and used, ignore for now @JsonProperty("tvTypes") val tvTypes: List?, - // Most often a language tag like "en" or "zh-TW" @JsonProperty("language") val language: String?, @JsonProperty("iconUrl") val iconUrl: String?, // Automatically generated by the gradle plugin @JsonProperty("fileSize") val fileSize: Long?, - @JsonProperty("fileHash") val fileHash: String?, ) @@ -79,26 +73,7 @@ object RepositoryManager { val PREBUILT_REPOSITORIES: Array by lazy { getKey("PREBUILT_REPOSITORIES") ?: emptyArray() } - private val GH_REGEX = - Regex("^https://raw.githubusercontent.com/([A-Za-z0-9-]+)/([A-Za-z0-9_.-]+)/(.*)$") - - - /** Returns a SHA-256 string of the file content. - * Example: "sha256-b70462c264cb7f90fc2860a8e58d7544ce747ff347d1d11fa093623901853573" **/ - @WorkerThread - fun sha256(file: File): String { - val digest = MessageDigest.getInstance("SHA-256") - - file.inputStream().use { fis -> - val buffer = ByteArray(8192) - var read = fis.read(buffer) - while (read != -1) { - digest.update(buffer, 0, read) - read = fis.read(buffer) - } - } - return "sha256-" + digest.digest().joinToString("") { "%02x".format(it) } - } + private val GH_REGEX = Regex("^https://raw.githubusercontent.com/([A-Za-z0-9-]+)/([A-Za-z0-9_.-]+)/(.*)$") /* Convert raw.githubusercontent.com urls to cdn.jsdelivr.net if enabled in settings */ fun convertRawGitUrl(url: String): String { @@ -119,12 +94,12 @@ object RepositoryManager { else fixedUrl } } else if (fixedUrl.matches("^[a-zA-Z0-9!_-]+$".toRegex())) { - safeAsync { + suspendSafeApiCall { app.get("https://cutt.ly/${fixedUrl}", allowRedirects = false).let { it2 -> it2.headers["Location"]?.let { url -> - if (url.startsWith("https://cutt.ly/404")) return@safeAsync null - if (url.removeSuffix("/") == "https://cutt.ly") return@safeAsync null - return@safeAsync url + if (url.startsWith("https://cutt.ly/404")) return@suspendSafeApiCall null + if (url.removeSuffix("/") == "https://cutt.ly") return@suspendSafeApiCall null + return@suspendSafeApiCall url } } } @@ -132,7 +107,7 @@ object RepositoryManager { } suspend fun parseRepository(url: String): Repository? { - return safeAsync { + return suspendSafeApiCall { // Take manifestVersion and such into account later app.get(convertRawGitUrl(url)).parsedSafe() } @@ -163,52 +138,21 @@ object RepositoryManager { }.flatten() } - suspend fun downloadPluginToFile( - context: Context, pluginUrl: String, - file: File, - expectedFileHash: String? + file: File ): File? { - return safeAsync { - val parentDir = file.parentFile ?: return@safeAsync null - parentDir.mkdirs() + return suspendSafeApiCall { + file.mkdirs() - // Prevent corrupting the plugin file if the operation fails - val tempFile = File.createTempFile(file.name, ".tmp", context.cacheDir) + // Overwrite if exists + if (file.exists()) { + file.delete() + } + file.createNewFile() val body = app.get(convertRawGitUrl(pluginUrl)).okhttpResponse.body - - body.byteStream().use { body -> - tempFile.outputStream().use { fileSteam -> - body.copyTo(fileSteam) - } - } - - if (expectedFileHash != null) { - val downloadHash = sha256(tempFile) - if (expectedFileHash != downloadHash) { - tempFile.delete() - throw IllegalStateException("Extension hash mismatch when validating '${file.name}'! Expected: '$expectedFileHash', got: '$downloadHash'.") - } - } - - // We prefer the operation to be atomic - try { - Files.move( - tempFile.toPath(), - file.toPath(), - StandardCopyOption.REPLACE_EXISTING, - StandardCopyOption.ATOMIC_MOVE - ) - } catch (_: AtomicMoveNotSupportedException) { - Files.move( - tempFile.toPath(), - file.toPath(), - StandardCopyOption.REPLACE_EXISTING - ) - } - + write(body.byteStream(), file.outputStream()) file } } @@ -247,7 +191,7 @@ object RepositoryManager { // Unload all plugins, not using deletePlugin since we // delete all data and files in deleteRepositoryData - safe { + normalSafeApiCall { file.listFiles { plugin: File -> unloadPlugin(plugin.absolutePath) false @@ -256,4 +200,13 @@ object RepositoryManager { PluginManager.deleteRepositoryData(file.absolutePath) } + + private fun write(stream: InputStream, output: OutputStream) { + val input = BufferedInputStream(stream) + val dataBuffer = ByteArray(512) + var readBytes: Int + while (input.read(dataBuffer).also { readBytes = it } != -1) { + output.write(dataBuffer, 0, readBytes) + } + } } diff --git a/app/src/main/java/com/lagradost/cloudstream3/plugins/VotingApi.kt b/app/src/main/java/com/lagradost/cloudstream3/plugins/VotingApi.kt index 85a806f0b..d1b702f4c 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/plugins/VotingApi.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/plugins/VotingApi.kt @@ -2,9 +2,9 @@ package com.lagradost.cloudstream3.plugins import android.util.Log import android.widget.Toast -import com.lagradost.cloudstream3.CloudStreamApp.Companion.context -import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey -import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey +import com.lagradost.cloudstream3.AcraApplication.Companion.context +import com.lagradost.cloudstream3.AcraApplication.Companion.getKey +import com.lagradost.cloudstream3.AcraApplication.Companion.setKey import com.lagradost.cloudstream3.R import java.security.MessageDigest import com.lagradost.cloudstream3.app @@ -12,76 +12,87 @@ import com.lagradost.cloudstream3.utils.Coroutines.main import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock -object VotingApi { - +object VotingApi { // please do not cheat the votes lol private const val LOGKEY = "VotingApi" - private const val API_DOMAIN = "https://api.countify.xyz" - private fun transformUrl(url: String): String = + private const val API_DOMAIN = "https://counterapi.com/api" + + private fun transformUrl(url: String): String = // dont touch or all votes get reset MessageDigest .getInstance("SHA-256") .digest("${url}#funny-salt".toByteArray()) .fold("") { str, it -> str + "%02x".format(it) } - suspend fun SitePlugin.getVotes(): Int = getVotes(url) - fun SitePlugin.hasVoted(): Boolean = hasVoted(url) - suspend fun SitePlugin.vote(): Int = vote(url) - fun SitePlugin.canVote(): Boolean = canVote(this.url) + suspend fun SitePlugin.getVotes(): Int { + return getVotes(url) + } + fun SitePlugin.hasVoted(): Boolean { + return hasVoted(url) + } + + suspend fun SitePlugin.vote(): Int { + return vote(url) + } + + fun SitePlugin.canVote(): Boolean { + return canVote(this.url) + } + + // Plugin url to Int private val votesCache = mutableMapOf() + private fun getRepository(pluginUrl: String) = pluginUrl + .split("/") + .drop(2) + .take(3) + .joinToString("-") + private suspend fun readVote(pluginUrl: String): Int { - val id = transformUrl(pluginUrl) - val url = "$API_DOMAIN/get-total/$id" - Log.d(LOGKEY, "Requesting GET: $url") - return app.get(url).parsedSafe()?.count ?: 0 + val url = "${API_DOMAIN}/cs-${getRepository(pluginUrl)}/vote/${transformUrl(pluginUrl)}?readOnly=true" + Log.d(LOGKEY, "Requesting: $url") + return app.get(url).parsedSafe()?.value ?: 0 } private suspend fun writeVote(pluginUrl: String): Boolean { - val id = transformUrl(pluginUrl) - val url = "$API_DOMAIN/increment/$id" - Log.d(LOGKEY, "Requesting POST: $url") - return app.post(url, emptyMap()) - .parsedSafe()?.count != null + val url = "${API_DOMAIN}/cs-${getRepository(pluginUrl)}/vote/${transformUrl(pluginUrl)}" + Log.d(LOGKEY, "Requesting: $url") + return app.get(url).parsedSafe()?.value != null } suspend fun getVotes(pluginUrl: String): Int = - votesCache[pluginUrl] ?: readVote(pluginUrl).also { - votesCache[pluginUrl] = it - } + votesCache[pluginUrl] ?: readVote(pluginUrl).also { + votesCache[pluginUrl] = it + } fun hasVoted(pluginUrl: String) = getKey("cs3-votes/${transformUrl(pluginUrl)}") ?: false - fun canVote(pluginUrl: String): Boolean = - PluginManager.urlPlugins.contains(pluginUrl) + fun canVote(pluginUrl: String): Boolean { + return PluginManager.urlPlugins.contains(pluginUrl) + } private val voteLock = Mutex() - suspend fun vote(pluginUrl: String): Int { + // Prevent multiple requests at the same time. voteLock.withLock { if (!canVote(pluginUrl)) { main { - Toast.makeText( - context, - R.string.extension_install_first, - Toast.LENGTH_SHORT - ).show() + Toast.makeText(context, R.string.extension_install_first, Toast.LENGTH_SHORT) + .show() } return getVotes(pluginUrl) } if (hasVoted(pluginUrl)) { main { - Toast.makeText( - context, - R.string.already_voted, - Toast.LENGTH_SHORT - ).show() + Toast.makeText(context, R.string.already_voted, Toast.LENGTH_SHORT) + .show() } return getVotes(pluginUrl) } + if (writeVote(pluginUrl)) { setKey("cs3-votes/${transformUrl(pluginUrl)}", true) votesCache[pluginUrl] = votesCache[pluginUrl]?.plus(1) ?: 1 @@ -91,8 +102,7 @@ object VotingApi { } } - private data class CountifyResult( - val id: String? = null, - val count: Int? = null + private data class Result( + val value: Int? ) -} +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/services/BackupWorkManager.kt b/app/src/main/java/com/lagradost/cloudstream3/services/BackupWorkManager.kt index f130831c6..4ef841f58 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/services/BackupWorkManager.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/services/BackupWorkManager.kt @@ -1,8 +1,6 @@ package com.lagradost.cloudstream3.services import android.content.Context -import android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC -import android.os.Build.VERSION.SDK_INT import androidx.core.app.NotificationCompat import androidx.work.Constraints import androidx.work.CoroutineWorker @@ -84,11 +82,12 @@ class BackupWorkManager(val context: Context, workerParams: WorkerParameters) : BACKUP_CHANNEL_DESCRIPTION ) - val foregroundInfo = if (SDK_INT >= 29) + setForeground( ForegroundInfo( - BACKUP_NOTIFICATION_ID, backupNotificationBuilder.build(), FOREGROUND_SERVICE_TYPE_DATA_SYNC - ) else ForegroundInfo(BACKUP_NOTIFICATION_ID, backupNotificationBuilder.build()) - setForeground(foregroundInfo) + BACKUP_NOTIFICATION_ID, + backupNotificationBuilder.build() + ) + ) BackupUtils.backup(context) diff --git a/app/src/main/java/com/lagradost/cloudstream3/services/DownloadQueueService.kt b/app/src/main/java/com/lagradost/cloudstream3/services/DownloadQueueService.kt deleted file mode 100644 index e07747a86..000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/services/DownloadQueueService.kt +++ /dev/null @@ -1,279 +0,0 @@ -package com.lagradost.cloudstream3.services - -import android.Manifest -import android.app.Service -import android.content.Context -import android.content.Intent -import android.content.pm.PackageManager -import android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC -import android.os.Build.VERSION.SDK_INT -import android.os.IBinder -import android.util.Log -import androidx.core.app.NotificationCompat -import androidx.core.app.NotificationManagerCompat -import androidx.core.app.PendingIntentCompat -import com.lagradost.cloudstream3.CloudStreamApp.Companion.removeKey -import com.lagradost.cloudstream3.MainActivity -import com.lagradost.cloudstream3.MainActivity.Companion.lastError -import com.lagradost.cloudstream3.MainActivity.Companion.setLastError -import com.lagradost.cloudstream3.R -import com.lagradost.cloudstream3.mvvm.debugAssert -import com.lagradost.cloudstream3.mvvm.debugWarning -import com.lagradost.cloudstream3.mvvm.logError -import com.lagradost.cloudstream3.mvvm.safe -import com.lagradost.cloudstream3.plugins.PluginManager -import com.lagradost.cloudstream3.utils.AppContextUtils.createNotificationChannel -import com.lagradost.cloudstream3.utils.Coroutines.ioSafe -import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute -import com.lagradost.cloudstream3.utils.downloader.DownloadQueueManager -import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager -import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager.KEY_RESUME_IN_QUEUE -import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager.KEY_RESUME_PACKAGES -import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager.downloadEvent -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.FlowPreview -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.debounce -import kotlinx.coroutines.flow.takeWhile -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.flow.updateAndGet -import kotlinx.coroutines.withTimeoutOrNull -import kotlin.system.measureTimeMillis -import kotlin.time.Duration.Companion.milliseconds -import kotlin.time.Duration.Companion.seconds - -class DownloadQueueService : Service() { - companion object { - const val TAG = "DownloadQueueService" - const val DOWNLOAD_QUEUE_CHANNEL_ID = "cloudstream3.download.queue" - const val DOWNLOAD_QUEUE_CHANNEL_NAME = "Download queue service" - const val DOWNLOAD_QUEUE_CHANNEL_DESCRIPTION = "App download queue notification." - const val DOWNLOAD_QUEUE_NOTIFICATION_ID = 917194232 // Random unique - @Volatile - var isRunning = false - - fun getIntent( - context: Context, - ): Intent { - return Intent(context, DownloadQueueService::class.java) - } - - private val _downloadInstances: MutableStateFlow> = - MutableStateFlow(emptyList()) - - /** Flow of all active downloads, not queued. May temporarily contain completed or failed EpisodeDownloadInstances. - * Completed or failed instances are automatically removed by the download queue service. - * - */ - val downloadInstances: StateFlow> = - _downloadInstances - - private val totalDownloadFlow = - downloadInstances.combine(DownloadQueueManager.queue) { instances, queue -> - instances to queue - } - .combine(VideoDownloadManager.currentDownloads) { (instances, queue), currentDownloads -> - Triple(instances, queue, currentDownloads) - } - } - - - private val baseNotification by lazy { - val intent = Intent(this, MainActivity::class.java) - val pendingIntent = - PendingIntentCompat.getActivity(this, 0, intent, 0, false) - - val activeDownloads = resources.getQuantityString(R.plurals.downloads_active, 0).format(0) - val activeQueue = resources.getQuantityString(R.plurals.downloads_queued, 0).format(0) - - NotificationCompat.Builder(this, DOWNLOAD_QUEUE_CHANNEL_ID) - .setOngoing(true) // Make it persistent - .setAutoCancel(false) - .setColorized(false) - .setOnlyAlertOnce(true) - .setSilent(true) - .setShowWhen(false) - // If low priority then the notification might not show :( - .setPriority(NotificationCompat.PRIORITY_DEFAULT) - .setColor(this.colorFromAttribute(R.attr.colorPrimary)) - .setContentText(activeDownloads) - .setSubText(activeQueue) - .setContentIntent(pendingIntent) - .setSmallIcon(R.drawable.download_icon_load) - } - - - private fun updateNotification(context: Context, downloads: Int, queued: Int) { - if (context.checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) - != PackageManager.PERMISSION_GRANTED - ) return - - val activeDownloads = - resources.getQuantityString(R.plurals.downloads_active, downloads).format(downloads) - val activeQueue = - resources.getQuantityString(R.plurals.downloads_queued, queued).format(queued) - - val newNotification = baseNotification - .setContentText(activeDownloads) - .setSubText(activeQueue) - .build() - - safe { - NotificationManagerCompat.from(context) - .notify(DOWNLOAD_QUEUE_NOTIFICATION_ID, newNotification) - } - } - - // We always need to listen to events, even before the download is launched. - // Stopping link loading is an event which can trigger before downloading. - val downloadEventListener = { event: Pair -> - when (event.second) { - VideoDownloadManager.DownloadActionType.Stop -> { - removeKey(KEY_RESUME_PACKAGES, event.first.toString()) - removeKey(KEY_RESUME_IN_QUEUE, event.first.toString()) - DownloadQueueManager.cancelDownload(event.first) - } - - else -> {} - } - } - - @OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class) - override fun onCreate() { - isRunning = true - val context: Context = this // To make code more readable - - Log.d(TAG, "Download queue service started.") - this.createNotificationChannel( - DOWNLOAD_QUEUE_CHANNEL_ID, - DOWNLOAD_QUEUE_CHANNEL_NAME, - DOWNLOAD_QUEUE_CHANNEL_DESCRIPTION - ) - if (SDK_INT >= 29) { - startForeground( - DOWNLOAD_QUEUE_NOTIFICATION_ID, - baseNotification.build(), - FOREGROUND_SERVICE_TYPE_DATA_SYNC - ) - } else { - startForeground(DOWNLOAD_QUEUE_NOTIFICATION_ID, baseNotification.build()) - } - - downloadEvent += downloadEventListener - - val queueJob = ioSafe { - // Ensure this is up to date to prevent race conditions with MainActivity launches - setLastError(context) - // Early return, to prevent waiting for plugins in safe mode - if (lastError != null) return@ioSafe - - // Try to ensure all plugins are loaded before starting the downloader. - // To prevent infinite stalls we use a timeout of 15 seconds, it is judged as long enough - val timeout = 15.seconds - val timeTaken = withTimeoutOrNull(timeout) { - measureTimeMillis { - while (!(PluginManager.loadedOnlinePlugins && PluginManager.loadedLocalPlugins)) { - delay(100.milliseconds) - } - } - } - - debugWarning({ timeTaken == null || timeTaken > 3_000 }, { - "Abnormally long downloader startup time of: ${timeTaken ?: timeout.inWholeMilliseconds}ms" - }) - debugAssert({ timeTaken == null }, { "Downloader startup should not time out" }) - - totalDownloadFlow - .debounce { (instances, queue) -> - // Filter away incorrect transient queue states. - // For example when we pop the queue and add a download instance there exists a transient state where - // there is no queue and no download instances (leading to an early exit) - if (instances.isEmpty() && queue.isEmpty()) { - 500.milliseconds - } else { - 0.milliseconds - } - } - .takeWhile { (instances, queue) -> - // Stop if destroyed - isRunning - // Run as long as there is a queue to process - && (instances.isNotEmpty() || queue.isNotEmpty()) - // Run as long as there are no app crashes - && lastError == null - } - .collect { (_, queue, currentDownloads) -> - // Remove completed or failed - val newInstances = _downloadInstances.updateAndGet { currentInstances -> - currentInstances.filterNot { it.isCompleted || it.isFailed || it.isCancelled } - } - - val maxDownloads = VideoDownloadManager.maxConcurrentDownloads(context) - val currentInstanceCount = newInstances.size - - val newDownloads = minOf( - // Cannot exceed the max downloads - maxOf(0, maxDownloads - currentInstanceCount), - // Cannot start more downloads than the queue size - queue.size - ) - - // Cant start multiple downloads at once. If this is rerun it may start too many downloads. - if (newDownloads > 0) { - _downloadInstances.update { instances -> - val downloadInstance = DownloadQueueManager.popQueue(context) - if (downloadInstance != null) { - downloadInstance.startDownload() - instances + downloadInstance - } else { - instances - } - } - } - - // The downloads actually displayed to the user with a notification - val currentVisualDownloads = - currentDownloads.size + newInstances.count { - currentDownloads.contains(it.downloadQueueWrapper.id) - .not() - } - // Just the queue - val currentVisualQueue = queue.size - - updateNotification(context, currentVisualDownloads, currentVisualQueue) - } - } - - // Stop self regardless of job outcome - queueJob.invokeOnCompletion { throwable -> - if (throwable != null) { - logError(throwable) - } - safe { - stopSelf() - } - } - } - - override fun onDestroy() { - Log.d(TAG, "Download queue service stopped.") - downloadEvent -= downloadEventListener - isRunning = false - super.onDestroy() - } - - override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { - return START_STICKY // We want the service restarted if its killed - } - - override fun onBind(intent: Intent?): IBinder? = null - - override fun onTimeout(reason: Int) { - stopSelf() - Log.e(TAG, "Service stopped due to timeout: $reason") - } - -} diff --git a/app/src/main/java/com/lagradost/cloudstream3/services/SubscriptionWorkManager.kt b/app/src/main/java/com/lagradost/cloudstream3/services/SubscriptionWorkManager.kt index 7134650ed..00c74dfff 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/services/SubscriptionWorkManager.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/services/SubscriptionWorkManager.kt @@ -1,12 +1,12 @@ package com.lagradost.cloudstream3.services +import android.annotation.SuppressLint import android.app.NotificationManager +import android.app.PendingIntent import android.content.Context import android.content.Intent -import android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC -import android.os.Build.VERSION.SDK_INT +import android.os.Build import androidx.core.app.NotificationCompat -import androidx.core.app.PendingIntentCompat import androidx.core.net.toUri import androidx.work.* import com.lagradost.cloudstream3.* @@ -14,7 +14,7 @@ import com.lagradost.cloudstream3.APIHolder.getApiFromNameNull import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.plugins.PluginManager -import com.lagradost.cloudstream3.utils.txt +import com.lagradost.cloudstream3.ui.result.txt import com.lagradost.cloudstream3.utils.AppContextUtils.createNotificationChannel import com.lagradost.cloudstream3.utils.AppContextUtils.getApiDubstatusSettings import com.lagradost.cloudstream3.utils.Coroutines.ioWork @@ -22,7 +22,7 @@ import com.lagradost.cloudstream3.utils.DataStoreHelper import com.lagradost.cloudstream3.utils.DataStoreHelper.getAllSubscriptions import com.lagradost.cloudstream3.utils.DataStoreHelper.getDub import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute -import com.lagradost.cloudstream3.utils.downloader.DownloadUtils.getImageBitmapFromUrl +import com.lagradost.cloudstream3.utils.VideoDownloadManager.getImageBitmapFromUrl import kotlinx.coroutines.withTimeoutOrNull import java.util.concurrent.TimeUnit @@ -75,7 +75,7 @@ class SubscriptionWorkManager(val context: Context, workerParams: WorkerParamete .setPriority(NotificationCompat.PRIORITY_DEFAULT) .setColor(context.colorFromAttribute(R.attr.colorPrimary)) .setContentTitle(context.getString(R.string.subscription_in_progress_notification)) - .setSmallIcon(com.google.android.gms.cast.framework.R.drawable.quantum_ic_refresh_white_24) + .setSmallIcon(R.drawable.quantum_ic_refresh_white_24) .setProgress(0, 0, true) private val updateNotificationBuilder = @@ -98,6 +98,7 @@ class SubscriptionWorkManager(val context: Context, workerParams: WorkerParamete ) } + @SuppressLint("UnspecifiedImmutableFlag") override suspend fun doWork(): Result { try { // println("Update subscriptions!") @@ -107,13 +108,12 @@ class SubscriptionWorkManager(val context: Context, workerParams: WorkerParamete SUBSCRIPTION_CHANNEL_DESCRIPTION ) - val foregroundInfo = if (SDK_INT >= 29) + setForeground( ForegroundInfo( SUBSCRIPTION_NOTIFICATION_ID, - progressNotificationBuilder.build(), - FOREGROUND_SERVICE_TYPE_DATA_SYNC - ) else ForegroundInfo(SUBSCRIPTION_NOTIFICATION_ID, progressNotificationBuilder.build(),) - setForeground(foregroundInfo) + progressNotificationBuilder.build() + ) + ) val subscriptions = getAllSubscriptions() @@ -128,18 +128,18 @@ class SubscriptionWorkManager(val context: Context, workerParams: WorkerParamete updateProgress(max, progress, true) // We need all plugins loaded. - PluginManager.___DO_NOT_CALL_FROM_A_PLUGIN_loadAllOnlinePlugins(context) - PluginManager.___DO_NOT_CALL_FROM_A_PLUGIN_loadAllLocalPlugins(context, false) + PluginManager.loadAllOnlinePlugins(context) + PluginManager.loadAllLocalPlugins(context, false) - subscriptions.amap { savedData -> + subscriptions.apmap { savedData -> try { - val id = savedData.id ?: return@amap null - val api = getApiFromNameNull(savedData.apiName) ?: return@amap null + val id = savedData.id ?: return@apmap null + val api = getApiFromNameNull(savedData.apiName) ?: return@apmap null // Reasonable timeout to prevent having this worker run forever. val response = withTimeoutOrNull(60_000) { api.load(savedData.url) as? EpisodeResponse - } ?: return@amap null + } ?: return@apmap null val dubPreference = getDub(id) ?: if ( @@ -183,10 +183,19 @@ class SubscriptionWorkManager(val context: Context, workerParams: WorkerParamete val intent = Intent(context, MainActivity::class.java).apply { data = savedData.url.toUri() flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK - }.putExtra(MainActivity.API_NAME_EXTRA_KEY, api.name) + } val pendingIntent = - PendingIntentCompat.getActivity(context, 0, intent, 0, false) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + PendingIntent.getActivity( + context, + 0, + intent, + PendingIntent.FLAG_IMMUTABLE + ) + } else { + PendingIntent.getActivity(context, 0, intent, 0) + } val poster = ioWork { savedData.posterUrl?.let { url -> diff --git a/app/src/main/java/com/lagradost/cloudstream3/services/VideoDownloadService.kt b/app/src/main/java/com/lagradost/cloudstream3/services/VideoDownloadService.kt index d63b18cdc..6151a0edd 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/services/VideoDownloadService.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/services/VideoDownloadService.kt @@ -2,13 +2,12 @@ package com.lagradost.cloudstream3.services import android.app.Service import android.content.Intent import android.os.IBinder -import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager +import com.lagradost.cloudstream3.utils.VideoDownloadManager import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.cancel import kotlinx.coroutines.launch -/** Handle notification actions such as pause/resume downloads */ class VideoDownloadService : Service() { private val downloadScope = CoroutineScope(Dispatchers.Default) @@ -43,3 +42,19 @@ class VideoDownloadService : Service() { super.onDestroy() } } +// override fun onHandleIntent(intent: Intent?) { +// if (intent != null) { +// val id = intent.getIntExtra("id", -1) +// val type = intent.getStringExtra("type") +// if (id != -1 && type != null) { +// val state = when (type) { +// "resume" -> VideoDownloadManager.DownloadActionType.Resume +// "pause" -> VideoDownloadManager.DownloadActionType.Pause +// "stop" -> VideoDownloadManager.DownloadActionType.Stop +// else -> return +// } +// VideoDownloadManager.downloadEvent.invoke(Pair(id, state)) +// } +// } +// } +//} diff --git a/app/src/main/java/com/lagradost/cloudstream3/subtitles/AbstractSubProvider.kt b/app/src/main/java/com/lagradost/cloudstream3/subtitles/AbstractSubProvider.kt index 9e6f241fb..df64caabc 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/subtitles/AbstractSubProvider.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/subtitles/AbstractSubProvider.kt @@ -1,8 +1,12 @@ package com.lagradost.cloudstream3.subtitles +import androidx.annotation.WorkerThread import androidx.core.net.toUri import com.lagradost.cloudstream3.MainActivity.Companion.deleteFileOnExit import com.lagradost.cloudstream3.app +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 okio.BufferedSource import okio.buffer @@ -11,6 +15,32 @@ import okio.source import java.io.File import java.util.zip.ZipInputStream +interface AbstractSubProvider { + val idPrefix: String + + @WorkerThread + suspend fun search(query: SubtitleSearch): List? { + throw NotImplementedError() + } + + @WorkerThread + suspend fun load(data: SubtitleEntity): String? { + throw NotImplementedError() + } + + @WorkerThread + suspend fun SubtitleResource.getResources(data: SubtitleEntity) { + this.addUrl(load(data)) + } + + @WorkerThread + suspend fun getResource(data: SubtitleEntity): SubtitleResource { + return SubtitleResource().apply { + this.getResources(data) + } + } +} + /** * A builder for subtitle files. * @see addUrl @@ -91,3 +121,4 @@ 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 3bc5f2733..2e14c3c46 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/AccountManager.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/AccountManager.kt @@ -1,165 +1,149 @@ -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 +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) + } +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/AuthAPI.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/AuthAPI.kt index 184a9fbcc..8b085bc0b 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/AuthAPI.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/AuthAPI.kt @@ -1,280 +1,23 @@ package com.lagradost.cloudstream3.syncproviders -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.DataStoreHelper -import com.lagradost.cloudstream3.utils.UiText -import com.lagradost.cloudstream3.utils.txt -import java.net.URL -import java.security.SecureRandom -import java.util.Date -import java.util.concurrent.TimeUnit +interface AuthAPI { + val name: String + val icon: Int? -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 requiresLogin: Boolean -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 + val createAccountUrl : String? - fun isRefreshTokenExpired(marginSec: Long = 10L) = - refreshTokenLifetime != null && (System.currentTimeMillis() / 1000) + marginSec >= refreshTokenLifetime -} + // don't change this as all keys depend on it + val idPrefix: String -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 -) + // if this returns null then you are not logged in + fun loginInfo(): LoginInfo? + fun logOut() -/** - * 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 deleted file mode 100644 index 645a19e3a..000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/AuthRepo.kt +++ /dev/null @@ -1,168 +0,0 @@ -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 deleted file mode 100644 index 5efb88e5b..000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/BackupAPI.kt +++ /dev/null @@ -1,14 +0,0 @@ -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 new file mode 100644 index 000000000..8b6fdf463 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/InAppAuthAPI.kt @@ -0,0 +1,66 @@ +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 new file mode 100644 index 000000000..3d0bb9402 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/OAuth2API.kt @@ -0,0 +1,27 @@ +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 deleted file mode 100644 index a1149b5f8..000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/SubtitleAPI.kt +++ /dev/null @@ -1,37 +0,0 @@ -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 deleted file mode 100644 index 0b8c3e5ae..000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/SubtitleRepo.kt +++ /dev/null @@ -1,95 +0,0 @@ -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.atomicListOf - -/** 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 = atomicListOf() - private var searchCacheIndex: Int = 0 - private val resourceCache = atomicListOf() - private var resourceCacheIndex: Int = 0 - const val CACHE_SIZE = 20 - } - - @WorkerThread - suspend fun resource(data: SubtitleEntity): Result = runCatching { - val cached = resourceCache.withLock { - var found: SubtitleResource? = null - for (item in resourceCache) { - // 20 min save - if (item.query == data && (unixTime - item.unixTime) < 60 * 20) { - found = item.response - break - } - } - found - } - if (cached != null) return@runCatching cached - - val returnValue = api.resource(freshAuth(), data) - resourceCache.withLock { - 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 { - val cached = searchCache.withLock { - var found: List? = null - for (item in searchCache) { - // 120 min save - if (item.query == query && (unixTime - item.unixTime) < 60 * 120) { - found = item.response - break - } - } - found - } - - if (cached != null) return@runCatching cached - val returnValue = api.search(freshAuth(), query) ?: emptyList() - - // only cache valid return values - if (returnValue.isNotEmpty()) { - val add = SavedSearchResponse(unixTime, returnValue, query) - searchCache.withLock { - 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 61% 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 f30a64748..dcb8bbead 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/SyncAPI.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/SyncApi.kt @@ -1,194 +1,170 @@ -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.Levenshtein -import com.lagradost.cloudstream3.utils.UiText -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 { - -Levenshtein.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 -} +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.ui.result.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 +} \ 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 de82624fc..9363cb6fb 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/SyncRepo.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/SyncRepo.kt @@ -1,30 +1,48 @@ -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()) - } -} +package com.lagradost.cloudstream3.syncproviders + +import com.lagradost.cloudstream3.ErrorLoadingException +import com.lagradost.cloudstream3.mvvm.Resource +import com.lagradost.cloudstream3.mvvm.normalSafeApiCall +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 normalSafeApiCall { repo.loginInfo() != null } ?: false + } + + fun getIdFromUrl(url: String): String? = normalSafeApiCall { + repo.getIdFromUrl(url) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/Addic7ed.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/Addic7ed.kt index 144efff99..db4676393 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,205 +1,108 @@ package com.lagradost.cloudstream3.syncproviders.providers -import com.lagradost.cloudstream3.AllLanguagesName -import com.lagradost.cloudstream3.app -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 +import com.lagradost.cloudstream3.app +import com.lagradost.cloudstream3.subtitles.AbstractSubApi +import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities +import com.lagradost.cloudstream3.utils.SubtitleHelper -class Addic7ed : SubtitleAPI() { +class Addic7ed : AbstractSubApi { 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 String.fixUrl(): String { - val url = this + private fun fixUrl(url: String): String { return if (url.startsWith("/")) HOST + url else if (!url.startsWith("http")) "$HOST/$url" else url + } - 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() + override suspend fun search(query: AbstractSubtitleEntities.SubtitleSearch): List { + val lang = query.lang + val queryLang = SubtitleHelper.fromTwoLettersToLanguage(lang.toString()) + val queryText = query.query.trim() val epNum = query.epNumber ?: 0 val seasonNum = query.seasonNumber ?: 0 val yearNum = query.year ?: 0 - val searchQuery = if (seasonNum > 0) "$title $seasonNum $epNum" else title - var downloadPage = "" - fun newSubtitleEntity ( - displayName: String?, - link: String?, + fun cleanResources( + results: MutableList, + name: String, + link: String, + headers: Map, isHearingImpaired: Boolean - ): SubtitleEntity? { - if (displayName.isNullOrBlank() || link.isNullOrBlank()) return null - return SubtitleEntity( - idPrefix = this.idPrefix, - name = displayName, - lang = langTagIETF, - data = link, - source = this.name, - type = if (seasonNum > 0) TvType.TvSeries else TvType.Movie, - epNumber = epNum, - seasonNumber = seasonNum, - year = yearNum, - headers = mapOf("referer" to "$HOST/"), - isHearingImpaired = isHearingImpaired + ) { + results.add( + AbstractSubtitleEntities.SubtitleEntity( + idPrefix = idPrefix, + name = name, + lang = queryLang.toString(), + data = link, + source = this.name, + type = if (seasonNum > 0) TvType.TvSeries else TvType.Movie, + epNumber = epNum, + seasonNumber = seasonNum, + year = yearNum, + headers = headers, + isHearingImpaired = isHearingImpaired + ) ) } - val response = app.get(url = "$HOST/search.php?search=$searchQuery&Submit=Search") - val hostDocument = response.document - - // 1st case: found one movie or episode. Redirected to $HOST/movie/1234 or $HOST/serie/show-name/$seasonNum/$epNum/ep-name - if (response.url.contains("/movie/") || response.url.contains("/serie/")) - downloadPage = response.url - - // 2nd case: found tv series ep list. Redirected to $HOST/show/1234 - else if (response.url.contains("/show/")) { - val showId = response.url.substringAfterLast("/") + val title = queryText.substringBefore("(").trim() + val url = "$HOST/search.php?search=${title}&Submit=Search" + val hostDocument = app.get(url).document + var searchResult = "" + if (!hostDocument.select("span:contains($title)").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 doc = app.get( - "$HOST/ajax_loadShow.php?show=$showId&season=$seasonNum&langs=|$langNumAddic7ed|&hd=0&hi=0", + "$HOST/ajax_loadShow.php?show=$show&season=$seasonNum&langs=&hd=undefined&hi=undefined", referer = "$HOST/" ).document - - // get direct subtitles links from list - return doc.select("#season tbody tr").mapNotNull { node -> - if (node.select("td:eq(1)").text().toIntOrNull() == epNum) - newSubtitleEntity( - displayName = node.select("td:eq(2)").text() + "\n" + node.select("td:eq(4)").text(), - link = node.selectFirst("a[href~=updated\\/|original\\/]")?.attr("href")?.fixUrl(), - isHearingImpaired = node.select("td:eq(6)").text().isNotEmpty() - ) - else null + doc.select("#season tr:contains($queryLang)").mapNotNull { node -> + if (node.selectFirst("td")?.text() + ?.toIntOrNull() == seasonNum && node.select("td:eq(1)") + .text() + .toIntOrNull() == epNum + ) searchResult = fixUrl(node.select("a").attr("href")) } - // 3rd case: found several or no results. Still in $HOST/search.php?search=title - } else {// (response.url.contains("/search.php")) - downloadPage = hostDocument.select("table.tabel a").selectFirst({ - // tv series - if (seasonNum > 0) "a[href~=serie\\/.+\\/$seasonNum\\/$epNum\\/\\w]" - // movie + year - else if( yearNum > 0) "a[href~=movie\\/]:contains($yearNum)" - // movie - else "a[href~=movie\\/]" - }())?.attr("href")?.fixUrl() ?: return null } + val results = mutableListOf() + val document = app.get( + url = fixUrl(searchResult), + ).document - // filter download page by language. Do not work for movies :/ - if (downloadPage.contains("/serie/")) - downloadPage = downloadPage.substringBeforeLast("/") + "/$langNumAddic7ed" - val doc = app.get(url = downloadPage).document - - // get subtitles links from download page - return doc.select(".tabel95 .tabel95 tr:has(.language):contains($langName)").mapNotNull { node -> - val displayName = - doc.selectFirst("span.titulo")?.text()?.substringBefore(" Subtitle") + "\n" + - node.parent()!!.select(".NewsTitle").text().substringAfter("Version ").substringBefore(", Duration") - val link = - node.selectFirst("a[href~=updated\\/|original\\/]")?.attr("href")?.fixUrl() + document.select(".tabel95 .tabel95 tr:contains($queryLang)").mapNotNull { node -> + val name = if (seasonNum > 0) "${document.select(".titulo").text().replace("Subtitle","").trim()}${ + node.parent()!!.select(".NewsTitle").text().substringAfter("Version").substringBefore(", Duration") + }" else "${document.select(".titulo").text().replace("Subtitle","").trim()}${node.parent()!!.select(".NewsTitle").text().substringAfter("Version").substringBefore(", Duration")}" + val link = fixUrl(node.select("a.buttonDownload").attr("href")) val isHearingImpaired = - node.parent()!!.select("tr:last-child [title=\"Hearing Impaired\"]").isNotEmpty() - - newSubtitleEntity(displayName, link, isHearingImpaired) + !node.parent()!!.select("tr:last-child [title=\"Hearing Impaired\"]").isNullOrEmpty() + cleanResources(results, name, link, mapOf("referer" to "$HOST/"), isHearingImpaired) } + return results } - override suspend fun load( - auth: AuthData?, - subtitle: SubtitleEntity - ): String? { - return subtitle.data + override suspend fun load(data: AbstractSubtitleEntities.SubtitleEntity): String { + return data.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 177018e19..6112c7dbe 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,91 +1,93 @@ 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.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.* +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.mvvm.logError -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.mvvm.suspendSafeApiCall +import com.lagradost.cloudstream3.syncproviders.AccountManager +import com.lagradost.cloudstream3.syncproviders.AuthAPI 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.ui.result.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 com.lagradost.cloudstream3.utils.txt +import java.net.URL import java.net.URLEncoder import java.util.Locale -class AniListApi : SyncAPI() { +class AniListApi(index: Int) : AccountManager(index), 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 hasOAuth2 = true + override val supportDeviceAuth = false 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 loginRequest(): AuthLoginPage? = - AuthLoginPage("https://anilist.co/api/v2/oauth/authorize?client_id=$key&response_type=token") - - 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 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 } - // 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 fun logOut() { + requireLibraryRefresh = true + removeAccountKeys() } - override suspend fun user(token: AuthToken?): AuthUser? { - val user = getUser(token ?: return null) - ?: throw ErrorLoadingException("Unable to fetch user data") - - return AuthUser( - id = user.id, - name = user.name, - profilePicture = user.picture, - ) + override fun authenticate(activity: FragmentActivity?) { + val request = "https://anilist.co/api/v2/oauth/authorize?client_id=$key&response_type=token" + openBrowser(request, activity) } - override fun urlToId(url: String): String? = - url.removePrefix("$mainUrl/anime/").removeSuffix("/") + 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() + + switchToNewAccount() + setKey(accountId, ANILIST_UNIXTIME_KEY, endTime) + setKey(accountId, ANILIST_TOKEN_KEY, token) + val user = getUser() + requireLibraryRefresh = true + return user != null + } + + override fun getIdFromUrl(url: String): String { + return url.removePrefix("$mainUrl/anime/").removeSuffix("/") + } private fun getUrlFromId(id: Int): String { return "$mainUrl/anime/$id" } - override suspend fun search(auth: AuthData?, query: String): List? { - val data = searchShows(query) ?: return null + override suspend fun search(name: String): List? { + val data = searchShows(name) ?: return null return data.data?.page?.media?.map { SyncAPI.SyncSearchResult( it.title.romaji ?: return null, @@ -97,7 +99,7 @@ class AniListApi : SyncAPI() { } } - override suspend fun load(auth: AuthData?, id: String): SyncAPI.SyncResult? { + override suspend fun getResult(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 @@ -139,11 +141,11 @@ class AniListApi : SyncAPI() { } ) }, - publicScore = Score.from100(season.averageScore), + publicScore = season.averageScore?.times(100), recommendations = season.recommendations?.edges?.mapNotNull { rec -> val recMedia = rec.node.mediaRecommendation SyncAPI.SyncSearchResult( - name = recMedia?.title?.userPreferred ?: return@mapNotNull null, + name = recMedia.title?.userPreferred ?: return@mapNotNull null, this.name, recMedia.id?.toString() ?: return@mapNotNull null, getUrlFromId(recMedia.id), @@ -159,12 +161,12 @@ class AniListApi : SyncAPI() { ) } - override suspend fun status(auth: AuthData?, id: String): SyncAPI.AbstractSyncStatus? { + override suspend fun getStatus(id: String): SyncAPI.AbstractSyncStatus? { val internalId = id.toIntOrNull() ?: return null - val data = getDataAboutId(auth ?: return null, internalId) ?: return null + val data = getDataAboutId(internalId) ?: return null return SyncAPI.SyncStatus( - score = Score.from100(data.score), + score = data.score, watchedEpisodes = data.progress, status = SyncWatchType.fromInternalId(data.type?.value ?: return null), isFavorite = data.isFavourite, @@ -172,25 +174,24 @@ class AniListApi : SyncAPI() { ) } - override suspend fun updateStatus( - auth: AuthData?, - id: String, - newStatus: AbstractSyncStatus - ): Boolean { + override suspend fun score(id: String, status: SyncAPI.AbstractSyncStatus): Boolean { return postDataAboutId( - auth ?: return false, id.toIntOrNull() ?: return false, - fromIntToAnimeStatus(newStatus.status.internalId), - newStatus.score, - newStatus.watchedEpisodes - ) + fromIntToAnimeStatus(status.status.internalId), + status.score, + status.watchedEpisodes + ).also { + requireLibraryRefresh = requireLibraryRefresh || it + } } 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 { @@ -460,7 +461,21 @@ class AniListApi : SyncAPI() { } } - private suspend fun getDataAboutId(auth: AuthData, id: Int): AniListTitleHolder? { + 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? { 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) @@ -470,7 +485,7 @@ class AniListApi : SyncAPI() { mediaListEntry { progress status - score (format: POINT_100) + score (format: POINT_10) } title { english @@ -479,7 +494,7 @@ class AniListApi : SyncAPI() { } }""" - val data = postApi(auth.token, q, true) + val data = postApi(q, true) val d = parseJson(data ?: return null) val main = d.data?.media @@ -507,24 +522,37 @@ class AniListApi : SyncAPI() { } - 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 fun getAuth(): String? { + return getKey( + accountId, + ANILIST_TOKEN_KEY + ) } + private suspend fun postApi(q: String, cache: Boolean = false): String? { + return suspendSafeApiCall { + if (!checkToken()) { + app.post( + "https://graphql.anilist.co/", + headers = mapOf( + "Authorization" to "Bearer " + (getAuth() + ?: return@suspendSafeApiCall 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, @@ -596,7 +624,7 @@ class AniListApi : SyncAPI() { this.media.id.toString(), this.progress, this.media.episodes, - Score.from100(this.score), + this.score, this.updatedAt.toLong(), "AniList", TvType.Anime, @@ -624,23 +652,27 @@ class AniListApi : SyncAPI() { @JsonProperty("MediaListCollection") val mediaListCollection: MediaListCollection ) - private suspend fun getAniListAnimeListSmart(auth: AuthData): Array? { + 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 return if (requireLibraryRefresh) { - val list = getFullAniListList(auth)?.data?.mediaListCollection?.lists?.toTypedArray() + val list = getFullAniListList()?.data?.mediaListCollection?.lists?.toTypedArray() if (list != null) { - setKey(ANILIST_CACHED_LIST, auth.user.id.toString(), list) + setKey(ANILIST_CACHED_LIST, list) } list } else { - getKey>( - ANILIST_CACHED_LIST, - auth.user.id.toString() - ) as? Array + getAniListListCached() } } - override suspend fun library(auth: AuthData?): SyncAPI.LibraryMetadata? { - val list = getAniListAnimeListSmart(auth ?: return null)?.groupBy { + override suspend fun getPersonalLibrary(): SyncAPI.LibraryMetadata { + val list = getAniListAnimeListSmart()?.groupBy { convertAniListStringToStatus(it.status ?: "").stringRes }?.mapValues { group -> group.value.map { it.entries.map { entry -> entry.toLibraryItem() } }.flatten() @@ -667,8 +699,10 @@ class AniListApi : SyncAPI() { ) } - private suspend fun getFullAniListList(auth: AuthData): FullAnilistList? { - val userID = auth.user.id + private suspend fun getFullAniListList(): FullAnilistList? { + /** WARNING ASSUMES ONE USER! **/ + + val userID = getKey(accountId, ANILIST_USER_KEY)?.id ?: return null val mediaType = "ANIME" val query = """ @@ -711,11 +745,11 @@ class AniListApi : SyncAPI() { } } """ - val text = postApi(auth.token, query) + val text = postApi(query) return text?.toKotlinObject() } - suspend fun toggleLike(auth: AuthData, id: Int): Boolean { + suspend fun toggleLike(id: Int): Boolean { val q = """mutation (${'$'}animeId: Int = $id) { ToggleFavourite (animeId: ${'$'}animeId) { anime { @@ -728,7 +762,7 @@ class AniListApi : SyncAPI() { } } }""" - val data = postApi(auth.token, q) + val data = postApi(q) return data != "" } @@ -738,17 +772,15 @@ class AniListApi : SyncAPI() { data class MediaListId(@JsonProperty("id") val id: Long? = null) private suspend fun postDataAboutId( - auth: AuthData, id: Int, type: AniListStatusType, - score: Score?, + score: Int?, 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) { @@ -757,7 +789,7 @@ class AniListApi : SyncAPI() { } } """ - val response = postApi(auth.token, idQuery) + val response = postApi(idQuery) val listId = tryParseJson(response)?.data?.mediaList?.id ?: return false """ @@ -773,7 +805,7 @@ class AniListApi : SyncAPI() { 0, type.value )] - }, ${if (score != null) "${'$'}scoreRaw: Int = ${score.toInt(100)}" else ""} , ${if (progress != null) "${'$'}progress: Int = $progress" else ""}) { + }, ${if (score != null) "${'$'}scoreRaw: Int = ${score * 10}" else ""} , ${if (progress != null) "${'$'}progress: Int = $progress" else ""}) { SaveMediaListEntry (mediaId: ${'$'}id, status: ${'$'}status, scoreRaw: ${'$'}scoreRaw, progress: ${'$'}progress) { id status @@ -783,11 +815,11 @@ class AniListApi : SyncAPI() { }""" } - val data = postApi(auth.token, q) + val data = postApi(q) return data != "" } - private suspend fun getUser(token: AuthToken): AniListUser? { + private suspend fun getUser(setSettings: Boolean = true): AniListUser? { val q = """ { Viewer { @@ -805,15 +837,23 @@ class AniListApi : SyncAPI() { } } }""" - val data = postApi(token, q) + val data = postApi(q) if (data.isNullOrBlank()) return null val userData = parseJson(data) - val u = userData.data?.viewer ?: return null + val u = userData.data?.viewer 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 } @@ -877,8 +917,7 @@ class AniListApi : SyncAPI() { ) data class Recommendation( - val id: Long, - @JsonProperty("mediaRecommendation") val mediaRecommendation: SeasonMedia?, + @JsonProperty("mediaRecommendation") val mediaRecommendation: SeasonMedia, ) data class CharacterName( @@ -1008,8 +1047,8 @@ class AniListApi : 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?, ) @@ -1023,8 +1062,8 @@ class AniListApi : 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 new file mode 100644 index 000000000..94537ea33 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/DropboxApi.kt @@ -0,0 +1,35 @@ +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 e15a77c64..724d72163 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/KitsuApi.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/KitsuApi.kt @@ -1,676 +1,8 @@ package com.lagradost.cloudstream3.syncproviders.providers - -import androidx.annotation.StringRes import com.fasterxml.jackson.annotation.JsonProperty -import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey -import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey -import com.lagradost.cloudstream3.R -import com.lagradost.cloudstream3.Score -import com.lagradost.cloudstream3.ShowStatus -import com.lagradost.cloudstream3.TvType import com.lagradost.cloudstream3.app import com.lagradost.cloudstream3.mvvm.logError -import com.lagradost.cloudstream3.syncproviders.AuthData -import com.lagradost.cloudstream3.syncproviders.AuthLoginRequirement -import com.lagradost.cloudstream3.syncproviders.AuthLoginResponse -import com.lagradost.cloudstream3.syncproviders.AuthToken -import com.lagradost.cloudstream3.syncproviders.AuthUser -import com.lagradost.cloudstream3.syncproviders.SyncAPI -import com.lagradost.cloudstream3.syncproviders.SyncIdName -import com.lagradost.cloudstream3.ui.SyncWatchType -import com.lagradost.cloudstream3.ui.library.ListSorting -import com.lagradost.cloudstream3.utils.AppUtils.toJson -import com.lagradost.cloudstream3.utils.txt -import okhttp3.Interceptor -import okhttp3.Request -import okhttp3.RequestBody.Companion.toRequestBody -import okhttp3.Response -import java.text.SimpleDateFormat -import java.time.LocalDate -import java.time.ZoneId -import java.util.Date -import java.util.Locale - -const val KITSU_MAX_SEARCH_LIMIT = 20 - -class KitsuApi: SyncAPI() { - override var name = "Kitsu" - override val idPrefix = "kitsu" - - private val apiUrl = "https://kitsu.io/api/edge" - private val fallbackApiUrl = "https://kitsu.app/api/edge" - private val oauthUrl = "https://kitsu.io/api/oauth" - private val fallbackOauthUrl = "https://kitsu.app/api/oauth" - override val hasInApp = true - override val mainUrl = "https://kitsu.app" - override val icon = R.drawable.kitsu_icon - override val syncIdName = SyncIdName.Kitsu - override val createAccountUrl = mainUrl - - override val supportedWatchTypes = setOf( - SyncWatchType.WATCHING, - SyncWatchType.COMPLETED, - SyncWatchType.PLANTOWATCH, - SyncWatchType.DROPPED, - SyncWatchType.ONHOLD, - SyncWatchType.NONE - ) - - override val inAppLoginRequirement = AuthLoginRequirement( - password = true, - email = true - ) - - private class FallbackInterceptor(private val apiUrl: String, private val fallbackApiUrl: String) : Interceptor { - override fun intercept(chain: Interceptor.Chain): Response { - val request: Request = chain.request() - - try { - - val response = chain.proceed(request); - - if (response.isSuccessful) return response - - response.close() - - } catch (_: Exception) { - } - - val fallbackRequest: Request = request.newBuilder() - .url(request.url.toString().replaceFirst(apiUrl, fallbackApiUrl)) - .build() - - return chain.proceed(fallbackRequest) - - } - } - - private val apiFallbackInterceptor = FallbackInterceptor(apiUrl, fallbackApiUrl) - private val oauthFallbackInterceptor = FallbackInterceptor(oauthUrl, fallbackOauthUrl) - - override suspend fun login(form: AuthLoginResponse): AuthToken? { - val username = form.email ?: return null - val password = form.password ?: return null - - val grantType = "password" - - val token = app.post( - "$oauthUrl/token", - data = mapOf( - "grant_type" to grantType, - "username" to username, - "password" to password - ), - interceptor = oauthFallbackInterceptor - ).parsed() - - return AuthToken( - accessTokenLifetime = unixTime + token.expiresIn.toLong(), - refreshToken = token.refreshToken, - accessToken = token.accessToken, - ) - } - - override suspend fun refreshToken(token: AuthToken): AuthToken { - val res = app.post( - "$oauthUrl/token", - data = mapOf( - "grant_type" to "refresh_token", - "refresh_token" to token.refreshToken!! - ), - interceptor = oauthFallbackInterceptor - ).parsed() - - return AuthToken( - accessToken = res.accessToken, - refreshToken = res.refreshToken, - accessTokenLifetime = unixTime + res.expiresIn.toLong() - ) - } - - override suspend fun user(token: AuthToken?): AuthUser? { - val user = app.get( - "$apiUrl/users?filter[self]=true", - headers = mapOf( - "Authorization" to "Bearer ${token?.accessToken ?: return null}" - ), cacheTime = 0, - interceptor = apiFallbackInterceptor - ).parsed() - - if (user.data.isEmpty()) { - return null - } - - return AuthUser( - id = user.data[0].id.toInt(), - name = user.data[0].attributes.name, - profilePicture = user.data[0].attributes.avatar?.original - ) - } - - override suspend fun search(auth: AuthData?, query: String): List? { - val auth = auth?.token?.accessToken ?: return null - val animeSelectedFields = arrayOf("titles","canonicalTitle","posterImage","episodeCount") - val url = "$apiUrl/anime?filter[text]=$query&page[limit]=$KITSU_MAX_SEARCH_LIMIT&fields[anime]=${animeSelectedFields.joinToString(",")}" - - val res = app.get( - url, headers = mapOf( - "Authorization" to "Bearer $auth", - ), cacheTime = 0, - interceptor = apiFallbackInterceptor - ).parsed() - - return res.data.map { - val attributes = it.attributes - - val title = attributes.canonicalTitle ?: attributes.titles?.enJp ?: attributes.titles?.jaJp ?: "No title" - - SyncSearchResult( - title, - this.name, - it.id, - "$mainUrl/anime/${it.id}/", - attributes.posterImage?.large ?: attributes.posterImage?.medium - ) - } - } - - override suspend fun load(auth : AuthData?, id: String): SyncResult? { - val auth = auth?.token?.accessToken ?: return null - if (id.toIntOrNull() == null) { - return null - } - - data class KitsuResponse( - @field:JsonProperty(value = "data") - val data: KitsuNode, - ) - - val url = - "$apiUrl/anime/$id" - - val anime = app.get( - url, headers = mapOf( - "Authorization" to "Bearer $auth" - ), - interceptor = apiFallbackInterceptor - ).parsed().data.attributes - - return SyncResult( - id = id, - totalEpisodes = anime.episodeCount, - title = anime.canonicalTitle ?: anime.titles?.enJp ?: anime.titles?.jaJp.orEmpty(), - publicScore = Score.from(anime.ratingTwenty, 20), - duration = anime.episodeLength, - synopsis = anime.synopsis, - airStatus = when(anime.status) { - "finished" -> ShowStatus.Completed - "current" -> ShowStatus.Ongoing - else -> null - }, - nextAiring = null, - studio = null, - genres = null, - trailers = null, - startDate = LocalDate.parse(anime.startDate).toEpochDay(), - endDate = LocalDate.parse(anime.endDate).toEpochDay(), - recommendations = null, - nextSeason =null, - prevSeason = null, - actors = null, - ) - - } - - override suspend fun status(auth : AuthData?, id: String): AbstractSyncStatus? { - val accessToken = auth?.token?.accessToken ?: return null - val userId = auth.user.id - - val selectedFields = arrayOf("status","ratingTwenty", "progress") - - val url = - "$apiUrl/library-entries?filter[userId]=$userId&filter[animeId]=$id&fields[libraryEntries]=${selectedFields.joinToString(",")}" - - val anime = app.get( - url, headers = mapOf( - "Authorization" to "Bearer $accessToken" - ), - interceptor = apiFallbackInterceptor - ).parsed().data.firstOrNull()?.attributes - - if (anime == null) { - return SyncStatus( - score = null, - status = SyncWatchType.NONE, - isFavorite = null, - watchedEpisodes = null - ) - } - - return SyncStatus( - score = Score.from(anime.ratingTwenty, 20), - status = SyncWatchType.fromInternalId(kitsuStatusAsString.indexOf(anime.status)), - isFavorite = null, - watchedEpisodes = anime.progress, - ) - } - suspend fun getAnimeIdByTitle(title: String): String? { - - val animeSelectedFields = arrayOf("titles","canonicalTitle") - val url = "$apiUrl/anime?filter[text]=$title&page[limit]=$KITSU_MAX_SEARCH_LIMIT&fields[anime]=${animeSelectedFields.joinToString(",")}" - - val res = app.get(url, interceptor = apiFallbackInterceptor).parsed() - - return res.data.firstOrNull()?.id - - } - - override fun urlToId(url: String): String? = - Regex("""/anime/((.*)/|(.*))""").find(url)?.groupValues?.first() - - override suspend fun updateStatus( - auth : AuthData?, - id: String, - newStatus: AbstractSyncStatus - ): Boolean { - - return setScoreRequest( - auth ?: return false, - id.toIntOrNull() ?: return false, - fromIntToAnimeStatus(newStatus.status), - newStatus.score?.toInt(20), - newStatus.watchedEpisodes - ) - } - - private suspend fun setScoreRequest( - auth : AuthData, - id: Int, - status: KitsuStatusType? = null, - score: Int? = null, - numWatchedEpisodes: Int? = null, - ): Boolean { - - val libraryEntryId = getAnimeLibraryEntryId(auth, id) - - // Exists entry for anime in library - if (libraryEntryId != null) { - - // Delete anime from library - if (status == null || status == KitsuStatusType.None) { - - val res = app.delete( - "$apiUrl/library-entries/$libraryEntryId", - headers = mapOf( - "Authorization" to "Bearer ${auth.token.accessToken}" - ), - interceptor = apiFallbackInterceptor - ) - - - return res.isSuccessful - - } - - return setScoreRequest( - auth, - libraryEntryId, - kitsuStatusAsString[maxOf(0, status.value)], - score, - numWatchedEpisodes - ) - - } - - val data = mapOf( - "data" to mapOf( - "type" to "libraryEntries", - "attributes" to mapOf( - "ratingTwenty" to score, - "progress" to numWatchedEpisodes, - "status" to if (status == null) null else kitsuStatusAsString[maxOf(0, status.value)], - ), - "relationships" to mapOf( - "anime" to mapOf( - "data" to mapOf( - "type" to "anime", - "id" to id.toString() - ) - ), - "user" to mapOf( - "data" to mapOf( - "type" to "users", - "id" to auth.user.id - ) - ) - ) - ) - ) - - val res = app.post( - "$apiUrl/library-entries", - headers = mapOf( - "content-type" to "application/vnd.api+json", - "Authorization" to "Bearer ${auth.token.accessToken}" - ), - requestBody = data.toJson().toRequestBody(), - interceptor = apiFallbackInterceptor - ) - - return res.isSuccessful - - } - - @Suppress("UNCHECKED_CAST") - private suspend fun setScoreRequest( - auth : AuthData, - id: Int, - status: String? = null, - score: Int? = null, - numWatchedEpisodes: Int? = null, - ): Boolean { - val data = mapOf( - "data" to mapOf( - "type" to "libraryEntries", - "id" to id.toString(), - "attributes" to mapOf( - "ratingTwenty" to score, - "progress" to numWatchedEpisodes, - "status" to status - ) - ) - ) - - val res = app.patch( - "$apiUrl/library-entries/$id", - headers = mapOf( - "content-type" to "application/vnd.api+json", - "Authorization" to "Bearer ${auth.token.accessToken}" - ), - requestBody = data.toJson().toRequestBody(), - interceptor = apiFallbackInterceptor - ) - - - return res.isSuccessful - - } - - private suspend fun getAnimeLibraryEntryId(auth: AuthData, id: Int): Int? { - - val userId = auth.user.id - - val res = app.get( - "$apiUrl/library-entries?filter[userId]=$userId&filter[animeId]=$id", - headers = mapOf( - "Authorization" to "Bearer ${auth.token.accessToken}" - ), - interceptor = apiFallbackInterceptor - ).parsed().data.firstOrNull() ?: return null - - return res.id.toInt() - - } - - override suspend fun library(auth : AuthData?): LibraryMetadata? { - val list = getKitsuAnimeListSmart(auth ?: return null)?.groupBy { - convertToStatus(it.attributes.status ?: "").stringRes - }?.mapValues { group -> - group.value.map { it.toLibraryItem() } - } ?: emptyMap() - - // To fill empty lists when Kitsu does not return them - val baseMap = - KitsuStatusType.entries.filter { it.value >= 0 }.associate { - it.stringRes to emptyList() - } - - return LibraryMetadata( - (baseMap + list).map { LibraryList(txt(it.key), it.value) }, - setOf( - ListSorting.AlphabeticalA, - ListSorting.AlphabeticalZ, - ListSorting.UpdatedNew, - ListSorting.UpdatedOld, - ListSorting.ReleaseDateNew, - ListSorting.ReleaseDateOld, - ListSorting.RatingHigh, - ListSorting.RatingLow, - ) - ) - } - - private suspend fun getKitsuAnimeListSmart(auth : AuthData): Array? { - return if (requireLibraryRefresh) { - val list = getKitsuAnimeList(auth.token, auth.user.id) - setKey(KITSU_CACHED_LIST, auth.user.id.toString(), list) - list - } else { - getKey>(KITSU_CACHED_LIST, auth.user.id.toString()) as? Array - } - } - - private suspend fun getKitsuAnimeList(token: AuthToken, userId: Int): Array { - - val animeSelectedFields = arrayOf("titles","canonicalTitle","posterImage","synopsis","startDate","endDate","episodeCount") - val libraryEntriesSelectedFields = arrayOf("progress","ratingTwenty","updatedAt", "status") - val limit = 500 - var url = "$apiUrl/library-entries?filter[userId]=$userId&filter[kind]=anime&include=anime&page[limit]=$limit&page[offset]=0&fields[anime]=${animeSelectedFields.joinToString(",")}&fields[libraryEntries]=${libraryEntriesSelectedFields.joinToString(",")}" - - val fullList = mutableListOf() - - while (true) { - - val data: KitsuResponse = getKitsuAnimeListSlice(token, url) - - data.data.forEachIndexed { index, value -> - value.anime = data.included?.get(index) - } - - fullList.addAll(data.data) - - url = data.links?.next ?: break - } - - - return fullList.toTypedArray() - } - - private suspend fun getKitsuAnimeListSlice(token: AuthToken, url: String): KitsuResponse { - val res = app.get( - url, headers = mapOf( - "Authorization" to "Bearer ${token.accessToken}", - ), - interceptor = apiFallbackInterceptor - ).parsed() - return res - } - - - data class ResponseToken( - @JsonProperty("token_type") val tokenType: String, - @JsonProperty("expires_in") val expiresIn: Int, - @JsonProperty("access_token") val accessToken: String, - @JsonProperty("refresh_token") val refreshToken: String, - ) - - data class KitsuNode( - @JsonProperty("id") val id: String, - @JsonProperty("attributes") val attributes: KitsuNodeAttributes, - /* User list anime node */ - @JsonProperty("relationships") val relationships: KitsuRelationships?, - var anime: KitsuAnimeData? - ) { - fun toLibraryItem(): LibraryItem { - - val animeItem = this.anime - - val numEpisodes = animeItem?.attributes?.episodeCount - - val startDate = animeItem?.attributes?.startDate - - val posterImage = animeItem?.attributes?.posterImage - - val canonicalTitle = animeItem?.attributes?.canonicalTitle - val titles = animeItem?.attributes?.titles - - val animeId = animeItem?.id - - val synopsis: String? = animeItem?.attributes?.synopsis - - return LibraryItem( - canonicalTitle ?: titles?.enJp ?: titles?.jaJp.orEmpty(), - "https://kitsu.app/anime/${animeId}/", - this.id, - this.attributes.progress, - numEpisodes, - Score.from(this.attributes.ratingTwenty, 20), - parseDateLong(this.attributes.updatedAt), - "Kitsu", - TvType.Anime, - posterImage?.large ?: posterImage?.medium, - null, - null, - plot = synopsis, - releaseDate = if (startDate == null) null else try { - Date.from(LocalDate.parse(startDate).atStartOfDay() - .atZone(ZoneId.systemDefault()) - .toInstant()) - } catch (_: RuntimeException) { - null - } - ) - } - - } - - data class KitsuAnimeAttributes( - @JsonProperty("titles") val titles: KitsuTitles?, - @JsonProperty("canonicalTitle") val canonicalTitle: String?, - @JsonProperty("posterImage") val posterImage: KitsuPosterImage?, - @JsonProperty("synopsis") val synopsis: String?, - @JsonProperty("startDate") val startDate: String?, - @JsonProperty("endDate") val endDate: String?, - @JsonProperty("episodeCount") val episodeCount: Int?, - @JsonProperty("episodeLength") val episodeLength: Int?, - ) - - data class KitsuAnimeData( - @JsonProperty("id") val id: String, - @JsonProperty("attributes") val attributes: KitsuAnimeAttributes, - ) - - - data class KitsuNodeAttributes( - /* General attributes */ - @JsonProperty("titles") val titles: KitsuTitles?, - @JsonProperty("canonicalTitle") val canonicalTitle: String?, - @JsonProperty("posterImage") val posterImage: KitsuPosterImage?, - @JsonProperty("synopsis") val synopsis: String?, - @JsonProperty("startDate") val startDate: String?, - @JsonProperty("endDate") val endDate: String?, - @JsonProperty("episodeCount") val episodeCount: Int?, - @JsonProperty("episodeLength") val episodeLength: Int?, - /* User attributes */ - @JsonProperty("name") val name: String?, - @JsonProperty("location") val location: String?, - @JsonProperty("createdAt") val createdAt: String?, - @JsonProperty("avatar") val avatar: KitsuUserAvatar?, - /* User list anime attributes */ - @JsonProperty("progress") val progress: Int?, - @JsonProperty("ratingTwenty") val ratingTwenty: Int?, - @JsonProperty("updatedAt") val updatedAt: String?, - @JsonProperty("status") val status: String?, - ) - - data class KitsuRelationships( - @JsonProperty("anime") val anime: KitsuRelationshipsAnime? - ) - - data class KitsuRelationshipsAnime( - @JsonProperty("links") val links: KitsuLinks? - ) - - data class KitsuPosterImage( - @JsonProperty("large") val large: String?, - @JsonProperty("medium") val medium: String?, - ) - - data class KitsuTitles( - @JsonProperty("en_jp") val enJp: String?, - @JsonProperty("ja_jp") val jaJp: String? - ) - - data class KitsuUserAvatar( - @JsonProperty("original") val original: String? - ) - - data class KitsuLinks( - /* Pagination */ - @JsonProperty("first") val first: String?, - @JsonProperty("next") val next: String?, - @JsonProperty("last") val last: String?, - /* Relationships */ - @JsonProperty("related") val related: String? - ) - - data class KitsuResponse( - @JsonProperty("links") val links: KitsuLinks?, - @JsonProperty("data") val data: List, - /* When requesting related info (User library entry -> anime) */ - @JsonProperty("included") val included: List?, - ) - - - companion object { - - const val KITSU_CACHED_LIST: String = "kitsu_cached_list" - private fun parseDateLong(string: String?): Long? { - return try { - SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.getDefault()).parse( - string ?: return null - )?.time?.div(1000) - } catch (e: Exception) { - null - } - } - - private val kitsuStatusAsString = - arrayOf("current", "completed", "on_hold", "dropped", "planned") - private fun fromIntToAnimeStatus(inp: SyncWatchType): KitsuStatusType { - return when (inp) { - SyncWatchType.NONE -> KitsuStatusType.None - SyncWatchType.WATCHING -> KitsuStatusType.Watching - SyncWatchType.COMPLETED -> KitsuStatusType.Completed - SyncWatchType.ONHOLD -> KitsuStatusType.OnHold - SyncWatchType.DROPPED -> KitsuStatusType.Dropped - SyncWatchType.PLANTOWATCH -> KitsuStatusType.PlanToWatch - SyncWatchType.REWATCHING -> KitsuStatusType.Watching - } - } - - enum class KitsuStatusType(var value: Int, @StringRes val stringRes: Int) { - Watching(0, R.string.type_watching), - Completed(1, R.string.type_completed), - OnHold(2, R.string.type_on_hold), - Dropped(3, R.string.type_dropped), - PlanToWatch(4, R.string.type_plan_to_watch), - None(-1, R.string.type_none) - } - - private fun convertToStatus(string: String): KitsuStatusType { - return when (string) { - "current" -> KitsuStatusType.Watching - "completed" -> KitsuStatusType.Completed - "on_hold" -> KitsuStatusType.OnHold - "dropped" -> KitsuStatusType.Dropped - "planned" -> KitsuStatusType.PlanToWatch - else -> KitsuStatusType.None - } - } - } -} // modified code from from https://github.com/saikou-app/saikou/blob/main/app/src/main/java/ani/saikou/others/Kitsu.kt // GNU General Public License v3.0 https://github.com/saikou-app/saikou/blob/main/LICENSE.md @@ -810,4 +142,4 @@ query { val canonical: String? = null ) } -} +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/LocalList.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/LocalList.kt index 8f0d7ca6d..0d9a4d138 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,11 +1,13 @@ package com.lagradost.cloudstream3.syncproviders.providers +import androidx.fragment.app.FragmentActivity import com.lagradost.cloudstream3.R -import com.lagradost.cloudstream3.syncproviders.AuthData +import com.lagradost.cloudstream3.syncproviders.AuthAPI 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.ui.result.txt import com.lagradost.cloudstream3.ui.settings.Globals.TV import com.lagradost.cloudstream3.ui.settings.Globals.isLayout import com.lagradost.cloudstream3.utils.Coroutines.ioWork @@ -14,19 +16,56 @@ 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 createAccountUrl = null + override val supportDeviceAuth = false + override val createAccountUrl: Nothing? = null + override val idPrefix = "local" override var requireLibraryRefresh = true - override val syncIdName = SyncIdName.LocalList - override suspend fun library(auth : AuthData?): SyncAPI.LibraryMetadata? { + 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? { val watchStatusIds = ioWork { getAllWatchStateIds()?.map { id -> Pair(id, getResultWatchState(id)) @@ -63,10 +102,9 @@ 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 } @@ -74,8 +112,8 @@ class LocalList : SyncAPI() { result } - return LibraryMetadata( - list.map { LibraryList(txt(it.key), it.value) }, + return SyncAPI.LibraryMetadata( + list.map { SyncAPI.LibraryList(txt(it.key), it.value) }, setOf( ListSorting.AlphabeticalA, ListSorting.AlphabeticalZ, @@ -89,4 +127,8 @@ 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 c0a80b3c9..08c186531 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,112 +1,87 @@ 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.CloudStreamApp.Companion.getKey -import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey +import com.lagradost.cloudstream3.AcraApplication.Companion.getKey +import com.lagradost.cloudstream3.AcraApplication.Companion.openBrowser +import com.lagradost.cloudstream3.AcraApplication.Companion.setKey import com.lagradost.cloudstream3.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.syncproviders.AuthData -import com.lagradost.cloudstream3.syncproviders.AuthLoginPage -import com.lagradost.cloudstream3.syncproviders.AuthToken -import com.lagradost.cloudstream3.syncproviders.AuthUser +import com.lagradost.cloudstream3.mvvm.logError +import com.lagradost.cloudstream3.syncproviders.AccountManager +import com.lagradost.cloudstream3.syncproviders.AuthAPI 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.ui.result.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 com.lagradost.cloudstream3.utils.txt +import java.net.URL +import java.security.SecureRandom +import java.text.ParseException 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 : SyncAPI() { +class MALApi(index: Int) : AccountManager(index), SyncAPI { override var name = "MAL" + override val key = "1714d6f2f4f7cc19644384f8c4629910" + override val redirectUrl = "mallogin" override val idPrefix = "mal" - - val key = "1714d6f2f4f7cc19644384f8c4629910" + override var mainUrl = "https://myanimelist.net" 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 val supportedWatchTypes = setOf( - SyncWatchType.WATCHING, - SyncWatchType.COMPLETED, - SyncWatchType.PLANTOWATCH, - SyncWatchType.DROPPED, - SyncWatchType.ONHOLD, - SyncWatchType.NONE - ) + override fun logOut() { + requireLibraryRefresh = true + removeAccountKeys() + } - 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 - } - - 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" + override fun loginInfo(): AuthAPI.LoginInfo? { + getKey(accountId, MAL_USER_KEY)?.let { user -> + return AuthAPI.LoginInfo( + profilePicture = user.picture, + name = user.name, + accountIndex = accountIndex ) - ).parsed() - return AuthToken( - accessTokenLifetime = unixTime + token.expiresIn.toLong(), - refreshToken = token.refreshToken, - accessToken = token.accessToken + } + return null + } + + 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(auth: AuthData?, query: String): List? { - val auth = auth?.token?.accessToken ?: return null - val url = "$apiUrl/v2/anime?q=$query&limit=$MAL_MAX_SEARCH_LIMIT" + override suspend fun search(name: String): List { + 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 - ).parsed() - return res.data.map { + ).text + return parseJson(res).data.map { val node = it.node SyncAPI.SyncSearchResult( node.title, @@ -118,21 +93,19 @@ class MALApi : SyncAPI() { } } - override fun urlToId(url: String): String? = - Regex("""/anime/((.*)/|(.*))""").find(url)!!.groupValues.first() + override fun getIdFromUrl(url: String): String { + return Regex("""/anime/((.*)/|(.*))""").find(url)!!.groupValues.first() + } - override suspend fun updateStatus( - auth: AuthData?, - id: String, - newStatus: SyncAPI.AbstractSyncStatus - ): Boolean { + override suspend fun score(id: String, status: SyncAPI.AbstractSyncStatus): Boolean { return setScoreRequest( - auth?.token ?: return false, id.toIntOrNull() ?: return false, - fromIntToAnimeStatus(newStatus.status), - newStatus.score?.toInt(10), - newStatus.watchedEpisodes - ) + fromIntToAnimeStatus(status.status.internalId), + status.score, + status.watchedEpisodes + ).also { + requireLibraryRefresh = requireLibraryRefresh || it + } } data class MalAnime( @@ -225,14 +198,14 @@ class MALApi : SyncAPI() { ) } - override suspend fun load(auth: AuthData?, id: String): SyncAPI.SyncResult? { - val auth = auth?.token?.accessToken ?: return null + override suspend fun getResult(id: String): SyncAPI.SyncResult? { 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 = mapOf( + url, headers = if (auth == null) emptyMap() else mapOf( "Authorization" to "Bearer $auth" ) ).text @@ -241,7 +214,7 @@ class MALApi : SyncAPI() { id = internalId.toString(), totalEpisodes = malAnime.numEpisodes, title = malAnime.title, - publicScore = Score.from10(malAnime.mean), + publicScore = malAnime.mean?.toFloat()?.times(1000)?.toInt(), duration = malAnime.averageEpisodeDuration, synopsis = malAnime.synopsis, airStatus = when (malAnime.status) { @@ -271,20 +244,13 @@ class MALApi : SyncAPI() { } } - 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 + override suspend fun getStatus(id: String): SyncAPI.SyncStatus? { + val internalId = id.toIntOrNull() ?: return null + val data = + getDataAboutMalId(internalId)?.myListStatus //?: throw ErrorLoadingException("No my_list_status") return SyncAPI.SyncStatus( - score = Score.from10(data?.score), + score = data?.score, status = SyncWatchType.fromInternalId(malStatusAsString.indexOf(data?.status)), isFavorite = null, watchedEpisodes = data?.numEpisodesWatched, @@ -295,17 +261,14 @@ class MALApi : 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 when (string) { - "watching" -> MalStatusType.Watching - "completed" -> MalStatusType.Completed - "on_hold" -> MalStatusType.OnHold - "dropped" -> MalStatusType.Dropped - "plan_to_watch" -> MalStatusType.PlanToWatch - else -> MalStatusType.None - } + return fromIntToAnimeStatus(malStatusAsString.indexOf(string)) } enum class MalStatusType(var value: Int, @StringRes val stringRes: Int) { @@ -317,15 +280,16 @@ class MALApi : SyncAPI() { None(-1, R.string.type_none) } - private fun fromIntToAnimeStatus(inp: SyncWatchType): MalStatusType {//= AniListStatusType.values().first { it.value == inp } + private fun fromIntToAnimeStatus(inp: Int): MalStatusType {//= AniListStatusType.values().first { it.value == inp } return when (inp) { - 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 + -1 -> MalStatusType.None + 0 -> MalStatusType.Watching + 1 -> MalStatusType.Completed + 2 -> MalStatusType.OnHold + 3 -> MalStatusType.Dropped + 4 -> MalStatusType.PlanToWatch + 5 -> MalStatusType.Watching + else -> MalStatusType.None } } @@ -340,38 +304,85 @@ class MALApi : SyncAPI() { } } - override fun loginRequest(): AuthLoginPage? { - val codeVerifier = generateCodeVerifier() - val requestId = ++requestIdCounter + 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", "") val codeChallenge = codeVerifier val request = "$mainUrl/v1/oauth2/authorize?response_type=code&client_id=$key&code_challenge=$codeChallenge&state=RequestID$requestId" - - return AuthLoginPage( - url = request, - payload = PayLoad(requestId, codeVerifier).toJson() - ) + openBrowser(request, activity) } - 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() + private var requestId = 0 + private var codeVerifier = "" - return AuthToken( - accessToken = res.accessToken, - refreshToken = res.refreshToken, - accessTokenLifetime = unixTime + res.expiresIn.toLong() - ) + 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) + } } - private var requestIdCounter = 0 - + 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) + } + } private val allTitles = hashMapOf() @@ -430,7 +441,7 @@ class MALApi : SyncAPI() { this.node.id.toString(), this.listStatus?.numEpisodesWatched, this.node.numEpisodes, - Score.from10(this.listStatus?.score), + this.listStatus?.score?.times(10), parseDateLong(this.listStatus?.updatedAt), "MAL", TvType.Anime, @@ -438,16 +449,12 @@ class MALApi : 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} ) } } @@ -477,8 +484,23 @@ class MALApi : SyncAPI() { @JsonProperty("start_time") val startTime: String? ) - override suspend fun library(auth: AuthData?): LibraryMetadata? { - val list = getMalAnimeListSmart(auth ?: return null)?.groupBy { + 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 { convertToStatus(it.listStatus?.status ?: "").stringRes }?.mapValues { group -> group.value.map { it.toLibraryItem() } @@ -505,22 +527,13 @@ class MALApi : SyncAPI() { ) } - 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 { + private suspend fun getMalAnimeList(): Array { + checkMalToken() var offset = 0 val fullList = mutableListOf() val offsetRegex = Regex("""offset=(\d+)""") while (true) { - val data: MalList = getMalAnimeListSlice(token, offset) ?: break + val data: MalList = getMalAnimeListSlice(offset) ?: break fullList.addAll(data.data) offset = data.paging.next?.let { offsetRegex.find(it)?.groupValues?.get(1)?.toInt() } @@ -529,29 +542,128 @@ class MALApi : SyncAPI() { return fullList.toTypedArray() } - private suspend fun getMalAnimeListSlice(token: AuthToken, offset: Int = 0): MalList? { + private suspend fun getMalAnimeListSlice(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 ${token.accessToken}", + "Authorization" to "Bearer $auth", ), 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, @@ -574,7 +686,6 @@ class MALApi : SyncAPI() { @Suppress("UNCHECKED_CAST") private suspend fun setScoreRequest( - token: AuthToken, id: Int, status: String? = null, score: Int? = null, @@ -589,7 +700,7 @@ class MALApi : SyncAPI() { return app.put( "$apiUrl/v2/anime/$id/my_list_status", headers = mapOf( - "Authorization" to "Bearer ${token.accessToken}" + "Authorization" to "Bearer " + (getAuth() ?: return null) ), 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 4b17fdb29..37b956146 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,44 +2,56 @@ package com.lagradost.cloudstream3.syncproviders.providers import android.util.Log import com.fasterxml.jackson.annotation.JsonProperty -import com.lagradost.cloudstream3.app +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.subtitles.AbstractSubtitleEntities -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.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.utils.AppUtils -import com.lagradost.cloudstream3.utils.AppUtils.parseJson -import com.lagradost.cloudstream3.utils.AppUtils.toJson -import com.lagradost.cloudstream3.utils.SubtitleHelper.fromCodeToLangTagIETF -import com.lagradost.cloudstream3.utils.SubtitleHelper.fromCodeToOpenSubtitlesTag +import okhttp3.Interceptor +import okhttp3.Response -class OpenSubtitlesApi : SubtitleAPI() { - override val name = "OpenSubtitles" +class OpenSubtitlesApi(index: Int) : InAppAuthAPIManager(index), AbstractSubApi { override val idPrefix = "opensubtitles" - + override val name = "OpenSubtitles" override val icon = R.drawable.open_subtitles_icon - override val hasInApp = true - override val inAppLoginRequirement = AuthLoginRequirement( - password = true, - username = true, - ) - + override val requiresPassword = true + override val requiresUsername = 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 - const val userAgent = "Cloudstream3 v0.2" - val headers = mapOf("user-agent" to userAgent, "Api-Key" to API_KEY) + 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.1" + 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() + ) + } } private fun canDoRequest(): Boolean { @@ -57,53 +69,121 @@ class OpenSubtitlesApi : SubtitleAPI() { throw ErrorLoadingException("Too many requests") } - override suspend fun refreshToken(token: AuthToken): AuthToken? { - return login(parseJson(token.payload ?: return null)) + private fun getAuthKey(): SubtitleOAuthEntity? { + return getKey(accountId, OPEN_SUBTITLES_USER_KEY) } - 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 - ) + 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 login(form: AuthLoginResponse): AuthToken? { - val username = form.username ?: return null - val password = form.password ?: 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) + } + + /* + 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 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( + ), + data = mapOf( "username" to username, "password" to password ), - ).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() + interceptor = headerInterceptor ) + //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( - auth : AuthData?, - query: AbstractSubtitleEntities.SubtitleSearch - ): List? { + override suspend fun search(query: AbstractSubtitleEntities.SubtitleSearch): List? { throwIfCantDoRequest() - val langOpenSubTag = fromCodeToOpenSubtitlesTag(query.lang) ?: query.lang ?: "" + val fixedLang = fixLanguage(query.lang) val imdbId = query.imdbId?.replace("tt", "")?.toInt() ?: 0 val queryText = query.query @@ -116,17 +196,17 @@ class OpenSubtitlesApi : SubtitleAPI() { val searchQueryUrl = when (imdbId > 0) { //Use imdb_id to search if its valid - true -> "$HOST/subtitles?imdb_id=$imdbId&languages=${langOpenSubTag}$yearQuery$epQuery$seasonQuery" - false -> "$HOST/subtitles?query=${queryText}&languages=${langOpenSubTag}$yearQuery$epQuery$seasonQuery" + true -> "$HOST/subtitles?imdb_id=$imdbId&languages=${fixedLang}$yearQuery$epQuery$seasonQuery" + false -> "$HOST/subtitles?query=${queryText}&languages=${fixedLang}$yearQuery$epQuery$seasonQuery" } val req = app.get( url = searchQueryUrl, headers = mapOf( Pair("Content-Type", "application/json") - ) + headers, + ), + interceptor = headerInterceptor ) - Log.i(TAG, "searchQueryUrl => ${searchQueryUrl}") Log.i(TAG, "Search Req => ${req.text}") if (!req.isSuccessful) { if (req.code == 429) @@ -147,7 +227,7 @@ class OpenSubtitlesApi : SubtitleAPI() { //Use any valid name/title in hierarchy val name = filename ?: featureDetails?.movieName ?: featureDetails?.title ?: featureDetails?.parentTitle ?: attr.release ?: query.query - val langTagIETF = fromCodeToLangTagIETF(attr.language) ?: "" + val lang = fixLanguageReverse(attr.language) ?: "" val resEpNum = featureDetails?.episodeNumber ?: query.epNumber val resSeasonNum = featureDetails?.seasonNumber ?: query.seasonNumber val year = featureDetails?.year ?: query.year @@ -161,7 +241,7 @@ class OpenSubtitlesApi : SubtitleAPI() { AbstractSubtitleEntities.SubtitleEntity( idPrefix = this.idPrefix, name = name, - lang = langTagIETF, + lang = lang, data = resultData, type = type, source = this.name, @@ -181,12 +261,7 @@ class OpenSubtitlesApi : SubtitleAPI() { Process data returned from search. Returns string url for the subtitle file. */ - - override suspend fun load( - auth : AuthData?, - subtitle: AbstractSubtitleEntities.SubtitleEntity - ): String? { - if(auth == null) return null + override suspend fun load(data: AbstractSubtitleEntities.SubtitleEntity): String? { throwIfCantDoRequest() val req = app.post( @@ -194,14 +269,15 @@ class OpenSubtitlesApi : SubtitleAPI() { headers = mapOf( Pair( "Authorization", - "Bearer ${auth.token.accessToken ?: throw ErrorLoadingException("No access token active in current session")}" + "Bearer ${currentSession?.accessToken ?: throw ErrorLoadingException("No access token active in current session")}" ), Pair("Content-Type", "application/json"), Pair("Accept", "*/*") - ) + headers, + ), data = mapOf( - Pair("file_id", subtitle.data) - ) + Pair("file_id", data.data) + ), + interceptor = headerInterceptor ) Log.i(TAG, "Request result => (${req.code}) ${req.text}") //Log.i(TAG, "Request headers => ${req.headers}") @@ -218,6 +294,13 @@ class OpenSubtitlesApi : SubtitleAPI() { 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 3110b23ac..50517f9d1 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,36 +2,38 @@ 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.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.mvvm.suspendSafeApiCall +import com.lagradost.cloudstream3.syncproviders.AccountManager +import com.lagradost.cloudstream3.syncproviders.AuthAPI +import com.lagradost.cloudstream3.syncproviders.OAuth2API 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.ui.result.txt import com.lagradost.cloudstream3.utils.AppUtils.toJson -import com.lagradost.cloudstream3.utils.AppUtils.tryParseJson import com.lagradost.cloudstream3.utils.DataStoreHelper.toYear -import com.lagradost.cloudstream3.utils.txt +import okhttp3.Interceptor +import okhttp3.Response import java.math.BigInteger import java.security.SecureRandom import java.text.SimpleDateFormat @@ -43,22 +45,25 @@ import kotlin.time.Duration import kotlin.time.DurationUnit import kotlin.time.toDuration -class SimklApi : SyncAPI() { +class SimklApi(index: Int) : AccountManager(index), 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 @@ -96,7 +101,7 @@ class SimklApi : SyncAPI() { fun cleanOldCache() { getKeys(SIMKL_CACHE_KEY)?.forEach { - val isOld = CloudStreamApp.getKey>(it)?.isFresh() == false + val isOld = AcraApplication.getKey>(it)?.isFresh() == false if (isOld) { removeKey(it) } @@ -117,8 +122,13 @@ class SimklApi : SyncAPI() { * Gets cached object, if object is not fresh returns null and removes it from cache */ inline fun getKey(path: String): T? { + // Required for generic otherwise "LinkedHashMap cannot be cast to MediaObject" + val type = mapper.typeFactory.constructParametricType( + SimklCacheWrapper::class.java, + T::class.java + ) val cache = getKey(SIMKL_CACHE_KEY, path)?.let { - tryParseJson>(it) + mapper.readValue>(it, type) } return if (cache?.isFresh() == true) { @@ -138,6 +148,10 @@ class SimklApi : 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" @@ -223,23 +237,13 @@ class SimklApi : SyncAPI() { /** https://simkl.docs.apiary.io/#reference/users/settings/receive-settings */ data class SettingsResponse( - @JsonProperty("user") - val user: User, - @JsonProperty("account") - val account: Account, + val user: User ) { 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( @@ -361,7 +365,7 @@ class SimklApi : SyncAPI() { class SimklScoreBuilder private constructor() { data class Builder( private var url: String? = null, - private var headers: Map? = null, + private var interceptor: Interceptor? = null, private var ids: MediaObject.Ids? = null, private var score: Int? = null, private var status: Int? = null, @@ -370,7 +374,7 @@ class SimklApi : SyncAPI() { // Required for knowing if the status should be overwritten private var onList: Boolean = false ) { - fun token(token: AuthToken) = apply { this.headers = getHeaders(token) } + fun interceptor(interceptor: Interceptor) = apply { this.interceptor = interceptor } fun apiUrl(url: String) = apply { this.url = url } fun ids(ids: MediaObject.Ids) = apply { this.ids = ids } fun score(score: Int?, oldScore: Int?) = apply { @@ -419,7 +423,7 @@ class SimklApi : 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", @@ -427,7 +431,7 @@ class SimklApi : SyncAPI() { shows = listOf(HistoryMediaObject(ids = ids)), movies = emptyList() ), - headers = headers + interceptor = interceptor ).isSuccessful } else { val statusResponse = this.status?.let { setStatus -> @@ -448,7 +452,7 @@ class SimklApi : SyncAPI() { ) ), movies = emptyList() ), - headers = headers + interceptor = interceptor ).isSuccessful } ?: true @@ -465,7 +469,7 @@ class SimklApi : SyncAPI() { ), movies = emptyList() ), - headers = headers + interceptor = interceptor ).isSuccessful } ?: true @@ -492,7 +496,7 @@ class SimklApi : SyncAPI() { ) ), movies = emptyList() ), - headers = headers + interceptor = interceptor ).isSuccessful } else { true @@ -504,9 +508,6 @@ class SimklApi : 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?, @@ -663,7 +664,7 @@ class SimklApi : SyncAPI() { movie.ids.simkl.toString(), this.watchedEpisodesCount, this.totalEpisodesCount, - Score.from10(this.userRating), + this.userRating?.times(10), getUnixTime(lastWatchedAt) ?: 0, "Simkl", TvType.Movie, @@ -696,7 +697,7 @@ class SimklApi : SyncAPI() { show.ids.simkl.toString(), this.watchedEpisodesCount, this.totalEpisodesCount, - Score.from10(this.userRating), + this.userRating?.times(10), getUnixTime(lastWatchedAt) ?: 0, "Simkl", TvType.Anime, @@ -745,7 +746,7 @@ class SimklApi : 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( @@ -756,12 +757,14 @@ class SimklApi : 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 suspendSafeApiCall { + app.post("$mainUrl/users/settings", interceptor = interceptor) + .parsedSafe()?.user + } + } /** * Useful to get episodes on demand to prevent unnecessary requests. @@ -779,7 +782,7 @@ class SimklApi : SyncAPI() { class SimklSyncStatus( override var status: SyncWatchType, - override var score: Score?, + override var score: Int?, val oldScore: Int?, override var watchedEpisodes: Int?, val episodeConstructor: SimklEpisodeConstructor, @@ -791,8 +794,7 @@ class SimklApi : SyncAPI() { val oldStatus: String? ) : SyncAPI.AbstractSyncStatus() - override suspend fun status(auth: AuthData?, id: String): SyncAPI.AbstractSyncStatus? { - if (auth == null) return null + override suspend fun getStatus(id: String): SyncAPI.AbstractSyncStatus? { val realIds = readIdFromString(id) // Key which assumes all ids are the same each time :/ @@ -816,7 +818,7 @@ class SimklApi : SyncAPI() { searchResult.hasEnded() ) - val foundItem = getSyncListSmart(auth)?.let { list -> + val foundItem = getSyncListSmart()?.let { list -> listOf(list.shows, list.anime, list.movies).flatten().firstOrNull { show -> realIds.any { (database, id) -> show.getIds().matchesId(database, id) @@ -834,7 +836,7 @@ class SimklApi : SyncAPI() { ) } ?: return null, - score = Score.from10(foundItem.userRating), + score = foundItem.userRating, watchedEpisodes = foundItem.watchedEpisodesCount, maxEpisodes = searchResult.totalEpisodes, episodeConstructor = episodeConstructor, @@ -845,7 +847,7 @@ class SimklApi : SyncAPI() { } else { return SimklSyncStatus( status = SyncWatchType.fromInternalId(SimklListStatusType.None.value), - score = null, + score = 0, watchedEpisodes = 0, maxEpisodes = if (searchResult.type == "movie") 0 else searchResult.totalEpisodes, episodeConstructor = episodeConstructor, @@ -856,26 +858,22 @@ class SimklApi : SyncAPI() { } } - override suspend fun updateStatus( - auth: AuthData?, - id: String, - newStatus: AbstractSyncStatus - ): Boolean { + override suspend fun score(id: String, status: SyncAPI.AbstractSyncStatus): Boolean { val parsedId = readIdFromString(id) lastScoreTime = unixTime - val simklStatus = newStatus as? SimklSyncStatus + val simklStatus = status as? SimklSyncStatus val builder = SimklScoreBuilder.Builder() .apiUrl(this.mainUrl) - .score(newStatus.score?.toInt(10), simklStatus?.oldScore) + .score(status.score, simklStatus?.oldScore) .status( - newStatus.status.internalId, - (newStatus as? SimklSyncStatus)?.oldStatus?.let { oldStatus -> + status.status.internalId, + (status as? SimklSyncStatus)?.oldStatus?.let { oldStatus -> SimklListStatusType.entries.firstOrNull { it.originalName == oldStatus }?.value }) - .token(auth?.token ?: return false) + .interceptor(interceptor) .ids(MediaObject.Ids.fromMap(parsedId)) @@ -883,12 +881,11 @@ class SimklApi : SyncAPI() { val episodes = simklStatus?.episodeConstructor?.getEpisodes() // All episodes if marked as completed - val watchedEpisodes = - if (newStatus.status.internalId == SimklListStatusType.Completed.value) { - episodes?.size - } else { - newStatus.watchedEpisodes - } + val watchedEpisodes = if (status.status.internalId == SimklListStatusType.Completed.value) { + episodes?.size + } else { + status.watchedEpisodes + } builder.episodes(episodes?.toList(), watchedEpisodes, simklStatus?.oldEpisodes) @@ -909,26 +906,39 @@ class SimklApi : SyncAPI() { ).parsedSafe() } - override suspend fun search(auth: AuthData?, query: String): List? { + override suspend fun search(name: String): List? { return app.get( - "$mainUrl/search/", params = mapOf("client_id" to CLIENT_ID, "q" to query) + "$mainUrl/search/", params = mapOf("client_id" to CLIENT_ID, "q" to name) ).parsedSafe>()?.mapNotNull { it.toSyncSearchResult() } } - override fun loginRequest(): AuthLoginPage? { - val lastLoginState = BigInteger(130, SecureRandom()).toString(32) + override fun authenticate(activity: FragmentActivity?) { + lastLoginState = BigInteger(130, SecureRandom()).toString(32) val url = - "https://simkl.com/oauth/authorize?response_type=code&client_id=$CLIENT_ID&redirect_uri=$APP_STRING://${redirectUrlIdentifier}&state=$lastLoginState" - - return AuthLoginPage( - url = url, - payload = lastLoginState - ) + "https://simkl.com/oauth/authorize?response_type=code&client_id=$CLIENT_ID&redirect_uri=$APP_STRING://${redirectUrl}&state=$lastLoginState" + openBrowser(url, activity) } - override suspend fun load(auth: AuthData?, id: String): SyncResult? = null + override fun loginInfo(): AuthAPI.LoginInfo? { + return getKey(accountId, SIMKL_USER_KEY)?.let { user -> + AuthAPI.LoginInfo( + name = user.name, + profilePicture = user.avatar, + accountIndex = accountIndex + ) + } + } - private suspend fun getSyncListSince(auth: AuthData, since: Long?): AllItemsResponse? { + override fun logOut() { + requireLibraryRefresh = true + removeAccountKeys() + } + + override suspend fun getResult(id: String): SyncAPI.SyncResult? { + return null + } + + private suspend fun getSyncListSince(since: Long?): AllItemsResponse? { val params = getDateTime(since)?.let { mapOf("date_from" to it) } ?: emptyMap() @@ -937,22 +947,23 @@ class SimklApi : SyncAPI() { return app.get( "$mainUrl/sync/all-items/", params = params, - headers = getHeaders(auth.token) + interceptor = interceptor ).parsedSafe() } - private suspend fun getActivities(token: AuthToken): ActivitiesResponse? { - return app.post("$mainUrl/sync/activities", headers = getHeaders(token)).parsedSafe() + private suspend fun getActivities(): ActivitiesResponse? { + return app.post("$mainUrl/sync/activities", interceptor = interceptor).parsedSafe() } - private fun getSyncListCached(auth: AuthData): AllItemsResponse? { - return getKey(SIMKL_CACHED_LIST, auth.user.id.toString()) + private fun getSyncListCached(): AllItemsResponse? { + return getKey(accountId, SIMKL_CACHED_LIST) } - 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()) + private suspend fun getSyncListSmart(): AllItemsResponse? { + if (token == null) return null + + val activities = getActivities() + val lastCacheUpdate = getKey(accountId, SIMKL_CACHED_LIST_TIME) val lastRemoval = listOf( activities?.tvShows?.removedFromList, activities?.anime?.removedFromList, @@ -972,28 +983,26 @@ class SimklApi : 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(SIMKL_CACHED_LIST_TIME, userId, lastRemoval) - getSyncListSince(auth, null) + setKey(accountId, SIMKL_CACHED_LIST_TIME, lastRemoval) + getSyncListSince(null) } else if (lastCacheUpdate < lastRealUpdate || lastCacheUpdate < lastScoreTime) { debugPrint { "Partial list update in ${this.name}." } - setKey(SIMKL_CACHED_LIST_TIME, userId, lastCacheUpdate) - AllItemsResponse.merge( - getSyncListCached(auth), - getSyncListSince(auth, lastCacheUpdate) - ) + setKey(accountId, SIMKL_CACHED_LIST_TIME, lastCacheUpdate) + AllItemsResponse.merge(getSyncListCached(), getSyncListSince(lastCacheUpdate)) } else { debugPrint { "Cached list update in ${this.name}." } - getSyncListCached(auth) + getSyncListCached() } debugPrint { "List sizes: movies=${list?.movies?.size}, shows=${list?.shows?.size}, anime=${list?.anime?.size}" } - setKey(SIMKL_CACHED_LIST, userId, list) + setKey(accountId, SIMKL_CACHED_LIST, list) return list } - override suspend fun library(auth: AuthData?): SyncAPI.LibraryMetadata? { - val list = getSyncListSmart(auth ?: return null) ?: return null + + override suspend fun getPersonalLibrary(): SyncAPI.LibraryMetadata? { + val list = getSyncListSmart() ?: return null val baseMap = SimklListStatusType.entries @@ -1029,17 +1038,17 @@ class SimklApi : SyncAPI() { ) } - override fun urlToId(url: String): String? { + override fun getIdFromUrl(url: String): String { val simklUrlRegex = Regex("""https://simkl\.com/[^/]*/(\d+).*""") return simklUrlRegex.find(url)?.groupValues?.get(1) ?: "" } - override suspend fun pinRequest(): AuthPinData? { + override suspend fun getDevicePin(): OAuth2API.PinAuthData? { val pinAuthResp = app.get( - "$mainUrl/oauth/pin?client_id=$CLIENT_ID&redirect_uri=$APP_STRING://${redirectUrlIdentifier}" + "$mainUrl/oauth/pin?client_id=$CLIENT_ID&redirect_uri=$APP_STRING://${redirectUrl}" ).parsedSafe() ?: return null - return AuthPinData( + return OAuth2API.PinAuthData( deviceCode = pinAuthResp.deviceCode, userCode = pinAuthResp.userCode, verificationUrl = pinAuthResp.verificationUrl, @@ -1048,38 +1057,56 @@ class SimklApi : SyncAPI() { ) } - override suspend fun login(payload: AuthPinData): AuthToken? { + override suspend fun handleDeviceAuth(pinAuthData: OAuth2API.PinAuthData): Boolean { val pinAuthResp = app.get( - "$mainUrl/oauth/pin/${payload.userCode}?client_id=$CLIENT_ID" - ).parsedSafe() ?: return null + "$mainUrl/oauth/pin/${pinAuthData.userCode}?client_id=$CLIENT_ID" + ).parsedSafe() ?: return false - return AuthToken( - accessToken = pinAuthResp.accessToken ?: return null, - ) + 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 + } + + setKey(accountId, SIMKL_USER_KEY, user) + registerAccount() + requireLibraryRefresh = true + return true + } + return false } - override suspend fun login(redirectUrl: String, payload: String?): AuthToken? { - val uri = redirectUrl.toUri() + override suspend fun handleRedirect(url: String): Boolean { + val uri = url.toUri() val state = uri.getQueryParameter("state") // Ensure consistent state - if (state != payload) return null + if (state != lastLoginState) return false + lastLoginState = "" - val code = uri.getQueryParameter("code") ?: return null - val tokenResponse = app.post( + val code = uri.getQueryParameter("code") ?: return false + val token = app.post( "$mainUrl/oauth/token", json = TokenRequest(code) - ).parsedSafe() ?: return null + ).parsedSafe() ?: return false - return AuthToken( - accessToken = tokenResponse.accessToken, - ) - } + switchToNewAccount() + setKey(accountId, SIMKL_TOKEN_KEY, token.accessToken) - 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 - ) + val user = getUser() + if (user == null) { + removeKey(accountId, SIMKL_TOKEN_KEY) + switchToOldAccount() + return false + } + + setKey(accountId, SIMKL_USER_KEY, user) + registerAccount() + requireLibraryRefresh = true + + return true } } diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/SubSource.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/SubSource.kt index 19122768e..8dad1f88c 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,33 +3,27 @@ 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 : SubtitleAPI() { - override val name = "SubSource" +class SubSourceApi : AbstractSubProvider { override val idPrefix = "subsource" - - override val requiresLogin = false + val name = "SubSource" companion object { const val APIURL = "https://api.subsource.net/api" const val DOWNLOADENDPOINT = "https://api.subsource.net/api/downloadSub" } - override suspend fun search( - auth: AuthData?, - query: AbstractSubtitleEntities.SubtitleSearch - ): List? { + override suspend fun search(query: AbstractSubtitleEntities.SubtitleSearch): List? { //Only supports Imdb Id search for now if (query.imdbId == null) return null - val queryLang = SubtitleHelper.fromTagToEnglishLanguageName(query.lang) + val queryLang = SubtitleHelper.fromTwoLettersToLanguage(query.lang!!) val type = if ((query.seasonNumber ?: 0) > 0) TvType.TvSeries else TvType.Movie val searchRes = app.post( @@ -93,17 +87,15 @@ class SubSourceApi : SubtitleAPI() { } } - override suspend fun SubtitleResource.getResources( - auth: AuthData?, - subtitle: AbstractSubtitleEntities.SubtitleEntity - ) { - val parsedSub = parseJson(subtitle.data) + override suspend fun SubtitleResource.getResources(data: AbstractSubtitleEntities.SubtitleEntity) { + + val parsedSub = parseJson(data.data) val subRes = app.post( url = "$APIURL/getSub", data = mapOf( "movie" to parsedSub.movie, - "lang" to subtitle.lang, + "lang" to data.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 1f1e6de44..376795c51 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,71 +1,88 @@ package com.lagradost.cloudstream3.syncproviders.providers import com.fasterxml.jackson.annotation.JsonProperty -import com.lagradost.cloudstream3.app +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.subtitles.AbstractSubtitleEntities import com.lagradost.cloudstream3.subtitles.SubtitleResource -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.syncproviders.AuthAPI.LoginInfo +import com.lagradost.cloudstream3.syncproviders.InAppAuthAPI +import com.lagradost.cloudstream3.syncproviders.InAppAuthAPIManager -class SubDlApi : SubtitleAPI() { - override val name = "SubDL" +class SubDlApi(index: Int) : InAppAuthAPIManager(index), AbstractSubApi { override val idPrefix = "subdl" - + override val name = "SubDL" override val icon = R.drawable.subdl_logo_big - override val hasInApp = true - override val inAppLoginRequirement = AuthLoginRequirement(password = true, email = true) - override val requiresLogin = true + override val requiresPassword = true + override val requiresEmail = true override val createAccountUrl = "https://subdl.com/panel/register" companion object { - const val APIURL = "https://apiold.subdl.com" + const val APIURL = "https://api.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 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 + override suspend fun initialize() { + currentSession = getAuthKey() + } + + override fun logOut() { + setAuthKey(null) + removeAccountKeys() + currentSession = getAuthKey() + } + 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 + } + + override fun getLatestLoginData(): InAppAuthAPI.LoginData? { + val current = getAuthKey() ?: return null + return InAppAuthAPI.LoginData( + email = current.userEmail, + password = current.pass + ) + } + + override fun loginInfo(): LoginInfo? { + getAuthKey()?.let { user -> + return LoginInfo( + profilePicture = null, + name = user.name ?: user.userEmail, + accountIndex = accountIndex ) - ).parsed() - - val apiResponse = app.get( - url = "$APIURL/user/userApi", - headers = mapOf( - "Authorization" to "Bearer ${tokenResponse.token}" - ) - ).parsed() - - return AuthToken(accessToken = apiResponse.apiKey, payload = email) + } + 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}" @@ -79,8 +96,8 @@ class SubDlApi : SubtitleAPI() { val searchQueryUrl = when (idQuery) { //Use imdb/tmdb id to search if its valid - null -> "$APIENDPOINT?api_key=${apiKey}&film_name=$queryText&languages=$langSubdlCode$epQuery$seasonQuery$yearQuery" - else -> "$APIENDPOINT?api_key=${apiKey}$idQuery&languages=$langSubdlCode$epQuery$seasonQuery$yearQuery" + null -> "$APIENDPOINT?api_key=${currentSession?.apiKey}&film_name=$queryText&languages=${query.lang}$epQuery$seasonQuery$yearQuery" + else -> "$APIENDPOINT?api_key=${currentSession?.apiKey}$idQuery&languages=${query.lang}$epQuery$seasonQuery$yearQuery" } val req = app.get( @@ -92,9 +109,7 @@ class SubDlApi : SubtitleAPI() { return req.parsedSafe()?.subtitles?.map { subtitle -> - val langTagIETF = - langTagIETF2subdl.entries.find { it.value == subtitle.lang }?.key ?: - subtitle.lang + val lang = subtitle.lang.replaceFirstChar { it.uppercase() } val resEpNum = subtitle.episode ?: query.epNumber val resSeasonNum = subtitle.season ?: query.seasonNumber val type = if ((resSeasonNum ?: 0) > 0) TvType.TvSeries else TvType.Movie @@ -102,7 +117,7 @@ class SubDlApi : SubtitleAPI() { AbstractSubtitleEntities.SubtitleEntity( idPrefix = this.idPrefix, name = subtitle.releaseName, - lang = langTagIETF, + lang = lang, data = "${DOWNLOADENDPOINT}${subtitle.url}", type = type, source = this.name, @@ -113,15 +128,58 @@ class SubDlApi : SubtitleAPI() { } } - override suspend fun SubtitleResource.getResources( - auth: AuthData?, - subtitle: AbstractSubtitleEntities.SubtitleEntity - ) { - this.addZipUrl(subtitle.data) { name, _ -> + override suspend fun SubtitleResource.getResources(data: AbstractSubtitleEntities.SubtitleEntity) { + this.addZipUrl(data.data) { name, _ -> name } } + private suspend fun initLogin(useremail: String, password: String): Boolean { + + val tokenResponse = app.post( + url = "$APIURL/login", + data = 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, @@ -131,7 +189,7 @@ class SubDlApi : SubtitleAPI() { ) data class OAuthTokenResponse( - @JsonProperty("token") val token: String, + @JsonProperty("token") val token: String? = null, @JsonProperty("userData") val userData: UserData? = null, @JsonProperty("status") val status: Boolean? = null, @JsonProperty("message") val message: String? = null, @@ -149,7 +207,7 @@ class SubDlApi : SubtitleAPI() { data class ApiKeyResponse( @JsonProperty("ok") val ok: Boolean? = false, - @JsonProperty("api_key") val apiKey: String, + @JsonProperty("api_key") val apiKey: String? = null, @JsonProperty("usage") val usage: Usage? = null, ) @@ -177,83 +235,13 @@ class SubDlApi : SubtitleAPI() { data class Subtitle( @JsonProperty("release_name") val releaseName: String, @JsonProperty("name") val name: String, - @JsonProperty("lang") val lang: String, // subdl language code + @JsonProperty("lang") val lang: String, @JsonProperty("author") val author: String? = null, @JsonProperty("url") val url: String? = null, @JsonProperty("subtitlePage") val subtitlePage: String? = null, @JsonProperty("season") val season: Int? = null, @JsonProperty("episode") val episode: Int? = null, - @JsonProperty("language") val language: String? = null, // full language name + @JsonProperty("language") val language: String? = null, @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 8ec082520..9150cfc5e 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/APIRepository.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/APIRepository.kt @@ -9,29 +9,22 @@ import com.lagradost.cloudstream3.LoadResponse import com.lagradost.cloudstream3.MainAPI import com.lagradost.cloudstream3.MainActivity.Companion.afterPluginsLoadedEvent import com.lagradost.cloudstream3.MainPageRequest -import com.lagradost.cloudstream3.SearchResponseList +import com.lagradost.cloudstream3.SearchResponse import com.lagradost.cloudstream3.SubtitleFile import com.lagradost.cloudstream3.TvType import com.lagradost.cloudstream3.fixUrl import com.lagradost.cloudstream3.mvvm.Resource import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.mvvm.safeApiCall -import com.lagradost.cloudstream3.newSearchResponseList -import com.lagradost.cloudstream3.utils.Coroutines.atomicListOf +import com.lagradost.cloudstream3.utils.Coroutines.threadSafeListOf import com.lagradost.cloudstream3.utils.ExtractorLink import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.GlobalScope.coroutineContext import kotlinx.coroutines.async import kotlinx.coroutines.delay -import kotlinx.coroutines.withTimeout class APIRepository(val api: MainAPI) { companion object { - // 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 MIN_TIMEOUT = 5_000L - var dubStatusActive = HashSet() val noneApi = object : MainAPI() { @@ -55,18 +48,16 @@ class APIRepository(val api: MainAPI) { val hash: Pair ) - private val cache = atomicListOf() + private val cache = threadSafeListOf() private var cacheIndex: Int = 0 const val CACHE_SIZE = 20 - - fun getTimeout(desired: Long?): Long { - return (desired ?: DEFAULT_TIMEOUT).coerceIn(MIN_TIMEOUT, MAX_TIMEOUT) - } } private fun afterPluginsLoaded(forceReload: Boolean) { if (forceReload) { - cache.clear() + synchronized(cache) { + cache.clear() + } } } @@ -84,66 +75,54 @@ class APIRepository(val api: MainAPI) { suspend fun load(url: String): Resource { return safeApiCall { - withTimeout(getTimeout(api.loadTimeoutMs)) { - if (isInvalidData(url)) throw ErrorLoadingException() - val fixedUrl = api.fixUrl(url) - val lookingForHash = Pair(api.name, fixedUrl) + if (isInvalidData(url)) throw ErrorLoadingException() + val fixedUrl = api.fixUrl(url) + val lookingForHash = Pair(api.name, fixedUrl) - val cached = cache.withLock { - var found: LoadResponse? = null - for (item in cache) { - // 10 min save - if (item.hash == lookingForHash && (unixTime - item.unixTime) < 60 * 10) { - found = item.response - break - } + synchronized(cache) { + for (item in cache) { + // 10 min save + if (item.hash == lookingForHash && (unixTime - item.unixTime) < 60 * 10) { + return@safeApiCall item.response } - found } + } - if (cached != null) return@withTimeout cached - api.load(fixedUrl)?.also { response -> - // Remove all blank tags as early as possible - response.tags = response.tags?.filter { it.isNotBlank() } - val add = SavedLoadResponse(unixTime, response, lookingForHash) + api.load(fixedUrl)?.also { response -> + // Remove all blank tags as early as possible + response.tags = response.tags?.filter { it.isNotBlank() } + val add = SavedLoadResponse(unixTime, response, lookingForHash) - cache.withLock { - if (cache.size > CACHE_SIZE) { - cache[cacheIndex] = add // rolling cache - cacheIndex = (cacheIndex + 1) % CACHE_SIZE - } else { - cache.add(add) - } + synchronized(cache) { + if (cache.size > CACHE_SIZE) { + cache[cacheIndex] = add // rolling cache + cacheIndex = (cacheIndex + 1) % CACHE_SIZE + } else { + cache.add(add) } - } ?: throw ErrorLoadingException() - } + } + } ?: throw ErrorLoadingException() } } - suspend fun search(query: String, page: Int): Resource { + suspend fun search(query: String): Resource> { if (query.isEmpty()) - return Resource.Success(newSearchResponseList(emptyList())) + return Resource.Success(emptyList()) return safeApiCall { - withTimeout(getTimeout(api.searchTimeoutMs)) { - (api.search(query, page) - ?: throw ErrorLoadingException()) - // .filter { typesActive.contains(it.type) } - } + return@safeApiCall (api.search(query) + ?: throw ErrorLoadingException()) +// .filter { typesActive.contains(it.type) } + .toList() } } - suspend fun quickSearch(query: String): Resource { + suspend fun quickSearch(query: String): Resource> { if (query.isEmpty()) - return Resource.Success(newSearchResponseList(emptyList())) + return Resource.Success(emptyList()) return safeApiCall { - withTimeout(getTimeout(api.quickSearchTimeoutMs)) { - newSearchResponseList( - api.quickSearch(query) ?: throw ErrorLoadingException(), - false - ) - } + api.quickSearch(query) ?: throw ErrorLoadingException() } } @@ -155,40 +134,38 @@ class APIRepository(val api: MainAPI) { suspend fun getMainPage(page: Int, nameIndex: Int? = null): Resource> { return safeApiCall { - withTimeout(getTimeout(api.getMainPageTimeoutMs)) { - api.lastHomepageRequest = unixTimeMS + api.lastHomepageRequest = unixTimeMS + + nameIndex?.let { api.mainPage.getOrNull(it) }?.let { data -> + listOf( + api.getMainPage( + page, + MainPageRequest(data.name, data.data, data.horizontalImages) + ) + ) + } ?: run { + if (api.sequentialMainPage) { + var first = true + api.mainPage.map { data -> + if (!first) // dont want to sleep on first request + delay(api.sequentialMainPageDelay) + first = false - nameIndex?.let { api.mainPage.getOrNull(it) }?.let { data -> - listOf( api.getMainPage( page, MainPageRequest(data.name, data.data, data.horizontalImages) ) - ) - } ?: run { - if (api.sequentialMainPage) { - var first = true + } + } else { + with(CoroutineScope(coroutineContext)) { api.mainPage.map { data -> - if (!first) // dont want to sleep on first request - delay(api.sequentialMainPageDelay) - first = false - - api.getMainPage( - page, - MainPageRequest(data.name, data.data, data.horizontalImages) - ) - } - } else { - with(CoroutineScope(coroutineContext)) { - api.mainPage.map { data -> - async { - api.getMainPage( - page, - MainPageRequest(data.name, data.data, data.horizontalImages) - ) - } - }.map { it.await() } - } + async { + api.getMainPage( + page, + MainPageRequest(data.name, data.data, data.horizontalImages) + ) + } + }.map { it.await() } } } } @@ -209,12 +186,10 @@ class APIRepository(val api: MainAPI) { ): Boolean { if (isInvalidData(data)) return false // this makes providers cleaner return try { - withTimeout(getTimeout(api.loadLinksTimeoutMs)) { - api.loadLinks(data, isCasting, subtitleCallback, callback) - } + api.loadLinks(data, isCasting, subtitleCallback, callback) } catch (throwable: Throwable) { logError(throwable) return false } } -} +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/BaseAdapter.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/BaseAdapter.kt index 4ebb7564c..e930961c5 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/BaseAdapter.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/BaseAdapter.kt @@ -1,55 +1,34 @@ package com.lagradost.cloudstream3.ui -import android.content.Context import android.view.View import android.view.ViewGroup -import android.widget.ImageView import androidx.core.view.children +import androidx.fragment.app.Fragment +import androidx.fragment.app.viewModels +import androidx.lifecycle.ViewModel import androidx.recyclerview.widget.AsyncDifferConfig import androidx.recyclerview.widget.AsyncListDiffer import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView.ViewHolder import androidx.viewbinding.ViewBinding -import coil3.dispose -import java.util.WeakHashMap import java.util.concurrent.CopyOnWriteArrayList open class ViewHolderState(val view: ViewBinding) : ViewHolder(view.root) { open fun save(): T? = null open fun restore(state: T) = Unit + open fun onViewAttachedToWindow() = Unit + open fun onViewDetachedFromWindow() = Unit + open fun onViewRecycled() = Unit } -abstract class NoStateAdapter( - diffCallback: DiffUtil.ItemCallback = BaseDiffCallback() -) : BaseAdapter(0, diffCallback) -/** Creates a new shared pool, using the supplied lambda as a constructor. - * - * The reason for this complicated structure is that a pool should not be shared between contexts - * as it makes coil fuck up, and theming. - * */ -fun newSharedPool(lambda: RecyclerView.RecycledViewPool.() -> Unit = { }): Pair, RecyclerView.RecycledViewPool.() -> Unit> = - WeakHashMap() to lambda - -/** Sets the shared pool of the recyclerview */ -fun RecyclerView.setRecycledViewPool(pool: Pair, RecyclerView.RecycledViewPool.() -> Unit>) { - val ctx = context ?: return - synchronized(pool.first) { - this.setRecycledViewPool(pool.first.getOrPut(ctx) { - RecyclerView.RecycledViewPool().apply(pool.second) - }) - } +// Based of the concept https://github.com/brahmkshatriya/echo/blob/main/app%2Fsrc%2Fmain%2Fjava%2Fdev%2Fbrahmkshatriya%2Fecho%2Fui%2Fadapters%2FMediaItemsContainerAdapter.kt#L108-L154 +class StateViewModel : ViewModel() { + val layoutManagerStates = hashMapOf>() } -/** Clears the shared pool of views */ -fun Pair, RecyclerView.RecycledViewPool.() -> Unit>.clear() { - synchronized(this.first) { - for (pool in this.first.values) { - pool?.clear() - } - } -} +abstract class NoStateAdapter(fragment: Fragment) : BaseAdapter(fragment, 0) /** * BaseAdapter is a persistent state stored adapter that supports headers and footers. @@ -70,14 +49,13 @@ fun Pair, RecyclerView.Recyc abstract class BaseAdapter< T : Any, S : Any>( + fragment: Fragment, val id: Int = 0, diffCallback: DiffUtil.ItemCallback = BaseDiffCallback() ) : RecyclerView.Adapter>() { open val footers: Int = 0 open val headers: Int = 0 - val immutableCurrentList: List get() = mDiffer.currentList - fun getItem(position: Int): T { return mDiffer.currentList[position] } @@ -107,33 +85,9 @@ abstract class BaseAdapter< AsyncDifferConfig.Builder(diffCallback).build() ) - /** - * Instantly submits a **new and fresh** list. This means that no changes like moves are done as - * we assume the new list is not the same thing as the old list, nothing is shared. - * - * The views are rendered instantly as a result, so no fade/pop-ins or similar. - * - * Use `submitList` for general use, as that can reuse old views. - * */ - open fun submitIncomparableList(list: List?, commitCallback : Runnable? = null) { - // This leverages a quirk in the submitList function that has a fast case for null arrays - // What this implies is that as long as we do a double submit we can ensure no pop-ins, - // as the changes are the entire list instead of calculating deltas - submitList(null) - submitList(list, commitCallback) - } - - /** - * @param commitCallback Optional runnable that is executed when the List is committed, if it is committed. - * This is needed for some tasks as submitList will use a background thread for diff - * */ - open fun submitList(list: Collection?, commitCallback : Runnable? = null) { + open fun submitList(list: List?) { // deep copy at least the top list, because otherwise adapter can go crazy - if (list.isNullOrEmpty()) { - mDiffer.submitList(null, commitCallback) // It is "faster" to submit null than emptyList() - } else { - mDiffer.submitList(CopyOnWriteArrayList(list), commitCallback) - } + mDiffer.submitList(list?.let { CopyOnWriteArrayList(it) }) } override fun getItemCount(): Int { @@ -147,25 +101,16 @@ abstract class BaseAdapter< open fun onBindFooter(holder: ViewHolderState) = Unit open fun onBindHeader(holder: ViewHolderState) = Unit open fun onCreateContent(parent: ViewGroup): ViewHolderState = throw NotImplementedError() - open fun onCreateCustomContent( - parent: ViewGroup, - viewType: Int - ) = onCreateContent(parent) - open fun onCreateFooter(parent: ViewGroup): ViewHolderState = throw NotImplementedError() - open fun onCreateCustomFooter( - parent: ViewGroup, - viewType: Int - ) = onCreateFooter(parent) - open fun onCreateHeader(parent: ViewGroup): ViewHolderState = throw NotImplementedError() - open fun onCreateCustomHeader( - parent: ViewGroup, - viewType: Int - ) = onCreateHeader(parent) - override fun onViewAttachedToWindow(holder: ViewHolderState) {} - override fun onViewDetachedFromWindow(holder: ViewHolderState) {} + override fun onViewAttachedToWindow(holder: ViewHolderState) { + holder.onViewAttachedToWindow() + } + + override fun onViewDetachedFromWindow(holder: ViewHolderState) { + holder.onViewDetachedFromWindow() + } @Suppress("UNCHECKED_CAST") fun save(recyclerView: RecyclerView) { @@ -176,20 +121,21 @@ abstract class BaseAdapter< } } - fun clearState() { - layoutManagerStates[id]?.clear() + fun clear() { + stateViewModel.layoutManagerStates[id]?.clear() } @Suppress("UNCHECKED_CAST") private fun getState(holder: ViewHolderState): S? = - layoutManagerStates[id]?.get(holder.absoluteAdapterPosition) as? S + stateViewModel.layoutManagerStates[id]?.get(holder.absoluteAdapterPosition) as? S private fun setState(holder: ViewHolderState) { - if (id == 0) return - if (!layoutManagerStates.contains(id)) { - layoutManagerStates[id] = HashMap() + if(id == 0) return + + if (!stateViewModel.layoutManagerStates.contains(id)) { + stateViewModel.layoutManagerStates[id] = HashMap() } - layoutManagerStates[id]?.let { map -> + stateViewModel.layoutManagerStates[id]?.let { map -> map[holder.absoluteAdapterPosition] = holder.save() } } @@ -212,40 +158,30 @@ abstract class BaseAdapter< super.onDetachedFromRecyclerView(recyclerView) } - open fun customContentViewType(item: T): Int = 0 - open fun customFooterViewType(): Int = 0 - open fun customHeaderViewType(): Int = 0 - final override fun getItemViewType(position: Int): Int { if (position < headers) { - return HEADER or customHeaderViewType() + return HEADER } - val realPosition = position - headers - if (realPosition >= mDiffer.currentList.size) { - return FOOTER or customFooterViewType() + if (position - headers >= mDiffer.currentList.size) { + return FOOTER } - return CONTENT or customContentViewType(getItem(realPosition)) + + return CONTENT } + private val stateViewModel: StateViewModel by fragment.viewModels() + final override fun onViewRecycled(holder: ViewHolderState) { setState(holder) - onClearView(holder) + holder.onViewRecycled() super.onViewRecycled(holder) } - /** Same as onViewRecycled, but for the purpose of cleaning the view of any relevant data. - * - * If an item view has large or expensive data bound to it such as large bitmaps, this may be a good place to release those resources. - * - * Use this with `clearImage` - * */ - open fun onClearView(holder: ViewHolderState) {} - final override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolderState { - return when (viewType and TYPE_MASK) { - CONTENT -> onCreateCustomContent(parent, viewType and CUSTOM_MASK) - HEADER -> onCreateCustomHeader(parent, viewType and CUSTOM_MASK) - FOOTER -> onCreateCustomFooter(parent, viewType and CUSTOM_MASK) + return when (viewType) { + CONTENT -> onCreateContent(parent) + HEADER -> onCreateHeader(parent) + FOOTER -> onCreateFooter(parent) else -> throw NotImplementedError() } } @@ -260,7 +196,7 @@ abstract class BaseAdapter< super.onBindViewHolder(holder, position, payloads) return } - when (getItemViewType(position) and TYPE_MASK) { + when (getItemViewType(position)) { CONTENT -> { val realPosition = position - headers val item = getItem(realPosition) @@ -278,7 +214,7 @@ abstract class BaseAdapter< } final override fun onBindViewHolder(holder: ViewHolderState, position: Int) { - when (getItemViewType(position) and TYPE_MASK) { + when (getItemViewType(position)) { CONTENT -> { val realPosition = position - headers val item = getItem(realPosition) @@ -300,20 +236,9 @@ abstract class BaseAdapter< } companion object { - val layoutManagerStates = hashMapOf>() - fun clearImage(image: ImageView?) { - image?.dispose() - } - - // Use the lowermost MASK_SIZE bits for the custom content, - // use the uppermost 32 - MASK_SIZE to the type - private const val MASK_SIZE = 28 - private const val CUSTOM_MASK = (1 shl MASK_SIZE) - 1 - private const val TYPE_MASK = CUSTOM_MASK.inv() - const val HEADER: Int = 3 shl MASK_SIZE - const val FOOTER: Int = 2 shl MASK_SIZE - /** For custom content, write `CONTENT or X` when calling setMaxRecycledViews */ - const val CONTENT: Int = 1 shl MASK_SIZE + private const val HEADER: Int = 1 + private const val FOOTER: Int = 2 + private const val CONTENT: Int = 0 } } @@ -323,5 +248,5 @@ class BaseDiffCallback( ) : DiffUtil.ItemCallback() { override fun areItemsTheSame(oldItem: T, newItem: T): Boolean = itemSame(oldItem, newItem) override fun areContentsTheSame(oldItem: T, newItem: T): Boolean = contentSame(oldItem, newItem) - override fun getChangePayload(oldItem: T, newItem: T): Any? = Any() + override fun getChangePayload(oldItem: T, newItem: T): Any = Any() } \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/BaseFragment.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/BaseFragment.kt deleted file mode 100644 index 72955e7cf..000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/BaseFragment.kt +++ /dev/null @@ -1,278 +0,0 @@ -package com.lagradost.cloudstream3.ui - -import android.content.res.Configuration -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.widget.Toast -import androidx.annotation.LayoutRes -import androidx.fragment.app.DialogFragment -import androidx.fragment.app.Fragment -import androidx.preference.PreferenceFragmentCompat -import androidx.viewbinding.ViewBinding -import com.google.android.material.bottomsheet.BottomSheetDialogFragment -import com.lagradost.cloudstream3.CommonActivity.showToast -import com.lagradost.cloudstream3.R -import com.lagradost.cloudstream3.mvvm.logError -import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setSystemBarsPadding -import com.lagradost.cloudstream3.utils.txt - -/** - * A base Fragment class that simplifies ViewBinding usage and handles view inflation safely. - * - * This class allows two modes of creating ViewBinding: - * 1. Inflate: Using the standard `inflate()` method provided by generated ViewBinding classes. - * 2. Bind: Using `bind()` on an existing root view. - * - * It also provides hooks for: - * - Safe initialization of the binding (`onBindingCreated`) - * - Automatic padding adjustment for system bars (`fixPadding`) - * - Optional layout resource selection via `pickLayout()` - * - * @param T The type of ViewBinding for this Fragment. - * @param bindingCreator The strategy used to create the binding instance. - */ -private interface BaseFragmentHelper { - val bindingCreator: BaseFragment.BindingCreator - - var _binding: T? - val binding: T? get() = _binding - - fun createBinding( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View? { - val layoutId = pickLayout() - val root: View? = layoutId?.let { inflater.inflate(it, container, false) } - _binding = try { - when (val creator = bindingCreator) { - is BaseFragment.BindingCreator.Inflate -> creator.fn(inflater, container, false) - is BaseFragment.BindingCreator.Bind -> { - if (root != null) creator.fn(root) - else throw IllegalStateException("Root view is null for bind()") - } - } - } catch (t: Throwable) { - showToast( - txt(R.string.unable_to_inflate, t.message ?: ""), - Toast.LENGTH_LONG - ) - logError(t) - null - } - - return _binding?.root ?: root - } - - /** - * Called after the fragment's view has been created. - * - * This method is `final` to ensure that the binding is properly initialized and - * system bar padding adjustments are applied before any subclass logic runs. - * Subclasses should use [onBindingCreated] instead of overriding this method directly. - */ - fun onViewReady(view: View, savedInstanceState: Bundle?) { - fixLayout(view) - binding?.let { onBindingCreated(it, savedInstanceState) } - } - - /** - * Called when the binding is safely created and view is ready. - * Can be overridden to provide fragment-specific initialization. - * - * @param binding The safely created ViewBinding. - * @param savedInstanceState Saved state bundle or null. - */ - fun onBindingCreated(binding: T, savedInstanceState: Bundle?) { - onBindingCreated(binding) - } - - /** - * Called when the binding is safely created and view is ready. - * Overload without savedInstanceState for convenience. - * - * @param binding The safely created ViewBinding. - */ - fun onBindingCreated(binding: T) {} - - /** - * Pick a layout resource ID for the fragment. - * - * Return `null` by default. Override to provide a layout resource when using - * `BindingCreator.Bind`. Not needed if using `BindingCreator.Inflate`. - * - * @return Layout resource ID or null. - */ - @LayoutRes - fun pickLayout(): Int? = null - - /** - * Ensures the layout of the root view is correctly adjusted for the current configuration. - * - * This may include applying padding for system bars, adjusting insets, or performing other - * layout updates. `fixLayout` should remain idempotent, as it can be called multiple - * times on the same view, such as during configuration changes (e.g. device rotation) or when - * the view is recreated. - * - * @param view The root view to adjust. - */ - fun fixLayout(view: View) -} - -abstract class BaseFragment( - override val bindingCreator: BindingCreator -) : Fragment(), BaseFragmentHelper { - override var _binding: T? = null - - /** Safer activity?.onBackPressedDispatcher?.onBackPressed() with fallback behavior instead of app crash */ - fun dispatchBackPressed() { - try { - activity?.onBackPressedDispatcher?.onBackPressed() - } catch (_: IllegalStateException) { - // FragmentManager is already executing transactions, so try again - delayedDispatchBackPressed(5) - } catch (t: Throwable) { - logError(t) - } - } - - /** Recursive back press when available */ - private fun delayedDispatchBackPressed(remaining: Int) { - if (remaining <= 0) return - binding?.root?.postDelayed({ - try { - activity?.onBackPressedDispatcher?.onBackPressed() - } catch (_: IllegalStateException) { - // FragmentManager is already executing transactions, so try again - delayedDispatchBackPressed(remaining - 1) - } catch (t: Throwable) { - logError(t) - } - }, 200) - } - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View? = createBinding(inflater, container, savedInstanceState) - - final override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - onViewReady(view, savedInstanceState) - } - - /** - * Called when the device configuration changes (e.g., orientation). - * Re-applies system bar padding fixes to the root view to ensure it - * readjusts for orientation changes. - */ - override fun onConfigurationChanged(newConfig: Configuration) { - super.onConfigurationChanged(newConfig) - view?.let { fixLayout(it) } - } - - /** Cleans up the binding reference when the view is destroyed to avoid memory leaks. */ - override fun onDestroyView() { - super.onDestroyView() - _binding = null - } - - /** - * Sealed class representing the two strategies for creating a ViewBinding instance. - */ - sealed class BindingCreator { - - /** - * Use the standard inflate() method for creating the binding. - * - * @param fn Lambda that inflates the binding. - */ - class Inflate( - val fn: (LayoutInflater, ViewGroup?, Boolean) -> T - ) : BindingCreator() - - /** - * Use bind() on an existing root view to create the binding. This should - * be used if you are differing per device layouts, such as different - * layouts for TV and Phone. - * - * @param fn Lambda that binds the root view. - */ - class Bind( - val fn: (View) -> T - ) : BindingCreator() - } -} - -abstract class BaseDialogFragment( - override val bindingCreator: BaseFragment.BindingCreator -) : DialogFragment(), BaseFragmentHelper { - override var _binding: T? = null - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View? = createBinding(inflater, container, savedInstanceState) - - final override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - onViewReady(view, savedInstanceState) - } - - /** @see [BaseFragment.onConfigurationChanged] for documentation. */ - override fun onConfigurationChanged(newConfig: Configuration) { - super.onConfigurationChanged(newConfig) - view?.let { fixLayout(it) } - } - - /** Cleans up the binding reference when the view is destroyed to avoid memory leaks. */ - override fun onDestroyView() { - super.onDestroyView() - _binding = null - } -} - -abstract class BaseBottomSheetDialogFragment( - override val bindingCreator: BaseFragment.BindingCreator -) : BottomSheetDialogFragment(), BaseFragmentHelper { - override var _binding: T? = null - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View? = createBinding(inflater, container, savedInstanceState) - - final override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - onViewReady(view, savedInstanceState) - } - - /** @see [BaseFragment.onConfigurationChanged] for documentation. */ - override fun onConfigurationChanged(newConfig: Configuration) { - super.onConfigurationChanged(newConfig) - view?.let { fixLayout(it) } - } - - /** Cleans up the binding reference when the view is destroyed to avoid memory leaks. */ - override fun onDestroyView() { - super.onDestroyView() - _binding = null - } -} - -abstract class BasePreferenceFragmentCompat() : PreferenceFragmentCompat() { - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - setSystemBarsPadding() - } - - override fun onConfigurationChanged(newConfig: Configuration) { - super.onConfigurationChanged(newConfig) - setSystemBarsPadding() - } -} diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/ControllerActivity.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/ControllerActivity.kt index 2aadfb13c..4b5d680c3 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/ControllerActivity.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/ControllerActivity.kt @@ -12,7 +12,9 @@ import android.widget.ImageView import android.widget.LinearLayout import android.widget.ListView import androidx.appcompat.app.AlertDialog -import com.google.android.gms.cast.MediaLoadOptions +import com.fasterxml.jackson.databind.DeserializationFeature +import com.fasterxml.jackson.databind.json.JsonMapper +import com.fasterxml.jackson.module.kotlin.kotlinModule import com.google.android.gms.cast.MediaQueueItem import com.google.android.gms.cast.MediaSeekOptions import com.google.android.gms.cast.MediaStatus.REPEAT_MODE_REPEAT_OFF @@ -102,6 +104,9 @@ data class MetadataHolder( class SelectSourceController(val view: ImageView, val activity: ControllerActivity) : UIController() { + private val mapper: JsonMapper = JsonMapper.builder().addModule(kotlinModule()) + .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false).build() + init { view.setImageResource(R.drawable.ic_baseline_playlist_play_24) view.setOnClickListener { @@ -234,27 +239,12 @@ class SelectSourceController(val view: ImageView, val activity: ControllerActivi loadMirror(index + 1) } } else { - val mediaLoadOptions = - MediaLoadOptions.Builder() - .setPlayPosition(startAt) - .setAutoplay(true) - .build() - awaitLinks( - remoteMediaClient?.load( - mediaItem, - mediaLoadOptions - ) - ) { + awaitLinks(remoteMediaClient?.load(mediaItem, true, startAt)) { loadMirror(index + 1) } } } catch (e: Exception) { - val mediaLoadOptions = - MediaLoadOptions.Builder() - .setPlayPosition(startAt) - .setAutoplay(true) - .build() - awaitLinks(remoteMediaClient?.load(mediaItem, mediaLoadOptions)) { + awaitLinks(remoteMediaClient?.load(mediaItem, true, startAt)) { loadMirror(index + 1) } } @@ -298,13 +288,7 @@ class SelectSourceController(val view: ImageView, val activity: ControllerActivi val currentDuration = remoteMediaClient?.streamDuration val currentPosition = remoteMediaClient?.approximateStreamPosition if (currentDuration != null && currentPosition != null) - DataStoreHelper.setViewPosAndResume( - epData.id, - currentPosition, - currentDuration, - epData, - meta.episodes.getOrNull(index + 1) - ) + DataStoreHelper.setViewPos(epData.id, currentPosition, currentDuration) } catch (t: Throwable) { logError(t) } @@ -320,7 +304,7 @@ class SelectSourceController(val view: ImageView, val activity: ControllerActivi val isSuccessful = safeApiCall { generator.generateLinks( clearCache = false, - sourceTypes = LOADTYPE_CHROMECAST, + allowedTypes = LOADTYPE_CHROMECAST, callback = { it.first?.let { link -> currentLinks.add(link) @@ -328,9 +312,7 @@ class SelectSourceController(val view: ImageView, val activity: ControllerActivi }, subtitleCallback = { currentSubs.add(it) }, - offset = 0, - isCasting = true - ) + isCasting = true) } val sortedLinks = sortUrls(currentLinks) @@ -443,4 +425,4 @@ class ControllerActivity : ExpandedControllerActivity() { SkipNextEpisodeController(skipOpButton) ) } -} +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/CustomRecyclerViews.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/CustomRecyclerViews.kt index 302358538..78ad2a6bf 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/CustomRecyclerViews.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/CustomRecyclerViews.kt @@ -3,7 +3,6 @@ package com.lagradost.cloudstream3.ui import android.content.Context import android.util.AttributeSet import android.view.View -import androidx.core.content.withStyledAttributes import androidx.core.view.children import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.RecyclerView @@ -155,9 +154,10 @@ class AutofitRecyclerView @JvmOverloads constructor(context: Context, attrs: Att init { if (attrs != null) { - context.withStyledAttributes(attrs, intArrayOf(android.R.attr.columnWidth)) { - columnWidth = getDimensionPixelSize(0, -1) - } + val attrsArray = intArrayOf(android.R.attr.columnWidth) + val array = context.obtainStyledAttributes(attrs, attrsArray) + columnWidth = array.getDimensionPixelSize(0, -1) + array.recycle() } layoutManager = manager diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/EasterEggMonke.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/EasterEggMonke.kt new file mode 100644 index 000000000..4879d2e0b --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/EasterEggMonke.kt @@ -0,0 +1,97 @@ +// https://github.com/googlecodelabs/android-kotlin-animation-property-animation/tree/master/begin + +package com.lagradost.cloudstream3.ui + +import android.animation.Animator +import android.animation.AnimatorListenerAdapter +import android.animation.AnimatorSet +import android.animation.ObjectAnimator +import android.os.Bundle +import android.os.Handler +import android.view.View +import android.view.animation.AccelerateInterpolator +import android.view.animation.LinearInterpolator +import android.widget.FrameLayout +import androidx.appcompat.app.AppCompatActivity +import androidx.appcompat.widget.AppCompatImageView +import androidx.core.view.isVisible +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.databinding.ActivityEasterEggMonkeBinding + +class EasterEggMonke : AppCompatActivity() { + + lateinit var binding : ActivityEasterEggMonkeBinding + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + binding = ActivityEasterEggMonkeBinding.inflate(layoutInflater) + setContentView(binding.root) + + val handler = Handler(mainLooper) + lateinit var runnable: Runnable + runnable = Runnable { + shower() + handler.postDelayed(runnable, 300) + } + handler.postDelayed(runnable, 1000) + } + + private fun shower() { + + val containerW = binding.frame.width + val containerH = binding.frame.height + var starW: Float = binding.monke.width.toFloat() + var starH: Float = binding.monke.height.toFloat() + + val newStar = AppCompatImageView(this) + val idx = (monkeys.size * Math.random()).toInt() + newStar.setImageResource(monkeys[idx]) + newStar.isVisible = true + newStar.layoutParams = FrameLayout.LayoutParams(FrameLayout.LayoutParams.WRAP_CONTENT, + FrameLayout.LayoutParams.WRAP_CONTENT) + binding.frame.addView(newStar) + + newStar.scaleX += Math.random().toFloat() * 1.5f + newStar.scaleY = newStar.scaleX + starW *= newStar.scaleX + starH *= newStar.scaleY + + newStar.translationX = Math.random().toFloat() * containerW - starW / 2 + + val mover = ObjectAnimator.ofFloat(newStar, View.TRANSLATION_Y, -starH, containerH + starH) + mover.interpolator = AccelerateInterpolator(1f) + + val rotator = ObjectAnimator.ofFloat(newStar, View.ROTATION, + (Math.random() * 1080).toFloat()) + rotator.interpolator = LinearInterpolator() + + val set = AnimatorSet() + set.playTogether(mover, rotator) + set.duration = (Math.random() * 1500 + 2500).toLong() + + set.addListener(object : AnimatorListenerAdapter() { + override fun onAnimationEnd(animation: Animator) { + binding.frame.removeView(newStar) + } + }) + + set.start() + } + + companion object { + val monkeys = listOf( + R.drawable.monke_benene, + R.drawable.monke_burrito, + R.drawable.monke_coco, + R.drawable.monke_cookie, + R.drawable.monke_flusdered, + R.drawable.monke_funny, + R.drawable.monke_like, + R.drawable.monke_party, + R.drawable.monke_sob, + R.drawable.monke_drink, + R.drawable.benene, + R.drawable.ic_launcher_foreground + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/EasterEggMonkeFragment.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/EasterEggMonkeFragment.kt deleted file mode 100644 index 9be862077..000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/EasterEggMonkeFragment.kt +++ /dev/null @@ -1,177 +0,0 @@ -package com.lagradost.cloudstream3.ui - -import android.animation.Animator -import android.animation.AnimatorListenerAdapter -import android.animation.ObjectAnimator -import android.annotation.SuppressLint -import android.view.MotionEvent -import android.view.View -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.lifecycle.lifecycleScope -import com.lagradost.cloudstream3.R -import com.lagradost.cloudstream3.databinding.FragmentEasterEggMonkeBinding -import com.lagradost.cloudstream3.utils.UIHelper.hideSystemUI -import com.lagradost.cloudstream3.utils.UIHelper.showSystemUI -import kotlinx.coroutines.Job -import kotlinx.coroutines.delay -import kotlinx.coroutines.isActive -import kotlinx.coroutines.launch -import kotlin.random.Random - -class EasterEggMonkeFragment : BaseFragment( - BaseFragment.BindingCreator.Inflate(FragmentEasterEggMonkeBinding::inflate) -) { - - // planet of monks - private val monkeys: List = listOf( - R.drawable.monke_benene, - R.drawable.monke_burrito, - R.drawable.monke_coco, - R.drawable.monke_cookie, - R.drawable.monke_flusdered, - R.drawable.monke_funny, - R.drawable.monke_like, - R.drawable.monke_party, - R.drawable.monke_sob, - R.drawable.monke_drink, - R.drawable.benene, - R.drawable.ic_launcher_foreground, - R.drawable.quick_novel_icon, - ) - - private val activeMonkeys = mutableListOf() - private var spawningJob: Job? = null - - override fun fixLayout(view: View) = Unit - - override fun onBindingCreated(binding: FragmentEasterEggMonkeBinding) { - activity?.hideSystemUI() - spawningJob = lifecycleScope.launch { - delay(1000) - while (isActive) { - spawnMonkey(binding) - delay(500) - } - } - } - - private fun spawnMonkey(binding: FragmentEasterEggMonkeBinding) { - val newMonkey = ImageView(context ?: return).apply { - setImageResource(monkeys.random()) - isVisible = true - } - - val initialScale = Random.nextFloat() * 1.5f + 0.5f - newMonkey.scaleX = initialScale - newMonkey.scaleY = initialScale - - newMonkey.measure(View.MeasureSpec.UNSPECIFIED, View.MeasureSpec.UNSPECIFIED) - val monkeyW = newMonkey.measuredWidth * initialScale - val monkeyH = newMonkey.measuredHeight * initialScale - - newMonkey.x = Random.nextFloat() * (binding.frame.width.toFloat() - monkeyW) - newMonkey.y = Random.nextFloat() * (binding.frame.height.toFloat() - monkeyH) - - binding.frame.addView(newMonkey, FrameLayout.LayoutParams( - FrameLayout.LayoutParams.WRAP_CONTENT, FrameLayout.LayoutParams.WRAP_CONTENT - )) - - activeMonkeys.add(newMonkey) - - newMonkey.alpha = 0f - ObjectAnimator.ofFloat(newMonkey, View.ALPHA, 0f, 1f).apply { - duration = Random.nextLong(1000, 2500) - interpolator = AccelerateInterpolator() - start() - } - - @SuppressLint("ClickableViewAccessibility") - newMonkey.setOnTouchListener { view, event -> handleTouch(view, event, binding) } - - startFloatingAnimation(newMonkey, binding) - } - - private fun startFloatingAnimation(monkey: ImageView, binding: FragmentEasterEggMonkeBinding) { - val floatUpAnimator = ObjectAnimator.ofFloat( - monkey, View.TRANSLATION_Y, monkey.y, -monkey.height.toFloat() - ).apply { - duration = Random.nextLong(8000, 15000) - interpolator = LinearInterpolator() - } - - floatUpAnimator.addListener(object : AnimatorListenerAdapter() { - override fun onAnimationEnd(animation: Animator) { - binding.frame.removeView(monkey) - activeMonkeys.remove(monkey) - } - }) - - floatUpAnimator.start() - monkey.tag = floatUpAnimator - } - - private fun handleTouch( - view: View, - event: MotionEvent, - binding: FragmentEasterEggMonkeBinding - ): Boolean { - val monkey = view as ImageView - when (event.action) { - MotionEvent.ACTION_DOWN -> { - (monkey.tag as? ObjectAnimator)?.pause() - return true - } - - MotionEvent.ACTION_MOVE -> { - // Update both X and Y positions properly - monkey.x = event.rawX - monkey.width / 2 - monkey.y = event.rawY - monkey.height / 2 - - // Check if monkey touches the screen edge - if (isTouchingEdge(monkey, binding)) { - removeMonkey(monkey, binding) - } - return true - } - - MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> { - if (isTouchingEdge(monkey, binding)) { - removeMonkey(monkey, binding) - } else { - startFloatingAnimation(monkey, binding) - } - return true - } - } - return false - } - - 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, binding: FragmentEasterEggMonkeBinding) { - // Fade out and remove the monkey - ObjectAnimator.ofFloat(monkey, View.ALPHA, 1f, 0f).apply { - duration = 300 - addListener(object : AnimatorListenerAdapter() { - override fun onAnimationEnd(animation: Animator) { - binding.frame.removeView(monkey) - activeMonkeys.remove(monkey) - } - }) - start() - } - } - - override fun onDestroyView() { - super.onDestroyView() - activity?.showSystemUI() - spawningJob?.cancel() - } -} diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/HeaderViewDecoration.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/HeaderViewDecoration.kt new file mode 100644 index 000000000..40c03012a --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/HeaderViewDecoration.kt @@ -0,0 +1,42 @@ +package com.lagradost.cloudstream3.ui + +import android.graphics.Canvas +import android.graphics.Rect +import android.view.View +import androidx.recyclerview.widget.RecyclerView + +class HeaderViewDecoration(private val customView: View) : RecyclerView.ItemDecoration() { + override fun onDraw(c: Canvas, parent: RecyclerView, state: RecyclerView.State) { + super.onDraw(c, parent, state) + customView.layout(parent.left, 0, parent.right, customView.measuredHeight) + for (i in 0 until parent.childCount) { + val view = parent.getChildAt(i) + if (parent.getChildAdapterPosition(view) == 0) { + c.save() + val height = customView.measuredHeight + val top = view.top - height + c.translate(0f, top.toFloat()) + customView.draw(c) + c.restore() + break + } + } + } + + override fun getItemOffsets( + outRect: Rect, + view: View, + parent: RecyclerView, + state: RecyclerView.State + ) { + if (parent.getChildAdapterPosition(view) == 0) { + customView.measure( + View.MeasureSpec.makeMeasureSpec(parent.measuredWidth, View.MeasureSpec.AT_MOST), + View.MeasureSpec.makeMeasureSpec(parent.measuredHeight, View.MeasureSpec.AT_MOST) + ) + outRect.set(0, customView.measuredHeight, 0, 0) + } else { + outRect.setEmpty() + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/MiniControllerFragment.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/MiniControllerFragment.kt index bd8541e6b..aba6395f9 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/MiniControllerFragment.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/MiniControllerFragment.kt @@ -7,12 +7,12 @@ import android.view.View import android.widget.LinearLayout import android.widget.ProgressBar import android.widget.RelativeLayout -import androidx.core.content.withStyledAttributes import com.google.android.gms.cast.framework.media.widget.MiniControllerFragment import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.utils.UIHelper.adjustAlpha import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute import com.lagradost.cloudstream3.utils.UIHelper.toPx +import java.lang.ref.WeakReference class MyMiniControllerFragment : MiniControllerFragment() { @@ -25,15 +25,26 @@ class MyMiniControllerFragment : MiniControllerFragment() { // I KNOW, KINDA SPAGHETTI SOLUTION, BUT IT WORKS override fun onInflate(context: Context, attributeSet: AttributeSet, bundle: Bundle?) { - if (currentColor == 0) { - context.withStyledAttributes(attributeSet, R.styleable.CustomCast, 0, 0) { - if (hasValue(R.styleable.CustomCast_customCastBackgroundColor)) { - currentColor = getColor(R.styleable.CustomCast_customCastBackgroundColor, 0) - } - } - } - super.onInflate(context, attributeSet, bundle) + + // somehow this leaks and I really dont know why, it seams like if you go back to a fragment with this, it leaks???? + if (currentColor == 0) { + WeakReference( + context.obtainStyledAttributes( + attributeSet, + R.styleable.CustomCast + ) + ).apply { + if (get() + ?.hasValue(R.styleable.CustomCast_customCastBackgroundColor) == true + ) { + currentColor = + get() + ?.getColor(R.styleable.CustomCast_customCastBackgroundColor, 0) ?: 0 + } + get()?.recycle() + }.clear() + } } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { @@ -42,8 +53,8 @@ class MyMiniControllerFragment : MiniControllerFragment() { // SEE https://github.com/dandar3/android-google-play-services-cast-framework/blob/master/res/layout/cast_mini_controller.xml try { val progressBar: ProgressBar? = view.findViewById(R.id.progressBar) - val containerAll: LinearLayout? = view.findViewById(com.google.android.gms.cast.framework.R.id.container_all) - val containerCurrent: RelativeLayout? = view.findViewById(com.google.android.gms.cast.framework.R.id.container_current) + val containerAll: LinearLayout? = view.findViewById(R.id.container_all) + val containerCurrent: RelativeLayout? = view.findViewById(R.id.container_current) context?.let { ctx -> progressBar?.setBackgroundColor( @@ -68,4 +79,4 @@ class MyMiniControllerFragment : MiniControllerFragment() { // JUST IN CASE } } -} \ No newline at end of file +} 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 ec0ef5c6b..b778ba5a7 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/WatchType.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/WatchType.kt @@ -18,6 +18,15 @@ 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 0d951bf6a..5e2b97e57 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/WebviewFragment.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/WebviewFragment.kt @@ -1,12 +1,17 @@ package com.lagradost.cloudstream3.ui import android.os.Bundle +import android.view.LayoutInflater import android.view.View +import android.view.ViewGroup import android.webkit.JavascriptInterface import android.webkit.WebResourceRequest import android.webkit.WebView import android.webkit.WebViewClient +import androidx.annotation.OptIn +import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentActivity +import androidx.media3.common.util.UnstableApi import androidx.navigation.fragment.findNavController import com.lagradost.cloudstream3.MainActivity import com.lagradost.cloudstream3.USER_AGENT @@ -14,18 +19,19 @@ import com.lagradost.cloudstream3.databinding.FragmentWebviewBinding import com.lagradost.cloudstream3.network.WebViewResolver import com.lagradost.cloudstream3.utils.AppContextUtils.loadRepository -class WebviewFragment : BaseFragment( - BaseFragment.BindingCreator.Inflate(FragmentWebviewBinding::inflate) -) { - override fun fixLayout(view: View) = Unit +class WebviewFragment : Fragment() { - override fun onBindingCreated(binding: FragmentWebviewBinding) { + var binding: FragmentWebviewBinding? = null + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) val url = arguments?.getString(WEBVIEW_URL) ?: "".also { findNavController().popBackStack() } - binding.webView.webViewClient = object : WebViewClient() { + binding?.webView?.webViewClient = object : WebViewClient() { + @OptIn(UnstableApi::class) override fun shouldOverrideUrlLoading( view: WebView?, request: WebResourceRequest? @@ -40,17 +46,28 @@ class WebviewFragment : BaseFragment( return super.shouldOverrideUrlLoading(view, request) } } - - binding.webView.apply { + binding?.webView?.apply { WebViewResolver.webViewUserAgent = settings.userAgentString addJavascriptInterface(RepoApi(activity), "RepoApi") settings.javaScriptEnabled = true settings.userAgentString = USER_AGENT settings.domStorageEnabled = true +// WebView.setWebContentsDebuggingEnabled(true) loadUrl(url) } + + } + + override fun onCreateView( + inflater: LayoutInflater, container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + val localBinding = FragmentWebviewBinding.inflate(inflater, container, false) + binding = localBinding + // Inflate the layout for this fragment + return localBinding.root//inflater.inflate(R.layout.fragment_webview, container, false) } companion object { @@ -67,4 +84,4 @@ class WebviewFragment : BaseFragment( activity?.loadRepository(repoUrl) } } -} +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/account/AccountAdapter.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/account/AccountAdapter.kt index 92d33d0f3..de0b5c058 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,193 +1,157 @@ 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 coil3.transform.RoundedCornersTransformation +import androidx.recyclerview.widget.RecyclerView +import androidx.viewbinding.ViewBinding 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.result.setImage 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.DataStoreHelper -import com.lagradost.cloudstream3.utils.ImageLoader.loadImage +import com.lagradost.cloudstream3.utils.UIHelper.setImage class AccountAdapter( + private val accounts: List, private val accountSelectCallback: (DataStoreHelper.Account) -> Unit, private val accountCreateCallback: (DataStoreHelper.Account) -> Unit, private val accountEditCallback: (DataStoreHelper.Account) -> Unit, private val accountDeleteCallback: (DataStoreHelper.Account) -> Unit -) : NoStateAdapter() { +) : RecyclerView.Adapter() { companion object { const val VIEW_TYPE_SELECT_ACCOUNT = 0 + const val VIEW_TYPE_ADD_ACCOUNT = 1 const val VIEW_TYPE_EDIT_ACCOUNT = 2 } + inner class AccountViewHolder(private val binding: ViewBinding) : + RecyclerView.ViewHolder(binding.root) { - override val footers: Int = 1 - var viewType = VIEW_TYPE_SELECT_ACCOUNT + fun bind(account: DataStoreHelper.Account?) { + when (binding) { + is AccountListItemBinding -> binding.apply { + if (account == null) return@apply - override fun customContentViewType(item: DataStoreHelper.Account): Int { - return viewType - } + val isTv = isLayout(TV or EMULATOR) || !root.isInTouchMode - override fun onBindContent( - holder: ViewHolderState, - item: DataStoreHelper.Account, - position: Int - ) { - when (val binding = holder.view) { - is AccountListItemBinding -> binding.apply { - val isTv = isLayout(TV or EMULATOR) || !root.isInTouchMode + val isLastUsedAccount = account.keyIndex == DataStoreHelper.selectedKeyIndex - val isLastUsedAccount = item.keyIndex == DataStoreHelper.selectedKeyIndex + accountName.text = account.name + accountImage.setImage(account.image) + lockIcon.isVisible = account.lockPin != null + outline.isVisible = !isTv && isLastUsedAccount - accountName.text = item.name - accountImage.loadImage(item.image) - lockIcon.isVisible = item.lockPin != null - outline.isVisible = !isTv && isLastUsedAccount + if (isTv) { + // For emulator but this is fine on TV also + root.isFocusableInTouchMode = true + if (isLastUsedAccount) { + root.requestFocus() + } - if (isTv) { - // For emulator but this is fine on TV also - root.isFocusableInTouchMode = true - if (isLastUsedAccount) { - root.requestFocus() + root.foreground = ContextCompat.getDrawable( + root.context, + R.drawable.outline_drawable + ) + } else { + root.setOnLongClickListener { + showAccountEditDialog( + context = root.context, + account = account, + isNewAccount = false, + accountEditCallback = { account -> accountEditCallback.invoke(account) }, + accountDeleteCallback = { account -> accountDeleteCallback.invoke(account) } + ) + + true + } } - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + root.setOnClickListener { + accountSelectCallback.invoke(account) + } + } + + is AccountListItemEditBinding -> binding.apply { + if (account == null) return@apply + + val isTv = isLayout(TV or EMULATOR) || !root.isInTouchMode + + val isLastUsedAccount = account.keyIndex == DataStoreHelper.selectedKeyIndex + + accountName.text = account.name + accountImage.setImage( + account.image, + fadeIn = false, + radius = 10 + ) + lockIcon.isVisible = account.lockPin != null + outline.isVisible = !isTv && isLastUsedAccount + + if (isTv) { + // For emulator but this is fine on TV also + root.isFocusableInTouchMode = true + if (isLastUsedAccount) { + root.requestFocus() + } + root.foreground = ContextCompat.getDrawable( root.context, R.drawable.outline_drawable ) } - } else { - root.setOnLongClickListener { + + root.setOnClickListener { showAccountEditDialog( context = root.context, - account = item, + account = account, isNewAccount = false, - accountEditCallback = { account -> - accountEditCallback.invoke( - account - ) - }, - accountDeleteCallback = { account -> - accountDeleteCallback.invoke( - account - ) - } + accountEditCallback = { account -> accountEditCallback.invoke(account) }, + accountDeleteCallback = { account -> accountDeleteCallback.invoke(account) } ) - - true } } - root.setOnClickListener { - accountSelectCallback.invoke(item) - } - } + is AccountListItemAddBinding -> binding.apply { + root.setOnClickListener { + val remainingImages = + DataStoreHelper.profileImages.toSet() - accounts.filter { it.customImage == null } + .mapNotNull { DataStoreHelper.profileImages.getOrNull(it.defaultImageIndex) }.toSet() - is AccountListItemEditBinding -> binding.apply { - val isTv = isLayout(TV or EMULATOR) || !root.isInTouchMode + val image = + DataStoreHelper.profileImages.indexOf(remainingImages.randomOrNull() ?: DataStoreHelper.profileImages.random()) + val keyIndex = (accounts.maxOfOrNull { it.keyIndex } ?: 0) + 1 - val isLastUsedAccount = item.keyIndex == DataStoreHelper.selectedKeyIndex + val accountName = root.context.getString(R.string.account) - accountName.text = item.name - accountImage.loadImage(item.image) { - RoundedCornersTransformation(10f) - } - lockIcon.isVisible = item.lockPin != null - outline.isVisible = !isTv && isLastUsedAccount - - if (isTv) { - // For emulator but this is fine on TV also - root.isFocusableInTouchMode = true - if (isLastUsedAccount) { - root.requestFocus() - } - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - root.foreground = ContextCompat.getDrawable( + showAccountEditDialog( root.context, - R.drawable.outline_drawable + DataStoreHelper.Account( + keyIndex = keyIndex, + name = "$accountName $keyIndex", + customImage = null, + defaultImageIndex = image + ), + isNewAccount = true, + accountEditCallback = { account -> accountCreateCallback.invoke(account) }, + accountDeleteCallback = {} ) } } - - root.setOnClickListener { - showAccountEditDialog( - context = root.context, - account = item, - isNewAccount = false, - accountEditCallback = { account -> accountEditCallback.invoke(account) }, - accountDeleteCallback = { account -> - accountDeleteCallback.invoke( - account - ) - } - ) - } } } } - override fun onBindFooter(holder: ViewHolderState) { - val binding = holder.view as? AccountListItemAddBinding ?: return - binding.apply { - root.setOnClickListener { - val accounts = this@AccountAdapter.immutableCurrentList - - val remainingImages = - DataStoreHelper.profileImages.toSet() - accounts.filter { it.customImage == null } - .mapNotNull { DataStoreHelper.profileImages.getOrNull(it.defaultImageIndex) } - .toSet() - - val image = - DataStoreHelper.profileImages.indexOf( - remainingImages.randomOrNull() - ?: DataStoreHelper.profileImages.random() - ) - val keyIndex = (accounts.maxOfOrNull { it.keyIndex } ?: 0) + 1 - - val accountName = root.context.getString(R.string.account) - - showAccountEditDialog( - root.context, - DataStoreHelper.Account( - keyIndex = keyIndex, - name = "$accountName $keyIndex", - customImage = null, - defaultImageIndex = image - ), - isNewAccount = true, - accountEditCallback = { account -> accountCreateCallback.invoke(account) }, - accountDeleteCallback = {} - ) - } - } - } - - override fun onCreateFooter(parent: ViewGroup): ViewHolderState { - return ViewHolderState( - AccountListItemAddBinding.inflate( - LayoutInflater.from(parent.context), - parent, - false - ) - ) - } - - override fun onCreateContent(parent: ViewGroup): ViewHolderState { - return ViewHolderState( - when (viewType) { + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): AccountViewHolder = + AccountViewHolder( + binding = when (viewType) { VIEW_TYPE_SELECT_ACCOUNT -> { AccountListItemBinding.inflate( LayoutInflater.from(parent.context), @@ -195,7 +159,13 @@ class AccountAdapter( false ) } - + VIEW_TYPE_ADD_ACCOUNT -> { + AccountListItemAddBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false + ) + } VIEW_TYPE_EDIT_ACCOUNT -> { AccountListItemEditBinding.inflate( LayoutInflater.from(parent.context), @@ -203,9 +173,28 @@ class AccountAdapter( false ) } - else -> throw IllegalArgumentException("Invalid view type") } ) + + override fun onBindViewHolder(holder: AccountViewHolder, position: Int) { + holder.bind(accounts.getOrNull(position)) + } + + var viewType = 0 + + override fun getItemViewType(position: Int): Int { + if (viewType != 0 && position != accounts.count()) { + return viewType + } + + return when (position) { + accounts.count() -> VIEW_TYPE_ADD_ACCOUNT + else -> VIEW_TYPE_SELECT_ACCOUNT + } + } + + override fun getItemCount(): Int { + return accounts.count() + 1 } } \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/account/AccountHelper.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/account/AccountHelper.kt index 1d6b41e5b..d2aca862b 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 @@ -3,12 +3,11 @@ package com.lagradost.cloudstream3.ui.account import android.app.Activity import android.content.Context import android.content.DialogInterface -import android.os.Bundle +import android.content.Intent 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 @@ -17,27 +16,21 @@ 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.CloudStreamApp.Companion.getActivity -import com.lagradost.cloudstream3.CommonActivity.showToast +import com.lagradost.cloudstream3.AcraApplication.Companion.getActivity 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 +import com.lagradost.cloudstream3.ui.result.setImage import com.lagradost.cloudstream3.ui.result.setLinearListLayout import com.lagradost.cloudstream3.utils.AppContextUtils.setDefaultFocus import com.lagradost.cloudstream3.utils.DataStoreHelper import com.lagradost.cloudstream3.utils.DataStoreHelper.getDefaultAccount -import com.lagradost.cloudstream3.utils.ImageLoader.loadImage import com.lagradost.cloudstream3.utils.UIHelper.dismissSafe -import com.lagradost.cloudstream3.utils.UIHelper.navigate import com.lagradost.cloudstream3.utils.UIHelper.showInputMethod object AccountHelper { @@ -97,13 +90,12 @@ object AccountHelper { } // Handle the profile picture and its interactions - binding.accountImage.loadImage(account.image) + binding.accountImage.setImage(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) + binding.accountImage.setImage(currentEditAccount.image) } // Handle applying changes @@ -163,53 +155,6 @@ 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( @@ -272,7 +217,7 @@ object AccountHelper { val activity = context.getActivity() if (activity is AccountSelectActivity) { isPinValid = true - activity.accountViewModel.handleAccountSelect(getDefaultAccount(context), activity) + activity.viewModel.handleAccountSelect(getDefaultAccount(context), activity) } } } @@ -362,10 +307,9 @@ object AccountHelper { builder.show() binding.manageAccountsButton.setOnClickListener { - activity.navigate( - R.id.accountSelectActivity, - Bundle().apply { putBoolean("isEditingFromMainActivity", true) } - ) + val accountSelectIntent = Intent(activity, AccountSelectActivity::class.java) + accountSelectIntent.putExtra("isEditingFromMainActivity", true) + activity.startActivity(accountSelectIntent) builder.dismissSafe() } @@ -392,6 +336,7 @@ object AccountHelper { activity.observe(viewModel.accounts) { liveAccounts -> recyclerView.adapter = AccountAdapter( + liveAccounts, accountSelectCallback = { account -> viewModel.handleAccountSelect(account, activity) builder.dismissSafe() @@ -399,9 +344,7 @@ object AccountHelper { accountCreateCallback = { viewModel.handleAccountUpdate(it, activity) }, accountEditCallback = { viewModel.handleAccountUpdate(it, activity) }, accountDeleteCallback = { viewModel.handleAccountDelete(it, activity) } - ).apply { - submitList(liveAccounts) - } + ) activity.observe(viewModel.selectedKeyIndex) { selectedKeyIndex -> // Scroll to current account (which is focused by default) diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/account/AccountSelectActivity.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/account/AccountSelectActivity.kt index ad323c7d1..0da69f9c7 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 @@ -1,10 +1,11 @@ package com.lagradost.cloudstream3.ui.account import android.annotation.SuppressLint +import android.content.Intent import android.os.Bundle import android.util.Log -import androidx.fragment.app.FragmentActivity -import androidx.activity.viewModels +import androidx.appcompat.app.AppCompatActivity +import androidx.lifecycle.ViewModelProvider import androidx.preference.PreferenceManager import androidx.recyclerview.widget.GridLayoutManager import com.lagradost.cloudstream3.CommonActivity @@ -31,22 +32,18 @@ import com.lagradost.cloudstream3.utils.BiometricAuthenticator.startBiometricAut import com.lagradost.cloudstream3.utils.DataStoreHelper.accounts import com.lagradost.cloudstream3.utils.DataStoreHelper.selectedKeyIndex import com.lagradost.cloudstream3.utils.DataStoreHelper.setAccount -import com.lagradost.cloudstream3.utils.UIHelper.enableEdgeToEdgeCompat -import com.lagradost.cloudstream3.utils.UIHelper.fixSystemBarsPadding -import com.lagradost.cloudstream3.utils.UIHelper.openActivity -import com.lagradost.cloudstream3.utils.UIHelper.setNavigationBarColorCompat +import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute -class AccountSelectActivity : FragmentActivity(), BiometricCallback { +class AccountSelectActivity : AppCompatActivity(), BiometricCallback { - companion object { - var hasLoggedIn: Boolean = false - } - - val accountViewModel: AccountViewModel by viewModels() + lateinit var viewModel: AccountViewModel @SuppressLint("NotifyDataSetChanged") override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + loadThemes(this) + + window.navigationBarColor = colorFromAttribute(R.attr.primaryBlackBackground) // Are we editing and coming from MainActivity? val isEditingFromMainActivity = intent.getBooleanExtra( @@ -54,24 +51,12 @@ 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 + viewModel = ViewModelProvider(this)[AccountViewModel::class.java] + fun askBiometricAuth() { if (isLayout(PHONE) && isAuthEnabled(this)) { @@ -89,7 +74,7 @@ class AccountSelectActivity : FragmentActivity(), BiometricCallback { } } - observe(accountViewModel.isAllowedLogin) { isAllowedLogin -> + observe(viewModel.isAllowedLogin) { isAllowedLogin -> if (isAllowedLogin) { // We are allowed to continue to MainActivity navigateToMainActivity() @@ -102,15 +87,13 @@ class AccountSelectActivity : FragmentActivity(), BiometricCallback { val currentAccount = accounts.firstOrNull { it.keyIndex == selectedKeyIndex } if (currentAccount?.lockPin != null) { CommonActivity.init(this) - accountViewModel.handleAccountSelect(currentAccount, this, true) + viewModel.handleAccountSelect(currentAccount, this, true) } else { if (accounts.count() > 1) { - showToast( - this, getString( - R.string.logged_account, - currentAccount?.name - ) - ) + showToast(this, getString( + R.string.logged_account, + currentAccount?.name + )) } navigateToMainActivity() @@ -123,19 +106,20 @@ 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 -> + observe(viewModel.accounts) { liveAccounts -> val adapter = AccountAdapter( + liveAccounts, // Handle the selected account accountSelectCallback = { - accountViewModel.handleAccountSelect(it, this) + viewModel.handleAccountSelect(it, this) }, - accountCreateCallback = { accountViewModel.handleAccountUpdate(it, this) }, + accountCreateCallback = { viewModel.handleAccountUpdate(it, this) }, accountEditCallback = { - accountViewModel.handleAccountUpdate(it, this) + viewModel.handleAccountUpdate(it, this) + // We came from MainActivity, return there // and switch to the edited account if (isEditingFromMainActivity) { @@ -143,10 +127,8 @@ class AccountSelectActivity : FragmentActivity(), BiometricCallback { navigateToMainActivity() } }, - accountDeleteCallback = { accountViewModel.handleAccountDelete(it, this) } - ).apply { - submitList(liveAccounts) - } + accountDeleteCallback = { viewModel.handleAccountDelete(it,this) } + ) recyclerView.adapter = adapter @@ -156,13 +138,13 @@ class AccountSelectActivity : FragmentActivity(), BiometricCallback { ) } - observe(accountViewModel.selectedKeyIndex) { selectedKeyIndex -> + observe(viewModel.selectedKeyIndex) { selectedKeyIndex -> // Scroll to current account (which is focused by default) val layoutManager = recyclerView.layoutManager as GridLayoutManager layoutManager.scrollToPositionWithOffset(selectedKeyIndex, 0) } - observe(accountViewModel.isEditing) { isEditing -> + observe(viewModel.isEditing) { isEditing -> if (isEditing) { binding.editAccountButton.setImageResource(R.drawable.ic_baseline_close_24) binding.title.setText(R.string.manage_accounts) @@ -177,7 +159,7 @@ class AccountSelectActivity : FragmentActivity(), BiometricCallback { } if (isEditingFromMainActivity) { - accountViewModel.setIsEditing(true) + viewModel.setIsEditing(true) } binding.editAccountButton.setOnClickListener { @@ -188,7 +170,7 @@ class AccountSelectActivity : FragmentActivity(), BiometricCallback { return@setOnClickListener } - accountViewModel.toggleIsEditing() + viewModel.toggleIsEditing() } if (isLayout(TV or EMULATOR)) { @@ -201,19 +183,17 @@ class AccountSelectActivity : FragmentActivity(), BiometricCallback { askBiometricAuth() } - @SuppressLint("UnsafeIntentLaunch") private fun navigateToMainActivity() { - hasLoggedIn = true - // We want to propagate any intent we get here to MainActivity since this is just an intermediary - openActivity(MainActivity::class.java, baseIntent = intent) + val mainIntent = Intent(this, MainActivity::class.java) + startActivity(mainIntent) finish() // Finish the account selection activity } override fun onAuthenticationSuccess() { - Log.i(BiometricAuthenticator.TAG, "Authentication successful in AccountSelectActivity") + Log.i(BiometricAuthenticator.TAG,"Authentication successful in AccountSelectActivity") } override fun onAuthenticationError() { finish() } -} +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/account/AccountViewModel.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/account/AccountViewModel.kt index 96eaf52a7..af62a2b08 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/account/AccountViewModel.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/account/AccountViewModel.kt @@ -4,8 +4,8 @@ import android.content.Context import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel -import com.lagradost.cloudstream3.CloudStreamApp.Companion.context -import com.lagradost.cloudstream3.CloudStreamApp.Companion.removeKeys +import com.lagradost.cloudstream3.AcraApplication.Companion.context +import com.lagradost.cloudstream3.AcraApplication.Companion.removeKeys import com.lagradost.cloudstream3.MainActivity import com.lagradost.cloudstream3.ui.account.AccountHelper.showPinInputDialog import com.lagradost.cloudstream3.utils.DataStoreHelper diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadAdapter.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadAdapter.kt index 1b48143a6..d211cb87c 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadAdapter.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadAdapter.kt @@ -1,6 +1,5 @@ package com.lagradost.cloudstream3.ui.download -import android.annotation.SuppressLint import android.text.format.Formatter.formatShortFileSize import android.view.LayoutInflater import android.view.ViewGroup @@ -8,18 +7,19 @@ import android.widget.CheckBox import androidx.core.content.ContextCompat import androidx.core.view.isVisible import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import androidx.recyclerview.widget.RecyclerView import androidx.viewbinding.ViewBinding import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.databinding.DownloadChildEpisodeBinding import com.lagradost.cloudstream3.databinding.DownloadHeaderEpisodeBinding import com.lagradost.cloudstream3.mvvm.logError -import com.lagradost.cloudstream3.ui.NoStateAdapter -import com.lagradost.cloudstream3.ui.ViewHolderState import com.lagradost.cloudstream3.ui.download.button.DownloadStatusTell import com.lagradost.cloudstream3.utils.AppContextUtils.getNameFull +import com.lagradost.cloudstream3.utils.DataStoreHelper.fixVisual import com.lagradost.cloudstream3.utils.DataStoreHelper.getViewPos -import com.lagradost.cloudstream3.utils.ImageLoader.loadImage -import com.lagradost.cloudstream3.utils.downloader.DownloadObjects +import com.lagradost.cloudstream3.utils.UIHelper.setImage +import com.lagradost.cloudstream3.utils.VideoDownloadHelper const val DOWNLOAD_ACTION_PLAY_FILE = 0 const val DOWNLOAD_ACTION_DELETE_FILE = 1 @@ -27,7 +27,6 @@ const val DOWNLOAD_ACTION_RESUME_DOWNLOAD = 2 const val DOWNLOAD_ACTION_PAUSE_DOWNLOAD = 3 const val DOWNLOAD_ACTION_DOWNLOAD = 4 const val DOWNLOAD_ACTION_LONG_CLICK = 5 -const val DOWNLOAD_ACTION_CANCEL_PENDING = 6 const val DOWNLOAD_ACTION_GO_TO_CHILD = 0 const val DOWNLOAD_ACTION_LOAD_RESULT = 1 @@ -35,22 +34,22 @@ const val DOWNLOAD_ACTION_LOAD_RESULT = 1 sealed class VisualDownloadCached { abstract val currentBytes: Long abstract val totalBytes: Long - abstract val data: DownloadObjects.DownloadCached + abstract val data: VideoDownloadHelper.DownloadCached abstract var isSelected: Boolean data class Child( override val currentBytes: Long, override val totalBytes: Long, - override val data: DownloadObjects.DownloadEpisodeCached, + override val data: VideoDownloadHelper.DownloadEpisodeCached, override var isSelected: Boolean, ) : VisualDownloadCached() data class Header( override val currentBytes: Long, override val totalBytes: Long, - override val data: DownloadObjects.DownloadHeaderCached, + override val data: VideoDownloadHelper.DownloadHeaderCached, override var isSelected: Boolean, - val child: DownloadObjects.DownloadEpisodeCached?, + val child: VideoDownloadHelper.DownloadEpisodeCached?, val currentOngoingDownloads: Int, val totalDownloads: Int, ) : VisualDownloadCached() @@ -58,19 +57,19 @@ sealed class VisualDownloadCached { data class DownloadClickEvent( val action: Int, - val data: DownloadObjects.DownloadEpisodeCached + val data: VideoDownloadHelper.DownloadEpisodeCached ) data class DownloadHeaderClickEvent( val action: Int, - val data: DownloadObjects.DownloadHeaderCached + val data: VideoDownloadHelper.DownloadHeaderCached ) class DownloadAdapter( private val onHeaderClickEvent: (DownloadHeaderClickEvent) -> Unit, private val onItemClickEvent: (DownloadClickEvent) -> Unit, private val onItemSelectionChanged: (Int, Boolean) -> Unit, -) : NoStateAdapter(DiffCallback()) { +) : ListAdapter(DiffCallback()) { private var isMultiDeleteState: Boolean = false @@ -79,224 +78,112 @@ class DownloadAdapter( private const val VIEW_TYPE_CHILD = 1 } + inner class DownloadViewHolder( + private val binding: ViewBinding + ) : RecyclerView.ViewHolder(binding.root) { - private fun bindHeader(binding: ViewBinding, card: VisualDownloadCached.Header?) { - if (binding !is DownloadHeaderEpisodeBinding || card == null) return + fun bind(card: VisualDownloadCached?) { + when (binding) { + is DownloadHeaderEpisodeBinding -> bindHeader(card as? VisualDownloadCached.Header) + is DownloadChildEpisodeBinding -> bindChild(card as? VisualDownloadCached.Child) + } + } - val data = card.data - binding.apply { - episodeHolder.apply { - if (isMultiDeleteState) { - setOnClickListener { - toggleIsChecked(deleteCheckbox, data.id) + private fun bindHeader(card: VisualDownloadCached.Header?) { + if (binding !is DownloadHeaderEpisodeBinding || card == null) return + + val data = card.data + binding.apply { + episodeHolder.apply { + if (isMultiDeleteState) { + setOnClickListener { + toggleIsChecked(deleteCheckbox, data.id) + } } + setOnLongClickListener { toggleIsChecked(deleteCheckbox, data.id) true } - } else { - setOnLongClickListener { - onItemSelectionChanged.invoke(data.id, true) - true - } } - } - downloadHeaderPoster.apply { - loadImage(data.poster) - if (isMultiDeleteState) { - setOnClickListener { - toggleIsChecked(deleteCheckbox, data.id) - } - } else { - setOnClickListener { - onHeaderClickEvent.invoke( - DownloadHeaderClickEvent( - DOWNLOAD_ACTION_LOAD_RESULT, - data + downloadHeaderPoster.apply { + setImage(data.poster) + if (isMultiDeleteState) { + setOnClickListener { + toggleIsChecked(deleteCheckbox, data.id) + } + } else { + setOnClickListener { + onHeaderClickEvent.invoke( + DownloadHeaderClickEvent( + DOWNLOAD_ACTION_LOAD_RESULT, + data + ) ) - ) + } + } + + setOnLongClickListener { + toggleIsChecked(deleteCheckbox, data.id) + true } } + downloadHeaderTitle.text = data.name + val formattedSize = formatShortFileSize(itemView.context, card.totalBytes) - setOnLongClickListener { - toggleIsChecked(deleteCheckbox, data.id) - true - } - } - downloadHeaderTitle.text = data.name - val formattedSize = formatShortFileSize(binding.root.context, card.totalBytes) + if (card.child != null) { + handleChildDownload(card, formattedSize) + } else handleParentDownload(card, formattedSize) - if (card.child != null) { - handleChildDownload(card, formattedSize) - } else handleParentDownload(card, formattedSize) + if (isMultiDeleteState) { + deleteCheckbox.setOnCheckedChangeListener { _, isChecked -> + onItemSelectionChanged.invoke(data.id, isChecked) + } + } else deleteCheckbox.setOnCheckedChangeListener(null) - if (isMultiDeleteState) { - deleteCheckbox.setOnCheckedChangeListener { _, isChecked -> - onItemSelectionChanged.invoke(data.id, isChecked) - } - } else deleteCheckbox.setOnCheckedChangeListener(null) - - deleteCheckbox.apply { - isVisible = isMultiDeleteState - isChecked = card.isSelected - } - } - } - - private fun DownloadHeaderEpisodeBinding.handleChildDownload( - card: VisualDownloadCached.Header, - formattedSize: String - ) { - card.child ?: return - downloadHeaderGotoChild.isVisible = false - - val posDur = getViewPos(card.data.id) - watchProgressContainer.isVisible = true - downloadHeaderEpisodeProgress.apply { - isVisible = posDur != null - posDur?.let { - val max = (it.duration / 1000).toInt() - val progress = (it.position / 1000).toInt() - - if (max > 0 && progress >= (0.95 * max).toInt()) { - playIcon.setImageResource(R.drawable.ic_baseline_check_24) - isVisible = false - } else { - playIcon.setImageResource(R.drawable.netflix_play) - this.max = max - this.progress = progress - isVisible = true + deleteCheckbox.apply { + isVisible = isMultiDeleteState + isChecked = card.isSelected } } } - downloadButton.resetView() - val status = downloadButton.getStatus(card.child.id, card.currentBytes, card.totalBytes) - if (status == DownloadStatusTell.IsDone) { - // We do this here instead if we are finished downloading - // so that we can use the value from the view model - // rather than extra unneeded disk operations and to prevent a - // delay in updating download icon state. - downloadButton.setProgress(card.currentBytes, card.totalBytes) - downloadButton.applyMetaData(card.child.id, card.currentBytes, card.totalBytes) - // We will let the view model handle this - downloadButton.doSetProgress = false - downloadButton.progressBar.progressDrawable = - downloadButton.getDrawableFromStatus(status) - ?.let { ContextCompat.getDrawable(downloadButton.context, it) } - downloadHeaderInfo.text = formattedSize - } else { - // We need to make sure we restore the correct progress - // when we refresh data in the adapter. - val drawable = downloadButton.getDrawableFromStatus(status)?.let { - ContextCompat.getDrawable(downloadButton.context, it) - } - downloadButton.statusView.setImageDrawable(drawable) - downloadButton.progressBar.progressDrawable = - ContextCompat.getDrawable( - downloadButton.context, - downloadButton.progressDrawable - ) - } + private fun DownloadHeaderEpisodeBinding.handleChildDownload( + card: VisualDownloadCached.Header, + formattedSize: String + ) { + card.child ?: return + downloadHeaderGotoChild.isVisible = false - downloadHeaderInfo.isVisible = true - downloadButton.setDefaultClickListener(card.child, downloadHeaderInfo, onItemClickEvent) - downloadButton.isVisible = !isMultiDeleteState - - if (!isMultiDeleteState) { - episodeHolder.setOnClickListener { - onItemClickEvent.invoke( - DownloadClickEvent( - DOWNLOAD_ACTION_PLAY_FILE, - card.child - ) - ) - } - } - } - - private fun DownloadHeaderEpisodeBinding.handleParentDownload( - card: VisualDownloadCached.Header, - formattedSize: String - ) { - downloadButton.resetViewData() - watchProgressContainer.isVisible = false - downloadButton.isVisible = false - downloadHeaderEpisodeProgress.isVisible = false - downloadHeaderGotoChild.isVisible = !isMultiDeleteState - - try { - downloadHeaderInfo.isVisible = true - downloadHeaderInfo.text = - downloadHeaderInfo.context.getString(R.string.extra_info_format).format( - card.totalDownloads, - downloadHeaderInfo.context.resources.getQuantityString( - R.plurals.episodes, - card.totalDownloads - ), - formattedSize - ) - } catch (e: Exception) { - downloadHeaderInfo.text = null - logError(e) - } - - if (!isMultiDeleteState) { - episodeHolder.setOnClickListener { - onHeaderClickEvent.invoke( - DownloadHeaderClickEvent( - DOWNLOAD_ACTION_GO_TO_CHILD, - card.data - ) - ) - } - } - } - - private fun bindChild(binding: ViewBinding, card: VisualDownloadCached.Child?) { - if (binding !is DownloadChildEpisodeBinding || card == null) return - - val data = card.data - binding.apply { - val posDur = getViewPos(data.id) - downloadChildEpisodeProgress.apply { + val posDur = getViewPos(card.data.id) + downloadHeaderEpisodeProgress.apply { isVisible = posDur != null posDur?.let { - val max = (it.duration / 1000).toInt() - val progress = (it.position / 1000).toInt() - - if (max > 0 && progress >= (0.95 * max).toInt()) { - downloadChildEpisodePlay.setImageResource(R.drawable.ic_baseline_check_24) - isVisible = false - } else { - downloadChildEpisodePlay.setImageResource(R.drawable.play_button_transparent) - this.max = max - this.progress = progress - isVisible = true - } + val visualPos = it.fixVisual() + max = (visualPos.duration / 1000).toInt() + progress = (visualPos.position / 1000).toInt() } } - downloadButton.resetView() - val status = downloadButton.getStatus(data.id, card.currentBytes, card.totalBytes) + val status = downloadButton.getStatus(card.child.id, card.currentBytes, card.totalBytes) if (status == DownloadStatusTell.IsDone) { // We do this here instead if we are finished downloading // so that we can use the value from the view model // rather than extra unneeded disk operations and to prevent a // delay in updating download icon state. downloadButton.setProgress(card.currentBytes, card.totalBytes) - downloadButton.applyMetaData(data.id, card.currentBytes, card.totalBytes) + downloadButton.applyMetaData(card.child.id, card.currentBytes, card.totalBytes) // We will let the view model handle this downloadButton.doSetProgress = false downloadButton.progressBar.progressDrawable = downloadButton.getDrawableFromStatus(status) ?.let { ContextCompat.getDrawable(downloadButton.context, it) } - downloadChildEpisodeTextExtra.text = - formatShortFileSize(downloadChildEpisodeTextExtra.context, card.totalBytes) + downloadHeaderInfo.text = formattedSize } else { // We need to make sure we restore the correct progress // when we refresh data in the adapter. + downloadButton.resetView() val drawable = downloadButton.getDrawableFromStatus(status)?.let { ContextCompat.getDrawable(downloadButton.context, it) } @@ -308,105 +195,199 @@ class DownloadAdapter( ) } - downloadButton.setDefaultClickListener( - data, - downloadChildEpisodeTextExtra, - onItemClickEvent - ) + downloadButton.setDefaultClickListener(card.child, downloadHeaderInfo, onItemClickEvent) downloadButton.isVisible = !isMultiDeleteState - downloadChildEpisodeText.apply { - text = context.getNameFull(data.name, data.episode, data.season) - isSelected = true // Needed for text repeating + if (!isMultiDeleteState) { + episodeHolder.setOnClickListener { + onItemClickEvent.invoke( + DownloadClickEvent( + DOWNLOAD_ACTION_PLAY_FILE, + card.child + ) + ) + } + } + } + + private fun DownloadHeaderEpisodeBinding.handleParentDownload( + card: VisualDownloadCached.Header, + formattedSize: String + ) { + downloadButton.isVisible = false + downloadHeaderEpisodeProgress.isVisible = false + downloadHeaderGotoChild.isVisible = !isMultiDeleteState + + try { + downloadHeaderInfo.text = + downloadHeaderInfo.context.getString(R.string.extra_info_format).format( + card.totalDownloads, + downloadHeaderInfo.context.resources.getQuantityString( + R.plurals.episodes, + card.totalDownloads + ), + formattedSize + ) + } catch (e: Exception) { + downloadHeaderInfo.text = null + logError(e) } - downloadChildEpisodeHolder.setOnClickListener { - onItemClickEvent.invoke(DownloadClickEvent(DOWNLOAD_ACTION_PLAY_FILE, data)) + if (!isMultiDeleteState) { + episodeHolder.setOnClickListener { + onHeaderClickEvent.invoke( + DownloadHeaderClickEvent( + DOWNLOAD_ACTION_GO_TO_CHILD, + card.data + ) + ) + } } + } - downloadChildEpisodeHolder.apply { - when { - isMultiDeleteState -> { - setOnClickListener { - toggleIsChecked(deleteCheckbox, data.id) - } - setOnLongClickListener { - toggleIsChecked(deleteCheckbox, data.id) - true - } + private fun bindChild(card: VisualDownloadCached.Child?) { + if (binding !is DownloadChildEpisodeBinding || card == null) return + + val data = card.data + binding.apply { + val posDur = getViewPos(data.id) + downloadChildEpisodeProgress.apply { + isVisible = posDur != null + posDur?.let { + val visualPos = it.fixVisual() + max = (visualPos.duration / 1000).toInt() + progress = (visualPos.position / 1000).toInt() } + } - else -> { - setOnClickListener { - onItemClickEvent.invoke( - DownloadClickEvent( - DOWNLOAD_ACTION_PLAY_FILE, - data + val status = downloadButton.getStatus(data.id, card.currentBytes, card.totalBytes) + if (status == DownloadStatusTell.IsDone) { + // We do this here instead if we are finished downloading + // so that we can use the value from the view model + // rather than extra unneeded disk operations and to prevent a + // delay in updating download icon state. + downloadButton.setProgress(card.currentBytes, card.totalBytes) + downloadButton.applyMetaData(data.id, card.currentBytes, card.totalBytes) + // We will let the view model handle this + downloadButton.doSetProgress = false + downloadButton.progressBar.progressDrawable = + downloadButton.getDrawableFromStatus(status) + ?.let { ContextCompat.getDrawable(downloadButton.context, it) } + downloadChildEpisodeTextExtra.text = + formatShortFileSize(downloadChildEpisodeTextExtra.context, card.totalBytes) + } else { + // We need to make sure we restore the correct progress + // when we refresh data in the adapter. + downloadButton.resetView() + val drawable = downloadButton.getDrawableFromStatus(status)?.let { + ContextCompat.getDrawable(downloadButton.context, it) + } + downloadButton.statusView.setImageDrawable(drawable) + downloadButton.progressBar.progressDrawable = + ContextCompat.getDrawable( + downloadButton.context, + downloadButton.progressDrawable + ) + } + + downloadButton.setDefaultClickListener( + data, + downloadChildEpisodeTextExtra, + onItemClickEvent + ) + downloadButton.isVisible = !isMultiDeleteState + + downloadChildEpisodeText.apply { + text = context.getNameFull(data.name, data.episode, data.season) + isSelected = true // Needed for text repeating + } + + downloadChildEpisodeHolder.setOnClickListener { + onItemClickEvent.invoke(DownloadClickEvent(DOWNLOAD_ACTION_PLAY_FILE, data)) + } + + downloadChildEpisodeHolder.apply { + when { + isMultiDeleteState -> { + setOnClickListener { + toggleIsChecked(deleteCheckbox, data.id) + } + } + + else -> { + setOnClickListener { + onItemClickEvent.invoke( + DownloadClickEvent( + DOWNLOAD_ACTION_PLAY_FILE, + data + ) ) - ) - } - - setOnLongClickListener { - onItemSelectionChanged.invoke(data.id, true) - true + } } } - } - } - if (isMultiDeleteState) { - deleteCheckbox.setOnCheckedChangeListener { _, isChecked -> - onItemSelectionChanged.invoke(data.id, isChecked) + setOnLongClickListener { + toggleIsChecked(deleteCheckbox, data.id) + true + } } - } else deleteCheckbox.setOnCheckedChangeListener(null) - deleteCheckbox.apply { - isVisible = isMultiDeleteState - isChecked = card.isSelected + if (isMultiDeleteState) { + deleteCheckbox.setOnCheckedChangeListener { _, isChecked -> + onItemSelectionChanged.invoke(data.id, isChecked) + } + } else deleteCheckbox.setOnCheckedChangeListener(null) + + deleteCheckbox.apply { + isVisible = isMultiDeleteState + isChecked = card.isSelected + } } } } - override fun onCreateCustomContent(parent: ViewGroup, viewType: Int): ViewHolderState { + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): DownloadViewHolder { val inflater = LayoutInflater.from(parent.context) val binding = when (viewType) { VIEW_TYPE_HEADER -> DownloadHeaderEpisodeBinding.inflate(inflater, parent, false) VIEW_TYPE_CHILD -> DownloadChildEpisodeBinding.inflate(inflater, parent, false) else -> throw IllegalArgumentException("Invalid view type") } - return ViewHolderState(binding) + return DownloadViewHolder(binding) } - override fun onBindContent( - holder: ViewHolderState, - item: VisualDownloadCached, - position: Int - ) { - when (val binding = holder.view) { - is DownloadHeaderEpisodeBinding -> bindHeader( - binding, - item as? VisualDownloadCached.Header - ) - - is DownloadChildEpisodeBinding -> bindChild( - binding, - item as? VisualDownloadCached.Child - ) - } + override fun onBindViewHolder(holder: DownloadViewHolder, position: Int) { + holder.bind(getItem(position)) } - override fun customContentViewType(item: VisualDownloadCached): Int { - return when (item) { + override fun getItemViewType(position: Int): Int { + return when (getItem(position)) { is VisualDownloadCached.Child -> VIEW_TYPE_CHILD is VisualDownloadCached.Header -> VIEW_TYPE_HEADER + else -> throw IllegalArgumentException("Invalid data type at position $position") } } - @SuppressLint("NotifyDataSetChanged") fun setIsMultiDeleteState(value: Boolean) { if (isMultiDeleteState == value) return isMultiDeleteState = value - notifyDataSetChanged() // This is shit, but what can you do? + notifyItemRangeChanged(0, itemCount) + } + + fun notifyAllSelected() { + currentList.indices.forEach { index -> + if (!currentList[index].isSelected) { + notifyItemChanged(index) + } + } + } + + fun notifySelectionStates() { + currentList.indices.forEach { index -> + if (currentList[index].isSelected) { + notifyItemChanged(index) + } + } } private fun toggleIsChecked(checkbox: CheckBox, itemId: Int) { diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadButtonSetup.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadButtonSetup.kt index dae70ebd7..83e0d0167 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadButtonSetup.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadButtonSetup.kt @@ -4,8 +4,8 @@ import android.content.DialogInterface import android.net.Uri import androidx.appcompat.app.AlertDialog import com.google.android.material.snackbar.Snackbar -import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey -import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKeys +import com.lagradost.cloudstream3.AcraApplication.Companion.getKey +import com.lagradost.cloudstream3.AcraApplication.Companion.getKeys import com.lagradost.cloudstream3.CommonActivity.activity import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.mvvm.logError @@ -18,9 +18,8 @@ import com.lagradost.cloudstream3.utils.DOWNLOAD_EPISODE_CACHE import com.lagradost.cloudstream3.utils.DOWNLOAD_HEADER_CACHE import com.lagradost.cloudstream3.utils.SnackbarHelper.showSnackbar import com.lagradost.cloudstream3.utils.UIHelper.navigate -import com.lagradost.cloudstream3.utils.downloader.DownloadObjects -import com.lagradost.cloudstream3.utils.downloader.DownloadQueueManager -import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager +import com.lagradost.cloudstream3.utils.VideoDownloadHelper +import com.lagradost.cloudstream3.utils.VideoDownloadManager import kotlinx.coroutines.MainScope object DownloadButtonSetup { @@ -83,7 +82,7 @@ object DownloadButtonSetup { } else { val pkg = VideoDownloadManager.getDownloadResumePackage(ctx, id) if (pkg != null) { - DownloadQueueManager.addToQueue(pkg.toWrapper()) + VideoDownloadManager.downloadFromResumeUsingWorker(ctx, pkg) } else { VideoDownloadManager.downloadEvent.invoke( Pair(click.data.id, VideoDownloadManager.DownloadActionType.Resume) @@ -96,7 +95,7 @@ object DownloadButtonSetup { DOWNLOAD_ACTION_LONG_CLICK -> { activity?.let { act -> val length = - VideoDownloadManager.getDownloadFileInfo( + VideoDownloadManager.getDownloadFileInfoAndUpdateSettings( act, click.data.id )?.fileLength @@ -111,31 +110,24 @@ object DownloadButtonSetup { } } - DOWNLOAD_ACTION_CANCEL_PENDING -> { - DownloadQueueManager.cancelDownload(id) - } - DOWNLOAD_ACTION_PLAY_FILE -> { activity?.let { act -> - val parent = getKey( + val parent = getKey( DOWNLOAD_HEADER_CACHE, click.data.parentId.toString() ) ?: return val episodes = getKeys(DOWNLOAD_EPISODE_CACHE) ?.mapNotNull { - getKey(it) + getKey(it) } ?.filter { it.parentId == click.data.parentId } val items = mutableListOf() - val allRelevantEpisodes = - episodes?.sortedWith(compareBy { - it.season ?: 0 - }.thenBy { it.episode }) + val allRelevantEpisodes = episodes?.sortedWith(compareBy { it.season ?: 0 }.thenBy { it.episode }) allRelevantEpisodes?.forEach { - val keyInfo = getKey( + val keyInfo = getKey( VideoDownloadManager.KEY_DOWNLOAD_INFO, it.id.toString() ) ?: return@forEach @@ -149,7 +141,7 @@ object DownloadButtonSetup { uri = Uri.EMPTY, id = it.id, parentId = it.parentId, - name = it.name ?: act.getString(R.string.downloaded_file), + name = act.getString(R.string.downloaded_file), season = it.season, episode = it.episode, headerName = parent.name, @@ -162,8 +154,7 @@ object DownloadButtonSetup { } act.navigate( R.id.global_to_navigation_player, GeneratorPlayer.newInstance( - DownloadFileGenerator(items), - items.indexOfFirst { it.id == click.data.id } + DownloadFileGenerator(items).apply { goto(items.indexOfFirst { it.id == click.data.id }) } ) ) } diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadChildFragment.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadChildFragment.kt index d44ea0020..d5c463dfd 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadChildFragment.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadChildFragment.kt @@ -1,35 +1,32 @@ package com.lagradost.cloudstream3.ui.download import android.os.Bundle +import android.os.Handler +import android.os.Looper import android.text.format.Formatter.formatShortFileSize +import android.view.LayoutInflater import android.view.View -import androidx.core.view.isGone +import android.view.ViewGroup import androidx.core.view.isVisible -import androidx.fragment.app.activityViewModels +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModelProvider import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.databinding.FragmentChildDownloadsBinding -import com.lagradost.cloudstream3.mvvm.Resource import com.lagradost.cloudstream3.mvvm.observe -import com.lagradost.cloudstream3.mvvm.observeNullable -import com.lagradost.cloudstream3.ui.BaseFragment import com.lagradost.cloudstream3.ui.download.DownloadButtonSetup.handleDownloadClick import com.lagradost.cloudstream3.ui.result.FOCUS_SELF import com.lagradost.cloudstream3.ui.result.setLinearListLayout import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR import com.lagradost.cloudstream3.ui.settings.Globals.PHONE -import com.lagradost.cloudstream3.ui.settings.Globals.TV -import com.lagradost.cloudstream3.ui.settings.Globals.isLandscape import com.lagradost.cloudstream3.ui.settings.Globals.isLayout import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.attachBackPressedCallback import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.detachBackPressedCallback -import com.lagradost.cloudstream3.utils.UIHelper.fixSystemBarsPadding +import com.lagradost.cloudstream3.utils.UIHelper.fixPaddingStatusbar import com.lagradost.cloudstream3.utils.UIHelper.setAppBarNoScrollFlagsOnTV -class DownloadChildFragment : BaseFragment( - BaseFragment.BindingCreator.Inflate(FragmentChildDownloadsBinding::inflate) -) { - - private val downloadViewModel: DownloadViewModel by activityViewModels() +class DownloadChildFragment : Fragment() { + private lateinit var downloadsViewModel: DownloadViewModel + private var binding: FragmentChildDownloadsBinding? = null companion object { fun newInstance(headerName: String, folder: String): Bundle { @@ -41,105 +38,100 @@ class DownloadChildFragment : BaseFragment( } override fun onDestroyView() { - activity?.detachBackPressedCallback("Downloads") - downloadViewModel.clearChildren() + activity?.detachBackPressedCallback() + binding = null super.onDestroyView() } - override fun fixLayout(view: View) { - fixSystemBarsPadding( - view, - padBottom = isLandscape(), - padLeft = isLayout(TV or EMULATOR) - ) + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + downloadsViewModel = ViewModelProvider(this)[DownloadViewModel::class.java] + val localBinding = FragmentChildDownloadsBinding.inflate(inflater, container, false) + binding = localBinding + return localBinding.root } - override fun onBindingCreated(binding: FragmentChildDownloadsBinding) { + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + /** + * We never want to retain multi-delete state + * when navigating to downloads. Setting this state + * immediately can sometimes result in the observer + * not being notified in time to update the UI. + * + * By posting to the main looper, we ensure that this + * operation is executed after the view has been fully created + * and all initializations are completed, allowing the + * observer to properly receive and handle the state change. + */ + Handler(Looper.getMainLooper()).post { + downloadsViewModel.setIsMultiDeleteState(false) + } + + /** + * We have to make sure selected items are + * cleared here as well so we don't run in an + * inconsistent state where selected items do + * not match the multi delete state we are in. + */ + downloadsViewModel.clearSelectedItems() + val folder = arguments?.getString("folder") val name = arguments?.getString("name") if (folder == null) { - dispatchBackPressed() + activity?.onBackPressedDispatcher?.onBackPressed() return } - context?.let { downloadViewModel.updateChildList(it, folder) } - - binding.downloadChildToolbar.apply { + binding?.downloadChildToolbar?.apply { title = name if (isLayout(PHONE or EMULATOR)) { setNavigationIcon(R.drawable.ic_baseline_arrow_back_24) setNavigationOnClickListener { - dispatchBackPressed() + activity?.onBackPressedDispatcher?.onBackPressed() } } setAppBarNoScrollFlagsOnTV() } - binding.downloadDeleteAppbar.setAppBarNoScrollFlagsOnTV() + binding?.downloadDeleteAppbar?.setAppBarNoScrollFlagsOnTV() - observe(downloadViewModel.childCards) { cards -> - when (cards) { - is Resource.Success -> { - if (cards.value.isEmpty()) { - dispatchBackPressed() - } - (binding.downloadChildList.adapter as? DownloadAdapter)?.submitList(cards.value) - } - - else -> { - (binding.downloadChildList.adapter as? DownloadAdapter)?.submitList(null) - } + observe(downloadsViewModel.childCards) { + if (it.isEmpty()) { + activity?.onBackPressedDispatcher?.onBackPressed() + return@observe } + + (binding?.downloadChildList?.adapter as? DownloadAdapter)?.submitList(it) } - - observe(downloadViewModel.selectedBytes) { - updateDeleteButton(downloadViewModel.selectedItemIds.value?.count() ?: 0, it) - } - - - binding.apply { - btnDelete.setOnClickListener { view -> - downloadViewModel.handleMultiDelete(view.context ?: return@setOnClickListener) - } - - btnCancel.setOnClickListener { - downloadViewModel.cancelSelection() - } - - btnToggleAll.setOnClickListener { - val allSelected = downloadViewModel.isAllChildrenSelected() - if (allSelected) { - downloadViewModel.clearSelectedItems() - } else { - downloadViewModel.selectAllChildren() - } - } - } - - observeNullable(downloadViewModel.selectedItemIds) { selection -> - val isMultiDeleteState = selection != null - val adapter = binding.downloadChildList.adapter as? DownloadAdapter + observe(downloadsViewModel.isMultiDeleteState) { isMultiDeleteState -> + val adapter = binding?.downloadChildList?.adapter as? DownloadAdapter adapter?.setIsMultiDeleteState(isMultiDeleteState) - binding.downloadDeleteAppbar.isVisible = isMultiDeleteState - binding.downloadChildToolbar.isGone = isMultiDeleteState - - if (selection == null) { - activity?.detachBackPressedCallback("Downloads") - return@observeNullable - } - activity?.attachBackPressedCallback("Downloads") { - downloadViewModel.cancelSelection() + binding?.downloadDeleteAppbar?.isVisible = isMultiDeleteState + if (!isMultiDeleteState) { + activity?.detachBackPressedCallback() + downloadsViewModel.clearSelectedItems() + binding?.downloadChildToolbar?.isVisible = true } + } + observe(downloadsViewModel.selectedBytes) { + updateDeleteButton(downloadsViewModel.selectedItemIds.value?.count() ?: 0, it) + } + observe(downloadsViewModel.selectedItemIds) { + handleSelectedChange(it) + updateDeleteButton(it.count(), downloadsViewModel.selectedBytes.value ?: 0L) - updateDeleteButton(selection.count(), downloadViewModel.selectedBytes.value ?: 0L) + binding?.btnDelete?.isVisible = it.isNotEmpty() + binding?.selectItemsText?.isVisible = it.isEmpty() - binding.btnDelete.isVisible = selection.isNotEmpty() - binding.selectItemsText.isVisible = selection.isEmpty() - - val allSelected = downloadViewModel.isAllChildrenSelected() + val allSelected = downloadsViewModel.isAllSelected() if (allSelected) { - binding.btnToggleAll.setText(R.string.deselect_all) - } else binding.btnToggleAll.setText(R.string.select_all) + binding?.btnToggleAll?.setText(R.string.deselect_all) + } else binding?.btnToggleAll?.setText(R.string.select_all) } val adapter = DownloadAdapter( @@ -147,18 +139,18 @@ class DownloadChildFragment : BaseFragment( { click -> if (click.action == DOWNLOAD_ACTION_DELETE_FILE) { context?.let { ctx -> - downloadViewModel.handleSingleDelete(ctx, click.data.id) + downloadsViewModel.handleSingleDelete(ctx, click.data.id) } } else handleDownloadClick(click) }, { itemId, isChecked -> if (isChecked) { - downloadViewModel.addSelected(itemId) - } else downloadViewModel.removeSelected(itemId) + downloadsViewModel.addSelected(itemId) + } else downloadsViewModel.removeSelected(itemId) } ) - binding.downloadChildList.apply { + binding?.downloadChildList?.apply { setHasFixedSize(true) setItemViewCacheSize(20) this.adapter = adapter @@ -168,6 +160,43 @@ class DownloadChildFragment : BaseFragment( nextDown = FOCUS_SELF, ) } + + context?.let { downloadsViewModel.updateChildList(it, folder) } + fixPaddingStatusbar(binding?.downloadChildRoot) + } + + private fun handleSelectedChange(selected: MutableSet) { + if (selected.isNotEmpty()) { + binding?.downloadDeleteAppbar?.isVisible = true + binding?.downloadChildToolbar?.isVisible = false + activity?.attachBackPressedCallback { + downloadsViewModel.setIsMultiDeleteState(false) + } + + binding?.btnDelete?.setOnClickListener { + context?.let { ctx -> + downloadsViewModel.handleMultiDelete(ctx) + } + } + + binding?.btnCancel?.setOnClickListener { + downloadsViewModel.setIsMultiDeleteState(false) + } + + binding?.btnToggleAll?.setOnClickListener { + val allSelected = downloadsViewModel.isAllSelected() + val adapter = binding?.downloadChildList?.adapter as? DownloadAdapter + if (allSelected) { + adapter?.notifySelectionStates() + downloadsViewModel.clearSelectedItems() + } else { + adapter?.notifyAllSelected() + downloadsViewModel.selectAllItems() + } + } + + downloadsViewModel.setIsMultiDeleteState(true) + } } private fun updateDeleteButton(count: Int, selectedBytes: Long) { diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadFragment.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadFragment.kt index abc432ef9..dfa7635c1 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadFragment.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadFragment.kt @@ -7,8 +7,13 @@ import android.content.Context import android.content.Intent import android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION import android.os.Build +import android.os.Bundle +import android.os.Handler +import android.os.Looper import android.text.format.Formatter.formatShortFileSize +import android.view.LayoutInflater import android.view.View +import android.view.ViewGroup import android.widget.LinearLayout import android.widget.TextView import android.widget.Toast @@ -17,28 +22,23 @@ import androidx.annotation.StringRes import androidx.core.view.isGone import androidx.core.view.isVisible import androidx.core.widget.doOnTextChanged -import androidx.fragment.app.activityViewModels +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModelProvider import com.lagradost.cloudstream3.CommonActivity.showToast import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.databinding.FragmentDownloadsBinding import com.lagradost.cloudstream3.databinding.StreamInputBinding import com.lagradost.cloudstream3.isEpisodeBased -import com.lagradost.cloudstream3.mvvm.Resource -import com.lagradost.cloudstream3.mvvm.safe +import com.lagradost.cloudstream3.mvvm.normalSafeApiCall import com.lagradost.cloudstream3.mvvm.observe -import com.lagradost.cloudstream3.mvvm.observeNullable -import com.lagradost.cloudstream3.ui.BaseFragment import com.lagradost.cloudstream3.ui.download.DownloadButtonSetup.handleDownloadClick -import com.lagradost.cloudstream3.ui.download.queue.DownloadQueueViewModel import com.lagradost.cloudstream3.ui.player.BasicLink import com.lagradost.cloudstream3.ui.player.GeneratorPlayer import com.lagradost.cloudstream3.ui.player.LinkGenerator import com.lagradost.cloudstream3.ui.player.OfflinePlaybackHelper.playUri import com.lagradost.cloudstream3.ui.result.FOCUS_SELF import com.lagradost.cloudstream3.ui.result.setLinearListLayout -import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR import com.lagradost.cloudstream3.ui.settings.Globals.TV -import com.lagradost.cloudstream3.ui.settings.Globals.isLandscape import com.lagradost.cloudstream3.ui.settings.Globals.isLayout import com.lagradost.cloudstream3.utils.AppContextUtils.loadResult import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.attachBackPressedCallback @@ -46,7 +46,7 @@ import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.detachBackPres import com.lagradost.cloudstream3.utils.DOWNLOAD_EPISODE_CACHE import com.lagradost.cloudstream3.utils.DataStore.getFolderName import com.lagradost.cloudstream3.utils.UIHelper.dismissSafe -import com.lagradost.cloudstream3.utils.UIHelper.fixSystemBarsPadding +import com.lagradost.cloudstream3.utils.UIHelper.fixPaddingStatusbar import com.lagradost.cloudstream3.utils.UIHelper.hideKeyboard import com.lagradost.cloudstream3.utils.UIHelper.navigate import com.lagradost.cloudstream3.utils.UIHelper.setAppBarNoScrollFlagsOnTV @@ -54,12 +54,9 @@ import java.net.URI const val DOWNLOAD_NAVIGATE_TO = "downloadpage" -class DownloadFragment : BaseFragment( - BaseFragment.BindingCreator.Inflate(FragmentDownloadsBinding::inflate) -) { - - private val downloadViewModel: DownloadViewModel by activityViewModels() - private val downloadQueueViewModel: DownloadQueueViewModel by activityViewModels() +class DownloadFragment : Fragment() { + private lateinit var downloadsViewModel: DownloadViewModel + private var binding: FragmentDownloadsBinding? = null private fun View.setLayoutWidth(weight: Long) { val param = LinearLayout.LayoutParams( @@ -71,136 +68,118 @@ class DownloadFragment : BaseFragment( } override fun onDestroyView() { - activity?.detachBackPressedCallback("Downloads") + activity?.detachBackPressedCallback() + binding = null super.onDestroyView() } - override fun fixLayout(view: View) { - fixSystemBarsPadding( - view, - padBottom = isLandscape(), - padLeft = isLayout(TV or EMULATOR) - ) + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + downloadsViewModel = ViewModelProvider(this)[DownloadViewModel::class.java] + val localBinding = FragmentDownloadsBinding.inflate(inflater, container, false) + binding = localBinding + return localBinding.root } - override fun onBindingCreated(binding: FragmentDownloadsBinding) { + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) hideKeyboard() - binding.downloadAppbar.setAppBarNoScrollFlagsOnTV() - binding.downloadDeleteAppbar.setAppBarNoScrollFlagsOnTV() + binding?.downloadStorageAppbar?.setAppBarNoScrollFlagsOnTV() + binding?.downloadDeleteAppbar?.setAppBarNoScrollFlagsOnTV() - observe(downloadViewModel.headerCards) { cards -> - when (cards) { - is Resource.Success -> { - (binding.downloadList.adapter as? DownloadAdapter)?.submitList(cards.value) - binding.textNoDownloads.isVisible = cards.value.isEmpty() - binding.downloadLoading.isVisible = false - binding.downloadList.isVisible = true - } - - is Resource.Loading -> { - binding.downloadList.isVisible = false - binding.downloadLoading.isVisible = true - } - - is Resource.Failure -> { - binding.downloadList.isVisible = true - binding.downloadLoading.isVisible = false - } - } + /** + * We never want to retain multi-delete state + * when navigating to downloads. Setting this state + * immediately can sometimes result in the observer + * not being notified in time to update the UI. + * + * By posting to the main looper, we ensure that this + * operation is executed after the view has been fully created + * and all initializations are completed, allowing the + * observer to properly receive and handle the state change. + */ + Handler(Looper.getMainLooper()).post { + downloadsViewModel.setIsMultiDeleteState(false) } - observe(downloadViewModel.availableBytes) { + /** + * We have to make sure selected items are + * cleared here as well so we don't run in an + * inconsistent state where selected items do + * not match the multi delete state we are in. + */ + downloadsViewModel.clearSelectedItems() + + observe(downloadsViewModel.headerCards) { + (binding?.downloadList?.adapter as? DownloadAdapter)?.submitList(it) + binding?.downloadLoading?.isVisible = false + binding?.textNoDownloads?.isVisible = it.isEmpty() + } + observe(downloadsViewModel.availableBytes) { updateStorageInfo( - binding.root.context, + view.context, it, R.string.free_storage, - binding.downloadFreeTxt, - binding.downloadFree + binding?.downloadFreeTxt, + binding?.downloadFree ) } - observe(downloadViewModel.usedBytes) { + observe(downloadsViewModel.usedBytes) { updateStorageInfo( - binding.root.context, + view.context, it, R.string.used_storage, - binding.downloadUsedTxt, - binding.downloadUsed + binding?.downloadUsedTxt, + binding?.downloadUsed ) - val hasBytes = it > 0 - if (hasBytes) { - binding.downloadLoadingBytes.stopShimmer() - } else binding.downloadLoadingBytes.startShimmer() - - binding.downloadBytesBar.isVisible = hasBytes - binding.downloadLoadingBytes.isGone = hasBytes + // 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 } - observe(downloadViewModel.downloadBytes) { + observe(downloadsViewModel.downloadBytes) { updateStorageInfo( - binding.root.context, + view.context, it, R.string.app_storage, - binding.downloadAppTxt, - binding.downloadApp + binding?.downloadAppTxt, + binding?.downloadApp ) } - observe(downloadQueueViewModel.childCards) { cards -> - val size = cards.currentDownloads.size + cards.queue.size - val context = binding.root.context - val baseText = context.getString(R.string.download_queue) - binding.downloadQueueText.text = if (size > 0) { - "$baseText (${cards.currentDownloads.size}/$size)" - } else { - baseText - } + observe(downloadsViewModel.selectedBytes) { + updateDeleteButton(downloadsViewModel.selectedItemIds.value?.count() ?: 0, it) } - - observe(downloadViewModel.selectedBytes) { - updateDeleteButton(downloadViewModel.selectedItemIds.value?.count() ?: 0, it) - } - - binding.apply { - btnDelete.setOnClickListener { view -> - downloadViewModel.handleMultiDelete(view.context ?: return@setOnClickListener) - } - - btnCancel.setOnClickListener { - downloadViewModel.cancelSelection() - } - - btnToggleAll.setOnClickListener { - val allSelected = downloadViewModel.isAllHeadersSelected() - if (allSelected) { - downloadViewModel.clearSelectedItems() - } else { - downloadViewModel.selectAllHeaders() + observe(downloadsViewModel.isMultiDeleteState) { isMultiDeleteState -> + val adapter = binding?.downloadList?.adapter as? DownloadAdapter + adapter?.setIsMultiDeleteState(isMultiDeleteState) + binding?.downloadDeleteAppbar?.isVisible = isMultiDeleteState + if (!isMultiDeleteState) { + activity?.detachBackPressedCallback() + 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(downloadsViewModel.selectedItemIds) { + handleSelectedChange(it) + updateDeleteButton(it.count(), downloadsViewModel.selectedBytes.value ?: 0L) - observeNullable(downloadViewModel.selectedItemIds) { selection -> - val isMultiDeleteState = selection != null - val adapter = binding.downloadList.adapter as? DownloadAdapter - adapter?.setIsMultiDeleteState(isMultiDeleteState) - binding.downloadDeleteAppbar.isVisible = isMultiDeleteState - binding.downloadAppbar.isGone = isMultiDeleteState + binding?.btnDelete?.isVisible = it.isNotEmpty() + binding?.selectItemsText?.isVisible = it.isEmpty() - if (selection == null) { - activity?.detachBackPressedCallback("Downloads") - return@observeNullable - } - activity?.attachBackPressedCallback("Downloads") { - downloadViewModel.cancelSelection() - } - updateDeleteButton(selection.count(), downloadViewModel.selectedBytes.value ?: 0L) - - binding.btnDelete.isVisible = selection.isNotEmpty() - binding.selectItemsText.isVisible = selection.isEmpty() - - val allSelected = downloadViewModel.isAllHeadersSelected() + val allSelected = downloadsViewModel.isAllSelected() if (allSelected) { - binding.btnToggleAll.setText(R.string.deselect_all) - } else binding.btnToggleAll.setText(R.string.select_all) + binding?.btnToggleAll?.setText(R.string.deselect_all) + } else binding?.btnToggleAll?.setText(R.string.select_all) } val adapter = DownloadAdapter( @@ -208,29 +187,29 @@ class DownloadFragment : BaseFragment( { click -> if (click.action == DOWNLOAD_ACTION_DELETE_FILE) { context?.let { ctx -> - downloadViewModel.handleSingleDelete(ctx, click.data.id) + downloadsViewModel.handleSingleDelete(ctx, click.data.id) } } else handleDownloadClick(click) }, { itemId, isChecked -> if (isChecked) { - downloadViewModel.addSelected(itemId) - } else downloadViewModel.removeSelected(itemId) + downloadsViewModel.addSelected(itemId) + } else downloadsViewModel.removeSelected(itemId) } ) - binding.downloadList.apply { + binding?.downloadList?.apply { setHasFixedSize(true) setItemViewCacheSize(20) this.adapter = adapter setLinearListLayout( isHorizontal = false, nextRight = FOCUS_SELF, - nextDown = R.id.download_queue_button, + nextDown = FOCUS_SELF, ) } - binding.apply { + binding?.apply { openLocalVideoButton.apply { isGone = isLayout(TV) setOnClickListener { openLocalVideo() } @@ -239,25 +218,16 @@ class DownloadFragment : BaseFragment( 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 { downloadViewModel.updateHeaderList(it) } + context?.let { downloadsViewModel.updateHeaderList(it) } + fixPaddingStatusbar(binding?.downloadRoot) } private fun handleItemClick(click: DownloadHeaderClickEvent) { @@ -274,11 +244,45 @@ class DownloadFragment : BaseFragment( } DOWNLOAD_ACTION_LOAD_RESULT -> { - activity?.loadResult(click.data.url, click.data.apiName, click.data.name) + activity?.loadResult(click.data.url, click.data.apiName) } } } + private fun handleSelectedChange(selected: MutableSet) { + if (selected.isNotEmpty()) { + binding?.downloadDeleteAppbar?.isVisible = true + binding?.downloadStorageAppbar?.isVisible = false + activity?.attachBackPressedCallback { + 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 = @@ -305,7 +309,7 @@ class DownloadFragment : BaseFragment( .setType("video/*") .addCategory(Intent.CATEGORY_OPENABLE) .addFlags(FLAG_GRANT_READ_URI_PERMISSION) // Request temporary access - safe { + normalSafeApiCall { videoResultLauncher.launch( Intent.createChooser( intent, @@ -348,9 +352,9 @@ class DownloadFragment : BaseFragment( LinkGenerator( listOf(BasicLink(url)), extract = true, - refererUrl = referer, - id = url.hashCode() - ), 0 + referer = referer, + isM3u8 = binding.hlsSwitch.isChecked + ) ) ) dialog.dismissSafe(activity) @@ -363,7 +367,7 @@ class DownloadFragment : BaseFragment( } private fun activateSwitchOnHls(text: String?, binding: StreamInputBinding) { - binding.hlsSwitch.isChecked = safe { + binding.hlsSwitch.isChecked = normalSafeApiCall { URI(text).path?.substringAfterLast(".")?.contains("m3u") } == true } @@ -381,7 +385,7 @@ class DownloadFragment : BaseFragment( ActivityResultContracts.StartActivityForResult() ) { result -> if (result.resultCode != Activity.RESULT_OK) return@registerForActivityResult - val selectedVideoUri = result.data?.data ?: return@registerForActivityResult + val selectedVideoUri = result?.data?.data ?: return@registerForActivityResult playUri(activity ?: return@registerForActivityResult, selectedVideoUri) } } \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadViewModel.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadViewModel.kt index 0d35d5670..137f1355e 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadViewModel.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadViewModel.kt @@ -5,119 +5,91 @@ import android.content.DialogInterface import android.os.Environment import android.os.StatFs import androidx.appcompat.app.AlertDialog -import androidx.core.content.edit import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.lagradost.api.Log import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.isEpisodeBased -import com.lagradost.cloudstream3.mvvm.Resource import com.lagradost.cloudstream3.mvvm.launchSafe import com.lagradost.cloudstream3.mvvm.logError -import com.lagradost.cloudstream3.services.DownloadQueueService -import com.lagradost.cloudstream3.services.DownloadQueueService.Companion.downloadInstances import com.lagradost.cloudstream3.utils.AppContextUtils.getNameFull import com.lagradost.cloudstream3.utils.AppContextUtils.setDefaultFocus -import com.lagradost.cloudstream3.utils.ConsistentLiveData -import com.lagradost.cloudstream3.utils.Coroutines.ioSafe -import com.lagradost.cloudstream3.utils.Coroutines.ioWork import com.lagradost.cloudstream3.utils.DOWNLOAD_EPISODE_CACHE -import com.lagradost.cloudstream3.utils.DOWNLOAD_EPISODE_CACHE_BACKUP import com.lagradost.cloudstream3.utils.DOWNLOAD_HEADER_CACHE -import com.lagradost.cloudstream3.utils.DOWNLOAD_HEADER_CACHE_BACKUP import com.lagradost.cloudstream3.utils.DataStore.getFolderName import com.lagradost.cloudstream3.utils.DataStore.getKey import com.lagradost.cloudstream3.utils.DataStore.getKeys -import com.lagradost.cloudstream3.utils.DataStore.getSharedPrefs -import com.lagradost.cloudstream3.utils.DataStoreHelper.getAllResumeStateIds -import com.lagradost.cloudstream3.utils.DataStoreHelper.getLastWatched -import com.lagradost.cloudstream3.utils.ResourceLiveData -import com.lagradost.cloudstream3.utils.downloader.DownloadObjects -import com.lagradost.cloudstream3.utils.downloader.DownloadQueueManager -import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager.deleteFilesAndUpdateSettings -import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager.getDownloadFileInfo +import com.lagradost.cloudstream3.utils.VideoDownloadHelper +import com.lagradost.cloudstream3.utils.VideoDownloadManager.deleteFilesAndUpdateSettings +import com.lagradost.cloudstream3.utils.VideoDownloadManager.getDownloadFileInfoAndUpdateSettings import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext class DownloadViewModel : ViewModel() { - companion object { - const val TAG = "DownloadViewModel" - } - private val _headerCards = - ResourceLiveData>(Resource.Loading()) - val headerCards: LiveData>> = _headerCards + private val _headerCards = MutableLiveData>() + val headerCards: LiveData> = _headerCards - private val _childCards = ResourceLiveData>(Resource.Loading()) - val childCards: LiveData>> = _childCards + private val _childCards = MutableLiveData>() + val childCards: LiveData> = _childCards - private val _usedBytes = ConsistentLiveData() + private val _usedBytes = MutableLiveData() val usedBytes: LiveData = _usedBytes - private val _availableBytes = ConsistentLiveData() + private val _availableBytes = MutableLiveData() val availableBytes: LiveData = _availableBytes - private val _downloadBytes = ConsistentLiveData() + private val _downloadBytes = MutableLiveData() val downloadBytes: LiveData = _downloadBytes - private val _selectedBytes = ConsistentLiveData(0) + private val _selectedBytes = MutableLiveData(0) val selectedBytes: LiveData = _selectedBytes - private val _selectedItemIds = ConsistentLiveData?>(null) - val selectedItemIds: LiveData?> = _selectedItemIds + private val _isMultiDeleteState = MutableLiveData(false) + val isMultiDeleteState: LiveData = _isMultiDeleteState + private val _selectedItemIds = MutableLiveData>(mutableSetOf()) + val selectedItemIds: LiveData> = _selectedItemIds - fun cancelSelection() { - updateSelectedItems { null } + private var previousVisual: List? = null + + fun setIsMultiDeleteState(value: Boolean) { + _isMultiDeleteState.postValue(value) } fun addSelected(itemId: Int) { - updateSelectedItems { it?.plus(itemId) ?: setOf(itemId) } + updateSelectedItems { it.add(itemId) } } fun removeSelected(itemId: Int) { - updateSelectedItems { it?.minus(itemId) ?: emptySet() } + updateSelectedItems { it.remove(itemId) } } - fun selectAllHeaders() { - updateSelectedItems { - _headerCards.success.orEmpty() - .map { item -> item.data.id }.toSet() - } - } - - fun selectAllChildren() { - updateSelectedItems { - _childCards.success.orEmpty() - .map { item -> item.data.id }.toSet() - } + fun selectAllItems() { + val items = headerCards.value.orEmpty() + childCards.value.orEmpty() + updateSelectedItems { it.addAll(items.map { item -> item.data.id }) } } fun clearSelectedItems() { // We need this to be done immediately // so we can't use postValue - updateSelectedItems { emptySet() } + _selectedItemIds.value = mutableSetOf() + updateSelectedItems { it.clear() } } - fun isAllChildrenSelected(): Boolean { + fun isAllSelected(): Boolean { val currentSelected = selectedItemIds.value ?: return false - val children = _childCards.success.orEmpty() - return currentSelected.size == children.size && children.all { it.data.id in currentSelected } + val items = headerCards.value.orEmpty() + childCards.value.orEmpty() + return items.count() == currentSelected.count() && items.all { it.data.id in currentSelected } } - fun isAllHeadersSelected(): Boolean { - val currentSelected = selectedItemIds.value ?: return false - val headers = _headerCards.success.orEmpty() - return currentSelected.size == headers.size && headers.all { it.data.id in currentSelected } - } - - private fun updateSelectedItems(action: (Set?) -> Set?) { - val currentSelected = action(selectedItemIds.value) + private fun updateSelectedItems(action: (MutableSet) -> Unit) { + val currentSelected = selectedItemIds.value ?: mutableSetOf() + action(currentSelected) _selectedItemIds.postValue(currentSelected) - postHeaders() - postChildren() updateSelectedBytes() + updateSelectedCards() } private fun updateSelectedBytes() = viewModelScope.launchSafe { @@ -126,173 +98,61 @@ class DownloadViewModel : ViewModel() { _selectedBytes.postValue(totalSelectedBytes) } + private fun updateSelectedCards() = viewModelScope.launchSafe { + val currentSelected = selectedItemIds.value ?: return@launchSafe - fun removeRedundantEpisodeKeys(context: Context, keys: List>) { - val settingsManager = context.getSharedPrefs() - ioSafe { - settingsManager.edit { - keys.forEach { (parentId, childId) -> - Log.i(TAG, "Removing download episode key: ${parentId}/${childId}") - val oldPath = getFolderName( - getFolderName( - DOWNLOAD_EPISODE_CACHE, - parentId.toString() - ), - childId.toString() - ) - val newPath = getFolderName( - getFolderName( - DOWNLOAD_EPISODE_CACHE_BACKUP, - parentId.toString() - ), - childId.toString() - ) - - val oldPref = settingsManager.getString(oldPath, null) - // Cowardly future backup solution in case the key removal fails in some edge case. - // This and all backup keys may be removed in a future update if the key removal is proven to be robust. - this.putString(newPath, oldPref) - this.remove(oldPath) - } + headerCards.value?.let { headers -> + headers.forEach { header -> + header.isSelected = header.data.id in currentSelected } + _headerCards.postValue(headers) } - } - fun removeRedundantHeaderKeys( - context: Context, - cached: List, - totalBytesUsedByChild: Map, - totalDownloads: Map - ) { - val settingsManager = context.getSharedPrefs() - ioSafe { - // Do not remove headers used by resume watching - val resumeWatchingIds = - getAllResumeStateIds()?.mapNotNull { id -> - getLastWatched(id)?.parentId - }?.toSet() ?: emptySet() - - settingsManager.edit { - cached.forEach { header -> - val downloads = totalDownloads[header.id] ?: 0 - val bytes = totalBytesUsedByChild[header.id] ?: 0 - - if ( (downloads <= 0 || bytes <= 0) && !resumeWatchingIds.contains(header.id) ) { - Log.i(TAG, "Removing download header key: ${header.id}") - val oldPAth = getFolderName(DOWNLOAD_HEADER_CACHE, header.id.toString()) - val newPath = - getFolderName(DOWNLOAD_HEADER_CACHE_BACKUP, header.id.toString()) - val oldPref = settingsManager.getString(oldPAth, null) - // Cowardly future backup solution in case the key removal fails in some edge case. - // This and all backup keys may be removed in a future update if the key removal is proven to be robust. - this.putString(newPath, oldPref) - this.remove(oldPAth) - } - } + childCards.value?.let { children -> + children.forEach { child -> + child.isSelected = child.data.id in currentSelected } + _childCards.postValue(children) } } fun updateHeaderList(context: Context) = viewModelScope.launchSafe { - // Do not push loading as it interrupts the UI - //_headerCards.postValue(Resource.Loading()) - - val visual = ioWork { + val visual = withContext(Dispatchers.IO) { val children = context.getKeys(DOWNLOAD_EPISODE_CACHE) - .mapNotNull { context.getKey(it) } + .mapNotNull { context.getKey(it) } .distinctBy { it.id } // Remove duplicates - val isCurrentlyDownloading = - DownloadQueueService.isRunning || downloadInstances.value.isNotEmpty() || DownloadQueueManager.queue.value.isNotEmpty() - - val downloadStats = + val (totalBytesUsedByChild, currentBytesUsedByChild, totalDownloads) = calculateDownloadStats(context, children) val cached = context.getKeys(DOWNLOAD_HEADER_CACHE) - .mapNotNull { context.getKey(it) } - - // Download stats and header keys may change when downloading. - // To prevent the downloader and key removal from colliding, simply do not prune keys when downloading. - if (!isCurrentlyDownloading) { - removeRedundantHeaderKeys( - context, - cached, - downloadStats.totalBytesUsedByChild, - downloadStats.totalDownloads - ) - } - // calculateDownloadStats already performed checks when creating redundantDownloads, no extra check required - removeRedundantEpisodeKeys(context, downloadStats.redundantDownloads) + .mapNotNull { context.getKey(it) } createVisualDownloadList( - context, - cached, - downloadStats.totalBytesUsedByChild, - downloadStats.currentBytesUsedByChild, - downloadStats.totalDownloads + context, cached, totalBytesUsedByChild, currentBytesUsedByChild, totalDownloads ) } - updateStorageStats(visual) - postHeaders(visual) + if (visual != previousVisual) { + previousVisual = visual + updateStorageStats(visual) + _headerCards.postValue(visual) + } } - fun postHeaders(newValue: List? = null) { - val newValue = newValue ?: _headerCards.success ?: return - val selection = selectedItemIds.value ?: emptySet() - _headerCards.postValue(Resource.Success(newValue.map { - it.copy( - isSelected = selection.contains( - it.data.id - ) - ) - })) - } - - fun postChildren(newValue: List? = null) { - val newValue = newValue ?: _childCards.success ?: return - val selection = selectedItemIds.value ?: emptySet() - _childCards.postValue(Resource.Success(newValue.map { - it.copy( - isSelected = selection.contains( - it.data.id - ) - ) - })) - } - - private data class DownloadStats( - val totalBytesUsedByChild: Map, - val currentBytesUsedByChild: Map, - val totalDownloads: Map, - /** Parent ID to child ID. Keys to be removed. */ - val redundantDownloads: List> - ) - private fun calculateDownloadStats( context: Context, - children: List - ): DownloadStats { + children: List + ): Triple, Map, Map> { // parentId : bytes val totalBytesUsedByChild = mutableMapOf() // parentId : bytes val currentBytesUsedByChild = mutableMapOf() // parentId : downloadsCount val totalDownloads = mutableMapOf() - val redundantDownloads = mutableListOf>() children.forEach { child -> - val childFile = getDownloadFileInfo(context, child.id) - - if (childFile == null) { - // It may not be a redundant child if something is currently downloading. - // DOWNLOAD_EPISODE_CACHE gets created before KEY_DOWNLOAD_INFO in the downloader - // leading to valid situations where getDownloadFileInfo is null, but we do not want to remove DOWNLOAD_EPISODE_CACHE - if (!DownloadQueueService.isRunning && downloadInstances.value.isEmpty() && DownloadQueueManager.queue.value.isEmpty()) { - redundantDownloads.add(child.parentId to child.id) - } - return@forEach - } + val childFile = getDownloadFileInfoAndUpdateSettings(context, child.id) ?: return@forEach if (childFile.fileLength <= 1) return@forEach val len = childFile.totalBytes @@ -302,17 +162,12 @@ class DownloadViewModel : ViewModel() { currentBytesUsedByChild.merge(child.parentId, flen, Long::plus) totalDownloads.merge(child.parentId, 1, Int::plus) } - return DownloadStats( - totalBytesUsedByChild, - currentBytesUsedByChild, - totalDownloads, - redundantDownloads - ) + return Triple(totalBytesUsedByChild, currentBytesUsedByChild, totalDownloads) } private fun createVisualDownloadList( context: Context, - cached: List, + cached: List, totalBytesUsedByChild: Map, currentBytesUsedByChild: Map, totalDownloads: Map @@ -321,17 +176,13 @@ class DownloadViewModel : ViewModel() { val downloads = totalDownloads[it.id] ?: 0 val bytes = totalBytesUsedByChild[it.id] ?: 0 val currentBytes = currentBytesUsedByChild[it.id] ?: 0 - - if (bytes <= 0 || downloads <= 0) { - return@mapNotNull null - } + if (bytes <= 0 || downloads <= 0) return@mapNotNull null val isSelected = selectedItemIds.value?.contains(it.id) ?: false - val movieEpisode = - if (it.type.isEpisodeBased()) null else context.getKey( - DOWNLOAD_EPISODE_CACHE, - getFolderName(it.id.toString(), it.id.toString()) - ) + val movieEpisode = if (it.type.isEpisodeBased()) null else context.getKey( + DOWNLOAD_EPISODE_CACHE, + getFolderName(it.id.toString(), it.id.toString()) + ) VisualDownloadCached.Header( currentBytes = currentBytes, @@ -357,14 +208,12 @@ class DownloadViewModel : ViewModel() { } fun updateChildList(context: Context, folder: String) = viewModelScope.launchSafe { - _childCards.postValue(Resource.Loading()) // always push loading - val visual = withContext(Dispatchers.IO) { context.getKeys(folder).mapNotNull { key -> - context.getKey(key) + context.getKey(key) }.mapNotNull { val isSelected = selectedItemIds.value?.contains(it.id) ?: false - val info = getDownloadFileInfo(context, it.id) ?: return@mapNotNull null + val info = getDownloadFileInfoAndUpdateSettings(context, it.id) ?: return@mapNotNull null VisualDownloadCached.Child( currentBytes = info.fileLength, totalBytes = info.totalBytes, @@ -372,21 +221,24 @@ class DownloadViewModel : ViewModel() { data = it, ) } - }.sortedWith( - compareBy( - // Sort by season first, and then by episode number, - // to ensure sorting is consistent. - { it.data.season ?: 0 }, - { it.data.episode } - )) + }.sortedWith(compareBy( + // Sort by season first, and then by episode number, + // to ensure sorting is consistent. + { it.data.season ?: 0 }, + { it.data.episode } + )) - postChildren(visual) + if (previousVisual != visual) { + previousVisual = visual + _childCards.postValue(visual) + } } private fun removeItems(idsToRemove: Set) = viewModelScope.launchSafe { - _selectedItemIds.postValue(null) - postHeaders(_headerCards.success?.filter { it.data.id !in idsToRemove }) - postChildren(_childCards.success?.filter { it.data.id !in idsToRemove }) + val updatedHeaders = headerCards.value.orEmpty().filter { it.data.id !in idsToRemove } + val updatedChildren = childCards.value.orEmpty().filter { it.data.id !in idsToRemove } + _headerCards.postValue(updatedHeaders) + _childCards.postValue(updatedChildren) } private fun updateStorageStats(visual: List) { @@ -440,7 +292,7 @@ class DownloadViewModel : ViewModel() { if (item.data.type.isEpisodeBased()) { val episodes = context.getKeys(DOWNLOAD_EPISODE_CACHE) .mapNotNull { - context.getKey( + context.getKey( it ) } @@ -464,7 +316,7 @@ class DownloadViewModel : ViewModel() { is VisualDownloadCached.Child -> { ids.add(item.data.id) - val parent = context.getKey( + val parent = context.getKey( DOWNLOAD_HEADER_CACHE, item.data.parentId.toString() ) @@ -493,16 +345,16 @@ class DownloadViewModel : ViewModel() { .joinToString(separator = "\n") { "• $it" } return when { - data.seriesNames.isNotEmpty() && data.names.isEmpty() -> { - context.getString(R.string.delete_message_series_only).format(formattedSeriesNames) - } - data.ids.count() == 1 -> { context.getString(R.string.delete_message).format( data.names.firstOrNull() ) } + data.seriesNames.isNotEmpty() && data.names.isEmpty() -> { + context.getString(R.string.delete_message_series_only).format(formattedSeriesNames) + } + data.parentName != null && data.names.isNotEmpty() -> { context.getString(R.string.delete_message_series_episodes) .format(data.parentName, formattedNames) @@ -531,6 +383,7 @@ class DownloadViewModel : ViewModel() { when (which) { DialogInterface.BUTTON_POSITIVE -> { viewModelScope.launchSafe { + setIsMultiDeleteState(false) deleteFilesAndUpdateSettings(context, ids, this) { successfulIds -> // We always remove parent because if we are deleting from here // and we have it as non-empty, it was triggered on @@ -561,8 +414,8 @@ class DownloadViewModel : ViewModel() { } private fun getSelectedItemsData(): List? { - val headers = _headerCards.success.orEmpty() - val children = _childCards.success.orEmpty() + val headers = headerCards.value.orEmpty() + val children = childCards.value.orEmpty() return selectedItemIds.value?.mapNotNull { id -> headers.find { it.data.id == id } ?: children.find { it.data.id == id } @@ -570,11 +423,10 @@ class DownloadViewModel : ViewModel() { } private fun getItemDataFromId(itemId: Int): List { - return (_headerCards.success.orEmpty() + _childCards.success.orEmpty()).filter { it.data.id == itemId } - } + val headers = headerCards.value.orEmpty() + val children = childCards.value.orEmpty() - fun clearChildren() { - _childCards.postValue(Resource.Loading()) + return (headers + children).filter { it.data.id == itemId } } private data class DeleteData( diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/download/button/BaseFetchButton.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/download/button/BaseFetchButton.kt index 382a770cd..908e3a80a 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/download/button/BaseFetchButton.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/download/button/BaseFetchButton.kt @@ -11,7 +11,7 @@ import androidx.core.widget.ContentLoadingProgressBar import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.utils.Coroutines.ioSafe import com.lagradost.cloudstream3.utils.Coroutines.mainWork -import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager +import com.lagradost.cloudstream3.utils.VideoDownloadManager typealias DownloadStatusTell = VideoDownloadManager.DownloadType @@ -62,7 +62,6 @@ abstract class BaseFetchButton(context: Context, attributeSet: AttributeSet) : open fun resetViewData() { // lastRequest = null - progressText = null isZeroBytes = true doSetProgress = true persistentId = null @@ -76,10 +75,10 @@ abstract class BaseFetchButton(context: Context, attributeSet: AttributeSet) : currentMetaData.id = id if (!doSetProgress) return - val appContext = context.applicationContext ioSafe { - val savedData = VideoDownloadManager.getDownloadFileInfo(appContext, id) + val savedData = VideoDownloadManager.getDownloadFileInfoAndUpdateSettings(context, id) + mainWork { if (savedData != null) { val downloadedBytes = savedData.fileLength @@ -87,7 +86,7 @@ abstract class BaseFetchButton(context: Context, attributeSet: AttributeSet) : setProgress(downloadedBytes, totalBytes) applyMetaData(id, downloadedBytes, totalBytes) - } + } else run { resetView() } } } } @@ -216,4 +215,4 @@ abstract class BaseFetchButton(context: Context, attributeSet: AttributeSet) : * Get a clean slate again, might be useful in recyclerview? * */ abstract fun resetView() -} +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/download/button/DownloadButton.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/download/button/DownloadButton.kt index 91c5dd72c..20a444611 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/download/button/DownloadButton.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/download/button/DownloadButton.kt @@ -8,7 +8,7 @@ import androidx.core.view.isVisible import com.google.android.material.button.MaterialButton import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.ui.download.DownloadClickEvent -import com.lagradost.cloudstream3.utils.downloader.DownloadObjects +import com.lagradost.cloudstream3.utils.VideoDownloadHelper class DownloadButton(context: Context, attributeSet: AttributeSet) : PieFetchButton(context, attributeSet) { @@ -18,7 +18,6 @@ class DownloadButton(context: Context, attributeSet: AttributeSet) : super.onAttachedToWindow() progressText = findViewById(R.id.result_movie_download_text_precentage) mainText = findViewById(R.id.result_movie_download_text) - setStatus(null) } override fun setStatus(status: DownloadStatusTell?) { @@ -36,7 +35,7 @@ class DownloadButton(context: Context, attributeSet: AttributeSet) : } override fun setDefaultClickListener( - card: DownloadObjects.DownloadEpisodeCached, + card: VideoDownloadHelper.DownloadEpisodeCached, textView: TextView?, callback: (DownloadClickEvent) -> Unit ) { diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/download/button/PieFetchButton.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/download/button/PieFetchButton.kt index f6f8a5ff8..29c2daa2c 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/download/button/PieFetchButton.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/download/button/PieFetchButton.kt @@ -10,14 +10,11 @@ import android.widget.ImageView import android.widget.TextView import androidx.annotation.MainThread import androidx.core.content.ContextCompat -import androidx.core.content.withStyledAttributes import androidx.core.view.isGone import androidx.core.view.isVisible -import com.lagradost.cloudstream3.CloudStreamApp.Companion.removeKey +import com.lagradost.cloudstream3.AcraApplication.Companion.removeKey import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.mvvm.logError -import com.lagradost.cloudstream3.services.DownloadQueueService.Companion.downloadInstances -import com.lagradost.cloudstream3.ui.download.DOWNLOAD_ACTION_CANCEL_PENDING import com.lagradost.cloudstream3.ui.download.DOWNLOAD_ACTION_DELETE_FILE import com.lagradost.cloudstream3.ui.download.DOWNLOAD_ACTION_DOWNLOAD import com.lagradost.cloudstream3.ui.download.DOWNLOAD_ACTION_LONG_CLICK @@ -26,10 +23,9 @@ import com.lagradost.cloudstream3.ui.download.DOWNLOAD_ACTION_PLAY_FILE import com.lagradost.cloudstream3.ui.download.DOWNLOAD_ACTION_RESUME_DOWNLOAD import com.lagradost.cloudstream3.ui.download.DownloadClickEvent import com.lagradost.cloudstream3.utils.UIHelper.popupMenuNoIcons -import com.lagradost.cloudstream3.utils.downloader.DownloadObjects -import com.lagradost.cloudstream3.utils.downloader.DownloadQueueManager.queue -import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager -import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager.KEY_RESUME_PACKAGES +import com.lagradost.cloudstream3.utils.VideoDownloadHelper +import com.lagradost.cloudstream3.utils.VideoDownloadManager +import com.lagradost.cloudstream3.utils.VideoDownloadManager.KEY_RESUME_PACKAGES open class PieFetchButton(context: Context, attributeSet: AttributeSet) : BaseFetchButton(context, attributeSet) { @@ -67,7 +63,7 @@ open class PieFetchButton(context: Context, attributeSet: AttributeSet) : open fun onInflate() {} init { - context.withStyledAttributes(attributeSet, R.styleable.PieFetchButton, 0, 0) { + context.obtainStyledAttributes(attributeSet, R.styleable.PieFetchButton, 0, 0).apply { try { inflate( overrideLayout ?: getResourceId( @@ -76,7 +72,6 @@ open class PieFetchButton(context: Context, attributeSet: AttributeSet) : ) ) } catch (e: Exception) { - recycle() // Manually call recycle first to avoid memory leaks Log.e( "PieFetchButton", "Error inflating PieFetchButton, " + "check that you have declared the required aria2c attrs: aria2c_icon_scale aria2c_icon_color aria2c_outline_color aria2c_fill_color" @@ -84,6 +79,11 @@ open class PieFetchButton(context: Context, attributeSet: AttributeSet) : throw e } + + progressBar = findViewById(R.id.progress_downloaded) + progressBarBackground = findViewById(R.id.progress_downloaded_background) + statusView = findViewById(R.id.image_download_status) + animateWaiting = getBoolean( R.styleable.PieFetchButton_download_animate_waiting, true @@ -92,13 +92,16 @@ open class PieFetchButton(context: Context, attributeSet: AttributeSet) : R.styleable.PieFetchButton_download_hide_when_icon, true ) + waitingAnimation = getResourceId( R.styleable.PieFetchButton_download_waiting_animation, R.anim.rotate_around_center_point ) + activeOutline = getResourceId( R.styleable.PieFetchButton_download_outline_active, R.drawable.circle_shape ) + nonActiveOutline = getResourceId( R.styleable.PieFetchButton_download_outline_non_active, R.drawable.circle_shape_dotted @@ -126,29 +129,19 @@ open class PieFetchButton(context: Context, attributeSet: AttributeSet) : ) val fillIndex = getInt(R.styleable.PieFetchButton_download_fill, 0) + progressDrawable = getResourceId( R.styleable.PieFetchButton_download_fill_override, fillArray[fillIndex] ) + + progressBar.progressDrawable = ContextCompat.getDrawable(context, progressDrawable) + + recycle() } - - progressBar = findViewById(R.id.progress_downloaded) - progressBarBackground = findViewById(R.id.progress_downloaded_background) - statusView = findViewById(R.id.image_download_status) - - progressBar.progressDrawable = ContextCompat.getDrawable(context, progressDrawable) - - // resetView() + resetView() onInflate() } - - override fun onAttachedToWindow() { - super.onAttachedToWindow() - // Re-run all animations when the view gets visible. - // Otherwise views may run without animations after recycled - setStatusInternal(currentStatus) - } - private var currentStatus: DownloadStatusTell? = null /*private fun getActivity(): Activity? { var context = context @@ -169,31 +162,16 @@ open class PieFetchButton(context: Context, attributeSet: AttributeSet) : }*/ protected fun setDefaultClickListener( - view: View, textView: TextView?, card: DownloadObjects.DownloadEpisodeCached, + view: View, textView: TextView?, card: VideoDownloadHelper.DownloadEpisodeCached, callback: (DownloadClickEvent) -> Unit ) { this.progressText = textView this.setPersistentId(card.id) view.setOnClickListener { if (isZeroBytes) { - val localQueue = queue.value - val localInstances = downloadInstances.value - val id = card.id - - // If the download is already in queue or active downloads, provide an option to cancel it - if (localQueue.any { q -> q.id == id } || localInstances.any { i -> i.downloadQueueWrapper.id == id }) { - it.popupMenuNoIcons( - arrayListOf( - Pair(DOWNLOAD_ACTION_CANCEL_PENDING, R.string.cancel), - ) - ) { - callback(DownloadClickEvent(itemId, card)) - } - } else { - // Otherwise just start a download instantly - removeKey(KEY_RESUME_PACKAGES, card.id.toString()) - callback(DownloadClickEvent(DOWNLOAD_ACTION_DOWNLOAD, card)) - } + removeKey(KEY_RESUME_PACKAGES, card.id.toString()) + callback(DownloadClickEvent(DOWNLOAD_ACTION_DOWNLOAD, card)) + // callback.invoke(DownloadClickEvent(DOWNLOAD_ACTION_DOWNLOAD, data)) } else { val list = arrayListOf( Pair(DOWNLOAD_ACTION_PLAY_FILE, R.string.popup_play_file), @@ -234,7 +212,7 @@ open class PieFetchButton(context: Context, attributeSet: AttributeSet) : } open fun setDefaultClickListener( - card: DownloadObjects.DownloadEpisodeCached, + card: VideoDownloadHelper.DownloadEpisodeCached, textView: TextView?, callback: (DownloadClickEvent) -> Unit ) { @@ -304,8 +282,8 @@ open class PieFetchButton(context: Context, attributeSet: AttributeSet) : override fun setStatus(status: DownloadStatusTell?) { currentStatus = status - // Runs on the main thread, but also instant if it already is. - if (Looper.getMainLooper().isCurrentThread) { + // Runs on the main thread, but also instant if it already is + if (Looper.myLooper() == Looper.getMainLooper()) { try { setStatusInternal(status) } catch (t: Throwable) { diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/download/queue/DownloadQueueAdapter.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/download/queue/DownloadQueueAdapter.kt deleted file mode 100644 index 877fcfea8..000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/download/queue/DownloadQueueAdapter.kt +++ /dev/null @@ -1,274 +0,0 @@ -package com.lagradost.cloudstream3.ui.download.queue - - -import android.view.LayoutInflater -import android.view.ViewGroup -import androidx.core.view.isGone -import androidx.fragment.app.Fragment -import androidx.recyclerview.widget.ItemTouchHelper -import androidx.recyclerview.widget.RecyclerView -import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey -import com.lagradost.cloudstream3.R -import com.lagradost.cloudstream3.databinding.DownloadQueueItemBinding -import com.lagradost.cloudstream3.ui.BaseAdapter -import com.lagradost.cloudstream3.ui.BaseDiffCallback -import com.lagradost.cloudstream3.ui.ViewHolderState -import com.lagradost.cloudstream3.ui.download.DOWNLOAD_ACTION_CANCEL_PENDING -import com.lagradost.cloudstream3.ui.download.DOWNLOAD_ACTION_DELETE_FILE -import com.lagradost.cloudstream3.ui.download.DOWNLOAD_ACTION_PAUSE_DOWNLOAD -import com.lagradost.cloudstream3.ui.download.DOWNLOAD_ACTION_RESUME_DOWNLOAD -import com.lagradost.cloudstream3.ui.download.DownloadButtonSetup.handleDownloadClick -import com.lagradost.cloudstream3.ui.download.DownloadClickEvent -import com.lagradost.cloudstream3.ui.download.queue.DownloadQueueAdapter.Companion.DOWNLOAD_SEPARATOR_TAG -import com.lagradost.cloudstream3.utils.AppContextUtils.getNameFull -import com.lagradost.cloudstream3.utils.DOWNLOAD_EPISODE_CACHE -import com.lagradost.cloudstream3.utils.DataStore.getFolderName -import com.lagradost.cloudstream3.utils.DataStore.getKey -import com.lagradost.cloudstream3.utils.UIHelper.popupMenuNoIcons -import com.lagradost.cloudstream3.utils.downloader.DownloadObjects -import com.lagradost.cloudstream3.utils.downloader.DownloadObjects.DownloadQueueWrapper -import com.lagradost.cloudstream3.utils.downloader.DownloadQueueManager -import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager -import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager.KEY_DOWNLOAD_INFO - -/** An item in the adapter can either be a separator or a real item. - * isCurrentlyDownloading is used to fully update items as opposed to just moving them. */ -class DownloadAdapterItem(val item: DownloadQueueWrapper?) { - val isSeparator = item == null -} - - -class DownloadQueueAdapter(val fragment: Fragment) : BaseAdapter( - diffCallback = BaseDiffCallback( - itemSame = { a, b -> a.item?.id == b.item?.id }, - contentSame = { a, b -> - a.item == b.item - }) -) { - var currentDownloads = 0 - - companion object { - val DOWNLOAD_SEPARATOR_TAG = "DOWNLOAD_SEPARATOR_TAG" - } - - override fun onCreateContent(parent: ViewGroup): ViewHolderState { - val inflater = LayoutInflater.from(parent.context) - val binding = DownloadQueueItemBinding.inflate(inflater, parent, false) - return ViewHolderState(binding) - } - - override fun onBindContent( - holder: ViewHolderState, - item: DownloadAdapterItem, - position: Int - ) { - when (val binding = holder.view) { - is DownloadQueueItemBinding -> { - if (item.item == null) { - holder.itemView.tag = DOWNLOAD_SEPARATOR_TAG - bindSeparator(binding) - } else { - holder.itemView.tag = null - bind(binding, item.item) - } - } - } - } - - fun submitQueue(newQueue: DownloadAdapterQueue) { - val index = newQueue.currentDownloads.size - val current = newQueue.currentDownloads - val queue = newQueue.queue - currentDownloads = current.size - - val newList = - (current + queue).distinctBy { it.id }.map { DownloadAdapterItem(it) }.toMutableList() - .apply { - // Only add the separator if it actually separates something - if (index < this.size) { - add(index, DownloadAdapterItem(null)) - } - } - submitList(newList) - } - - fun bindSeparator(binding: DownloadQueueItemBinding) { - binding.apply { - separatorHolder.isGone = false - downloadChildEpisodeHolder.isGone = true - } - } - - fun bind( - binding: DownloadQueueItemBinding, - queueWrapper: DownloadQueueWrapper, - ) { - val context = binding.root.context - - binding.apply { - separatorHolder.isGone = true - downloadChildEpisodeHolder.isGone = false - - // Only set the child-text if child and parent are not the same - // This prevents setting movie titles twice - if (queueWrapper.id != queueWrapper.parentId) { - val mainName = queueWrapper.downloadItem?.resultName ?: queueWrapper.resumePackage?.item?.ep?.mainName - downloadChildEpisodeTextExtra.text = mainName - } else { - downloadChildEpisodeTextExtra.text = null - } - - downloadChildEpisodeTextExtra.isGone = downloadChildEpisodeTextExtra.text.isNullOrBlank() - - val status = VideoDownloadManager.downloadStatus[queueWrapper.id] - - downloadButton.setOnClickListener { view -> - val episodeCached = - getKey( - DOWNLOAD_EPISODE_CACHE, - getFolderName(queueWrapper.parentId.toString(), queueWrapper.id.toString()) - ) - - val downloadInfo = context.getKey( - KEY_DOWNLOAD_INFO, - queueWrapper.id.toString() - ) - - val isCurrentlyDownloading = queueWrapper.isCurrentlyDownloading() - - val actionList = arrayListOf>() - - if (isCurrentlyDownloading && episodeCached != null) { - // KEY_DOWNLOAD_INFO is used in the file deletion, and is required to exist to delete anything - if (downloadInfo != null) { - actionList.add(Pair(DOWNLOAD_ACTION_DELETE_FILE, R.string.popup_delete_file)) - } else { - actionList.add(Pair(DOWNLOAD_ACTION_CANCEL_PENDING, R.string.cancel)) - } - - val currentStatus = VideoDownloadManager.downloadStatus[queueWrapper.id] - - when (currentStatus) { - VideoDownloadManager.DownloadType.IsDownloading -> { - actionList.add( - Pair( - DOWNLOAD_ACTION_PAUSE_DOWNLOAD, - R.string.popup_pause_download - ) - ) - } - - VideoDownloadManager.DownloadType.IsPaused -> { - actionList.add( - Pair( - DOWNLOAD_ACTION_RESUME_DOWNLOAD, - R.string.popup_resume_download - ) - ) - } - - else -> {} - } - - view.popupMenuNoIcons( - actionList - ) { - handleDownloadClick(DownloadClickEvent(itemId, episodeCached)) - } - } else { - actionList.add(Pair(DOWNLOAD_ACTION_CANCEL_PENDING, R.string.cancel)) - - view.popupMenuNoIcons( - actionList - ) { - when (itemId) { - DOWNLOAD_ACTION_CANCEL_PENDING -> { - DownloadQueueManager.cancelDownload(queueWrapper.id) - } - } - } - } - } - - downloadButton.resetView() - downloadButton.setStatus(status) - downloadButton.setPersistentId(queueWrapper.id) - - downloadChildEpisodeText.apply { - val name = queueWrapper.downloadItem?.episode?.name - ?: queueWrapper.resumePackage?.item?.ep?.name - val episode = - queueWrapper.downloadItem?.episode?.episode - ?: queueWrapper.resumePackage?.item?.ep?.episode - val season = - queueWrapper.downloadItem?.episode?.season - ?: queueWrapper.resumePackage?.item?.ep?.season - text = context.getNameFull(name, episode, season) - isSelected = true // Needed for text repeating - } - } - } -} - - -class DragAndDropTouchHelper(adapter: DownloadQueueAdapter) : - ItemTouchHelper( - DragAndDropTouchHelperCallback(adapter) - ) - -private class DragAndDropTouchHelperCallback(private val adapter: DownloadQueueAdapter) : - ItemTouchHelper.Callback() { - override fun getMovementFlags( - recyclerView: RecyclerView, - viewHolder: RecyclerView.ViewHolder - ): Int { - val item = adapter.getItem(viewHolder.absoluteAdapterPosition) - val isDownloading = item.item?.isCurrentlyDownloading() == true - val dragFlags = if (item.isSeparator || isDownloading) { - 0 - } else { - ItemTouchHelper.UP or ItemTouchHelper.DOWN // Allow drag up/down - } - - val swipeFlags = 0 // Disable swipe functionality - return makeMovementFlags(dragFlags, swipeFlags) - } - - override fun onMove( - recyclerView: RecyclerView, - source: RecyclerView.ViewHolder, - target: RecyclerView.ViewHolder - ): Boolean { - val fromPosition = source.absoluteAdapterPosition - val toPosition = target.absoluteAdapterPosition - val separatorPosition = adapter.currentDownloads - - val toPositionNoSeparator = - if (separatorPosition < toPosition) toPosition - separatorPosition else toPosition - - if (source.itemView.tag == DOWNLOAD_SEPARATOR_TAG) { - return false - } else { - adapter.getItem(fromPosition).item?.let { downloadQueueInfo -> - DownloadQueueManager.reorderItem( - downloadQueueInfo, - toPositionNoSeparator - 1 - ) - } - } - - return true - } - - override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) { - - } - - override fun isLongPressDragEnabled(): Boolean { - return true // Enable drag with long press - } - - override fun isItemViewSwipeEnabled(): Boolean { - return false // Disable swipe by default - } -} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/download/queue/DownloadQueueFragment.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/download/queue/DownloadQueueFragment.kt deleted file mode 100644 index 071d8913d..000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/download/queue/DownloadQueueFragment.kt +++ /dev/null @@ -1,79 +0,0 @@ -package com.lagradost.cloudstream3.ui.download.queue - -import android.view.View -import androidx.appcompat.app.AlertDialog -import androidx.core.view.isGone -import androidx.fragment.app.activityViewModels -import com.lagradost.cloudstream3.R -import com.lagradost.cloudstream3.databinding.FragmentDownloadQueueBinding -import com.lagradost.cloudstream3.mvvm.observe -import com.lagradost.cloudstream3.ui.BaseFragment -import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR -import com.lagradost.cloudstream3.ui.settings.Globals.PHONE -import com.lagradost.cloudstream3.ui.settings.Globals.TV -import com.lagradost.cloudstream3.ui.settings.Globals.isLandscape -import com.lagradost.cloudstream3.ui.settings.Globals.isLayout -import com.lagradost.cloudstream3.utils.UIHelper.fixSystemBarsPadding -import com.lagradost.cloudstream3.utils.UIHelper.setAppBarNoScrollFlagsOnTV -import com.lagradost.cloudstream3.utils.downloader.DownloadQueueManager -import com.lagradost.cloudstream3.utils.txt - - -class DownloadQueueFragment : - BaseFragment(BindingCreator.Inflate(FragmentDownloadQueueBinding::inflate)) { - private val queueViewModel: DownloadQueueViewModel by activityViewModels() - - override fun onBindingCreated(binding: FragmentDownloadQueueBinding) { - val adapter = DownloadQueueAdapter(this@DownloadQueueFragment) - val clearQueueItem = binding.downloadQueueToolbar.menu?.findItem(R.id.cancel_all) - - observe(queueViewModel.childCards) { cards -> - val size = cards.queue.size + cards.currentDownloads.size - val isEmptyQueue = size == 0 - binding.downloadQueueList.isGone = isEmptyQueue - binding.textNoQueue.isGone = !isEmptyQueue - clearQueueItem?.isVisible = !isEmptyQueue - - adapter.submitQueue(cards) - } - - binding.apply { - downloadQueueToolbar.apply { - title = txt(R.string.download_queue).asString(context) - if (isLayout(PHONE or EMULATOR)) { - setNavigationIcon(R.drawable.ic_baseline_arrow_back_24) - setNavigationOnClickListener { - dispatchBackPressed() - } - } - setAppBarNoScrollFlagsOnTV() - clearQueueItem?.setOnMenuItemClickListener { - AlertDialog.Builder(context, R.style.AlertDialogCustom) - .setTitle(R.string.cancel_all) - .setMessage(R.string.cancel_queue_message) - .setPositiveButton(R.string.yes) { _, _ -> - DownloadQueueManager.removeAllFromQueue() - } - .setNegativeButton(R.string.no) { _, _ -> - }.show() - - true - } - } - - downloadQueueList.adapter = adapter - - // Drag and drop - val helper = DragAndDropTouchHelper(adapter) - helper.attachToRecyclerView(downloadQueueList) - } - } - - override fun fixLayout(view: View) { - fixSystemBarsPadding( - view, - padBottom = isLandscape(), - padLeft = isLayout(TV or EMULATOR) - ) - } -} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/download/queue/DownloadQueueViewModel.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/download/queue/DownloadQueueViewModel.kt deleted file mode 100644 index fc384cb4e..000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/download/queue/DownloadQueueViewModel.kt +++ /dev/null @@ -1,43 +0,0 @@ -package com.lagradost.cloudstream3.ui.download.queue - -import androidx.lifecycle.LiveData -import androidx.lifecycle.MutableLiveData -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import com.lagradost.cloudstream3.mvvm.launchSafe -import com.lagradost.cloudstream3.services.DownloadQueueService.Companion.downloadInstances -import com.lagradost.cloudstream3.utils.downloader.DownloadObjects -import com.lagradost.cloudstream3.utils.downloader.DownloadQueueManager -import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.launch - -data class DownloadAdapterQueue( - val currentDownloads: List, - val queue: List, -) - -class DownloadQueueViewModel : ViewModel() { - private val _childCards = MutableLiveData() - val childCards: LiveData = _childCards - private val totalDownloadFlow = - downloadInstances.combine(DownloadQueueManager.queue) { instances, queue -> - val current = instances.map { it.downloadQueueWrapper } - DownloadAdapterQueue(current, queue.toList()) - }.combine(VideoDownloadManager.currentDownloads) { total, _ -> - // We want to update the flow when currentDownloads updates, but we do not care about its value - total - } - - init { - viewModelScope.launch { - totalDownloadFlow.collect { queue -> - updateChildList(queue) - } - } - } - - fun updateChildList(downloads: DownloadAdapterQueue) { - _childCards.postValue(downloads) - } -} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeChildItemAdapter.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeChildItemAdapter.kt index 43f6d19ff..b25486eb1 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,32 +1,31 @@ package com.lagradost.cloudstream3.ui.home -import android.content.Context import android.view.LayoutInflater -import android.view.View import android.view.ViewGroup -import android.widget.FrameLayout -import androidx.preference.PreferenceManager +import androidx.fragment.app.Fragment import androidx.viewbinding.ViewBinding import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.SearchResponse -import com.lagradost.cloudstream3.databinding.HomeRemoveGridBinding -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 -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.UIHelper.isBottomLayout import com.lagradost.cloudstream3.utils.UIHelper.toPx class HomeScrollViewHolderState(view: ViewBinding) : ViewHolderState(view) { + /*private fun recursive(view : View) : Boolean { + if (view.isFocused) { + println("VIEW: $view | id=${view.id}") + } + return (view as? ViewGroup)?.children?.any { recursive(it) } ?: false + }*/ + // very shitty that we cant store the state when the view clears, // but this is because the focus clears before the view is removed // so we have to manually store it @@ -36,119 +35,31 @@ class HomeScrollViewHolderState(view: ViewBinding) : ViewHolderState(vi if (state) { wasFocused = false // only refocus if tv - if (isLayout(TV)) { + if(isLayout(TV)) { itemView.requestFocus() } } } } -class ResumeItemAdapter( - nextFocusUp: Int? = null, - nextFocusDown: Int? = null, - clickCallback: (SearchClickCallback) -> Unit, - private val removeCallback: (View) -> Unit, -) : HomeChildItemAdapter( - id = "resumeAdapter".hashCode(), - nextFocusUp = nextFocusUp, - nextFocusDown = nextFocusDown, - clickCallback = clickCallback -) { - // As there is no popup on TV we instead use the footer to clear - override val footers = if (isLayout(TV or EMULATOR)) 1 else 0 - - override fun onCreateFooter(parent: ViewGroup): ViewHolderState { - val expanded = parent.context.isBottomLayout() - val inflater = LayoutInflater.from(parent.context) - val binding = if (expanded) HomeRemoveGridExpandedBinding.inflate( - inflater, - parent, - false - ) else HomeRemoveGridBinding.inflate(inflater, parent, false) - return HomeScrollViewHolderState(binding) - } - - override fun onClearView(holder: ViewHolderState) { - // Clear the image, idk if this saves ram or not, but I guess? - clearImage(holder.view.root.findViewById(R.id.imageView)) - } - - override fun onBindFooter(holder: ViewHolderState) { - this.applyBinding(holder, false) - when (val binding = holder.view) { - is HomeRemoveGridBinding -> { - updateLayoutParms(binding.backgroundCard, setWidth, setHeight) - } - - is HomeRemoveGridExpandedBinding -> { - updateLayoutParms(binding.backgroundCard, setWidth, setHeight) - } - } - holder.itemView.apply { - if (isLayout(TV)) { - isFocusableInTouchMode = true - isFocusable = true - } - nextFocusUp?.let { - nextFocusUpId = it - } - nextFocusDown?.let { - nextFocusDownId = it - } - - setOnClickListener { v -> - removeCallback.invoke(v ?: return@setOnClickListener) - } - } - } -} - -/** Remember to set `updatePosterSize` to cache the poster size, - * otherwise the width and height is unset */ -open class HomeChildItemAdapter( +class HomeChildItemAdapter( + fragment: Fragment, id: Int, - var nextFocusUp: Int? = null, - var nextFocusDown: Int? = null, - var clickCallback: (SearchClickCallback) -> Unit, + private val nextFocusUp: Int? = null, + private val nextFocusDown: Int? = null, + private val clickCallback: (SearchClickCallback) -> Unit, ) : - BaseAdapter( - id, diffCallback = BaseDiffCallback( - itemSame = { a, b -> - a.url == b.url && a.name == b.name - }, - contentSame = { a, b -> - a == b - }) - ) { - var hasNext: Boolean = false + BaseAdapter(fragment, id) { var isHorizontal: Boolean = false - set(value) { - field = value - updateCachedPosterSize() - } - - private fun updateCachedPosterSize() { - setWidth = if (!isHorizontal) { - minPosterSize - } else { - maxPosterSize - } - setHeight = if (!isHorizontal) { - maxPosterSize - } else { - minPosterSize - } - } - - init { - updateCachedPosterSize() - } - - protected var setWidth = 0 - protected var setHeight = 0 + var hasNext: Boolean = false override fun onCreateContent(parent: ViewGroup): ViewHolderState { val expanded = parent.context.isBottomLayout() + /* val layout = if (bottom) R.layout.home_result_grid_expanded else R.layout.home_result_grid + + val root = LayoutInflater.from(parent.context).inflate(layout, parent, false) + val binding = HomeResultGridBinding.bind(root)*/ + val inflater = LayoutInflater.from(parent.context) val binding = if (expanded) HomeResultGridExpandedBinding.inflate( inflater, @@ -158,57 +69,58 @@ open class HomeChildItemAdapter( return HomeScrollViewHolderState(binding) } - companion object { - // The vast majority of the lag comes from creating the view - // This simply shares the views between all HomeChildItemAdapter - val sharedPool = - newSharedPool { setMaxRecycledViews(CONTENT, 20) } - - var minPosterSize: Int = 0 - var maxPosterSize: Int = 0 - - fun updatePosterSize(context: Context, value: Int? = null) { - val scale = value ?: PreferenceManager.getDefaultSharedPreferences(context) - ?.getInt(context.getString(R.string.poster_size_key), 0) ?: 0 - // Scale by +10% per step - val mul = 1.0f + scale * 0.1f - minPosterSize = (114.toPx.toFloat() * mul).toInt() - maxPosterSize = (180.toPx.toFloat() * mul).toInt() - } - - fun updateLayoutParms(layout: FrameLayout, width: Int, height: Int) { - val params = layout.layoutParams - if (params.height == height && params.width == width) return - - params.width = width - params.height = height - - layout.layoutParams = params - } - } - - protected fun applyBinding(holder: ViewHolderState, isFirstItem: Boolean) { - when (val binding = holder.view) { - is HomeResultGridBinding -> { - updateLayoutParms(binding.backgroundCard, setWidth, setHeight) - } - - is HomeResultGridExpandedBinding -> { - updateLayoutParms(binding.backgroundCard, setWidth, setHeight) - - if (isFirstItem) { // to fix tv - binding.backgroundCard.nextFocusLeftId = R.id.nav_rail_view - } - } - } - } - override fun onBindContent( holder: ViewHolderState, item: SearchResponse, position: Int ) { - applyBinding(holder, position == 0) + when (val binding = holder.view) { + is HomeResultGridBinding -> { + binding.backgroundCard.apply { + val min = 114.toPx + val max = 180.toPx + + layoutParams = + layoutParams.apply { + width = if (!isHorizontal) { + min + } else { + max + } + height = if (!isHorizontal) { + max + } else { + min + } + } + } + } + + is HomeResultGridExpandedBinding -> { + binding.backgroundCard.apply { + val min = 114.toPx + val max = 180.toPx + + layoutParams = + layoutParams.apply { + width = if (!isHorizontal) { + min + } else { + max + } + height = if (!isHorizontal) { + max + } else { + min + } + } + } + + if (position == 0) { // to fix tv + binding.backgroundCard.nextFocusLeftId = R.id.nav_rail_view + } + } + } SearchResultBuilder.bind( clickCallback = { click -> diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeFragment.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeFragment.kt index b68ef5962..2189c9e44 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,22 +5,17 @@ import android.app.Activity import android.content.Context import android.content.DialogInterface import android.content.Intent +import android.content.res.Configuration +import android.net.Uri import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import android.widget.AbsListView -import android.widget.ArrayAdapter -import android.widget.ImageView -import android.widget.ListView -import android.widget.TextView -import android.widget.Toast -import androidx.activity.ComponentActivity +import android.widget.* import androidx.appcompat.app.AlertDialog -import androidx.core.net.toUri import androidx.core.view.isGone -import androidx.core.view.isInvisible import androidx.core.view.isVisible +import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels import androidx.preference.PreferenceManager import androidx.recyclerview.widget.RecyclerView @@ -28,13 +23,9 @@ import com.google.android.material.bottomsheet.BottomSheetBehavior import com.google.android.material.bottomsheet.BottomSheetDialog import com.google.android.material.chip.Chip import com.lagradost.api.Log +import com.lagradost.cloudstream3.* import com.lagradost.cloudstream3.APIHolder.apis -import com.lagradost.cloudstream3.AllLanguagesName import com.lagradost.cloudstream3.CommonActivity.showToast -import com.lagradost.cloudstream3.MainAPI -import com.lagradost.cloudstream3.R -import com.lagradost.cloudstream3.SearchResponse -import com.lagradost.cloudstream3.TvType import com.lagradost.cloudstream3.databinding.FragmentHomeBinding import com.lagradost.cloudstream3.databinding.HomeEpisodesExpandedBinding import com.lagradost.cloudstream3.databinding.HomeSelectMainpageBinding @@ -45,51 +36,37 @@ import com.lagradost.cloudstream3.mvvm.observe import com.lagradost.cloudstream3.mvvm.observeNullable import com.lagradost.cloudstream3.ui.APIRepository.Companion.noneApi import com.lagradost.cloudstream3.ui.APIRepository.Companion.randomApi -import com.lagradost.cloudstream3.ui.BaseFragment import com.lagradost.cloudstream3.ui.account.AccountHelper.showAccountSelectLinear -import com.lagradost.cloudstream3.ui.account.AccountViewModel -import com.lagradost.cloudstream3.ui.search.SEARCH_ACTION_LOAD -import com.lagradost.cloudstream3.ui.search.SEARCH_ACTION_PLAY_FILE -import com.lagradost.cloudstream3.ui.search.SearchAdapter +import com.lagradost.cloudstream3.ui.result.txt +import com.lagradost.cloudstream3.ui.search.* import com.lagradost.cloudstream3.ui.search.SearchHelper.handleSearchClickCallback -import com.lagradost.cloudstream3.ui.setRecycledViewPool import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR import com.lagradost.cloudstream3.ui.settings.Globals.PHONE import com.lagradost.cloudstream3.ui.settings.Globals.TV -import com.lagradost.cloudstream3.ui.settings.Globals.isLandscape import com.lagradost.cloudstream3.ui.settings.Globals.isLayout import com.lagradost.cloudstream3.utils.AppContextUtils.filterProviderByPreferredMedia import com.lagradost.cloudstream3.utils.AppContextUtils.getApiProviderLangSettings -import com.lagradost.cloudstream3.utils.AppContextUtils.isNetworkAvailable import com.lagradost.cloudstream3.utils.AppContextUtils.isRecyclerScrollable import com.lagradost.cloudstream3.utils.AppContextUtils.loadSearchResult import com.lagradost.cloudstream3.utils.AppContextUtils.ownHide import com.lagradost.cloudstream3.utils.AppContextUtils.ownShow import com.lagradost.cloudstream3.utils.AppContextUtils.setDefaultFocus -import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper -import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.attachBackPressedCallback -import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.detachBackPressedCallback import com.lagradost.cloudstream3.utils.Coroutines.ioSafe import com.lagradost.cloudstream3.utils.DataStoreHelper -import com.lagradost.cloudstream3.utils.EmptyEvent +import com.lagradost.cloudstream3.utils.Event import com.lagradost.cloudstream3.utils.SubtitleHelper.getFlagFromIso -import com.lagradost.cloudstream3.utils.TvChannelUtils import com.lagradost.cloudstream3.utils.UIHelper.dismissSafe -import com.lagradost.cloudstream3.utils.UIHelper.fixSystemBarsPadding import com.lagradost.cloudstream3.utils.UIHelper.getSpanCount -import com.lagradost.cloudstream3.utils.UIHelper.navigate import com.lagradost.cloudstream3.utils.UIHelper.popupMenuNoIconsAndNoStringRes -import com.lagradost.cloudstream3.utils.UIHelper.toPx +import com.lagradost.cloudstream3.utils.UIHelper.setImage +import java.util.* -private const val TAG = "HomeFragment" -class HomeFragment : BaseFragment( - BindingCreator.Bind(FragmentHomeBinding::bind) -) { +class HomeFragment : Fragment() { companion object { - // Used for configuration changed events to fix any popups that are not attached to a fragment - val configEvent = EmptyEvent() + val configEvent = Event() var currentSpan = 1 + val listHomepageItems = mutableListOf() private val errorProfilePics = listOf( R.drawable.monke_benene, @@ -118,7 +95,6 @@ class HomeFragment : BaseFragment( //} // returns a BottomSheetDialog that will be hidden with OwnHidden upon hide, and must be saved to be able call ownShow in onCreateView - fun Activity.loadHomepageList( expand: HomeViewModel.ExpandableHomepageList, deleteCallback: (() -> Unit)? = null, @@ -179,7 +155,7 @@ class HomeFragment : BaseFragment( } } - builder.setTitle(R.string.clear_history) + builder.setTitle(R.string.delete_file) .setMessage( context.getString(R.string.delete_message).format( item.name @@ -200,17 +176,16 @@ class HomeFragment : BaseFragment( // Span settings - binding.homeExpandedRecycler.spanCount = context.getSpanCount(item.isHorizontalImages) - binding.homeExpandedRecycler.setRecycledViewPool(SearchAdapter.sharedPool) + binding.homeExpandedRecycler.spanCount = currentSpan + binding.homeExpandedRecycler.adapter = - SearchAdapter(binding.homeExpandedRecycler,item.isHorizontalImages) { callback -> + SearchAdapter(item.list.toMutableList(), binding.homeExpandedRecycler) { callback -> handleSearchClickCallback(callback) if (callback.action == SEARCH_ACTION_LOAD || callback.action == SEARCH_ACTION_PLAY_FILE) { bottomSheetDialogBuilder.ownHide() // we hide here because we want to resume it later //bottomSheetDialogBuilder.dismissSafe(this) } }.apply { - submitList(item.list) hasNext = expand.hasNext } @@ -234,7 +209,7 @@ class HomeFragment : BaseFragment( expandCallback?.invoke(name)?.let { newExpand -> (recyclerView.adapter as? SearchAdapter?)?.apply { hasNext = newExpand.hasNext - submitList(newExpand.list.list) + updateList(newExpand.list.list) } } } @@ -242,12 +217,9 @@ class HomeFragment : BaseFragment( } }) - val spanListener = Runnable { - binding.homeExpandedRecycler.spanCount = context.getSpanCount(item.isHorizontalImages) - // We want to rebind everything to update the UI, however we also want to avoid - // any animations ect, this is the easiest way to do this, and the most correct - @SuppressLint("NotifyDataSetChanged") - binding.homeExpandedRecycler.adapter?.notifyDataSetChanged() + val spanListener = { span: Int -> + binding.homeExpandedRecycler.spanCount = span + //(recycle.adapter as SearchAdapter).notifyDataSetChanged() } configEvent += spanListener @@ -271,20 +243,18 @@ class HomeFragment : BaseFragment( movies: Chip?, asian: Chip?, livestream: Chip?, - torrent: Chip?, nsfw: Chip?, others: Chip?, ): List>> { // This list should be same order as home screen to aid navigation return listOf( - Pair(movies, listOf(TvType.Movie)), + Pair(movies, listOf(TvType.Movie, TvType.Torrent)), Pair(tvs, listOf(TvType.TvSeries)), Pair(anime, listOf(TvType.Anime, TvType.OVA, TvType.AnimeMovie)), Pair(asian, listOf(TvType.AsianDrama)), Pair(cartoons, listOf(TvType.Cartoon)), Pair(docs, listOf(TvType.Documentary)), Pair(livestream, listOf(TvType.Live)), - Pair(torrent, listOf(TvType.Torrent)), Pair(nsfw, listOf(TvType.NSFW)), Pair(others, listOf(TvType.Others)), ) @@ -298,7 +268,6 @@ class HomeFragment : BaseFragment( header.homeSelectMovies, header.homeSelectAsian, header.homeSelectLivestreams, - header.homeSelectTorrents, header.homeSelectNsfw, header.homeSelectOthers ) @@ -317,7 +286,7 @@ class HomeFragment : BaseFragment( val pairList = getPairList(header) for ((button, types) in pairList) { button?.isChecked = - button.isVisible && selectedTypes.any { types.contains(it) } + button?.isVisible == true && selectedTypes.any { types.contains(it) } } } @@ -407,31 +376,8 @@ class HomeFragment : BaseFragment( dialog.dismissSafe() } - var pinnedphashset = DataStoreHelper.pinnedProviders.toHashSet() - val listView = dialog.findViewById(R.id.listview1) - - 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 - } - } + val arrayAdapter = ArrayAdapter(this, R.layout.sort_bottom_single_choice) listView?.adapter = arrayAdapter listView?.choiceMode = AbsListView.CHOICE_MODE_SINGLE @@ -439,39 +385,21 @@ class HomeFragment : BaseFragment( if (currentValidApis.isNotEmpty()) { currentApiName = currentValidApis[i].name //to switch to apply simply remove this - currentApiName.let(callback) + currentApiName?.let(callback) dialog.dismissSafe() } } fun updateList() { DataStoreHelper.homePreference = preSelectedTypes - val pinnedp = DataStoreHelper.pinnedProviders.toList() - pinnedphashset = pinnedp.toHashSet() + arrayAdapter.clear() - val sortedApis = validAPIs - .filter { - it.hasMainPage && (pinnedphashset.contains(it.name) || it.supportedTypes.any( - preSelectedTypes::contains - )) + currentValidApis = validAPIs.filter { api -> + api.hasMainPage && api.supportedTypes.any { + preSelectedTypes.contains(it) } - .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) - } + }.sortedBy { it.name.lowercase() }.toMutableList() + currentValidApis.addAll(0, validAPIs.subList(0, 2)) val names = currentValidApis.map { if (isMultiLang) "${getFlagFromIso(it.lang)?.plus(" ") ?: ""}${it.name}" else it.name } @@ -480,21 +408,6 @@ class HomeFragment : BaseFragment( 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, @@ -511,71 +424,47 @@ class HomeFragment : BaseFragment( } private val homeViewModel: HomeViewModel by activityViewModels() - private val accountViewModel: AccountViewModel by activityViewModels() - fun addMovies(cards: List) { - val ctx = context ?: run { - Log.e(TAG, "Context is null, aborting addMovies") - return - } + var binding: FragmentHomeBinding? = null - try { - val existingId = TvChannelUtils.getChannelId(ctx, getString(R.string.app_name)) - if (existingId != null) { - Log.d(TAG, "Channel ID: $existingId") - - val programCards = cards - - TvChannelUtils.addPrograms( - context = ctx, - channelId = existingId, - items = programCards - ) - } else { - Log.d(TAG, "Channel does not exist") - } - } catch (e: Exception) { - Log.e(TAG, "Error adding movies: $e") - } - } - - private fun deleteAll() { - val ctx = context ?: run { - Log.e(TAG, "Context is null, aborting deleteAll") - return - } - - try { - val existingId = TvChannelUtils.getChannelId(ctx, getString(R.string.app_name)) - if (existingId != null) { - Log.d(TAG, "Channel ID: $existingId") - TvChannelUtils.deleteStoredPrograms(ctx) - } else { - Log.d(TAG, "Channel does not exist") - } - } catch (e: Exception) { - Log.e(TAG, "Error deleting programs: ${e.message}") - } - } - - override fun pickLayout(): Int? = - if (isLayout(PHONE)) R.layout.fragment_home else R.layout.fragment_home_tv override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View? { + //homeViewModel = + // ViewModelProvider(this).get(HomeViewModel::class.java) + bottomSheetDialog?.ownShow() - return super.onCreateView(inflater, container, savedInstanceState) + val layout = + if (isLayout(TV or EMULATOR)) R.layout.fragment_home_tv else R.layout.fragment_home + val root = inflater.inflate(layout, container, false) + binding = try { + FragmentHomeBinding.bind(root) + } catch (t: Throwable) { + showToast(txt(R.string.unable_to_inflate, t.message ?: ""), Toast.LENGTH_LONG) + logError(t) + null + } + + return root } override fun onDestroyView() { - (activity as? ComponentActivity)?.detachBackPressedCallback("HomeFragment_BackPress") + bottomSheetDialog?.ownHide() + binding = null super.onDestroyView() } + private fun fixGrid() { + activity?.getSpanCount()?.let { + currentSpan = it + } + configEvent.invoke(currentSpan) + } + private val apiChangeClickListener = View.OnClickListener { view -> view.context.selectHomepage(currentApiName) { api -> homeViewModel.loadAndCancel(api, forceReload = true, fromUI = true) @@ -589,129 +478,59 @@ class HomeFragment : BaseFragment( }*/ } + override fun onConfigurationChanged(newConfig: Configuration) { + super.onConfigurationChanged(newConfig) + //(home_preview_viewpager?.adapter as? HomeScrollAdapter)?.notifyDataSetChanged() + fixGrid() + } + private var currentApiName: String? = null private var toggleRandomButton = false private var bottomSheetDialog: BottomSheetDialog? = null + + // https://github.com/vivchar/RendererRecyclerViewAdapter/blob/185251ee9d94fb6eb3e063b00d646b745186c365/example/src/main/java/com/github/vivchar/example/pages/github/GithubFragment.kt#L32 + // cry about it, but this is android we are talking about, we cant do the most simple shit without making a global variable + private var instanceState: Bundle = Bundle() private var homeMasterAdapter: HomeParentItemAdapterPreview? = null - var lastSavedHomepage: String? = null - - fun saveHomepageToTV(page: Map) { - // No need to update for phone - if (isLayout(PHONE)) { - return - } - val (name, data) = page.entries.firstOrNull() ?: return - // Modifying homepage is an expensive operation, and therefore we avoid it at all cost - if (name == lastSavedHomepage) { - return - } - Log.i(TAG, "Adding programs $name to TV") - lastSavedHomepage = name - ioSafe { - // empty the channel - deleteAll() - // insert the program from first array - addMovies(data.list.list) - } - } - - override fun fixLayout(view: View) { - fixSystemBarsPadding( - view, - padTop = false, - padBottom = isLandscape(), - padLeft = isLayout(TV or EMULATOR) - ) - - // Fix grid - configEvent.invoke() - } - @SuppressLint("SetTextI18n") - override fun onBindingCreated(binding: FragmentHomeBinding) { - context?.let { HomeChildItemAdapter.updatePosterSize(it) } - (activity as? ComponentActivity)?.attachBackPressedCallback("HomeFragment_BackPress") { - handleTvBackPress(this) - } - binding.apply { + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + fixGrid() + + binding?.apply { //homeChangeApiLoading.setOnClickListener(apiChangeClickListener) //homeChangeApiLoading.setOnClickListener(apiChangeClickListener) homeApiFab.setOnClickListener(apiChangeClickListener) - homeApiFab.setOnLongClickListener { - if (currentApiName == noneApi.name) return@setOnLongClickListener false - homeViewModel.loadAndCancel(currentApiName, forceReload = true, fromUI = true) - showToast(R.string.action_reload, Toast.LENGTH_SHORT) - true - } homeChangeApi.setOnClickListener(apiChangeClickListener) homeSwitchAccount.setOnClickListener { activity?.showAccountSelectLinear() } + homeRandom.setOnClickListener { + if (listHomepageItems.isNotEmpty()) { + activity.loadSearchResult(listHomepageItems.random()) + } + } homeMasterAdapter = HomeParentItemAdapterPreview( - homeViewModel, accountViewModel + fragment = this@HomeFragment, + homeViewModel, ) - homeMasterRecycler.setRecycledViewPool(ParentItemAdapter.sharedPool) homeMasterRecycler.adapter = homeMasterAdapter + //fixPaddingStatusbar(homeLoadingStatusbar) homeApiFab.isVisible = isLayout(PHONE) - homePreviewReloadProvider.setOnClickListener { - homeViewModel.loadAndCancel( - homeViewModel.apiName.value ?: noneApi.name, - forceReload = true, - fromUI = true - ) - showToast(R.string.action_reload, Toast.LENGTH_SHORT) - true - } - - homePreviewSearchButton.setOnClickListener { _ -> - // Open blank screen. - homeViewModel.queryTextSubmit("") - } - homeMasterRecycler.addOnScrollListener(object : RecyclerView.OnScrollListener() { override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { - if (isLayout(PHONE)) { - // Fab is only relevant to Phone - if (dy > 0) { //check for scroll down - homeApiFab.shrink() // hide - homeRandom.shrink() - } else if (dy < -5) { - if (isLayout(PHONE)) { - homeApiFab.extend() // show - homeRandom.extend() - } - } - } else { - // Header scrolling is only relevant to TV/Emulator - - val view = recyclerView.findViewHolderForAdapterPosition(0)?.itemView - val scrollParent = binding.homeApiHolder - - if (view == null) { - // The first view is not visible, so we can assume we have scrolled past it - scrollParent.isVisible = false - } else { - // A bit weird, but this is a major limitation we are working around here - // 1. We cant have a real parent to the recyclerview as android cant layout that without lagging - // 2. We cant put the view in the recyclerview, as it should always be shown - // 3. We cant mirror the view in the recyclerview as then it causes focus issues when swaping out the mirror view - // - // This means that if we want to have a parent view to the recyclerview we are out of luck - // Instead this uses getLocationInWindow to calculate how much the view should be scrolled - // as recyclerView has no scrollY (always 0) - // - // Then it manually "scrolls" it to the correct position - // - // Hopefully getLocationInWindow acts correctly on all devices - val rect = IntArray(2) - view.getLocationInWindow(rect) - scrollParent.isVisible = true - scrollParent.translationY = rect[1].toFloat() - 60.toPx + if (dy > 0) { //check for scroll down + homeApiFab.shrink() // hide + homeRandom.shrink() + } else if (dy < -5) { + if (isLayout(PHONE)) { + homeApiFab.extend() // show + homeRandom.extend() } } super.onScrolled(recyclerView, dx, dy) @@ -720,6 +539,7 @@ class HomeFragment : BaseFragment( } + //Load value for toggling Random button. Hide at startup context?.let { val settingsManager = PreferenceManager.getDefaultSharedPreferences(it) @@ -727,61 +547,52 @@ class HomeFragment : BaseFragment( settingsManager.getBoolean( getString(R.string.random_button_key), false - ) - binding.homeRandom.visibility = View.GONE - binding.homeRandomButtonTv.visibility = View.GONE + ) && isLayout(PHONE) + binding?.homeRandom?.visibility = View.GONE } observe(homeViewModel.apiName) { apiName -> currentApiName = apiName - binding.apply { - homeApiFab.text = apiName - homeChangeApi.text = apiName - homePreviewReloadProvider.isGone = (apiName == noneApi.name) - homePreviewSearchButton.isGone = (apiName == noneApi.name) - } + binding?.homeApiFab?.text = apiName + binding?.homeChangeApi?.text = apiName } observe(homeViewModel.page) { data -> - binding.apply { + binding?.apply { when (data) { is Resource.Success -> { + homeLoadingShimmer.stopShimmer() + val d = data.value + val mutableListOfResponse = mutableListOf() + listHomepageItems.clear() + (homeMasterRecycler.adapter as? ParentItemAdapter)?.submitList(d.values.map { it.copy( list = it.list.copy(list = it.list.list.toMutableList()) ) - }) - - saveHomepageToTV(d) + }.toMutableList()) homeLoading.isVisible = false homeLoadingError.isVisible = false homeMasterRecycler.isVisible = true - homeLoadingShimmer.stopShimmer() //home_loaded?.isVisible = true if (toggleRandomButton) { - val distinct = d.values - .flatMap { it.list.list } - .distinctBy { it.url } - val hasItems = distinct.isNotEmpty() - val isPhone = isLayout(PHONE) - val randomClickListener = View.OnClickListener { - distinct.randomOrNull()?.let { activity.loadSearchResult(it) } + //Flatten list + d.values.forEach { dlist -> + mutableListOfResponse.addAll(dlist.list.list) } + listHomepageItems.addAll(mutableListOfResponse.distinctBy { it.url }) - homeRandom.isVisible = isPhone && hasItems - homeRandom.setOnClickListener(randomClickListener) - homeRandomButtonTv.isVisible = !isPhone && hasItems - homeRandomButtonTv.setOnClickListener(randomClickListener) + homeRandom.isVisible = listHomepageItems.isNotEmpty() } else { homeRandom.isGone = true - homeRandomButtonTv.isGone = true } } is Resource.Failure -> { homeLoadingShimmer.stopShimmer() + resultErrorText.text = data.errorString homeReloadConnectionerror.setOnClickListener(apiChangeClickListener) homeReloadConnectionOpenInBrowser.setOnClickListener { view -> val validAPIs = apis//.filter { api -> api.hasMainPage } @@ -794,7 +605,7 @@ class HomeFragment : BaseFragment( }) { try { val i = Intent(Intent.ACTION_VIEW) - i.data = validAPIs[itemId].mainUrl.toUri() + i.data = Uri.parse(validAPIs[itemId].mainUrl) startActivity(i) } catch (e: Exception) { logError(e) @@ -804,50 +615,26 @@ class HomeFragment : BaseFragment( homeLoading.isVisible = false homeLoadingError.isVisible = true - homeMasterRecycler.isInvisible = true - - // Based on https://github.com/recloudstream/cloudstream/pull/1438 - val hasNoNetworkConnection = context?.isNetworkAvailable() == false - val isNetworkError = data.isNetworkError - - // Show the downloads button if we have any sort of network shenanigans - homeReloadConnectionGoToDownloads.isVisible = - hasNoNetworkConnection || isNetworkError - - // Only hide the open in browser button if we know this is not network shenanigans related to cs3 - homeReloadConnectionOpenInBrowser.isGone = hasNoNetworkConnection - - resultErrorText.text = if (hasNoNetworkConnection) { - getString(R.string.no_internet_connection) - } else { - data.errorString - } - - homeReloadConnectionGoToDownloads.setOnClickListener { - activity.navigate(R.id.navigation_downloads) - } - - (homeMasterRecycler.adapter as? ParentItemAdapter)?.apply { - submitList(null) - clearState() - } + homeMasterRecycler.isVisible = false + //home_loaded?.isVisible = false } is Resource.Loading -> { + (homeMasterRecycler.adapter as? ParentItemAdapter)?.submitList(listOf()) homeLoadingShimmer.startShimmer() homeLoading.isVisible = true homeLoadingError.isVisible = false - homeMasterRecycler.isInvisible = true - (homeMasterRecycler.adapter as? ParentItemAdapter)?.apply { - submitList(null) - clearState() - } + homeMasterRecycler.isVisible = false //home_loaded?.isVisible = false } } } } + + //context?.fixPaddingStatusbarView(home_statusbar) + //context?.fixPaddingStatusbar(home_padding) + observeNullable(homeViewModel.popup) { item -> if (item == null) { bottomSheetDialog?.dismissSafe() @@ -880,7 +667,7 @@ class HomeFragment : BaseFragment( //TODO READD THIS /*for (syncApi in OAuth2Apis) { - val login = SyncAPI2.loginInfo() + val login = syncApi.loginInfo() val pic = login?.profilePicture if (home_profile_picture?.setImage( pic, @@ -892,44 +679,4 @@ class HomeFragment : BaseFragment( } }*/ } - - private fun handleTvBackPress(helper: BackPressedCallbackHelper.CallbackHelper) { - // Only apply custom behavior on TV interface - if (!isLayout(TV)) { - helper.runDefault() - return - } - val currentFocus = activity?.currentFocus ?: run { - helper.runDefault() - return - } - // isInsideRecycle is true when focus is inside home_master_recycler - var parent = currentFocus.parent - var isInsideRecycler = false - while (parent != null) { - if (parent is View && parent.id == R.id.home_master_recycler) { - isInsideRecycler = true - break - } - parent = parent.parent - } - when { - // Case 1: Focus is within plugin content -> Move to plugin selector - isInsideRecycler -> { - binding?.homeMasterRecycler?.scrollToPosition(0) - // Defer focus request until after scroll ends - binding?.homeChangeApi?.post { - binding?.homeChangeApi?.requestFocus() - } - } - // Case 2: Focus is on plugin selector or nearby buttons -> Move to home navigation - currentFocus.id == R.id.home_change_api || - currentFocus.id == R.id.home_preview_reload_provider || - currentFocus.id == R.id.home_preview_search_button -> { - activity?.findViewById(R.id.navigation_home)?.requestFocus() - } - // Case 3: Any other location -> Use default back behavior - else -> helper.runDefault() - } - } } diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeParentItemAdapter.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeParentItemAdapter.kt index 6bdd1bf49..8bc0aa287 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeParentItemAdapter.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeParentItemAdapter.kt @@ -6,8 +6,10 @@ import android.os.Parcelable import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import androidx.fragment.app.Fragment import androidx.recyclerview.widget.RecyclerView import androidx.viewbinding.ViewBinding +import com.lagradost.cloudstream3.HomePageList import com.lagradost.cloudstream3.LoadResponse import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.databinding.HomepageParentBinding @@ -15,11 +17,9 @@ import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.ui.BaseAdapter import com.lagradost.cloudstream3.ui.BaseDiffCallback import com.lagradost.cloudstream3.ui.ViewHolderState -import com.lagradost.cloudstream3.ui.newSharedPool import com.lagradost.cloudstream3.ui.result.FOCUS_SELF import com.lagradost.cloudstream3.ui.result.setLinearListLayout import com.lagradost.cloudstream3.ui.search.SearchClickCallback -import com.lagradost.cloudstream3.ui.setRecycledViewPool import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR import com.lagradost.cloudstream3.ui.settings.Globals.PHONE import com.lagradost.cloudstream3.ui.settings.Globals.TV @@ -34,11 +34,13 @@ class LoadClickCallback( ) open class ParentItemAdapter( + open val fragment: Fragment, id: Int, private val clickCallback: (SearchClickCallback) -> Unit, private val moreInfoClickCallback: (HomeViewModel.ExpandableHomepageList) -> Unit, private val expandCallback: ((String) -> Unit)? = null, ) : BaseAdapter( + fragment, id, diffCallback = BaseDiffCallback( itemSame = { a, b -> a.list.name == b.list.name }, @@ -46,11 +48,6 @@ open class ParentItemAdapter( a.list.list == b.list.list }) ) { - companion object { - val sharedPool = - newSharedPool { setMaxRecycledViews(CONTENT, 4) } - } - data class ParentItemHolder(val binding: ViewBinding) : ViewHolderState(binding) { override fun save(): Bundle = Bundle().apply { val recyclerView = (binding as? HomepageParentBinding)?.homeChildRecyclerview @@ -63,16 +60,13 @@ open class ParentItemAdapter( override fun restore(state: Bundle) { (binding as? HomepageParentBinding)?.homeChildRecyclerview?.layoutManager?.onRestoreInstanceState( - state.getSafeParcelable("value") + state.getSafeParcelable("value") ) } } - override fun submitList( - list: Collection?, - commitCallback: Runnable? - ) { - super.submitList(list?.sortedBy { it.list.list.isEmpty() }, commitCallback) + override fun submitList(list: List?) { + super.submitList(list?.sortedBy { it.list.list.isEmpty() }) } override fun onUpdateContent( @@ -96,30 +90,17 @@ open class ParentItemAdapter( if (binding !is HomepageParentBinding) return val info = item.list binding.apply { - val currentAdapter = homeChildRecyclerview.adapter as? HomeChildItemAdapter - if (currentAdapter == null) { - homeChildRecyclerview.setRecycledViewPool(HomeChildItemAdapter.sharedPool) - homeChildRecyclerview.adapter = HomeChildItemAdapter( - id = id + position + 100, - clickCallback = clickCallback, - nextFocusUp = homeChildRecyclerview.nextFocusUpId, - nextFocusDown = homeChildRecyclerview.nextFocusDownId, - ).apply { - isHorizontal = info.isHorizontalImages - hasNext = item.hasNext - submitList(item.list.list) - } - } else { - currentAdapter.apply { - isHorizontal = info.isHorizontalImages - hasNext = item.hasNext - this.clickCallback = this@ParentItemAdapter.clickCallback - nextFocusUp = homeChildRecyclerview.nextFocusUpId - nextFocusDown = homeChildRecyclerview.nextFocusDownId - submitIncomparableList(item.list.list) - } + homeChildRecyclerview.adapter = HomeChildItemAdapter( + fragment = fragment, + id = id + position + 100, + clickCallback = clickCallback, + nextFocusUp = homeChildRecyclerview.nextFocusUpId, + nextFocusDown = homeChildRecyclerview.nextFocusDownId, + ).apply { + isHorizontal = info.isHorizontalImages + hasNext = item.hasNext + submitList(item.list.list) } - homeChildRecyclerview.setLinearListLayout( isHorizontal = true, nextLeft = startFocus, @@ -185,6 +166,11 @@ open class ParentItemAdapter( return ParentItemHolder(binding) } + + fun updateList(newList: List) { + submitList(newList.map { HomeViewModel.ExpandableHomepageList(it, 1, false) } + .toMutableList()) + } } @Suppress("DEPRECATION") diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeParentItemAdapterPreview.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeParentItemAdapterPreview.kt index 959806e56..361ca0b31 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeParentItemAdapterPreview.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeParentItemAdapterPreview.kt @@ -1,18 +1,15 @@ package com.lagradost.cloudstream3.ui.home -import android.content.Context import android.os.Bundle import android.os.Parcelable import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import android.widget.ImageView -import androidx.appcompat.app.AlertDialog import androidx.appcompat.widget.SearchView import androidx.core.content.ContextCompat import androidx.core.view.isGone import androidx.core.view.isVisible -import androidx.lifecycle.LifecycleOwner +import androidx.fragment.app.Fragment import androidx.lifecycle.findViewTreeLifecycleOwner import androidx.recyclerview.widget.RecyclerView import androidx.viewbinding.ViewBinding @@ -20,7 +17,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.CloudStreamApp.Companion.getActivity +import com.google.android.material.navigationrail.NavigationRailMenuView +import com.lagradost.cloudstream3.AcraApplication.Companion.getActivity import com.lagradost.cloudstream3.CommonActivity.activity import com.lagradost.cloudstream3.HomePageList import com.lagradost.cloudstream3.LoadResponse @@ -31,15 +29,12 @@ import com.lagradost.cloudstream3.databinding.FragmentHomeHeadBinding import com.lagradost.cloudstream3.databinding.FragmentHomeHeadTvBinding import com.lagradost.cloudstream3.mvvm.Resource import com.lagradost.cloudstream3.mvvm.debugException -import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.mvvm.observe import com.lagradost.cloudstream3.ui.ViewHolderState import com.lagradost.cloudstream3.ui.WatchType -import com.lagradost.cloudstream3.ui.account.AccountHelper.showAccountEditDialog import com.lagradost.cloudstream3.ui.account.AccountHelper.showAccountSelectLinear -import com.lagradost.cloudstream3.ui.account.AccountViewModel +import com.lagradost.cloudstream3.ui.home.HomeFragment.Companion.selectHomepage import com.lagradost.cloudstream3.ui.result.FOCUS_SELF -import com.lagradost.cloudstream3.ui.result.ResultFragment.bindLogo import com.lagradost.cloudstream3.ui.result.ResultViewModel2 import com.lagradost.cloudstream3.ui.result.START_ACTION_RESUME_LATEST import com.lagradost.cloudstream3.ui.result.getId @@ -50,23 +45,17 @@ import com.lagradost.cloudstream3.ui.search.SearchClickCallback import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR import com.lagradost.cloudstream3.ui.settings.Globals.TV import com.lagradost.cloudstream3.ui.settings.Globals.isLayout -import com.lagradost.cloudstream3.utils.AppContextUtils.html -import com.lagradost.cloudstream3.utils.AppContextUtils.setDefaultFocus import com.lagradost.cloudstream3.utils.DataStoreHelper -import com.lagradost.cloudstream3.utils.ImageLoader.loadImage import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showBottomDialog import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showOptionSelectStringRes import com.lagradost.cloudstream3.utils.UIHelper.fixPaddingStatusbarMargin import com.lagradost.cloudstream3.utils.UIHelper.fixPaddingStatusbarView import com.lagradost.cloudstream3.utils.UIHelper.populateChips -import androidx.core.graphics.toColorInt -import com.lagradost.cloudstream3.ui.setRecycledViewPool class HomeParentItemAdapterPreview( + override val fragment: Fragment, private val viewModel: HomeViewModel, - private val accountViewModel: AccountViewModel -) : ParentItemAdapter( - id = "HomeParentItemAdapterPreview".hashCode(), +) : ParentItemAdapter(fragment, id = "HomeParentItemAdapterPreview".hashCode(), clickCallback = { viewModel.click(it) }, moreInfoClickCallback = { @@ -104,33 +93,15 @@ class HomeParentItemAdapterPreview( ) } - return HeaderViewHolder(binding, viewModel, accountViewModel) + return HeaderViewHolder(binding, viewModel, fragment = fragment) } override fun onBindHeader(holder: ViewHolderState) { (holder as? HeaderViewHolder)?.bind() } - override fun onViewDetachedFromWindow(holder: ViewHolderState) { - when (holder) { - is HeaderViewHolder -> { - holder.onViewDetachedFromWindow() - } - } - } - - override fun onViewAttachedToWindow(holder: ViewHolderState) { - when (holder) { - is HeaderViewHolder -> { - holder.onViewAttachedToWindow() - } - } - } - private class HeaderViewHolder( - val binding: ViewBinding, - val viewModel: HomeViewModel, - accountViewModel: AccountViewModel, + val binding: ViewBinding, val viewModel: HomeViewModel, fragment: Fragment, ) : ViewHolderState(binding) { @@ -156,95 +127,66 @@ class HomeParentItemAdapterPreview( } } - val previewAdapter = HomeScrollAdapter { view, position, item -> - viewModel.click( - LoadClickCallback(0, view, position, item) - ) - } - - private val resumeAdapter = ResumeItemAdapter( + val previewAdapter = HomeScrollAdapter(fragment = fragment) + private val resumeAdapter = HomeChildItemAdapter( + fragment, + id = "resumeAdapter".hashCode(), nextFocusUp = itemView.nextFocusUpId, - nextFocusDown = itemView.nextFocusDownId, - removeCallback = { v -> - try { - val context = v.context ?: return@ResumeItemAdapter - val builder: AlertDialog.Builder = - AlertDialog.Builder(context) - // Copy pasted from https://github.com/recloudstream/cloudstream/pull/1658/files - builder.apply { - setTitle(R.string.clear_history) - setMessage( - context.getString(R.string.delete_message).format( - context.getString( - R.string.continue_watching - ) + nextFocusDown = itemView.nextFocusDownId + ) { callback -> + if (callback.action != SEARCH_ACTION_SHOW_METADATA) { + viewModel.click(callback) + return@HomeChildItemAdapter + } + callback.view.context?.getActivity()?.showOptionSelectStringRes( + callback.view, + callback.card.posterUrl, + listOf( + R.string.action_open_watching, + R.string.action_remove_watching + ), + listOf( + R.string.action_open_play, + R.string.action_open_watching, + R.string.action_remove_watching + ) + ) { (isTv, actionId) -> + when (actionId + if (isTv) 0 else 1) { + // play + 0 -> { + viewModel.click( + SearchClickCallback( + START_ACTION_RESUME_LATEST, + callback.view, + -1, + callback.card ) ) - setNegativeButton(R.string.cancel) { _, _ -> /*NO-OP*/ } - setPositiveButton(R.string.delete) { _, _ -> - DataStoreHelper.deleteAllResumeStateIds() + } + //info + 1 -> { + viewModel.click( + SearchClickCallback( + SEARCH_ACTION_LOAD, + callback.view, + -1, + callback.card + ) + ) + } + // remove + 2 -> { + val card = callback.card + if (card is DataStoreHelper.ResumeWatchingResult) { + DataStoreHelper.removeLastWatched(card.parentId) viewModel.reloadStored() } - show().setDefaultFocus() - } - } catch (t: Throwable) { - // This may throw a formatting error - logError(t) - } - }, - clickCallback = { callback -> - if (callback.action != SEARCH_ACTION_SHOW_METADATA) { - viewModel.click(callback) - return@ResumeItemAdapter - } - callback.view.context?.getActivity()?.showOptionSelectStringRes( - callback.view, - callback.card.posterUrl, - listOf( - R.string.action_open_watching, - R.string.action_remove_watching - ), - listOf( - R.string.action_open_play, - R.string.action_open_watching, - R.string.action_remove_watching - ) - ) { (isTv, actionId) -> - when (actionId + if (isTv) 0 else 1) { - // play - 0 -> { - viewModel.click( - SearchClickCallback( - START_ACTION_RESUME_LATEST, - callback.view, - -1, - callback.card - ) - ) - } - //info - 1 -> { - viewModel.click( - SearchClickCallback( - SEARCH_ACTION_LOAD, - callback.view, - -1, - callback.card - ) - ) - } - // remove - 2 -> { - val card = callback.card - if (card is DataStoreHelper.ResumeWatchingResult) { - DataStoreHelper.removeLastWatched(card.parentId) - viewModel.reloadStored() - } - } } } - }) + } + } private val bookmarkAdapter = HomeChildItemAdapter( + fragment, id = "bookmarkAdapter".hashCode(), nextFocusUp = itemView.nextFocusUpId, nextFocusDown = itemView.nextFocusDownId @@ -321,14 +263,9 @@ class HomeParentItemAdapterPreview( private val bookmarkRecyclerView: RecyclerView = itemView.findViewById(R.id.home_bookmarked_child_recyclerview) - private val headProfilePic: ImageView? = itemView.findViewById(R.id.home_head_profile_pic) - private val headProfilePicCard: View? = - itemView.findViewById(R.id.home_head_profile_padding) - - private val alternateHeadProfilePic: ImageView? = - itemView.findViewById(R.id.alternate_home_head_profile_pic) - private val alternateHeadProfilePicCard: View? = - itemView.findViewById(R.id.alternate_home_head_profile_padding) + private val homeAccount: View? = itemView.findViewById(R.id.home_preview_switch_account) + private val alternativeHomeAccount: View? = + itemView.findViewById(R.id.alternative_switch_account) private val topPadding: View? = itemView.findViewById(R.id.home_padding) @@ -339,73 +276,38 @@ class HomeParentItemAdapterPreview( fun onSelect(item: LoadResponse, position: Int) { (binding as? FragmentHomeHeadTvBinding)?.apply { - homePreviewDescription.isGone = item.plot.isNullOrBlank() - homePreviewDescription.text = item.plot?.html() ?: "" + homePreviewDescription.isGone = + item.plot.isNullOrBlank() + homePreviewDescription.text = + item.plot ?: "" - val scoreText = item.score?.toStringNull(0.1, 10, 1, false) - - scoreText?.let { score -> - homePreviewScore.text = - homePreviewScore.context.getString(R.string.extension_rating, score) - - // while it should never fail, we do this just in case - val rating = score.toDoubleOrNull() ?: item.score?.toDouble() ?: 0.0 - - val color = when { - rating < 5.0 -> "#eb2f2f".toColorInt() // Red - rating < 8.0 -> "#eda009".toColorInt() // Yellow - else -> "#3bb33b".toColorInt() // Green - } - homePreviewScore.backgroundTintList = - android.content.res.ColorStateList.valueOf(color) - } - homePreviewScore.isGone = scoreText == null - - item.year?.let { year -> - homePreviewYear.text = year.toString() - } - homePreviewYear.isGone = item.year == null - - val duration = item.duration - duration?.let { min -> - homePreviewDuration.text = - homePreviewDuration.context.getString(R.string.duration_format, min) - } - homePreviewDuration.isGone = duration == null || duration <= 0 - - val castText = item.actors?.take(3)?.joinToString(", ") { it.actor.name } - if (!castText.isNullOrBlank()) { - homePreviewCast.text = - homePreviewCast.context.getString(R.string.cast_format, castText) - homePreviewCast.isVisible = true - } else { - homePreviewCast.isVisible = false - } - - homePreviewText.text = item.name.html() + homePreviewText.text = item.name populateChips( homePreviewTags, item.tags?.take(6) ?: emptyList(), - R.style.ChipFilledSemiTransparent, - null - ) - - - bindLogo( - url = item.logoUrl, - headers = item.posterHeaders, - titleView = homePreviewText, - logoView = homeBackgroundPosterWatermarkBadgeHolder + R.style.ChipFilledSemiTransparent ) homePreviewTags.isGone = item.tags.isNullOrEmpty() + homePreviewPlayBtt.setOnClickListener { view -> + viewModel.click( + LoadClickCallback( + START_ACTION_RESUME_LATEST, + view, + position, + item + ) + ) + } + homePreviewInfoBtt.setOnClickListener { view -> viewModel.click( LoadClickCallback(0, view, position, item) ) } + } (binding as? FragmentHomeHeadBinding)?.apply { //homePreviewImage.setImage(item.posterUrl, item.posterHeaders) @@ -490,7 +392,7 @@ class HomeParentItemAdapterPreview( } } - fun onViewDetachedFromWindow() { + override fun onViewDetachedFromWindow() { previewViewpager.unregisterOnPageChangeCallback(previewCallback) } @@ -511,14 +413,12 @@ class HomeParentItemAdapterPreview( previewViewpager.adapter = previewAdapter resumeRecyclerView.adapter = resumeAdapter - bookmarkRecyclerView.setRecycledViewPool(HomeChildItemAdapter.sharedPool) bookmarkRecyclerView.adapter = bookmarkAdapter resumeRecyclerView.setLinearListLayout( nextLeft = R.id.nav_rail_view, nextRight = FOCUS_SELF ) - bookmarkRecyclerView.setLinearListLayout( nextLeft = R.id.nav_rail_view, nextRight = FOCUS_SELF @@ -539,80 +439,36 @@ class HomeParentItemAdapterPreview( } } - headProfilePicCard?.isGone = isLayout(TV or EMULATOR) - alternateHeadProfilePicCard?.isGone = isLayout(TV or EMULATOR) + homeAccount?.isGone = isLayout(TV or EMULATOR) - (headProfilePic ?: alternateHeadProfilePic)?.observe(viewModel.currentAccount) { currentAccount -> - headProfilePic?.loadImage(currentAccount?.image) - alternateHeadProfilePic?.loadImage(currentAccount?.image) - } - - headProfilePicCard?.setOnClickListener { + homeAccount?.setOnClickListener { activity?.showAccountSelectLinear() } - fun showAccountEditBox(context: Context): Boolean { - val currentAccount = DataStoreHelper.getCurrentAccount() - return if (currentAccount != null) { - showAccountEditDialog( - context = context, - account = currentAccount, - isNewAccount = false, - accountEditCallback = { accountViewModel.handleAccountUpdate(it, context) }, - accountDeleteCallback = { - accountViewModel.handleAccountDelete( - it, - context - ) - } - ) - true - } else false - } - - alternateHeadProfilePicCard?.setOnLongClickListener { - showAccountEditBox(it.context) - } - headProfilePicCard?.setOnLongClickListener { - showAccountEditBox(it.context) - } - - alternateHeadProfilePicCard?.setOnClickListener { + alternativeHomeAccount?.setOnClickListener { activity?.showAccountSelectLinear() } (binding as? FragmentHomeHeadTvBinding)?.apply { - /*homePreviewChangeApi.setOnClickListener { view -> + homePreviewChangeApi.setOnClickListener { view -> view.context.selectHomepage(viewModel.repo?.name) { api -> viewModel.loadAndCancel(api, forceReload = true, fromUI = true) } } - homePreviewReloadProvider.setOnClickListener { - viewModel.loadAndCancel( - viewModel.apiName.value ?: noneApi.name, - forceReload = true, - fromUI = true - ) - showToast(R.string.action_reload, Toast.LENGTH_SHORT) - true - } + homePreviewSearchButton.setOnClickListener { _ -> // Open blank screen. viewModel.queryTextSubmit("") - }*/ + } - // A workaround to the focus problem of always centering the view on focus - // as that causes higher android versions to stretch the ui when switching between shows - var lastFocusTimeoutMs = 0L - homePreviewInfoBtt.setOnFocusChangeListener { view, hasFocus -> - val lastFocusMs = lastFocusTimeoutMs - // Always reset timer, as we only want to update - // it if we have not interacted in half a second - lastFocusTimeoutMs = System.currentTimeMillis() - if (!hasFocus) return@setOnFocusChangeListener - if (lastFocusMs + 500L < System.currentTimeMillis()) { - MainActivity.centerView(view) - } + // This makes the hidden next buttons only available when on the info button + // Otherwise you might be able to go to the next item without being at the info button + homePreviewInfoBtt.setOnFocusChangeListener { _, hasFocus -> + homePreviewHiddenNextFocus.isFocusable = hasFocus + } + + homePreviewPlayBtt.setOnFocusChangeListener { _, hasFocus -> + homePreviewHiddenPrevFocus.isFocusable = hasFocus } homePreviewHiddenNextFocus.setOnFocusChangeListener { _, hasFocus -> @@ -625,13 +481,10 @@ class HomeParentItemAdapterPreview( if (!hasFocus) return@setOnFocusChangeListener if (previewViewpager.currentItem <= 0) { //Focus the Home item as the default focus will be the header item - (activity as? MainActivity)?.binding?.navRailView?.findViewById( - R.id.navigation_home - )?.requestFocus() + (activity as? MainActivity)?.binding?.navRailView?.findViewById(R.id.navigation_home)?.requestFocus() } else { previewViewpager.setCurrentItem(previewViewpager.currentItem - 1, true) - binding.homePreviewInfoBtt.requestFocus() - //binding.homePreviewPlayBtt.requestFocus() + binding.homePreviewPlayBtt.requestFocus() } } } @@ -658,7 +511,9 @@ class HomeParentItemAdapterPreview( params.height = 0 layoutParams = params } - } else fixPaddingStatusbarView(homeNonePadding) + } else { + fixPaddingStatusbarView(homeNonePadding) + } when (preview) { is Resource.Success -> { @@ -682,15 +537,6 @@ class HomeParentItemAdapterPreview( previewViewpager.isVisible = true previewViewpagerText.isVisible = true alternativeAccountPadding?.isVisible = false - (binding as? FragmentHomeHeadTvBinding)?.apply { - homePreviewInfoBtt.isVisible = true - } - // Explicitly bind the current item to ensure instant loading - val currentPos = previewViewpager.currentItem - val item = preview.value.second.getOrNull(currentPos) - if (item != null) { - onSelect(item, currentPos) - } } else -> { @@ -699,9 +545,6 @@ class HomeParentItemAdapterPreview( previewViewpager.isVisible = false previewViewpagerText.isVisible = false alternativeAccountPadding?.isVisible = true - (binding as? FragmentHomeHeadTvBinding)?.apply { - homePreviewInfoBtt.isVisible = false - } //previewHeader.isVisible = false } } @@ -770,19 +613,18 @@ class HomeParentItemAdapterPreview( } } - fun onViewAttachedToWindow() { + override fun onViewAttachedToWindow() { previewViewpager.registerOnPageChangeCallback(previewCallback) - previewViewpager.apply { + binding.root.findViewTreeLifecycleOwner()?.apply { observe(viewModel.preview) { updatePreview(it) } - /*if (binding is FragmentHomeHeadTvBinding) { + if (binding is FragmentHomeHeadTvBinding) { observe(viewModel.apiName) { name -> binding.homePreviewChangeApi.text = name - binding.homePreviewReloadProvider.isGone = (name == noneApi.name) } - }*/ + } observe(viewModel.resumeWatching) { updateResume(it) } @@ -798,7 +640,7 @@ class HomeParentItemAdapterPreview( } toggleListHolder?.isGone = visible.isEmpty() } - } + } ?: debugException { "Expected findViewTreeLifecycleOwner" } } } } diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeScrollAdapter.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeScrollAdapter.kt index e42e774b5..29186e83a 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeScrollAdapter.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeScrollAdapter.kt @@ -1,27 +1,23 @@ package com.lagradost.cloudstream3.ui.home +import android.content.res.Configuration import android.view.LayoutInflater -import android.view.View import android.view.ViewGroup import androidx.core.view.isGone +import androidx.fragment.app.Fragment import com.lagradost.cloudstream3.LoadResponse import com.lagradost.cloudstream3.databinding.HomeScrollViewBinding import com.lagradost.cloudstream3.databinding.HomeScrollViewTvBinding -import com.lagradost.cloudstream3.ui.BaseDiffCallback import com.lagradost.cloudstream3.ui.NoStateAdapter import com.lagradost.cloudstream3.ui.ViewHolderState -import com.lagradost.cloudstream3.ui.result.ResultFragment.bindLogo import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR import com.lagradost.cloudstream3.ui.settings.Globals.TV import com.lagradost.cloudstream3.ui.settings.Globals.isLayout -import com.lagradost.cloudstream3.utils.AppContextUtils.html -import com.lagradost.cloudstream3.utils.ImageLoader.loadImage +import com.lagradost.cloudstream3.utils.UIHelper.setImage class HomeScrollAdapter( - val callback: ((View, Int, LoadResponse) -> Unit) -) : NoStateAdapter(diffCallback = BaseDiffCallback(itemSame = { a, b -> - a.uniqueUrl == b.uniqueUrl && a.name == b.name -})) { + fragment: Fragment +) : NoStateAdapter(fragment) { var hasMoreItems: Boolean = false override fun onCreateContent(parent: ViewGroup): ViewHolderState { @@ -35,51 +31,33 @@ class HomeScrollAdapter( return ViewHolderState(binding) } - override fun onClearView(holder: ViewHolderState) { - when (val binding = holder.view) { - is HomeScrollViewBinding -> { - clearImage(binding.homeScrollPreview) - } - - is HomeScrollViewTvBinding -> { - clearImage(binding.homeScrollPreview) - } - } - } - override fun onBindContent( holder: ViewHolderState, item: LoadResponse, position: Int, ) { val binding = holder.view + val itemView = holder.itemView + val isHorizontal = + binding is HomeScrollViewTvBinding || itemView.resources.configuration.orientation == Configuration.ORIENTATION_LANDSCAPE - val posterUrl = item.backgroundPosterUrl ?: item.posterUrl + val posterUrl = + if (isHorizontal) item.backgroundPosterUrl ?: item.posterUrl else item.posterUrl + ?: item.backgroundPosterUrl when (binding) { is HomeScrollViewBinding -> { - binding.homeScrollPreview.loadImage(posterUrl) + binding.homeScrollPreview.setImage(posterUrl) binding.homeScrollPreviewTags.apply { text = item.tags?.joinToString(" • ") ?: "" isGone = item.tags.isNullOrEmpty() maxLines = 2 } - binding.homeScrollPreviewTitle.text = item.name.html() - - bindLogo( - url = item.logoUrl, - headers = item.posterHeaders, - titleView = binding.homeScrollPreviewTitle, - logoView = binding.homePreviewLogo - ) + binding.homeScrollPreviewTitle.text = item.name } is HomeScrollViewTvBinding -> { - binding.homeScrollPreview.isFocusable = false - binding.homeScrollPreview.setOnClickListener { view -> - callback.invoke(view ?: return@setOnClickListener, position, item) - } - binding.homeScrollPreview.loadImage(posterUrl) + binding.homeScrollPreview.setImage(posterUrl) } } } diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeViewModel.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeViewModel.kt index 8d48f5a68..06bbe83a2 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeViewModel.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeViewModel.kt @@ -7,14 +7,14 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.lagradost.cloudstream3.APIHolder.apis import com.lagradost.cloudstream3.APIHolder.getApiFromNameNull -import com.lagradost.cloudstream3.CloudStreamApp.Companion.context -import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey -import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey +import com.lagradost.cloudstream3.AcraApplication.Companion.context +import com.lagradost.cloudstream3.AcraApplication.Companion.getKey import com.lagradost.cloudstream3.CommonActivity.activity import com.lagradost.cloudstream3.HomePageList import com.lagradost.cloudstream3.LoadResponse import com.lagradost.cloudstream3.MainAPI import com.lagradost.cloudstream3.MainActivity +import com.lagradost.cloudstream3.MainActivity.Companion.lastError import com.lagradost.cloudstream3.SearchResponse import com.lagradost.cloudstream3.amap import com.lagradost.cloudstream3.mvvm.Resource @@ -40,7 +40,6 @@ import com.lagradost.cloudstream3.utils.AppContextUtils.filterSearchResultByFilm import com.lagradost.cloudstream3.utils.AppContextUtils.loadResult import com.lagradost.cloudstream3.utils.Coroutines.ioSafe import com.lagradost.cloudstream3.utils.DOWNLOAD_HEADER_CACHE -import com.lagradost.cloudstream3.utils.DOWNLOAD_HEADER_CACHE_BACKUP import com.lagradost.cloudstream3.utils.DataStoreHelper import com.lagradost.cloudstream3.utils.DataStoreHelper.deleteAllResumeStateIds import com.lagradost.cloudstream3.utils.DataStoreHelper.getAllResumeStateIds @@ -50,12 +49,13 @@ import com.lagradost.cloudstream3.utils.DataStoreHelper.getCurrentAccount import com.lagradost.cloudstream3.utils.DataStoreHelper.getLastWatched import com.lagradost.cloudstream3.utils.DataStoreHelper.getResultWatchState import com.lagradost.cloudstream3.utils.DataStoreHelper.getViewPos -import com.lagradost.cloudstream3.utils.downloader.DownloadObjects +import com.lagradost.cloudstream3.utils.VideoDownloadHelper import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.withContext import java.util.EnumSet import java.util.concurrent.CopyOnWriteArrayList +import kotlin.collections.set class HomeViewModel : ViewModel() { companion object { @@ -67,26 +67,11 @@ class HomeViewModel : ViewModel() { } val resumeWatchingResult = withContext(Dispatchers.IO) { resumeWatching?.mapNotNull { resume -> - val headerCache = getKey( + + val data = getKey( DOWNLOAD_HEADER_CACHE, resume.parentId.toString() - ) - - val data = if (headerCache == null) { - // We store resume watching data in download header cache - // Because downloads automatically pruned outdated download headers we - // removed resume watching data. We should restore the data for affected users. - val oldData = getKey( - DOWNLOAD_HEADER_CACHE_BACKUP, - resume.parentId.toString() - ) ?: return@mapNotNull null - - // Restore data - setKey(DOWNLOAD_HEADER_CACHE, resume.parentId.toString(), oldData) - oldData - } else { - headerCache - } + ) ?: return@mapNotNull null val watchPos = getViewPos(resume.episodeId) @@ -133,7 +118,7 @@ class HomeViewModel : ViewModel() { private var currentShuffledList: List = listOf() private fun autoloadRepo(): APIRepository { - return APIRepository(apis.withLock { apis.first { it.hasMainPage } }) + return APIRepository(synchronized(apis) { apis.first { it.hasMainPage } }) } private val _availableWatchStatusTypes = @@ -328,7 +313,7 @@ class HomeViewModel : ViewModel() { if (repo?.hasMainPage != true) { _page.postValue(Resource.Success(emptyMap())) - _preview.postValue(Resource.Failure(false, "No homepage")) + _preview.postValue(Resource.Failure(false, null, null, "No homepage")) return@ioSafe } @@ -390,6 +375,8 @@ class HomeViewModel : ViewModel() { _preview.postValue( Resource.Failure( false, + null, + null, "No homepage responses" ) ) @@ -494,7 +481,7 @@ class HomeViewModel : ViewModel() { } fun click(load: LoadClickCallback) { - loadResult(load.response.url, load.response.apiName, load.response.name, load.action) + loadResult(load.response.url, load.response.apiName, load.action) } // only save the key if it is from UI, as we don't want internal functions changing the setting @@ -535,18 +522,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.isSafeMode()) { + if (PluginManager.loadedOnlinePlugins || PluginManager.checkSafeModeFile() || lastError != null) { loadAndCancel(noneApi) } else { _page.postValue(Resource.Loading()) if (preferredApiName != null) - _apiName.postValue(preferredApiName) + _apiName.postValue(preferredApiName!!) } } else { // if the api is found, then set it to it and save key 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 c5f8fa3d9..5b240693b 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/library/LibraryFragment.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/library/LibraryFragment.kt @@ -7,16 +7,22 @@ import android.content.res.Configuration import android.os.Bundle import android.os.Handler import android.os.Looper +import android.util.TypedValue +import android.view.LayoutInflater import android.view.View +import android.view.ViewGroup import android.view.ViewGroup.FOCUS_AFTER_DESCENDANTS import android.view.ViewGroup.FOCUS_BLOCK_DESCENDANTS import android.view.animation.AlphaAnimation +import android.widget.ImageView import android.widget.TextView +import android.widget.Toast import androidx.annotation.StringRes import androidx.appcompat.widget.SearchView import androidx.core.view.allViews import androidx.core.view.isGone import androidx.core.view.isVisible +import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels import androidx.preference.PreferenceManager import androidx.recyclerview.widget.RecyclerView @@ -24,33 +30,35 @@ import com.google.android.material.tabs.TabLayout import com.google.android.material.tabs.TabLayoutMediator import com.lagradost.cloudstream3.APIHolder import com.lagradost.cloudstream3.APIHolder.allProviders -import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey -import com.lagradost.cloudstream3.CloudStreamApp.Companion.openBrowser -import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey +import com.lagradost.cloudstream3.AcraApplication.Companion.getKey +import com.lagradost.cloudstream3.AcraApplication.Companion.openBrowser +import com.lagradost.cloudstream3.AcraApplication.Companion.setKey +import com.lagradost.cloudstream3.CommonActivity import com.lagradost.cloudstream3.MainActivity import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.SearchResponse import com.lagradost.cloudstream3.databinding.FragmentLibraryBinding import com.lagradost.cloudstream3.mvvm.Resource import com.lagradost.cloudstream3.mvvm.debugAssert +import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.mvvm.observe import com.lagradost.cloudstream3.syncproviders.SyncAPI import com.lagradost.cloudstream3.syncproviders.SyncIdName import com.lagradost.cloudstream3.ui.AutofitRecyclerView import com.lagradost.cloudstream3.ui.quicksearch.QuickSearchFragment -import com.lagradost.cloudstream3.utils.txt -import com.lagradost.cloudstream3.ui.BaseFragment +import com.lagradost.cloudstream3.ui.result.txt import com.lagradost.cloudstream3.ui.search.SEARCH_ACTION_LOAD import com.lagradost.cloudstream3.ui.search.SEARCH_ACTION_SHOW_METADATA +import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR import com.lagradost.cloudstream3.ui.settings.Globals.PHONE -import com.lagradost.cloudstream3.ui.settings.Globals.isLandscape +import com.lagradost.cloudstream3.ui.settings.Globals.TV import com.lagradost.cloudstream3.ui.settings.Globals.isLayout import com.lagradost.cloudstream3.utils.AppContextUtils.loadResult import com.lagradost.cloudstream3.utils.AppContextUtils.loadSearchResult import com.lagradost.cloudstream3.utils.AppContextUtils.reduceDragSensitivity import com.lagradost.cloudstream3.utils.DataStoreHelper.currentAccount import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showBottomDialog -import com.lagradost.cloudstream3.utils.UIHelper.fixSystemBarsPadding +import com.lagradost.cloudstream3.utils.UIHelper.fixPaddingStatusbar import com.lagradost.cloudstream3.utils.UIHelper.getSpanCount import java.util.concurrent.CopyOnWriteArrayList import kotlin.math.abs @@ -76,10 +84,10 @@ data class ProviderLibraryData( val apiName: String ) -class LibraryFragment : BaseFragment( - BaseFragment.BindingCreator.Bind(FragmentLibraryBinding::bind) -) { +class LibraryFragment : Fragment() { companion object { + + val listLibraryItems = mutableListOf() fun newInstance() = LibraryFragment() /** @@ -90,10 +98,35 @@ class LibraryFragment : BaseFragment( private val libraryViewModel: LibraryViewModel by activityViewModels() + var binding: FragmentLibraryBinding? = null private var toggleRandomButton = false - override fun pickLayout(): Int? = - if (isLayout(PHONE)) R.layout.fragment_library else R.layout.fragment_library_tv + override fun onCreateView( + inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? + ): View { + val layout = + if (isLayout(TV or EMULATOR)) R.layout.fragment_library_tv else R.layout.fragment_library + val root = inflater.inflate(layout, container, false) + binding = try { + FragmentLibraryBinding.bind(root) + } catch (t: Throwable) { + CommonActivity.showToast( + txt(R.string.unable_to_inflate, t.message ?: ""), + Toast.LENGTH_LONG + ) + logError(t) + null + } + + return root + + //return inflater.inflate(R.layout.fragment_library, container, false) + } + + override fun onDestroyView() { + binding = null + super.onDestroyView() + } override fun onSaveInstanceState(outState: Bundle) { binding?.viewpager?.currentItem?.let { currentItem -> @@ -102,52 +135,48 @@ class LibraryFragment : BaseFragment( super.onSaveInstanceState(outState) } - private fun updateRandomVisibility(binding: FragmentLibraryBinding) { - if (!toggleRandomButton) { - binding.libraryRandom.isGone = true - binding.libraryRandomButtonTv.isGone = true - return - } + private fun updateRandom() { val position = libraryViewModel.currentPage.value ?: 0 val pages = (libraryViewModel.pages.value as? Resource.Success)?.value ?: return - val hasItems = pages[position].items.isNotEmpty() - val isPhone = isLayout(PHONE) - - binding.libraryRandom.isVisible = isPhone && hasItems - binding.libraryRandomButtonTv.isVisible = !isPhone && hasItems - } - - override fun fixLayout(view: View) { - fixSystemBarsPadding( - view, - padBottom = isLandscape(), - padLeft = !isLayout(PHONE) - ) + if (toggleRandomButton) { + listLibraryItems.clear() + listLibraryItems.addAll(pages[position].items) + binding?.libraryRandom?.isVisible = listLibraryItems.isNotEmpty() + } else { + binding?.libraryRandom?.isGone = true + } } @SuppressLint("ResourceType", "CutPasteId") - override fun onBindingCreated( - binding: FragmentLibraryBinding, - savedInstanceState: Bundle? - ) { - binding.sortFab.setOnClickListener(sortChangeClickListener) - binding.librarySort.setOnClickListener(sortChangeClickListener) + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + fixPaddingStatusbar(binding?.searchStatusBarPadding) - binding.libraryRoot.findViewById(androidx.appcompat.R.id.search_src_text) - ?.apply { - tag = "tv_no_focus_tag" - // Expand the Appbar when search bar is focused, fixing scroll up issue - setOnFocusChangeListener { _, _ -> - binding.searchBar.setExpanded(true) - } + binding?.sortFab?.setOnClickListener(sortChangeClickListener) + binding?.librarySort?.setOnClickListener(sortChangeClickListener) + + binding?.libraryRoot?.findViewById(R.id.search_src_text)?.apply { + tag = "tv_no_focus_tag" + //Expand the Appbar when search bar is focused, fixing scroll up issue + setOnFocusChangeListener { _, _ -> + binding?.searchBar?.setExpanded(true) } + } + + // Set the color for the search exit icon to the correct theme text color + val searchExitIcon = + binding?.mainSearch?.findViewById(androidx.appcompat.R.id.search_close_btn) + val searchExitIconColor = TypedValue() + + activity?.theme?.resolveAttribute(android.R.attr.textColor, searchExitIconColor, true) + searchExitIcon?.setColorFilter(searchExitIconColor.data) val searchCallback = Runnable { - val newText = binding.mainSearch.query.toString() + val newText = binding?.mainSearch?.query?.toString() ?: return@Runnable libraryViewModel.sort(ListSorting.Query, newText) } - binding.mainSearch.setOnQueryTextListener(object : SearchView.OnQueryTextListener { + binding?.mainSearch?.setOnQueryTextListener(object : SearchView.OnQueryTextListener { override fun onQueryTextSubmit(query: String?): Boolean { libraryViewModel.sort(ListSorting.Query, query) return true @@ -163,11 +192,11 @@ class LibraryFragment : BaseFragment( return true } - binding.mainSearch.removeCallbacks(searchCallback) + binding?.mainSearch?.removeCallbacks(searchCallback) // Delay the execution of the search operation by 1 second (adjust as needed) // this prevents running search when the user is typing - binding.mainSearch.postDelayed(searchCallback, 1000) + binding?.mainSearch?.postDelayed(searchCallback, 1000) return true } @@ -175,12 +204,11 @@ class LibraryFragment : BaseFragment( libraryViewModel.reloadPages(false) - binding.listSelector.setOnClickListener { + binding?.listSelector?.setOnClickListener { val items = libraryViewModel.availableApiNames val currentItem = libraryViewModel.currentApiName.value - activity?.showBottomDialog( - items, + activity?.showBottomDialog(items, items.indexOf(currentItem), txt(R.string.select_library).asString(it.context), false, @@ -197,9 +225,17 @@ class LibraryFragment : BaseFragment( settingsManager.getBoolean( getString(R.string.random_button_key), false - ) - binding.libraryRandom.visibility = View.GONE - binding.libraryRandomButtonTv.visibility = View.GONE + ) && isLayout(PHONE) + binding?.libraryRandom?.visibility = View.GONE + } + + binding?.libraryRandom?.setOnClickListener { + if (listLibraryItems.isNotEmpty()) { + val listLibraryItem = listLibraryItems.random() + libraryViewModel.currentSyncApi?.syncIdName?.let { + loadLibraryItem(it, listLibraryItem.syncId, listLibraryItem) + } + } } /** @@ -210,13 +246,14 @@ class LibraryFragment : BaseFragment( syncId: SyncIdName, apiName: String? = null, ) { - val availableProviders = allProviders.filter { - it.supportedSyncNames.contains(syncId) - }.map { it.name } + - // Add the api if it exists - (APIHolder.getApiFromNameNull(apiName)?.let { listOf(it.name) } - ?: emptyList()) - + val availableProviders = synchronized(allProviders) { + allProviders.filter { + it.supportedSyncNames.contains(syncId) + }.map { it.name } + + // Add the api if it exists + (APIHolder.getApiFromNameNull(apiName)?.let { listOf(it.name) } + ?: emptyList()) + } val baseOptions = listOf( LibraryOpenerType.Default, LibraryOpenerType.None, @@ -268,21 +305,22 @@ class LibraryFragment : BaseFragment( } } - binding.providerSelector.setOnClickListener { + binding?.providerSelector?.setOnClickListener { val syncName = libraryViewModel.currentSyncApi?.syncIdName ?: return@setOnClickListener activity?.showPluginSelectionDialog(syncName.name, syncName) } - binding.viewpager.setPageTransformer(LibraryScrollTransformer()) + binding?.viewpager?.setPageTransformer(LibraryScrollTransformer()) - binding.viewpager.adapter = ViewpagerAdapter( + binding?.viewpager?.adapter = ViewpagerAdapter( + fragment = this, { isScrollingDown: Boolean -> if (isScrollingDown) { - binding.sortFab.shrink() - binding.libraryRandom.shrink() + binding?.sortFab?.shrink() + binding?.libraryRandom?.shrink() } else { - binding.sortFab.extend() - binding.libraryRandom.extend() + binding?.sortFab?.extend() + binding?.libraryRandom?.extend() } }) callback@{ searchClickCallback -> // To prevent future accidents @@ -315,15 +353,15 @@ class LibraryFragment : BaseFragment( } } - binding.apply { + binding?.apply { viewpager.offscreenPageLimit = 2 viewpager.reduceDragSensitivity() searchBar.setExpanded(true) } val startLoading = Runnable { - binding.apply { - gridview.numColumns = root.context.getSpanCount() + binding?.apply { + gridview.numColumns = context?.getSpanCount() ?: 3 gridview.adapter = context?.let { LoadingPosterAdapter(it, 6 * 3) } libraryLoadingOverlay.isVisible = true @@ -333,7 +371,7 @@ class LibraryFragment : BaseFragment( } val stopLoading = Runnable { - binding.apply { + binding?.apply { gridview.adapter = null libraryLoadingOverlay.isVisible = false libraryLoadingShimmer.stopShimmer() @@ -349,7 +387,7 @@ class LibraryFragment : BaseFragment( val pages = resource.value val showNotice = pages.all { it.items.isEmpty() } - binding.apply { + binding?.apply { emptyListTextview.isVisible = showNotice if (showNotice) { if (libraryViewModel.availableApiNames.size > 1) { @@ -377,23 +415,10 @@ class LibraryFragment : BaseFragment( )*/ libraryViewModel.currentPage.value?.let { page -> - binding.viewpager.setCurrentItem(page, false) - binding.searchBar.setExpanded(true) + binding?.viewpager?.setCurrentItem(page, false) } - // Set up random button click listener - if (toggleRandomButton) { - val randomClickListener = View.OnClickListener { - val position = libraryViewModel.currentPage.value ?: 0 - val syncIdName = libraryViewModel.currentSyncApi?.syncIdName ?: return@OnClickListener - pages[position].items.randomOrNull()?.let { item -> - loadLibraryItem(syncIdName, item.syncId, item) - } - } - libraryRandom.setOnClickListener(randomClickListener) - libraryRandomButtonTv.setOnClickListener(randomClickListener) - } - updateRandomVisibility(binding) + updateRandom() // Only stop loading after 300ms to hide the fade effect the viewpager produces when updating // Without this there would be a flashing effect: @@ -434,20 +459,21 @@ class LibraryFragment : BaseFragment( tab.view.nextFocusDownId = R.id.search_result_root tab.view.setOnClickListener { - val currentItem = binding.viewpager.currentItem + val currentItem = + binding?.viewpager?.currentItem ?: return@setOnClickListener val distance = abs(position - currentItem) hideViewpager(distance) } //Expand the appBar on tab focus tab.view.setOnFocusChangeListener { _, _ -> - binding.searchBar.setExpanded(true) + binding?.searchBar?.setExpanded(true) } }.attach() - binding.libraryTabLayout.addOnTabSelectedListener(object : + binding?.libraryTabLayout?.addOnTabSelectedListener(object : TabLayout.OnTabSelectedListener { override fun onTabSelected(tab: TabLayout.Tab?) { - binding.libraryTabLayout.selectedTabPosition.let { page -> + binding?.libraryTabLayout?.selectedTabPosition?.let { page -> libraryViewModel.switchPage(page) } } @@ -472,11 +498,11 @@ class LibraryFragment : BaseFragment( } observe(libraryViewModel.currentPage) { position -> - updateRandomVisibility(binding) - val all = binding.viewpager.allViews.toList() - .filterIsInstance() + updateRandom() + val all = binding?.viewpager?.allViews?.toList() + ?.filterIsInstance() - all.forEach { view -> + all?.forEach { view -> view.isVisible = view.tag == position view.isFocusable = view.tag == position @@ -486,6 +512,14 @@ class LibraryFragment : BaseFragment( view.descendantFocusability = FOCUS_BLOCK_DESCENDANTS } } + + /*binding?.viewpager?.registerOnPageChangeCallback(object : + ViewPager2.OnPageChangeCallback() { + override fun onPageSelected(position: Int) { + + super.onPageSelected(position) + } + })*/ } private fun loadLibraryItem( @@ -527,7 +561,6 @@ class LibraryFragment : BaseFragment( activity?.loadResult( card.url, apiName, - card.name ) } @@ -544,10 +577,10 @@ class LibraryFragment : BaseFragment( } + @SuppressLint("NotifyDataSetChanged") override fun onConfigurationChanged(newConfig: Configuration) { + binding?.viewpager?.adapter?.notifyDataSetChanged() super.onConfigurationChanged(newConfig) - val adapter = binding?.viewpager?.adapter ?: return - adapter.notifyItemRangeChanged(0, adapter.itemCount) } private val sortChangeClickListener = View.OnClickListener { view -> @@ -555,8 +588,7 @@ class LibraryFragment : BaseFragment( txt(it.stringRes).asString(view.context) } - activity?.showBottomDialog( - methods, + activity?.showBottomDialog(methods, libraryViewModel.sortingMethods.indexOf(libraryViewModel.currentSortingMethod), txt(R.string.sort_by).asString(view.context), false, diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/library/LibraryViewModel.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/library/LibraryViewModel.kt index 38f7fcf9d..6c602e6c5 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,13 +4,12 @@ import androidx.annotation.StringRes import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel -import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey -import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey +import com.lagradost.cloudstream3.AcraApplication.Companion.getKey +import com.lagradost.cloudstream3.AcraApplication.Companion.setKey import com.lagradost.cloudstream3.MainActivity import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.mvvm.Resource -import com.lagradost.cloudstream3.mvvm.throwAbleToResource -import com.lagradost.cloudstream3.syncproviders.AccountManager +import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.SyncApis import com.lagradost.cloudstream3.syncproviders.SyncAPI import com.lagradost.cloudstream3.utils.Coroutines.ioSafe import com.lagradost.cloudstream3.utils.DataStoreHelper @@ -31,13 +30,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 @@ -45,7 +44,7 @@ class LibraryViewModel : ViewModel() { val currentApiName: LiveData = _currentApiName private val availableSyncApis - get() = AccountManager.syncApis.filter { it.isAvailable } + get() = SyncApis.filter { it.hasAccount() } var currentSyncApi = availableSyncApis.let { allApis -> val lastSelection = getKey("$currentAccount/$LAST_SYNC_API_KEY") @@ -98,17 +97,12 @@ class LibraryViewModel : ViewModel() { currentSyncApi?.let { repo -> _currentApiName.postValue(repo.name) _pages.postValue(Resource.Loading()) - 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")) + val libraryResource = repo.getPersonalLibrary() + if (libraryResource is Resource.Failure) { + _pages.postValue(libraryResource) return@let } + val library = (libraryResource as? Resource.Success)?.value ?: return@let sortingMethods = library.supportedListSorting.toList() repo.requireLibraryRefresh = false @@ -122,10 +116,7 @@ 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 066cf468d..b2de307f6 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/library/PageAdapter.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/library/PageAdapter.kt @@ -1,34 +1,35 @@ 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 ) : - NoStateAdapter(diffCallback = BaseDiffCallback(itemSame = { a, b -> - if (a.id != null || b.id != null) { - a.id == b.id - } else { - a.name == b.name && a.url == b.url - } - })) { - private val coverHeight: Int get() = (resView.itemWidth / 0.68).roundToInt() + AppContextUtils.DiffAdapter(items) { - override fun onCreateContent(parent: ViewGroup): ViewHolderState { - return ViewHolderState( + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { + return LibraryItemViewHolder( SearchResultGridExpandedBinding.inflate( LayoutInflater.from(parent.context), parent, @@ -37,45 +38,85 @@ class PageAdapter( ) } - override fun onClearView(holder: ViewHolderState) { - when (val binding = holder.view) { - is SearchResultGridExpandedBinding -> { - clearImage(binding.imageView) + override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { + when (holder) { + is LibraryItemViewHolder -> { + holder.bind(items[position], position) } } } - override fun onBindContent( - holder: ViewHolderState, - item: SyncAPI.LibraryItem, - position: Int - ) { - val binding = holder.view as? SearchResultGridExpandedBinding ?: return + private fun isDark(color: Int): Boolean { + return ColorUtils.calculateLuminance(color) < 0.5 + } - /** https://stackoverflow.com/questions/8817522/how-to-get-color-code-of-image-view */ - SearchResultBuilder.bind( - this@PageAdapter.clickCallback, - item, - position, - holder.itemView, - ) - - // See searchAdaptor for this, it basically fixes the height - val params = FrameLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - coverHeight - ) - if (params.height != binding.imageView.layoutParams.height || params.width != binding.imageView.layoutParams.width) { - binding.imageView.layoutParams = params + fun getDifferentColor(color: Int, ratio: Float = 0.7f): Int { + return if (isDark(color)) { + ColorUtils.blendARGB(color, Color.WHITE, ratio) + } else { + ColorUtils.blendARGB(color, Color.BLACK, ratio) } + } - val showProgress = item.episodesCompleted?.let{ it>0 } ?: false && item.episodesTotal != null - binding.watchProgress.isVisible = showProgress - if (showProgress) { - binding.watchProgress.max = item.episodesTotal - binding.watchProgress.progress = item.episodesCompleted + inner class LibraryItemViewHolder(val binding: SearchResultGridExpandedBinding) : + RecyclerView.ViewHolder(binding.root) { + + private val compactView = false//itemView.context.getGridIsCompact() + private val coverHeight: Int = + if (compactView) 80.toPx else (resView.itemWidth / 0.68).roundToInt() + + fun bind(item: SyncAPI.LibraryItem, position: Int) { + /** https://stackoverflow.com/questions/8817522/how-to-get-color-code-of-image-view */ + + SearchResultBuilder.bind( + this@PageAdapter.clickCallback, + item, + position, + itemView, + colorCallback = { palette -> + AcraApplication.context?.let { ctx -> + val defColor = ContextCompat.getColor(ctx, R.color.ratingColorBg) + var bg = palette.getDarkVibrantColor(defColor) + if (bg == defColor) { + bg = palette.getDarkMutedColor(defColor) + } + if (bg == defColor) { + bg = palette.getVibrantColor(defColor) + } + + val fg = + getDifferentColor(bg)//palette.getVibrantColor(ContextCompat.getColor(ctx,R.color.ratingColor)) + binding.textRating.apply { + setTextColor(ColorStateList.valueOf(fg)) + } + binding.textRating.compoundDrawables.getOrNull(0)?.setTint(fg) + binding.textRating.backgroundTintList = ColorStateList.valueOf(bg) + binding.watchProgress.apply { + progressTintList = ColorStateList.valueOf(fg) + progressBackgroundTintList = ColorStateList.valueOf(bg) + } + } + } + ) + + // See searchAdaptor for this, it basically fixes the height + if (!compactView) { + binding.imageView.apply { + layoutParams = FrameLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + coverHeight + ) + } + } + + val showProgress = item.episodesCompleted != null && item.episodesTotal != null + binding.watchProgress.isVisible = showProgress + if (showProgress) { + binding.watchProgress.max = item.episodesTotal!! + binding.watchProgress.progress = item.episodesCompleted!! + } + + binding.imageText.text = item.name } - - binding.imageText.text = item.name } } \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/library/ViewpagerAdapter.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/library/ViewpagerAdapter.kt index 68b6eb273..0110187f6 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/library/ViewpagerAdapter.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/library/ViewpagerAdapter.kt @@ -40,19 +40,19 @@ class ViewpagerAdapterViewHolderState(val binding: LibraryViewpagerPageBinding) } class ViewpagerAdapter( + fragment: Fragment, val scrollCallback: (isScrollingDown: Boolean) -> Unit, val clickCallback: (SearchClickCallback) -> Unit -) : BaseAdapter( +) : BaseAdapter(fragment, id = "ViewpagerAdapter".hashCode(), diffCallback = BaseDiffCallback( - itemSame = { a, b -> - a.title == b.title - }, - contentSame = { a, b -> - a.items == b.items && a.title == b.title - } - )) { - + itemSame = { a, b -> + a.title == b.title + }, + contentSame = { a, b -> + a.items == b.items && a.title == b.title + } +)) { override fun onCreateContent(parent: ViewGroup): ViewHolderState { return ViewpagerAdapterViewHolderState( LibraryViewpagerPageBinding.inflate(LayoutInflater.from(parent.context), parent, false) @@ -66,8 +66,7 @@ class ViewpagerAdapter( ) { val binding = holder.view if (binding !is LibraryViewpagerPageBinding) return - (binding.pageRecyclerview.adapter as? PageAdapter)?.submitList(item.items) - binding.pageRecyclerview.scrollToPosition(0) + (binding.pageRecyclerview.adapter as? PageAdapter)?.updateList(item.items) } override fun onBindContent(holder: ViewHolderState, item: SyncAPI.Page, position: Int) { @@ -76,21 +75,21 @@ class ViewpagerAdapter( binding.pageRecyclerview.tag = position binding.pageRecyclerview.apply { - spanCount = binding.root.context.getSpanCount() + spanCount = + binding.root.context.getSpanCount() ?: 3 if (adapter == null) { // || rebind // Only add the items after it has been attached since the items rely on ItemWidth // Which is only determined after the recyclerview is attached. // If this fails then item height becomes 0 when there is only one item doOnAttach { adapter = PageAdapter( + item.items.toMutableList(), this, clickCallback - ).apply { - submitList(item.items) - } + ) } } else { - (adapter as? PageAdapter)?.submitList(item.items) + (adapter as? PageAdapter)?.updateList(item.items) // scrollToPosition(0) } @@ -101,7 +100,7 @@ class ViewpagerAdapter( //Expand the top Appbar based on scroll direction up/down, simulate phone behavior if (isLayout(TV or EMULATOR)) { binding.root.rootView.findViewById(R.id.search_bar) - ?.apply { + .apply { if (diff <= 0) setExpanded(true) else diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/AbstractPlayerFragment.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/AbstractPlayerFragment.kt index e5a460b9a..ee987f444 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/AbstractPlayerFragment.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/AbstractPlayerFragment.kt @@ -1,16 +1,60 @@ package com.lagradost.cloudstream3.ui.player +import android.annotation.SuppressLint +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.graphics.drawable.AnimatedImageDrawable +import android.graphics.drawable.AnimatedVectorDrawable +import android.media.metrics.PlaybackErrorEvent +import android.os.Build import android.os.Bundle +import android.util.Log +import android.view.LayoutInflater import android.view.View +import android.view.ViewGroup +import android.view.WindowManager +import android.widget.FrameLayout import android.widget.ImageView -import androidx.annotation.OptIn +import android.widget.ProgressBar +import android.widget.Toast +import androidx.annotation.LayoutRes import androidx.annotation.StringRes -import androidx.media3.common.util.UnstableApi +import androidx.core.view.isGone +import androidx.core.view.isVisible +import androidx.fragment.app.Fragment +import androidx.media3.common.PlaybackException +import androidx.media3.exoplayer.ExoPlayer import androidx.media3.session.MediaSession +import androidx.media3.ui.AspectRatioFrameLayout +import androidx.media3.ui.DefaultTimeBar +import androidx.media3.ui.PlayerView import androidx.media3.ui.SubtitleView -import androidx.viewbinding.ViewBinding +import androidx.media3.ui.TimeBar +import androidx.preference.PreferenceManager +import androidx.vectordrawable.graphics.drawable.AnimatedVectorDrawableCompat +import com.github.rubensousa.previewseekbar.PreviewBar +import com.github.rubensousa.previewseekbar.media3.PreviewTimeBar +import com.lagradost.cloudstream3.CommonActivity.canEnterPipMode +import com.lagradost.cloudstream3.CommonActivity.isInPIPMode +import com.lagradost.cloudstream3.CommonActivity.keyEventListener +import com.lagradost.cloudstream3.CommonActivity.playerEventListener +import com.lagradost.cloudstream3.CommonActivity.screenWidth +import com.lagradost.cloudstream3.CommonActivity.showToast import com.lagradost.cloudstream3.R -import com.lagradost.cloudstream3.ui.BaseFragment +import com.lagradost.cloudstream3.mvvm.logError +import com.lagradost.cloudstream3.mvvm.normalSafeApiCall +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 enum class PlayerResize(@StringRes val nameRes: Int) { Fit(R.string.resize_fit), @@ -30,132 +74,586 @@ const val NEXT_WATCH_EPISODE_PERCENTAGE = 90 // when the player should sync the progress of "watched", TODO MAKE SETTING const val UPDATE_SYNC_PROGRESS_PERCENTAGE = 80 -@OptIn(UnstableApi::class) -abstract class AbstractPlayerFragment( - bindingCreator: BindingCreator -) : BaseFragment(bindingCreator), PlayerView.Callbacks { +abstract class AbstractPlayerFragment( + val player: IPlayer = CS3IPlayer() +) : Fragment() { + var resizeMode: Int = 0 + var subStyle: SaveCaptionStyle? = null + var subView: SubtitleView? = null + var isBuffering = true + protected open var hasPipModeSupport = true - // Stored pre-initialization so subclasses can set them before onBindingCreated. - private var _player: IPlayer = CS3IPlayer() + var playerPausePlayHolderHolder: FrameLayout? = null + var playerPausePlay: ImageView? = null + var playerBuffering: ProgressBar? = null + var playerView: PlayerView? = null + var piphide: FrameLayout? = null + var subtitleHolder: FrameLayout? = null - /** The shared [PlayerView] host that owns all player state and view references. */ - protected var playerHostView: PlayerView? = null + @LayoutRes + protected open var layout: Int = R.layout.fragment_player - var player: IPlayer - get() = playerHostView?.player ?: _player - set(value) { - _player = value - playerHostView?.player = value + open fun nextEpisode() { + throw NotImplementedError() + } + + open fun prevEpisode() { + throw NotImplementedError() + } + + open fun playerPositionChanged(position: Long, duration: Long) { + throw NotImplementedError() + } + + open fun playerStatusChanged(){} + + open fun playerDimensionsLoaded(width: Int, height: Int) { + throw NotImplementedError() + } + + open fun subtitlesChanged() { + throw NotImplementedError() + } + + open fun embeddedSubtitlesFetched(subtitles: List) { + throw NotImplementedError() + } + + open fun onTracksInfoChanged() { + throw NotImplementedError() + } + + open fun onTimestamp(timestamp: EpisodeSkip.SkipStamp?) { + + } + + open fun onTimestampSkipped(timestamp: EpisodeSkip.SkipStamp) { + + } + + open fun exitedPipMode() { + throw NotImplementedError() + } + + private fun keepScreenOn(on: Boolean) { + if (on) { + activity?.window?.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + } else { + activity?.window?.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + } + } + + private fun updateIsPlaying( + wasPlaying: CSPlayerLoading, + isPlaying: CSPlayerLoading + ) { + val isPlayingRightNow = CSPlayerLoading.IsPlaying == isPlaying + val isPausedRightNow = CSPlayerLoading.IsPaused == isPlaying + + keepScreenOn(!isPausedRightNow) + + isBuffering = CSPlayerLoading.IsBuffering == isPlaying + if (isBuffering) { + playerPausePlayHolderHolder?.isVisible = false + playerBuffering?.isVisible = true + } else { + playerPausePlayHolderHolder?.isVisible = true + playerBuffering?.isVisible = false + + if (wasPlaying != isPlaying) { + playerPausePlay?.setImageResource(if (isPlayingRightNow) R.drawable.play_to_pause else R.drawable.pause_to_play) + val drawable = playerPausePlay?.drawable + + var startedAnimation = false + if (Build.VERSION.SDK_INT > Build.VERSION_CODES.P) { + if (drawable is AnimatedImageDrawable) { + drawable.start() + startedAnimation = true + } + } + + if (drawable is AnimatedVectorDrawable) { + drawable.start() + startedAnimation = true + } + + if (drawable is AnimatedVectorDrawableCompat) { + drawable.start() + startedAnimation = true + } + + // somehow the phone is wacked + if (!startedAnimation) { + playerPausePlay?.setImageResource(if (isPlayingRightNow) R.drawable.netflix_pause else R.drawable.netflix_play) + } + } else { + playerPausePlay?.setImageResource(if (isPlayingRightNow) R.drawable.netflix_pause else R.drawable.netflix_play) + } } - val subView: SubtitleView? get() = playerHostView?.subView - val playerPausePlay: ImageView? get() = playerHostView?.playerPausePlay - - /** The underlying [androidx.media3.ui.PlayerView] widget (named to avoid conflict with our [PlayerView]). */ - val playerView: androidx.media3.ui.PlayerView? - get() = playerHostView?.exoPlayerView - - var currentPlayerStatus: CSPlayerLoading - get() = playerHostView?.currentPlayerStatus ?: CSPlayerLoading.IsBuffering - set(value) { playerHostView?.currentPlayerStatus = value } - - protected var mMediaSession: MediaSession? - get() = playerHostView?.mMediaSession - set(value) { playerHostView?.mMediaSession = value } - - // No-op callbacks (nextEpisode, prevEpisode, etc.) are intentionally left as - // open so subclasses can override only what they need. The ones below throw - // to make it obvious when an implementation is missing. - - override fun nextEpisode() { - throw NotImplementedError() - } - - override fun prevEpisode() { - throw NotImplementedError() - } - - override fun playerPositionChanged(position: Long, duration: Long) { - throw NotImplementedError() - } - - override fun playerDimensionsLoaded(width: Int, height: Int) { - throw NotImplementedError() - } - - override fun subtitlesChanged() { - throw NotImplementedError() - } - - override fun embeddedSubtitlesFetched(subtitles: List) { - throw NotImplementedError() - } - - override fun onTracksInfoChanged() { - throw NotImplementedError() - } - - override fun exitedPipMode() { - throw NotImplementedError() - } - - override fun hasNextMirror(): Boolean { - throw NotImplementedError() - } - - override fun nextMirror() { - throw NotImplementedError() - } - - /** Delegates to [PlayerView.playerError] by default; override to customize. */ - override fun playerError(exception: Throwable) { - playerHostView?.playerError(exception) - } - - /** Player fragments don't need system-bar padding adjustment by default. */ - override fun fixLayout(view: View) = Unit - - override fun onBindingCreated(binding: T, savedInstanceState: Bundle?) { - val ctx = context ?: return - playerHostView = PlayerView(ctx) - playerHostView?.player = _player - playerHostView?.callbacks = this - playerHostView?.bindViews(binding.root) - playerHostView?.initialize() + canEnterPipMode = isPlayingRightNow && hasPipModeSupport + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + activity?.let { act -> + PlayerPipHelper.updatePIPModeActions( + act, + isPlayingRightNow, + player.getAspectRatio() + ) + } + } } + private var pipReceiver: BroadcastReceiver? = null override fun onPictureInPictureModeChanged(isInPictureInPictureMode: Boolean) { super.onPictureInPictureModeChanged(isInPictureInPictureMode) - playerHostView?.onPictureInPictureModeChanged(isInPictureInPictureMode, activity) + try { + isInPIPMode = isInPictureInPictureMode + if (isInPictureInPictureMode) { + // Hide the full-screen UI (controls, etc.) while in picture-in-picture mode. + piphide?.isVisible = false + pipReceiver = object : BroadcastReceiver() { + override fun onReceive( + context: Context, + intent: Intent, + ) { + if (ACTION_MEDIA_CONTROL != intent.action) { + return + } + player.handleEvent( + CSPlayerEvent.entries[intent.getIntExtra( + EXTRA_CONTROL_TYPE, + 0 + )], source = PlayerEventSource.UI + ) + } + } + val filter = IntentFilter() + filter.addAction(ACTION_MEDIA_CONTROL) + 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 + normalSafeApiCall { + activity?.unregisterReceiver(it) + } + } + activity?.hideSystemUI() + this.view?.let { UIHelper.hideKeyboard(it) } + } + } catch (e: Exception) { + logError(e) + } + } + + open fun hasNextMirror(): Boolean { + throw NotImplementedError() + } + + open fun nextMirror() { + throw NotImplementedError() + } + + private fun requestAudioFocus() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + activity?.requestLocalAudioFocus(AppContextUtils.getFocusRequest()) + } + } + + open fun playerError(exception: Throwable) { + fun showToast(message: String, gotoNext: Boolean = false) { + if (gotoNext && hasNextMirror()) { + showToast( + message, + Toast.LENGTH_SHORT + ) + nextMirror() + } else { + showToast( + context?.getString(R.string.no_links_found_toast) + "\n" + message, + Toast.LENGTH_LONG + ) + activity?.popCurrentPage() + } + } + + val ctx = context ?: return + when (exception) { + is PlaybackException -> { + val msg = exception.message ?: "" + val errorName = exception.errorCodeName + when (val code = exception.errorCode) { + PlaybackException.ERROR_CODE_IO_FILE_NOT_FOUND, PlaybackException.ERROR_CODE_PARSING_CONTAINER_UNSUPPORTED, PlaybackException.ERROR_CODE_IO_NO_PERMISSION, PlaybackException.ERROR_CODE_IO_UNSPECIFIED -> { + showToast( + "${ctx.getString(R.string.source_error)}\n$errorName ($code)\n$msg", + gotoNext = true + ) + } + + PlaybackException.ERROR_CODE_REMOTE_ERROR, PlaybackException.ERROR_CODE_IO_BAD_HTTP_STATUS, PlaybackException.ERROR_CODE_TIMEOUT, PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_FAILED, PlaybackException.ERROR_CODE_IO_INVALID_HTTP_CONTENT_TYPE -> { + showToast( + "${ctx.getString(R.string.remote_error)}\n$errorName ($code)\n$msg", + gotoNext = true + ) + } + + PlaybackException.ERROR_CODE_DECODING_FAILED, PlaybackErrorEvent.ERROR_AUDIO_TRACK_INIT_FAILED, PlaybackErrorEvent.ERROR_AUDIO_TRACK_OTHER, 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 + ) + } + + 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 + ) + } + + else -> { + exception.message?.let { + showToast( + it, + gotoNext = false + ) + } + } + } + } + + private fun onSubStyleChanged(style: SaveCaptionStyle) { + if (player is CS3IPlayer) { + player.updateSubtitleStyle(style) + } + } + + @SuppressLint("UnsafeOptInUsageError") + private 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 + playerView?.setShowMultiWindowTimeBar(true) + playerView?.player = player + playerView?.performClick() + } + } + + private 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) + // } + //} + + /** 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) { + Log.i(TAG, "Handle event: $event") + when (event) { + is ResizedEvent -> { + playerDimensionsLoaded(event.width, event.height) + } + + is PlayerAttachedEvent -> { + playerUpdated(event.player) + } + + is SubtitlesUpdatedEvent -> { + subtitlesChanged() + } + + is TimestampSkippedEvent -> { + onTimestampSkipped(event.timestamp) + } + + is TimestampInvokedEvent -> { + onTimestamp(event.timestamp) + } + + is TracksChangedEvent -> { + onTracksInfoChanged() + } + + is EmbeddedSubtitlesFetchedEvent -> { + embeddedSubtitlesFetched(event.tracks) + } + + is ErrorEvent -> { + playerError(event.error) + } + + is RequestAudioFocusEvent -> { + requestAudioFocus() + } + + is EpisodeSeekEvent -> { + when (event.offset) { + -1 -> prevEpisode() + 1 -> nextEpisode() + else -> {} + } + } + + is StatusEvent -> { + updateIsPlaying(wasPlaying = event.wasPlaying, isPlaying = event.isPlaying) + playerStatusChanged() + } + + is PositionEvent -> { + playerPositionChanged(position = event.toMs, duration = event.durationMs) + } + + is VideoEndedEvent -> { + context?.let { ctx -> + // Resets subtitle delay on ended video + player.setSubtitleOffset(0) + + // Only play next episode if autoplay is on (default) + if (PreferenceManager.getDefaultSharedPreferences(ctx) + ?.getBoolean( + ctx.getString(R.string.autoplay_next_key), + true + ) == true + ) { + player.handleEvent( + CSPlayerEvent.NextEpisode, + source = PlayerEventSource.Player + ) + } + } + } + + is PauseEvent -> Unit + is PlayEvent -> Unit + } + } + + @SuppressLint("SetTextI18n", "UnsafeOptInUsageError") + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + resizeMode = DataStoreHelper.resizeMode + resize(resizeMode, false) + + player.releaseCallbacks() + player.initCallbacks( + eventHandler = ::mainCallback, + requestedListeningPercentages = listOf( + SKIP_OP_VIDEO_PERCENTAGE, + PRELOAD_NEXT_EPISODE_PERCENTAGE, + NEXT_WATCH_EPISODE_PERCENTAGE, + UPDATE_SYNC_PROGRESS_PERCENTAGE, + ), + ) + + 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 + ) + } + + override fun onScrubMove( + previewBar: PreviewBar?, + progress: Int, + fromUser: Boolean + ) { + } + + override fun onScrubStop(previewBar: PreviewBar?) { + if (resume) player.handleEvent(CSPlayerEvent.Play, PlayerEventSource.Player) + } + }) + 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(R.id.exo_subtitles) + subStyle = SubtitlesFragment.getCurrentSavedStyle() + player.initSubtitles(subView, subtitleHolder, subStyle) + (player.imageGenerator as? PreviewGenerator)?.params = ImageParams.new16by9(screenWidth) + + /*previewImageView?.doOnLayout { + (player.imageGenerator as? PreviewGenerator)?.params = ImageParams( + it.measuredWidth, + it.measuredHeight + ) + }*/ + /** this might seam a bit fucky and that is because it is, the seek event is captured twice, once by the player + * and once by the UI even if it should only be registered once by the UI */ + playerView?.findViewById(R.id.exo_progress) + ?.addListener(object : TimeBar.OnScrubListener { + override fun onScrubStart(timeBar: TimeBar, position: Long) = Unit + override fun onScrubMove(timeBar: TimeBar, position: Long) = Unit + override fun onScrubStop(timeBar: TimeBar, position: Long, canceled: Boolean) { + if (canceled) return + val playerDuration = player.getDuration() ?: return + val playerPosition = player.getPosition() ?: return + mainCallback( + PositionEvent( + source = PlayerEventSource.UI, + durationMs = playerDuration, + fromMs = playerPosition, + toMs = position + ) + ) + } + }) + + SubtitlesFragment.applyStyleEvent += ::onSubStyleChanged + + try { + context?.let { ctx -> + val settingsManager = PreferenceManager.getDefaultSharedPreferences( + ctx + ) + + val currentPrefCacheSize = + settingsManager.getInt(getString(R.string.video_buffer_size_key), 0) + val currentPrefDiskSize = + settingsManager.getInt(getString(R.string.video_buffer_disk_key), 0) + val currentPrefBufferSec = + settingsManager.getInt(getString(R.string.video_buffer_length_key), 0) + + player.cacheSize = currentPrefCacheSize * 1024L * 1024L + player.simpleCacheSize = currentPrefDiskSize * 1024L * 1024L + player.videoBufferMs = currentPrefBufferSec * 1000L + } + } catch (e: Exception) { + logError(e) + } + } + + /*context?.let { ctx -> + player.loadPlayer( + ctx, + false, + ExtractorLink( + "idk", + "bunny", + "http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4", + "", + Qualities.P720.value, + false + ), + ) + }*/ } override fun onDestroy() { - playerHostView?.release() + playerEventListener = null + keyEventListener = null + canEnterPipMode = false + mMediaSession?.release() + mMediaSession = null + playerView?.player = null + SubtitlesFragment.applyStyleEvent -= ::onSubStyleChanged + + keepScreenOn(false) super.onDestroy() } - override fun onPause() { - playerHostView?.releaseKeyEventListener() - super.onPause() + fun nextResize() { + resizeMode = (resizeMode + 1) % PlayerResize.entries.size + resize(resizeMode, true) + } + + fun resize(resize: Int, showToast: Boolean) { + resize(PlayerResize.entries[resize], showToast) + } + + @SuppressLint("UnsafeOptInUsageError") + fun resize(resize: PlayerResize, showToast: Boolean) { + DataStoreHelper.resizeMode = resize.ordinal + val type = when (resize) { + PlayerResize.Fill -> AspectRatioFrameLayout.RESIZE_MODE_FILL + PlayerResize.Fit -> AspectRatioFrameLayout.RESIZE_MODE_FIT + PlayerResize.Zoom -> AspectRatioFrameLayout.RESIZE_MODE_ZOOM + } + playerView?.resizeMode = type + + if (showToast) + showToast(resize.nameRes, Toast.LENGTH_SHORT) } override fun onStop() { - playerHostView?.onStop() + player.onStop() super.onStop() } override fun onResume() { context?.let { ctx -> - playerHostView?.onResume(ctx) + player.onResume(ctx) } + super.onResume() } - fun nextResize() { - playerHostView?.nextResize() + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { + val root = inflater.inflate(layout, container, false) + playerPausePlayHolderHolder = root.findViewById(R.id.player_pause_play_holder_holder) + playerPausePlay = root.findViewById(R.id.player_pause_play) + playerBuffering = root.findViewById(R.id.player_buffering) + playerView = root.findViewById(R.id.player_view) + piphide = root.findViewById(R.id.piphide) + subtitleHolder = root.findViewById(R.id.subtitle_holder) + return root } - - open fun resize(resize: PlayerResize, showToast: Boolean) { - playerHostView?.resize(resize, showToast) - } -} +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/CS3IPlayer.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/CS3IPlayer.kt index d7e10c814..1aa1b29bd 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 @@ -1,10 +1,7 @@ -@file:Suppress("DEPRECATION") - package com.lagradost.cloudstream3.ui.player import android.annotation.SuppressLint import android.content.Context -import android.content.DialogInterface import android.graphics.Bitmap import android.net.Uri import android.os.Handler @@ -12,11 +9,7 @@ import android.os.Looper import android.util.Log import android.util.Rational import android.widget.FrameLayout -import androidx.annotation.AnyThread -import androidx.annotation.MainThread import androidx.annotation.OptIn -import androidx.appcompat.app.AlertDialog -import androidx.core.net.toUri import androidx.media3.common.C.TIME_UNSET import androidx.media3.common.C.TRACK_TYPE_AUDIO import androidx.media3.common.C.TRACK_TYPE_TEXT @@ -30,7 +23,6 @@ import androidx.media3.common.TrackGroup import androidx.media3.common.TrackSelectionOverride import androidx.media3.common.Tracks import androidx.media3.common.VideoSize -// import androidx.media3.common.util.ExperimentalApi import androidx.media3.common.util.UnstableApi import androidx.media3.database.StandaloneDatabaseProvider import androidx.media3.datasource.DataSource @@ -40,85 +32,50 @@ import androidx.media3.datasource.HttpDataSource import androidx.media3.datasource.cache.CacheDataSource import androidx.media3.datasource.cache.LeastRecentlyUsedCacheEvictor import androidx.media3.datasource.cache.SimpleCache -import androidx.media3.datasource.cronet.CronetDataSource import androidx.media3.datasource.okhttp.OkHttpDataSource -import androidx.media3.exoplayer.DecoderCounters -import androidx.media3.exoplayer.DecoderReuseEvaluation -import androidx.media3.exoplayer.DefaultLivePlaybackSpeedControl import androidx.media3.exoplayer.DefaultLoadControl import androidx.media3.exoplayer.DefaultRenderersFactory import androidx.media3.exoplayer.ExoPlayer import androidx.media3.exoplayer.Renderer.STATE_ENABLED import androidx.media3.exoplayer.Renderer.STATE_STARTED import androidx.media3.exoplayer.SeekParameters -import androidx.media3.exoplayer.analytics.AnalyticsListener import androidx.media3.exoplayer.dash.DashMediaSource import androidx.media3.exoplayer.drm.DefaultDrmSessionManager import androidx.media3.exoplayer.drm.FrameworkMediaDrm -import androidx.media3.exoplayer.drm.HttpMediaDrmCallback import androidx.media3.exoplayer.drm.LocalMediaDrmCallback -import androidx.media3.exoplayer.hls.playlist.HlsPlaylistTracker import androidx.media3.exoplayer.source.ClippingMediaSource import androidx.media3.exoplayer.source.ConcatenatingMediaSource -import androidx.media3.exoplayer.source.ConcatenatingMediaSource2 import androidx.media3.exoplayer.source.DefaultMediaSourceFactory -import androidx.media3.exoplayer.source.MediaSource import androidx.media3.exoplayer.source.MergingMediaSource import androidx.media3.exoplayer.source.SingleSampleMediaSource -import androidx.media3.exoplayer.text.TextOutput import androidx.media3.exoplayer.text.TextRenderer import androidx.media3.exoplayer.trackselection.DefaultTrackSelector import androidx.media3.exoplayer.trackselection.TrackSelector -import androidx.media3.extractor.mp4.FragmentedMp4Extractor import androidx.media3.ui.SubtitleView import androidx.preference.PreferenceManager import com.lagradost.cloudstream3.APIHolder.getApiFromNameNull -import com.lagradost.cloudstream3.AudioFile -import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey -import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey -import com.lagradost.cloudstream3.CommonActivity.activity -import com.lagradost.cloudstream3.ErrorLoadingException +import com.lagradost.cloudstream3.AcraApplication.Companion.getKey +import com.lagradost.cloudstream3.AcraApplication.Companion.setKey import com.lagradost.cloudstream3.MainActivity.Companion.deleteFileOnExit -import com.lagradost.cloudstream3.R -import com.lagradost.cloudstream3.TvType import com.lagradost.cloudstream3.USER_AGENT import com.lagradost.cloudstream3.app import com.lagradost.cloudstream3.mvvm.debugAssert import com.lagradost.cloudstream3.mvvm.logError -import com.lagradost.cloudstream3.mvvm.safe -import com.lagradost.cloudstream3.ui.player.CustomDecoder.Companion.fixSubtitleAlignment -import com.lagradost.cloudstream3.ui.player.live.LiveHelper -import com.lagradost.cloudstream3.ui.player.live.PREFERRED_LIVE_OFFSET -import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR -import com.lagradost.cloudstream3.ui.settings.Globals.PHONE -import com.lagradost.cloudstream3.ui.settings.Globals.isLayout +import com.lagradost.cloudstream3.mvvm.normalSafeApiCall import com.lagradost.cloudstream3.ui.subtitles.SaveCaptionStyle -import com.lagradost.cloudstream3.ui.subtitles.SubtitlesFragment.Companion.applyStyle import com.lagradost.cloudstream3.utils.AppContextUtils.isUsingMobileData -import com.lagradost.cloudstream3.utils.AppContextUtils.setDefaultFocus -import com.lagradost.cloudstream3.utils.CLEARKEY_DRM_UUID -import com.lagradost.cloudstream3.utils.Coroutines.ioSafe -import com.lagradost.cloudstream3.utils.Coroutines.runOnMainThread import com.lagradost.cloudstream3.utils.DataStoreHelper.currentAccount import com.lagradost.cloudstream3.utils.DrmExtractorLink +import com.lagradost.cloudstream3.utils.EpisodeSkip import com.lagradost.cloudstream3.utils.ExtractorLink import com.lagradost.cloudstream3.utils.ExtractorLinkPlayList import com.lagradost.cloudstream3.utils.ExtractorLinkType -import com.lagradost.cloudstream3.utils.PLAYREADY_DRM_UUID -import com.lagradost.cloudstream3.utils.SubtitleHelper.fromTagToLanguageName -import com.lagradost.cloudstream3.utils.WIDEVINE_DRM_UUID -import com.lagradost.cloudstream3.utils.videoskip.VideoSkipStamp -import kotlinx.coroutines.delay -import okhttp3.Interceptor -import org.chromium.net.CronetEngine +import com.lagradost.cloudstream3.utils.SubtitleHelper.fromTwoLettersToLanguage import java.io.File -import java.security.SecureRandom import java.util.UUID -import java.util.concurrent.Executors import javax.net.ssl.HttpsURLConnection import javax.net.ssl.SSLContext import javax.net.ssl.SSLSession -import kotlin.uuid.toJavaUuid const val TAG = "CS3ExoPlayer" const val PREFERRED_AUDIO_LANGUAGE_KEY = "preferred_audio_language" @@ -135,7 +92,6 @@ const val toleranceAfterUs = 300_000L @OptIn(UnstableApi::class) class CS3IPlayer : IPlayer { - private var playerListener: Player.Listener? = null private var isPlaying = false private var exoPlayer: ExoPlayer? = null set(value) { @@ -153,10 +109,6 @@ class CS3IPlayer : IPlayer { val imageGenerator = IPreviewGenerator.new() private val seekActionTime = 30000L - private val isMediaSeekable - get() = exoPlayer?.let { - it.isCommandAvailable(Player.COMMAND_GET_CURRENT_MEDIA_ITEM) && it.isCurrentMediaItemSeekable - } ?: false private var ignoreSSL: Boolean = true private var playBackSpeed: Float = 1.0f @@ -172,9 +124,6 @@ class CS3IPlayer : IPlayer { private val subtitleHelper = PlayerSubtitleHelper() - /** If we want to play the audio only in the background when the app is not open */ - private var isAudioOnlyBackground = false - /** * This is a way to combine the MediaItem and its duration for the concatenating MediaSource. * @param durationUs does not matter if only one slice is present, since it will not concatenate @@ -186,11 +135,10 @@ class CS3IPlayer : IPlayer { ) data class DrmMetadata( - val kid: String? = null, - val key: String? = null, + val kid: String, + val key: String, val uuid: UUID, - val kty: String? = null, - val licenseUrl: String? = null, + val kty: String, val keyRequestParameters: HashMap, ) @@ -209,42 +157,36 @@ class CS3IPlayer : IPlayer { private var eventHandler: ((PlayerEvent) -> Unit)? = null - @AnyThread fun event(event: PlayerEvent) { - // Ensure that all work is done on the main thread. - if (Looper.getMainLooper().isCurrentThread) { - eventHandler?.invoke(event) - } else runOnMainThread { - eventHandler?.invoke(event) - } + eventHandler?.invoke(event) } - /** - * As initCallbacks and releaseCallbacks must always be done, - * we use this to say that the player is in use. - * */ - @Volatile - var isPlayerActive: Boolean = false - override fun releaseCallbacks() { eventHandler = null - if (isPlayerActive) { - isPlayerActive = false - activePlayers -= 1 - releaseCronetEngine() - } } - @AnyThread override fun initCallbacks( - @MainThread eventHandler: ((PlayerEvent) -> Unit), + eventHandler: ((PlayerEvent) -> Unit), requestedListeningPercentages: List?, ) { this.requestedListeningPercentages = requestedListeningPercentages this.eventHandler = eventHandler - if (!isPlayerActive) { - isPlayerActive = true - activePlayers += 1 + } + + // I know, this is not a perfect solution, however it works for fixing subs + private fun reloadSubs() { + exoPlayer?.applicationLooper?.let { + try { + Handler(it).post { + try { + seekTime(1L, source = PlayerEventSource.Player) + } catch (e: Exception) { + logError(e) + } + } + } catch (e: Exception) { + logError(e) + } } } @@ -261,10 +203,6 @@ class CS3IPlayer : IPlayer { } override fun hasPreview(): Boolean { - // No previews on livestreams because the previews get outdated - if (exoPlayer?.isCurrentMediaItemDynamic == true) { - return false - } return imageGenerator.hasPreview() } @@ -306,7 +244,6 @@ class CS3IPlayer : IPlayer { gen.clear(sameEpisode) } } - loadOnlinePlayer(context, link) } else if (data != null) { (imageGenerator as? PreviewGenerator)?.let { gen -> @@ -378,47 +315,44 @@ class CS3IPlayer : IPlayer { ?: return } - override fun setPreferredAudioTrack(trackLanguage: String?, id: String?, formatIndex: Int?) { + override fun setPreferredAudioTrack(trackLanguage: String?, id: String?) { preferredAudioTrackLanguage = trackLanguage - id?.let { trackId -> - val trackFormatIndex = formatIndex ?: 0 - exoPlayer?.currentTracks?.groups - ?.filter { it.type == TRACK_TYPE_AUDIO } - ?.find { group -> - group.getFormats().any { (format, _) -> - format.id == trackId - } - } - ?.let { group -> - exoPlayer?.trackSelectionParameters - ?.buildUpon() - ?.setOverrideForType( - TrackSelectionOverride( - group.mediaTrackGroup, - trackFormatIndex - ) + + if (id != null) { + val audioTrack = + exoPlayer?.currentTracks?.groups?.filter { it.type == TRACK_TYPE_AUDIO } + ?.getTrack(id) + + if (audioTrack != null) { + exoPlayer?.trackSelectionParameters = exoPlayer?.trackSelectionParameters + ?.buildUpon() + ?.setOverrideForType( + TrackSelectionOverride( + audioTrack.first, + audioTrack.second ) - ?.build() - } - ?.let { newParams -> - exoPlayer?.trackSelectionParameters = newParams - return - } + ) + ?.build() + ?: return + return + } } - // Fallback to language-based selection + exoPlayer?.trackSelectionParameters = exoPlayer?.trackSelectionParameters ?.buildUpon() ?.setPreferredAudioLanguage(trackLanguage) - ?.build() ?: return + ?.build() + ?: return } + /** * Gets all supported formats in a list * */ private fun List.getFormats(): List> { - return this.flatMap { + return this.map { it.getFormats() - } + }.flatten() } private fun Tracks.Group.getFormats(): List> { @@ -429,14 +363,11 @@ class CS3IPlayer : IPlayer { } } - private fun Format.toAudioTrack(formatIndex: Int?): AudioTrack { + private fun Format.toAudioTrack(): AudioTrack { return AudioTrack( - this.id, + this.id?.stripTrackId(), this.label, - this.language, - this.sampleMimeType, - this.channelCount, - formatIndex ?: 0, + this.language ) } @@ -445,7 +376,7 @@ class CS3IPlayer : IPlayer { this.id?.stripTrackId(), this.label, this.language, - this.sampleMimeType, + this.sampleMimeType ) } @@ -456,35 +387,27 @@ class CS3IPlayer : IPlayer { this.language, this.width, this.height, - this.sampleMimeType ) } override fun getVideoTracks(): CurrentTracks { - val allTrackGroups = exoPlayer?.currentTracks?.groups ?: emptyList() - val videoTracks = allTrackGroups.filter { it.type == TRACK_TYPE_VIDEO } + val allTracks = exoPlayer?.currentTracks?.groups ?: emptyList() + val videoTracks = allTracks.filter { it.type == TRACK_TYPE_VIDEO } .getFormats() .map { it.first.toVideoTrack() } - var currentAudioTrack: AudioTrack? = null - val audioTracks = allTrackGroups.filter { it.type == TRACK_TYPE_AUDIO } - .flatMap { group -> - group.getFormats().map { (format, formatIndex) -> - val audioTrack = format.toAudioTrack(formatIndex) - if (group.isTrackSelected(formatIndex)) { - currentAudioTrack = audioTrack - } - audioTrack - } - } - val textTracks = allTrackGroups.filter { it.type == TRACK_TYPE_TEXT } - .getFormats() + val audioTracks = allTracks.filter { it.type == TRACK_TYPE_AUDIO }.getFormats() + .map { it.first.toAudioTrack() } + + val textTracks = allTracks.filter { it.type == TRACK_TYPE_TEXT }.getFormats() .map { it.first.toSubtitleTrack() } + val currentTextTracks = textTracks.filter { track -> playerSelectedSubtitleTracks.any { it.second && it.first == track.id } } + return CurrentTracks( exoPlayer?.videoFormat?.toVideoTrack(), - currentAudioTrack, + exoPlayer?.audioFormat?.toAudioTrack(), currentTextTracks, videoTracks, audioTracks, @@ -498,43 +421,60 @@ class CS3IPlayer : IPlayer { override fun setPreferredSubtitles(subtitle: SubtitleData?): Boolean { Log.i(TAG, "setPreferredSubtitles init $subtitle") currentSubtitles = subtitle - val trackSelector = exoPlayer?.trackSelector as? DefaultTrackSelector ?: return false - // Disable subtitles if null - if (subtitle == null) { - trackSelector.setParameters( - trackSelector.buildUponParameters() - .setTrackTypeDisabled(TRACK_TYPE_TEXT, true) - .clearOverridesOfType(TRACK_TYPE_TEXT) - ) - return false - } - // Handle subtitle based on status - when (subtitleHelper.subtitleStatus(subtitle)) { - SubtitleStatus.REQUIRES_RELOAD -> { - Log.i(TAG, "setPreferredSubtitles REQUIRES_RELOAD") - return true - } - SubtitleStatus.NOT_FOUND -> { - Log.i(TAG, "setPreferredSubtitles NOT_FOUND") - return true - } + fun getTextTrack(id: String) = + exoPlayer?.currentTracks?.groups?.filter { it.type == TRACK_TYPE_TEXT } + ?.getTrack(id) + + return (exoPlayer?.trackSelector as? DefaultTrackSelector?)?.let { trackSelector -> + if (subtitle == null) { + trackSelector.setParameters( + trackSelector.buildUponParameters() + .setTrackTypeDisabled(TRACK_TYPE_TEXT, true) + .clearOverridesOfType(TRACK_TYPE_TEXT) + ) + } else { + when (subtitleHelper.subtitleStatus(subtitle)) { + SubtitleStatus.REQUIRES_RELOAD -> { + Log.i(TAG, "setPreferredSubtitles REQUIRES_RELOAD") + return@let true + } + + SubtitleStatus.IS_ACTIVE -> { + Log.i(TAG, "setPreferredSubtitles IS_ACTIVE") - SubtitleStatus.IS_ACTIVE -> { - Log.i(TAG, "setPreferredSubtitles IS_ACTIVE") - exoPlayer?.currentTracks?.groups - ?.filter { it.type == TRACK_TYPE_TEXT } - ?.getTrack(subtitle.getId()) - ?.let { (trackGroup, trackIndex) -> trackSelector.setParameters( trackSelector.buildUponParameters() - .setTrackTypeDisabled(TRACK_TYPE_TEXT, false) - .setOverrideForType(TrackSelectionOverride(trackGroup, trackIndex)) + .apply { + val track = getTextTrack(subtitle.getId()) + if (track != null) { + setTrackTypeDisabled(TRACK_TYPE_TEXT, false) + setOverrideForType( + TrackSelectionOverride( + track.first, + track.second + ) + ) + } + } ) + + // ugliest code I have written, it seeks 1ms to *update* the subtitles + //exoPlayer?.applicationLooper?.let { + // Handler(it).postDelayed({ + // seekTime(1L) + // }, 1) + //} } - return false + + SubtitleStatus.NOT_FOUND -> { + Log.i(TAG, "setPreferredSubtitles NOT_FOUND") + return@let true + } + } } - } + return false + } ?: false } private var currentSubtitleOffset: Long = 0 @@ -543,10 +483,10 @@ class CS3IPlayer : IPlayer { currentSubtitleOffset = offset CustomDecoder.subtitleOffset = offset if (currentTextRenderer?.state == STATE_ENABLED || currentTextRenderer?.state == STATE_STARTED) { - exoPlayer?.currentPosition?.also { pos -> + exoPlayer?.currentPosition?.let { pos -> // This seems to properly refresh all subtitles // It needs to be done as all subtitle cues with timings are pre-processed - currentTextRenderer?.resetPosition(pos, false) + currentTextRenderer?.resetPosition(pos) } } } @@ -590,35 +530,18 @@ class CS3IPlayer : IPlayer { private fun releasePlayer(saveTime: Boolean = true) { Log.i(TAG, "releasePlayer") - eventLooperIndex += 1 + if (saveTime) updatedTime() - currentTextRenderer = null - currentSubtitleDecoder = null - exoPlayer?.apply { playWhenReady = false - - // This may look weird, however on some TV devices the audio does not stop playing - // so this may fix it? - try { - pause() - } catch (t: Throwable) { - // No documented exception, but just to be extra safe - logError(t) - } - playerListener?.let { - removeListener(it) - playerListener = null - } stop() release() } //simpleCache?.release() exoPlayer = null - event(PlayerAttachedEvent(null)) //simpleCache = null } @@ -626,23 +549,18 @@ class CS3IPlayer : IPlayer { Log.i(TAG, "onStop") saveData() - if (!isAudioOnlyBackground) { - handleEvent(CSPlayerEvent.Pause, PlayerEventSource.Player) - } + handleEvent(CSPlayerEvent.Pause, PlayerEventSource.Player) //releasePlayer() } override fun onPause() { Log.i(TAG, "onPause") saveData() - if (!isAudioOnlyBackground) { - handleEvent(CSPlayerEvent.Pause, PlayerEventSource.Player) - } + handleEvent(CSPlayerEvent.Pause, PlayerEventSource.Player) //releasePlayer() } override fun onResume(context: Context) { - isAudioOnlyBackground = false if (exoPlayer == null) reloadPlayer(context) } @@ -658,62 +576,6 @@ class CS3IPlayer : IPlayer { } companion object { - private const val CRONET_TIMEOUT_MS = 15_000 - - /** - * Single shared engine, to minimize the overhead of maintaining many as: - * 1. Cpu time/Startup time - * 2. Mem consumption/GC - * 3. Disk usage, as we simply use the same folder - * */ - private var cronetEngine: CronetEngine? = null - - /** - * How many active sessions we have. - * - * However in reality it should never go negative or be more than 1, - * but this makes more sense architecturally. - * */ - @Volatile - private var activePlayers = 0 - - /** Unique monotonically increasing id to keep track of the last release call */ - @Volatile - private var cronetReleasedId = 0 - - fun releaseCronetEngine() { - if (cronetEngine == null) return - - // Delayed release, as we do not want to restart it when opening trailers ect - val id = ++cronetReleasedId - val posted = Handler(Looper.getMainLooper()).postDelayed({ - // This might get dropped, but that should be very rare - // and should not affect it. - releaseCronetEngineInstantly(id) - }, 60_000) // 1min timeout before release - - // If not posted, then run instantly - if (!posted) { - releaseCronetEngineInstantly(id) - } - } - - private fun releaseCronetEngineInstantly(id: Int) { - // We should release if and only if this was the last call, and - // there is no active players - if (activePlayers == 0 && id == cronetReleasedId) { - try { - cronetEngine?.shutdown() - } catch (t: Throwable) { - logError(t) - } finally { - Log.d(TAG, "CronetEngine shutdown") - // Even if it fails to shutdown, the GC should take care of it - cronetEngine = null - } - } - } - /** * Setting this variable is permanent across app sessions. **/ @@ -733,97 +595,41 @@ class CS3IPlayer : IPlayer { private var simpleCache: SimpleCache? = null - /// Create a small factory for small things, no cache, no cronet - private fun createOnlineSource( - headers: Map?, - interceptor: Interceptor? - ): HttpDataSource.Factory { - val client = if (interceptor == null) { - app.baseClient - } else { - app.baseClient.newBuilder() - .addInterceptor(interceptor) - .build() - } - val source = OkHttpDataSource.Factory(client).setUserAgent(USER_AGENT) + var requestSubtitleUpdate: (() -> Unit)? = null - if (!headers.isNullOrEmpty()) { - source.setDefaultRequestProperties(headers) - } - return source - } - - fun tryCreateEngine(context: Context, diskCacheSize: Long): CronetEngine? { - // Fast case, no need to recreate it - cronetEngine?.let { - return it - } - - // https://gist.github.com/ShivamKumarJha/3c8398b47053ae05112d2a8f8b5de531 - return try { - val cacheDirectory = File(context.cacheDir, "CronetEngine") - cacheDirectory.deleteRecursively() - if (!cacheDirectory.exists()) { - cacheDirectory.mkdirs() - } - CronetEngine.Builder(context) - .enableBrotli(true) - .enableHttp2(true) - .enableQuic(true) - .setStoragePath(cacheDirectory.absolutePath) - .setLibraryLoader(null) - .enableHttpCache(CronetEngine.Builder.HTTP_CACHE_DISK, diskCacheSize) - .build().also { buildEngine -> - Log.d( - TAG, - "Created CronetEngine with cache at ${cacheDirectory.absolutePath}" - ) - cronetEngine = buildEngine - } - } catch (t: Throwable) { - logError(t) - // Something went wrong, so we use the backup okhttp - null + private fun createOnlineSource(headers: Map): HttpDataSource.Factory { + val source = OkHttpDataSource.Factory(app.baseClient).setUserAgent(USER_AGENT) + return source.apply { + setDefaultRequestProperties(headers) } } - private fun createVideoSource( - link: ExtractorLink, - engine: CronetEngine?, - interceptor: Interceptor?, - ): HttpDataSource.Factory { - val userAgent = link.headers.entries.find { - it.key.equals("User-Agent", ignoreCase = true) - }?.value ?: USER_AGENT + private fun createOnlineSource(link: ExtractorLink): HttpDataSource.Factory { + val provider = getApiFromNameNull(link.source) + val interceptor = provider?.getVideoInterceptor(link) val source = if (interceptor == null) { - if (engine == null) { - Log.d(TAG, "Using DefaultHttpDataSource for $link") - OkHttpDataSource.Factory(app.baseClient).setUserAgent(userAgent) - } else { - Log.d(TAG, "Using CronetDataSource for $link") - CronetDataSource.Factory(engine, Executors.newSingleThreadExecutor()) - .setUserAgent(userAgent) - .setConnectionTimeoutMs(CRONET_TIMEOUT_MS) - .setReadTimeoutMs(CRONET_TIMEOUT_MS) - .setResetTimeoutOnRedirects(true) - .setHandleSetCookieRequests(true) - } + DefaultHttpDataSource.Factory() //TODO USE app.baseClient + .setUserAgent(USER_AGENT) + .setAllowCrossProtocolRedirects(true) //https://stackoverflow.com/questions/69040127/error-code-io-bad-http-status-exoplayer-android } else { - Log.d(TAG, "Using OkHttpDataSource for $link") val client = app.baseClient.newBuilder() .addInterceptor(interceptor) .build() - OkHttpDataSource.Factory(client).setUserAgent(userAgent) + OkHttpDataSource.Factory(client).setUserAgent(USER_AGENT) } // Do no include empty referer, if the provider wants those they can use the header map. val refererMap = if (link.referer.isBlank()) emptyMap() else mapOf("referer" to link.referer) - - // These are extra headers the browser like to insert, not sure if we want to include them - // for WIDEVINE/drm as well? Do that if someone gets 404 and creates an issue. - val headers = refererMap + link.headers // Adds the headers from the provider, e.g Authorization + val headers = mapOf( + "accept" to "*/*", + "sec-ch-ua" to "\"Chromium\";v=\"91\", \" Not;A Brand\";v=\"99\"", + "sec-ch-ua-mobile" to "?0", + "sec-fetch-user" to "?1", + "sec-fetch-mode" to "navigate", + "sec-fetch-dest" to "video" + ) + refererMap + link.headers // Adds the headers from the provider, e.g Authorization return source.apply { setDefaultRequestProperties(headers) @@ -881,12 +687,153 @@ class CS3IPlayer : IPlayer { private var currentSubtitleDecoder: CustomSubtitleDecoderFactory? = null private var currentTextRenderer: TextRenderer? = null + + private fun buildExoPlayer( + context: Context, + mediaItemSlices: List, + subSources: List, + currentWindow: Int, + playbackPosition: Long, + playBackSpeed: Float, + subtitleOffset: Long, + cacheSize: Long, + videoBufferMs: Long, + 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 + ): ExoPlayer { + val exoPlayerBuilder = + ExoPlayer.Builder(context) + .setRenderersFactory { eventHandler, videoRendererEventListener, audioRendererEventListener, textRendererOutput, metadataRendererOutput -> + DefaultRenderersFactory(context).apply { + setEnableDecoderFallback(true) + // Enable Ffmpeg extension. + setExtensionRendererMode(DefaultRenderersFactory.EXTENSION_RENDERER_MODE_PREFER) + }.createRenderers( + eventHandler, + videoRendererEventListener, + audioRendererEventListener, + textRendererOutput, + metadataRendererOutput + ).map { + if (it is TextRenderer) { + CustomDecoder.subtitleOffset = subtitleOffset + val decoder = CustomSubtitleDecoderFactory() + val currentTextRenderer = TextRenderer( + textRendererOutput, + eventHandler.looper, + decoder + ).apply { + // Required to make the decoder work with old subtitles + // Upgrade CustomSubtitleDecoderFactory when media3 supports it + experimentalSetLegacyDecodingEnabled(true) + }.also { renderer -> + this.currentTextRenderer = renderer + this.currentSubtitleDecoder = decoder + } + currentTextRenderer + } else + it + }.toTypedArray() + } + .setTrackSelector( + trackSelector ?: getTrackSelector( + context, + maxVideoHeight + ) + ) + // Allows any seeking to be +- 0.3s to allow for faster seeking + .setSeekParameters(SeekParameters(toleranceBeforeUs, toleranceAfterUs)) + .setLoadControl( + DefaultLoadControl.Builder() + .setTargetBufferBytes( + if (cacheSize <= 0) { + DefaultLoadControl.DEFAULT_TARGET_BUFFER_BYTES + } else { + if (cacheSize > Int.MAX_VALUE) Int.MAX_VALUE else cacheSize.toInt() + } + ) + .setBackBuffer( + 30000, + true + ) + .setBufferDurationsMs( + DefaultLoadControl.DEFAULT_MIN_BUFFER_MS, + if (videoBufferMs <= 0) { + DefaultLoadControl.DEFAULT_MAX_BUFFER_MS + } else { + videoBufferMs.toInt() + }, + DefaultLoadControl.DEFAULT_BUFFER_FOR_PLAYBACK_MS, + DefaultLoadControl.DEFAULT_BUFFER_FOR_PLAYBACK_AFTER_REBUFFER_MS + ).build() + ) + + + val factory = + if (cacheFactory == null) DefaultMediaSourceFactory(context) + else DefaultMediaSourceFactory(cacheFactory) + + // If there is only one item then treat it as normal, if multiple: concatenate the items. + val videoMediaSource = if (mediaItemSlices.size == 1) { + val item = mediaItemSlices.first() + + item.drm?.let { drm -> + val drmCallback = + LocalMediaDrmCallback("{\"keys\":[{\"kty\":\"${drm.kty}\",\"k\":\"${drm.key}\",\"kid\":\"${drm.kid}\"}],\"type\":\"temporary\"}".toByteArray()) + val manager = DefaultDrmSessionManager.Builder() + .setPlayClearSamplesWithoutKeys(true) + .setMultiSession(false) + .setKeyRequestParameters(drm.keyRequestParameters) + .setUuidAndExoMediaDrmProvider(drm.uuid, FrameworkMediaDrm.DEFAULT_PROVIDER) + .build(drmCallback) + val manifestDataSourceFactory = DefaultHttpDataSource.Factory() + + DashMediaSource.Factory(manifestDataSourceFactory) + .setDrmSessionManagerProvider { manager } + .createMediaSource(item.mediaItem) + } ?: run { + factory.createMediaSource(item.mediaItem) + } + } else { + val source = ConcatenatingMediaSource() + mediaItemSlices.map { 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), + item.durationUs + ) + ) + } + source + } + + //println("PLAYBACK POS $playbackPosition") + return exoPlayerBuilder.build().apply { + setPlayWhenReady(playWhenReady) + seekTo(currentWindow, playbackPosition) + setMediaSource( + MergingMediaSource( + videoMediaSource, *subSources.toTypedArray() + ), + playbackPosition + ) + setHandleAudioBecomingNoisy(true) + setPlaybackSpeed(playBackSpeed) + } + } } - private fun getCurrentTimestamp(writePosition: Long? = null): VideoSkipStamp? { + private fun getCurrentTimestamp(writePosition: Long? = null): EpisodeSkip.SkipStamp? { val position = writePosition ?: this@CS3IPlayer.getPosition() ?: return null for (lastTimeStamp in lastTimeStamps) { - if (lastTimeStamp.timestamp.startMs <= position && (position + (toleranceBeforeUs / 1000L) + 1) < lastTimeStamp.timestamp.endMs) { + if (lastTimeStamp.startMs <= position && (position + (toleranceBeforeUs / 1000L) + 1) < lastTimeStamp.endMs) { return lastTimeStamp } } @@ -921,21 +868,13 @@ class CS3IPlayer : IPlayer { } override fun seekTo(time: Long, source: PlayerEventSource) { - if (isMediaSeekable) { - updatedTime(time, source) - exoPlayer?.seekTo(time) - } else { - Log.i(TAG, "Media is not seekable, we can not seek to $time") - } + updatedTime(time, source) + exoPlayer?.seekTo(time) } private fun ExoPlayer.seekTime(time: Long, source: PlayerEventSource) { - if (isMediaSeekable) { - updatedTime(currentPosition + time, source) - seekTo(currentPosition + time) - } else { - Log.i(TAG, "Media is not seekable, we can not seek to $time") - } + updatedTime(currentPosition + time, source) + seekTo(currentPosition + time) } override fun handleEvent(event: CSPlayerEvent, source: PlayerEventSource) { @@ -945,22 +884,6 @@ class CS3IPlayer : IPlayer { when (event) { CSPlayerEvent.Play -> { event(PlayEvent(source)) - // If the player was stopped (e.g. notification dismissed) it lands in - // STATE_IDLE. A bare play() call is a no-op in that state, re-prepare and - // then resume to the current position once we are in STATE_READY again. - if (playbackState == Player.STATE_IDLE) { - val seekPosition = currentPosition - exoPlayer?.addListener(object : Player.Listener { - private var seekApplied = false - override fun onPlaybackStateChanged(playbackState: Int) { - if (seekApplied || playbackState != Player.STATE_READY) return - seekApplied = true - exoPlayer?.seekTo(currentWindow, seekPosition) - exoPlayer?.removeListener(this) - } - }) - prepare() - } play() } @@ -1014,16 +937,11 @@ class CS3IPlayer : IPlayer { if (lastTimeStamp.skipToNextEpisode) { handleEvent(CSPlayerEvent.NextEpisode, source) } else { - seekTo(lastTimeStamp.timestamp.endMs + 1L) + seekTo(lastTimeStamp.endMs + 1L) } event(TimestampSkippedEvent(timestamp = lastTimeStamp, source = source)) } } - - CSPlayerEvent.PlayAsAudio -> { - isAudioOnlyBackground = true - activity?.moveTaskToBack(false) - } } } } catch (t: Throwable) { @@ -1032,361 +950,16 @@ class CS3IPlayer : IPlayer { } } - // we want to push metadata when loading torrents, so we just set up a looper that loops until - // the index changes, this way only 1 looper is active at a time, and modifying eventLooperIndex - // will kill any active loopers - private var eventLooperIndex = 0 - private fun torrentEventLooper(hash: String) = ioSafe { - eventLooperIndex += 2 - // very shitty, but should work fine - // release player is called once for the new link - val currentIndex = eventLooperIndex + 1 - while (eventLooperIndex <= currentIndex && eventHandler != null) { - try { - val status = Torrent.get(hash) - event( - DownloadEvent( - connections = status.activePeers, - downloadSpeed = status.downloadSpeed?.toLong()!!, - totalBytes = status.torrentSize!!, - downloadedBytes = status.bytesRead!!, - ) - ) - } catch (_: NullPointerException) { - } catch (t: Throwable) { - logError(t) - } - delay(1000) - } - } - - private fun buildExoPlayer( - context: Context, - mediaItemSlices: List, - subSources: List, - currentWindow: Int, - playbackPosition: Long, - playBackSpeed: Float, - subtitleOffset: Long, - cacheSize: Long, - videoBufferMs: Long, - onlineSource: HttpDataSource.Factory? = null, - playWhenReady: Boolean = true, - trackSelector: TrackSelector? = null, - /** - * Sets the m3u8 preferred video quality, will not force stop anything with higher quality. - * Does not work if trackSelector is defined. - **/ - maxVideoHeight: Int? = null, - /** External audio tracks to merge with the video */ - audioSources: List = emptyList() - ): ExoPlayer { - val exoPlayerBuilder = - ExoPlayer.Builder(context) - .setMediaSourceFactory( - DefaultMediaSourceFactory(context).setLiveTargetOffsetMs( - PREFERRED_LIVE_OFFSET - ) - ) - .setLivePlaybackSpeedControl( - DefaultLivePlaybackSpeedControl.Builder() - .setFallbackMaxPlaybackSpeed(1.03f) - .setFallbackMinPlaybackSpeed(0.97f) - .build() - ) - .setRenderersFactory { eventHandler, videoRendererEventListener, audioRendererEventListener, _, metadataRendererOutput -> - val settingsManager = PreferenceManager.getDefaultSharedPreferences(context) - val current = settingsManager.getInt( - context.getString(R.string.software_decoding_key), - -1 - ) - val (isSoftwareDecodingEnabled, isSoftwareDecodingPreferred) = when (current) { - 0 -> true to false // HW+SW, aka on but prefer hw - 2 -> true to true // SW+HW, aka on but prefer sw - 1 -> false to false // HW, aka off - // -1 = automatic - // We do not want tv to have software decoding, because of crashes - else -> isLayout(PHONE or EMULATOR) to false - } - - val factory = if (isSoftwareDecodingEnabled) { - FixedNextRenderersFactory(context).apply { - setEnableDecoderFallback(true) - setExtensionRendererMode( - if (isSoftwareDecodingPreferred) - DefaultRenderersFactory.EXTENSION_RENDERER_MODE_PREFER - else - DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON - ) - } - } else { - // no nextlib = EXTENSION_RENDERER_MODE_OFF - DefaultRenderersFactory(context) - } - - val style = CustomDecoder.style - // Custom TextOutput to apply cue styling and rules to all subtitles - val customTextOutput = TextOutput { cue -> - // Do not remove filterNotNull as Java typesystem is fucked - val (bitmapCues, textCues) = cue.cues.toList() - .partition { it.bitmap != null } - - val styledBitmapCues = bitmapCues.map { bitmapCue -> - bitmapCue - .buildUpon() - .fixSubtitleAlignment() - .applyStyle(style) - .build() - } - - // Reuse memory, to avoid many allocations - val set = HashSet() - val buffer = StringBuilder() - - // Move cues into one single one - // This is to prevent text overlap in vtt (and potentially other) subtitle files - val styledTextCues = textCues.groupBy { - // Groups cues which share the same positon - it.lineAnchor to it.position.times(1000.0f).toInt() - }.mapNotNull { (_, entries) -> - set.clear() - buffer.clear() - var count = 0 - for (x in entries) { - // Only allow non null text, otherwise we might have "a\n\nb" - val text = x.text ?: continue - - // Prevent duplicate entries, this often happens when the subtitle file - // uses multiple text lines as outlines. Most commonly found in fansubs - // with fancy subtitle styling. - if (!set.add(text)) { - continue - } - if (++count > 1) buffer.append('\n') - - // Trim to avoid weird formatting if the last line ends with a newline - buffer.append(text.trim()) - } - - val combinedCueText = buffer.toString() - - // Use the style of the first entry as the base - entries - .firstOrNull() - ?.buildUpon() - ?.setText(combinedCueText) - ?.fixSubtitleAlignment() - ?.applyStyle(style) - ?.build() - } - - val combinedCues = styledBitmapCues + styledTextCues - - subtitleHelper.subtitleView?.setCues(combinedCues) - } - - factory.createRenderers( - eventHandler, - videoRendererEventListener, - audioRendererEventListener, - customTextOutput, - metadataRendererOutput - ).map { - if (it is TextRenderer) { - CustomDecoder.subtitleOffset = subtitleOffset - val decoder = CustomSubtitleDecoderFactory() - - // @OptIn(ExperimentalApi::class) - val currentTextRenderer = TextRenderer( - customTextOutput, - eventHandler.looper, - decoder - ).apply { - // Required to make the decoder work with old subtitles - // Upgrade CustomSubtitleDecoderFactory when media3 supports it - @Suppress("DEPRECATION") - experimentalSetLegacyDecodingEnabled(true) - }.also { renderer -> - currentTextRenderer = renderer - currentSubtitleDecoder = decoder - } - currentTextRenderer - } else - it - }.toTypedArray() - } - .setTrackSelector( - trackSelector ?: getTrackSelector( - context, - maxVideoHeight - ) - ) - // Allows any seeking to be +- 0.3s to allow for faster seeking - .setSeekParameters(SeekParameters(toleranceBeforeUs, toleranceAfterUs)) - .setLoadControl( - DefaultLoadControl.Builder() - .setTargetBufferBytes( - if (cacheSize <= 0) { - DefaultLoadControl.DEFAULT_TARGET_BUFFER_BYTES - } else { - if (cacheSize > Int.MAX_VALUE) Int.MAX_VALUE else cacheSize.toInt() - } - ) - .setBackBuffer( - 30000, - true - ) - .setBufferDurationsMs( - DefaultLoadControl.DEFAULT_MIN_BUFFER_MS, - if (videoBufferMs <= 0) { - DefaultLoadControl.DEFAULT_MAX_BUFFER_MS - } else { - videoBufferMs.toInt() - }, - DefaultLoadControl.DEFAULT_BUFFER_FOR_PLAYBACK_MS, - DefaultLoadControl.DEFAULT_BUFFER_FOR_PLAYBACK_AFTER_REBUFFER_MS - ).build() - ) - - // Because "Java rules" the media3 team hates to do open classes so we have to copy paste the entire thing to add a custom extractor - // This includes the updated MKV extractor that enabled seeking in formats where the seek information is at the back of the file - val extractorFactor = UpdatedDefaultExtractorsFactory() - .setFragmentedMp4ExtractorFlags(FragmentedMp4Extractor.FLAG_MERGE_FRAGMENTED_SIDX) - - // Create an online connection with cache for all online sources - val dataSourceFactory = if (onlineSource == null) { - null - } else { - if (simpleCache == null) - simpleCache = getCache(context, simpleCacheSize) - - val cacheFactory = CacheDataSource.Factory().apply { - simpleCache?.let { setCache(it) } - setUpstreamDataSourceFactory(onlineSource) - } - cacheFactory - } - - val defaultMediaSourceFactory = if (dataSourceFactory != null) { - DefaultMediaSourceFactory(dataSourceFactory, extractorFactor) - } else { - DefaultMediaSourceFactory(context, extractorFactor) - } - - // If there is only one item then treat it as normal, if multiple: concatenate the items. - val videoMediaSource = if (mediaItemSlices.size == 1) { - val item = mediaItemSlices.first() - - item.drm?.let { drm -> - when (drm.uuid) { - CLEARKEY_DRM_UUID.toJavaUuid() -> { - // 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() - .setPlayClearSamplesWithoutKeys(true) - .setMultiSession(false) - .setKeyRequestParameters(drm.keyRequestParameters) - .setUuidAndExoMediaDrmProvider( - drm.uuid, - FrameworkMediaDrm.DEFAULT_PROVIDER - ) - .build(drmCallback) - - DashMediaSource.Factory(client) - .setDrmSessionManagerProvider { manager } - .createMediaSource(item.mediaItem) - } - - WIDEVINE_DRM_UUID.toJavaUuid(), - PLAYREADY_DRM_UUID.toJavaUuid() -> { - // 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) - .setMultiSession(true) - .setKeyRequestParameters(drm.keyRequestParameters) - .setUuidAndExoMediaDrmProvider( - drm.uuid, - FrameworkMediaDrm.DEFAULT_PROVIDER - ) - .build(drmCallback) - - DashMediaSource.Factory(client) - .setDrmSessionManagerProvider { manager } - .createMediaSource(item.mediaItem) - } - - else -> { - Log.e( - TAG, - "DRM Metadata class is not supported: ${drm::class.simpleName}" - ) - null - } - } - } ?: run { - defaultMediaSourceFactory.createMediaSource(item.mediaItem) - } - } else { - try { - val source = ConcatenatingMediaSource2.Builder() - mediaItemSlices.forEach { item -> - source.add( - // The duration MUST be known for it to work properly, see https://github.com/google/ExoPlayer/issues/4727 - ClippingMediaSource( - defaultMediaSourceFactory.createMediaSource(item.mediaItem), - item.durationUs - ) - ) - } - source.build() - } catch (_: IllegalArgumentException) { - @Suppress("DEPRECATION") - val source = - ConcatenatingMediaSource() // FIXME figure out why ConcatenatingMediaSource2 seems to fail with Torrents only - mediaItemSlices.forEach { item -> - source.addMediaSource( - // The duration MUST be known for it to work properly, see https://github.com/google/ExoPlayer/issues/4727 - ClippingMediaSource( - defaultMediaSourceFactory.createMediaSource(item.mediaItem), - item.durationUs - ) - ) - } - source - } - } - return exoPlayerBuilder.build().apply { - setPlayWhenReady(playWhenReady) - seekTo(currentWindow, playbackPosition) - // Merge video, subtitles and external audio tracks - val allSources = listOf(videoMediaSource) + subSources + audioSources - setMediaSource( - MergingMediaSource(*allSources.toTypedArray()), - playbackPosition - ) - setHandleAudioBecomingNoisy(true) - setPlaybackSpeed(playBackSpeed) - this.addAnalyticsListener(tracksAnalyticsListener) - } - } - private fun loadExo( context: Context, mediaSlices: List, subSources: List, - audioSources: List = emptyList(), - onlineSource: HttpDataSource.Factory? = null, + cacheFactory: CacheDataSource.Factory? = null ) { Log.i(TAG, "loadExo") val settingsManager = PreferenceManager.getDefaultSharedPreferences(context) val maxVideoHeight = settingsManager.getInt( - context.getString(if (context.isUsingMobileData()) R.string.quality_pref_mobile_data_key else R.string.quality_pref_key), + context.getString(if (context.isUsingMobileData()) com.lagradost.cloudstream3.R.string.quality_pref_mobile_data_key else com.lagradost.cloudstream3.R.string.quality_pref_key), Int.MAX_VALUE ) @@ -1405,49 +978,24 @@ class CS3IPlayer : IPlayer { cacheSize = cacheSize, videoBufferMs = videoBufferMs, playWhenReady = isPlaying, // this keep the current state of the player + cacheFactory = cacheFactory, subtitleOffset = currentSubtitleOffset, - maxVideoHeight = maxVideoHeight, - audioSources = audioSources, - onlineSource = onlineSource, + maxVideoHeight = maxVideoHeight ) + requestSubtitleUpdate = ::reloadSubs + 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 } - // we want to avoid an empty exoplayer from sending events - // this is because we need PlayerAttachedEvent to be called to render the UI - // but don't really want the rest like Player.STATE_ENDED calling next episode - if (mediaSlices.isEmpty() && subSources.isEmpty()) { - return - } - - LiveHelper.registerPlayer(exoPlayer) - exoPlayer?.addListener(object : Player.Listener { override fun onTracksChanged(tracks: Tracks) { - safe { + normalSafeApiCall { val textTracks = tracks.groups.filter { it.type == TRACK_TYPE_TEXT } playerSelectedSubtitleTracks = @@ -1469,15 +1017,14 @@ class CS3IPlayer : IPlayer { return@mapNotNull SubtitleData( // Nicer looking displayed names - fromTagToLanguageName(format.language) + fromTwoLettersToLanguage(format.language!!) ?: format.language!!, - format.label ?: "", // See setPreferredTextLanguage format.id!!.stripTrackId(), SubtitleOrigin.EMBEDDED_IN_VIDEO, format.sampleMimeType ?: MimeTypes.APPLICATION_SUBRIP, emptyMap(), - format.language, + format.language ) } @@ -1487,19 +1034,13 @@ class CS3IPlayer : IPlayer { } } - // fixme: Use onPlaybackStateChanged(int) and onPlayWhenReadyChanged(boolean, int) instead. - @Suppress("OVERRIDE_DEPRECATION") + //fixme: Use onPlaybackStateChanged(int) and onPlayWhenReadyChanged(boolean, int) instead. override fun onPlayerStateChanged(playWhenReady: Boolean, playbackState: Int) { exoPlayer?.let { exo -> event( StatusEvent( wasPlaying = if (isPlaying) CSPlayerLoading.IsPlaying else CSPlayerLoading.IsPaused, - isPlaying = - when (playbackState) { - Player.STATE_ENDED -> CSPlayerLoading.IsEnded - Player.STATE_BUFFERING -> CSPlayerLoading.IsBuffering - else -> if (exo.isPlaying) CSPlayerLoading.IsPlaying else CSPlayerLoading.IsPaused - } + isPlaying = if (playbackState == Player.STATE_BUFFERING) CSPlayerLoading.IsBuffering else if (exo.isPlaying) CSPlayerLoading.IsPlaying else CSPlayerLoading.IsPaused ) ) isPlaying = exo.isPlaying @@ -1553,23 +1094,6 @@ class CS3IPlayer : IPlayer { exoPlayer?.prepare() } - // PlaylistStuckException usually happens when the player position is ahead of the live window. - // Seek to the default location in that case - error.cause is HlsPlaylistTracker.PlaylistStuckException -> { - val position = exoPlayer?.currentPosition ?: exoPlayer?.duration ?: 0 - - // Seek to live head - val aheadOfLive = LiveHelper.getLiveManager(exoPlayer)?.getTimeAheadOfLive(position) ?: 0 - - if (aheadOfLive > 100) { - exoPlayer?.seekTo(position - aheadOfLive) - } else { - exoPlayer?.seekToDefaultPosition() - } - exoPlayer?.prepare() - } - - else -> { event(ErrorEvent(error)) } @@ -1598,10 +1122,13 @@ 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( - context.getString(R.string.autoplay_next_key), + context.getString(com.lagradost.cloudstream3.R.string.autoplay_next_key), true ) == true ) { @@ -1634,16 +1161,16 @@ class CS3IPlayer : IPlayer { onRenderFirst() updatedTime(source = PlayerEventSource.Player) } - }.also { playerListener = it }) + }) } catch (t: Throwable) { Log.e(TAG, "loadExo error", t) event(ErrorEvent(t)) } } - private var lastTimeStamps: List = emptyList() + private var lastTimeStamps: List = emptyList() - override fun addTimeStamps(timeStamps: List) { + override fun addTimeStamps(timeStamps: List) { lastTimeStamps = timeStamps timeStamps.forEach { timestamp -> exoPlayer?.createMessage { _, _ -> @@ -1652,7 +1179,7 @@ class CS3IPlayer : IPlayer { // onTimestampInvoked?.invoke(payload) } ?.setLooper(Looper.getMainLooper()) - ?.setPosition(timestamp.timestamp.startMs) + ?.setPosition(timestamp.startMs) //?.setPayload(timestamp) ?.setDeleteAfterDelivery(false) ?.send() @@ -1666,6 +1193,20 @@ class CS3IPlayer : IPlayer { } Log.i(TAG, "Rendered first frame") hasUsedFirstRender = true + val invalid = exoPlayer?.duration?.let { duration -> + // Only errors short playback when not playing downloaded files + duration < 20_000L && currentDownloadedFile == null + // Concatenated sources (non 1 periodCount) bypasses the invalid check as exoPlayer.duration gives only the current period + // If you can get the total time that'd be better, but this is already niche. + && exoPlayer?.currentTimeline?.periodCount == 1 + && exoPlayer?.isCurrentMediaItemLive != true + } ?: false + + if (invalid) { + releasePlayer(saveTime = false) + event(ErrorEvent(InvalidFileException("Too short playback"))) + return + } setPreferredSubtitles(currentSubtitles) val format = exoPlayer?.videoFormat @@ -1696,11 +1237,12 @@ class CS3IPlayer : IPlayer { val mediaItem = getMediaItem(MimeTypes.VIDEO_MP4, data.uri) val offlineSourceFactory = context.createOfflineSource() + val onlineSourceFactory = createOnlineSource(emptyMap()) val (subSources, activeSubtitles) = getSubSources( + onlineSourceFactory = onlineSourceFactory, offlineSourceFactory = offlineSourceFactory, - subHelper = subtitleHelper, - interceptor = null, + subtitleHelper, ) subtitleHelper.setActiveSubtitles(activeSubtitles.toSet()) @@ -1712,20 +1254,20 @@ class CS3IPlayer : IPlayer { } private fun getSubSources( + onlineSourceFactory: HttpDataSource.Factory?, offlineSourceFactory: DataSource.Factory?, subHelper: PlayerSubtitleHelper, - interceptor: Interceptor?, ): Pair, List> { val activeSubtitles = ArrayList() val subSources = subHelper.getAllSubtitles().mapNotNull { sub -> - val subConfig = MediaItem.SubtitleConfiguration.Builder(sub.getFixedUrl().toUri()) + val subConfig = MediaItem.SubtitleConfiguration.Builder(Uri.parse(sub.getFixedUrl())) .setMimeType(sub.mimeType) .setLanguage("_${sub.name}") .setId(sub.getId()) .setSelectionFlags(0) .build() when (sub.origin) { - SubtitleOrigin.DOWNLOADED_FILE, SubtitleOrigin.EMBEDDED_IN_VIDEO -> { + SubtitleOrigin.DOWNLOADED_FILE -> { if (offlineSourceFactory != null) { activeSubtitles.add(sub) SingleSampleMediaSource.Factory(offlineSourceFactory) @@ -1736,164 +1278,46 @@ class CS3IPlayer : IPlayer { } SubtitleOrigin.URL -> { - val dataSourceFactory = createOnlineSource(sub.headers, interceptor) - activeSubtitles.add(sub) - SingleSampleMediaSource.Factory(dataSourceFactory) - .createMediaSource(subConfig, TIME_UNSET) + if (onlineSourceFactory != null) { + activeSubtitles.add(sub) + SingleSampleMediaSource.Factory(onlineSourceFactory.apply { + if (sub.headers.isNotEmpty()) + this.setDefaultRequestProperties(sub.headers) + }) + .createMediaSource(subConfig, TIME_UNSET) + } else { + null + } + } + + SubtitleOrigin.EMBEDDED_IN_VIDEO -> { + if (offlineSourceFactory != null) { + activeSubtitles.add(sub) + SingleSampleMediaSource.Factory(offlineSourceFactory) + .createMediaSource(subConfig, TIME_UNSET) + } else { + null + } } } } return Pair(subSources, activeSubtitles) } - /** - * Creates audio media sources from ExtractorLink's audioTracks - * @param audioTracks List of audio tracks from ExtractorLink - * @return List of MediaSource for audio tracks - */ - private fun getAudioSources( - audioTracks: List, - interceptor: Interceptor?, - ): List { - return audioTracks.mapNotNull { audio -> - try { - val mediaItem = getMediaItem(MimeTypes.AUDIO_UNKNOWN, audio.url) - val dataSourceFactory = createOnlineSource(audio.headers, interceptor) - DefaultMediaSourceFactory(dataSourceFactory).createMediaSource(mediaItem) - } catch (e: Exception) { - Log.e(TAG, "Failed to create audio source for ${audio.url}: ${e.message}") - null - } - } - } - override fun isActive(): Boolean { return exoPlayer != null } - @MainThread - private fun loadTorrent(context: Context, link: ExtractorLink) { - ioSafe { - // we check exoPlayer a lot here, and that is because we don't want to load exo after - // the user has left the player, in the case that the user click back when this is - // happening - try { - if (exoPlayer == null) return@ioSafe - val (newLink, status) = Torrent.transformLink(link) - val hash = status.hash - if (exoPlayer == null) return@ioSafe - runOnMainThread { - if (exoPlayer == null) return@runOnMainThread - releasePlayer() - if (hash != null) { - torrentEventLooper(hash) - } - loadOnlinePlayer(context, newLink) - } - } catch (t: Throwable) { - event(ErrorEvent(t)) - } - } - } - @SuppressLint("UnsafeOptInUsageError") - @MainThread - private fun loadOnlinePlayer(context: Context, link: ExtractorLink, retry: Boolean = false) { + private fun loadOnlinePlayer(context: Context, link: ExtractorLink) { Log.i(TAG, "loadOnlinePlayer $link") try { - val mime = when (link.type) { - ExtractorLinkType.M3U8 -> MimeTypes.APPLICATION_M3U8 - ExtractorLinkType.DASH -> MimeTypes.APPLICATION_MPD - ExtractorLinkType.VIDEO -> MimeTypes.VIDEO_MP4 - ExtractorLinkType.TORRENT, ExtractorLinkType.MAGNET -> { - // we check settings first, todo cleanup - val default = TvType.entries.toTypedArray() - .sorted() - .filter { it != TvType.NSFW } - .map { it.ordinal } - - val defaultSet = default.map { it.toString() }.toSet() - val currentPrefMedia = try { - PreferenceManager.getDefaultSharedPreferences(context) - .getStringSet( - context.getString(R.string.prefer_media_type_key), - defaultSet - ) - ?.mapNotNull { it.toIntOrNull() ?: return@mapNotNull null } - } catch (_: Throwable) { - null - } ?: default - - if (!currentPrefMedia.contains(TvType.Torrent.ordinal)) { - val errorMessage = context.getString(R.string.torrent_preferred_media) - event(ErrorEvent(ErrorLoadingException(errorMessage))) - return - } - - if (Torrent.hasAcceptedTorrentForThisSession == false) { - val errorMessage = context.getString(R.string.torrent_not_accepted) - event(ErrorEvent(ErrorLoadingException(errorMessage))) - return - } - // load the initial UI, we require an exoPlayer to be alive - if (!retry) { - // this causes a *bug* that restarts all torrents from 0 - // but I would call this a feature - releasePlayer() - loadExo(context, listOf(), listOf()) - } - event( - StatusEvent( - wasPlaying = CSPlayerLoading.IsPlaying, - isPlaying = CSPlayerLoading.IsBuffering - ) - ) - - if (Torrent.hasAcceptedTorrentForThisSession == true) { - loadTorrent(context, link) - return - } - - val builder: AlertDialog.Builder = AlertDialog.Builder(context) - - val dialogClickListener = - DialogInterface.OnClickListener { _, which -> - when (which) { - DialogInterface.BUTTON_POSITIVE -> { - Torrent.hasAcceptedTorrentForThisSession = true - loadTorrent(context, link) - } - - DialogInterface.BUTTON_NEGATIVE -> { - Torrent.hasAcceptedTorrentForThisSession = false - val errorMessage = - context.getString(R.string.torrent_not_accepted) - event(ErrorEvent(ErrorLoadingException(errorMessage))) - } - } - } - - builder.setTitle(R.string.play_torrent_button) - .setMessage(R.string.torrent_info) - // Ensure that the user will not accidentally start a torrent session. - .setCancelable(false).setOnCancelListener { - val errorMessage = context.getString(R.string.torrent_not_accepted) - event(ErrorEvent(ErrorLoadingException(errorMessage))) - } - .setPositiveButton(R.string.ok, dialogClickListener) - .setNegativeButton(R.string.go_back, dialogClickListener) - .show().setDefaultFocus() - - return - } - } - currentLink = link if (ignoreSSL) { // Disables ssl check val sslContext: SSLContext = SSLContext.getInstance("TLS") - sslContext.init(null, arrayOf(SSLTrustManager()), SecureRandom()) + sslContext.init(null, arrayOf(SSLTrustManager()), java.security.SecureRandom()) sslContext.createSSLEngine() HttpsURLConnection.setDefaultHostnameVerifier { _: String, _: SSLSession -> true @@ -1901,6 +1325,14 @@ class CS3IPlayer : IPlayer { HttpsURLConnection.setDefaultSSLSocketFactory(sslContext.socketFactory) } + val mime = when (link.type) { + ExtractorLinkType.M3U8 -> MimeTypes.APPLICATION_M3U8 + ExtractorLinkType.DASH -> MimeTypes.APPLICATION_MPD + ExtractorLinkType.VIDEO -> MimeTypes.VIDEO_MP4 + ExtractorLinkType.TORRENT -> throw IllegalArgumentException("No torrent support") + ExtractorLinkType.MAGNET -> throw IllegalArgumentException("No magnet support") + } + val mediaItems = when (link) { is ExtractorLinkPlayList -> link.playlist.map { @@ -1915,10 +1347,9 @@ class CS3IPlayer : IPlayer { drm = DrmMetadata( kid = link.kid, key = link.key, - uuid = link.uuid.toJavaUuid(), + uuid = link.uuid, kty = link.kty, - licenseUrl = link.licenseUrl, - keyRequestParameters = link.keyRequestParameters, + keyRequestParameters = link.keyRequestParameters ) ) ) @@ -1930,46 +1361,26 @@ class CS3IPlayer : IPlayer { ) } - // For DASH or HLS single streams (non-playlist), prefer the player's default - // live position instead of starting at 0. Use TIME_UNSET to let ExoPlayer pick - // the live/default position when no explicit start position was provided. - if (playbackPosition == 0L && (link.type == ExtractorLinkType.M3U8 || link.type == ExtractorLinkType.DASH)) { - playbackPosition = TIME_UNSET - } - - val provider = getApiFromNameNull(link.source) - val interceptor: Interceptor? = provider?.getVideoInterceptor(link) - - val onlineSourceFactory = - createVideoSource( - link = link, - engine = tryCreateEngine(context, simpleCacheSize), - interceptor = interceptor - ) - + val onlineSourceFactory = createOnlineSource(link) val offlineSourceFactory = context.createOfflineSource() val (subSources, activeSubtitles) = getSubSources( + onlineSourceFactory = onlineSourceFactory, offlineSourceFactory = offlineSourceFactory, - subHelper = subtitleHelper, - interceptor = interceptor, // Backwards compatibility, needs a new api to work properly - ) - - // Create audio sources from ExtractorLink's audioTracks - val audioSources = getAudioSources( - audioTracks = link.audioTracks, - interceptor = interceptor, // Backwards compatibility, needs a new api to work properly + subtitleHelper ) subtitleHelper.setActiveSubtitles(activeSubtitles.toSet()) - loadExo( - context = context, - mediaSlices = mediaItems, - subSources = subSources, - audioSources = audioSources, - onlineSource = onlineSourceFactory - ) + if (simpleCache == null) + simpleCache = getCache(context, simpleCacheSize) + + val cacheFactory = CacheDataSource.Factory().apply { + simpleCache?.let { setCache(it) } + setUpstreamDataSourceFactory(onlineSourceFactory) + } + + loadExo(context, mediaItems, subSources, cacheFactory) } catch (t: Throwable) { Log.e(TAG, "loadOnlinePlayer error", t) event(ErrorEvent(t)) @@ -1986,38 +1397,4 @@ class CS3IPlayer : IPlayer { loadOfflinePlayer(context, it) } } - - private val tracksAnalyticsListener = object : AnalyticsListener { - - override fun onVideoInputFormatChanged( - eventTime: AnalyticsListener.EventTime, - format: Format, - decoderReuseEvaluation: DecoderReuseEvaluation? - ) { - event(TracksChangedEvent()) - } - - override fun onAudioInputFormatChanged( - eventTime: AnalyticsListener.EventTime, - format: Format, - decoderReuseEvaluation: DecoderReuseEvaluation? - ) { - event(TracksChangedEvent()) - } - - override fun onVideoDisabled( - eventTime: AnalyticsListener.EventTime, - decoderCounters: DecoderCounters - ) { - event(TracksChangedEvent()) - } - - override fun onAudioDisabled( - eventTime: AnalyticsListener.EventTime, - decoderCounters: DecoderCounters - ) { - event(TracksChangedEvent()) - } - } - } diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/CustomSubripParser.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/CustomSubripParser.kt deleted file mode 100644 index c26a4f2df..000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/CustomSubripParser.kt +++ /dev/null @@ -1,296 +0,0 @@ -/* - * Copyright (C) 2016 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - - -/* -* This is a fork of media3 subrip parses as the developers fear a flexible player, and open classes. -*/ -package com.lagradost.cloudstream3.ui.player - -import android.text.Html -import android.text.Spanned -import android.text.TextUtils -import androidx.annotation.VisibleForTesting -import androidx.media3.common.C -import androidx.media3.common.Format -import androidx.media3.common.Format.CueReplacementBehavior -import androidx.media3.common.text.Cue -import androidx.media3.common.text.Cue.AnchorType -import androidx.media3.common.util.Consumer -import androidx.media3.common.util.Log -import androidx.media3.common.util.ParsableByteArray -import androidx.media3.common.util.UnstableApi -import androidx.media3.extractor.text.CuesWithTiming -import androidx.media3.extractor.text.SubtitleParser -import androidx.media3.extractor.text.SubtitleParser.OutputOptions -import com.google.common.base.Preconditions.checkNotNull -import com.google.common.collect.ImmutableList -import java.nio.charset.Charset -import java.nio.charset.StandardCharsets -import java.util.regex.Matcher -import java.util.regex.Pattern - -/** A [SubtitleParser] for SubRip. */ -@UnstableApi -class CustomSubripParser : SubtitleParser { - private val textBuilder: StringBuilder = StringBuilder() - private val tags: ArrayList = ArrayList() - private val parsableByteArray: ParsableByteArray = ParsableByteArray() - - override fun getCueReplacementBehavior(): @CueReplacementBehavior Int { - return CUE_REPLACEMENT_BEHAVIOR - } - - override fun parse( - data: ByteArray, - offset: Int, - length: Int, - outputOptions: OutputOptions, - output: Consumer - ) { - parsableByteArray.reset(data, /* limit= */offset + length) - parsableByteArray.setPosition(offset) - val charset = detectUtfCharset(parsableByteArray) - - val cuesWithTimingBeforeRequestedStartTimeUs: MutableList? = - if (outputOptions.startTimeUs != C.TIME_UNSET && outputOptions.outputAllCues) - ArrayList() - else - null - var currentLine: String? - while ((parsableByteArray.readLine(charset).also { currentLine = it }) != null) { - if (currentLine!!.isEmpty()) { - // Skip blank lines. - continue - } - - // Parse and check the index line. - try { - currentLine.toInt() - } catch (_: NumberFormatException) { - Log.w(TAG, "Skipping invalid index: $currentLine") - continue - } - - // Read and parse the timing line. - currentLine = parsableByteArray.readLine(charset) - if (currentLine == null) { - Log.w(TAG, "Unexpected end") - break - } - - val startTimeUs: Long - val endTimeUs: Long - val matcher = SUBRIP_TIMING_LINE.matcher(currentLine) - if (matcher.matches()) { - startTimeUs = parseTimecode(matcher, /* groupOffset= */1) - endTimeUs = parseTimecode(matcher, /* groupOffset= */6) - } else { - Log.w(TAG, "Skipping invalid timing: $currentLine") - continue - } - - // Read and parse the text and tags. - textBuilder.setLength(0) - tags.clear() - currentLine = parsableByteArray.readLine(charset) - while (!TextUtils.isEmpty(currentLine)) { - if (textBuilder.isNotEmpty()) { - textBuilder.append("
") - } - textBuilder.append(processLine(currentLine!!, tags)) - currentLine = parsableByteArray.readLine(charset) - } - - @Suppress("DEPRECATION") - val text = Html.fromHtml(textBuilder.toString()) - - var alignmentTag: String? = null - for (i in tags.indices) { - val tag = tags[i] - if (tag.matches(SUBRIP_ALIGNMENT_TAG.toRegex())) { - alignmentTag = tag - // Subsequent alignment tags should be ignored. - break - } - } - if (outputOptions.startTimeUs == C.TIME_UNSET || endTimeUs >= outputOptions.startTimeUs) { - output.accept( - CuesWithTiming( - ImmutableList.of(buildCue(text, alignmentTag)), - startTimeUs, /* durationUs= */ - endTimeUs - startTimeUs - ) - ) - } else cuesWithTimingBeforeRequestedStartTimeUs?.add( - CuesWithTiming( - ImmutableList.of(buildCue(text, alignmentTag)), - startTimeUs, /* durationUs= */ - endTimeUs - startTimeUs - ) - ) - } - if (cuesWithTimingBeforeRequestedStartTimeUs != null) { - for (cuesWithTiming in cuesWithTimingBeforeRequestedStartTimeUs) { - output.accept(cuesWithTiming) - } - } - } - - /** - * Determine UTF encoding of the byte array from a byte order mark (BOM), defaulting to UTF-8 if - * no BOM is found. - */ - private fun detectUtfCharset(data: ParsableByteArray): Charset { - val charset = data.readUtfCharsetFromBom() - return charset ?: StandardCharsets.UTF_8 - } - - /** - * Trims and removes tags from the given line. The removed tags are added to `tags`. - * - * @param line The line to process. - * @param tags A list to which removed tags will be added. - * @return The processed line. - */ - private fun processLine(line: String, tags: ArrayList): String { - var line = line - line = line.trim { it <= ' ' } - - var removedCharacterCount = 0 - val processedLine = StringBuilder(line) - val matcher = SUBRIP_TAG_PATTERN.matcher(line) - while (matcher.find()) { - val tag = matcher.group() - tags.add(tag) - val start = matcher.start() - removedCharacterCount - val tagLength = tag.length - processedLine.replace(start, /* end= */start + tagLength, /* str= */"") - removedCharacterCount += tagLength - } - - return processedLine.toString() - } - - /** - * Build a [Cue] based on the given text and alignment tag. - * - * @param text The text. - * @param alignmentTag The alignment tag, or `null` if no alignment tag is available. - * @return Built cue - */ - private fun buildCue(text: Spanned, alignmentTag: String?): Cue { - val cue = Cue.Builder().setText(text) - if (alignmentTag == null) { - return cue.build() - } - - // Horizontal alignment. - when (alignmentTag) { - ALIGN_BOTTOM_LEFT, ALIGN_MID_LEFT, ALIGN_TOP_LEFT -> cue.setPositionAnchor(Cue.ANCHOR_TYPE_START) - ALIGN_BOTTOM_RIGHT, ALIGN_MID_RIGHT, ALIGN_TOP_RIGHT -> cue.setPositionAnchor(Cue.ANCHOR_TYPE_END) - ALIGN_BOTTOM_MID, ALIGN_MID_MID, ALIGN_TOP_MID -> cue.setPositionAnchor(Cue.ANCHOR_TYPE_MIDDLE) - else -> cue.setPositionAnchor(Cue.ANCHOR_TYPE_MIDDLE) - } - - // Vertical alignment. - when (alignmentTag) { - ALIGN_BOTTOM_LEFT, ALIGN_BOTTOM_MID, ALIGN_BOTTOM_RIGHT -> cue.setLineAnchor(Cue.ANCHOR_TYPE_END) - ALIGN_TOP_LEFT, ALIGN_TOP_MID, ALIGN_TOP_RIGHT -> cue.setLineAnchor(Cue.ANCHOR_TYPE_START) - ALIGN_MID_LEFT, ALIGN_MID_MID, ALIGN_MID_RIGHT -> cue.setLineAnchor(Cue.ANCHOR_TYPE_MIDDLE) - else -> cue.setLineAnchor(Cue.ANCHOR_TYPE_MIDDLE) - } - - return cue.setPosition(getFractionalPositionForAnchorType(cue.getPositionAnchor())) - .setLine( - getFractionalPositionForAnchorType(cue.getLineAnchor()), - Cue.LINE_TYPE_FRACTION - ) - .build() - } - - companion object { - /** - * The [CueReplacementBehavior] for consecutive [CuesWithTiming] emitted by this - * implementation. - */ - const val CUE_REPLACEMENT_BEHAVIOR: @CueReplacementBehavior Int = - Format.CUE_REPLACEMENT_BEHAVIOR_MERGE - - // Fractional positions for use when alignment tags are present. - private const val START_FRACTION = 0.08f - private const val END_FRACTION = 1 - START_FRACTION - private const val MID_FRACTION = 0.5f - - private const val TAG = "SubripParser" - - // The google devs are useless, this entire class is just to override this - private const val SUBRIP_TIMECODE = "(?:(\\d+):)?(\\d+):(\\d+)(?:[,.](\\d+))?" - private val SUBRIP_TIMING_LINE: Pattern = - Pattern.compile("\\s*($SUBRIP_TIMECODE)\\s*-->\\s*($SUBRIP_TIMECODE)\\s*") - - // NOTE: Android Studio's suggestion to simplify '\\}' is incorrect [internal: b/144480183]. - private val SUBRIP_TAG_PATTERN: Pattern = Pattern.compile("\\{\\\\.*?\\}") - private const val SUBRIP_ALIGNMENT_TAG = "\\{\\\\an[1-9]\\}" - - // Alignment tags for SSA V4+. - private const val ALIGN_BOTTOM_LEFT = "{\\an1}" - private const val ALIGN_BOTTOM_MID = "{\\an2}" - private const val ALIGN_BOTTOM_RIGHT = "{\\an3}" - private const val ALIGN_MID_LEFT = "{\\an4}" - private const val ALIGN_MID_MID = "{\\an5}" - private const val ALIGN_MID_RIGHT = "{\\an6}" - private const val ALIGN_TOP_LEFT = "{\\an7}" - private const val ALIGN_TOP_MID = "{\\an8}" - private const val ALIGN_TOP_RIGHT = "{\\an9}" - - private fun parseTimecode(matcher: Matcher, groupOffset: Int): Long { - val hours = matcher.group(groupOffset + 1) - var timestampMs = if (hours != null) hours.toLong() * 60 * 60 * 1000 else 0 - timestampMs += checkNotNull(matcher.group(groupOffset + 2)) - .toLong() * 60 * 1000 - timestampMs += checkNotNull(matcher.group(groupOffset + 3)) - .toLong() * 1000 - val millis = matcher.group(groupOffset + 4) - - timestampMs += when (millis?.length) { - null -> 0L - 1 -> millis.toLong() * 100L - 2 -> millis.toLong() * 10L - 3 -> millis.toLong() * 1L - else -> millis.substring(0, 3).toLong() - } - - return timestampMs * 1000 - } - - // TODO(b/289983417): Make package-private again, once it is no longer needed in - // DelegatingSubtitleDecoderWithSubripParserTest.java (i.e. legacy subtitle flow is removed) - @VisibleForTesting(otherwise = VisibleForTesting.Companion.PRIVATE) - fun getFractionalPositionForAnchorType(anchorType: @AnchorType Int): Float { - return when (anchorType) { - Cue.ANCHOR_TYPE_START -> START_FRACTION - Cue.ANCHOR_TYPE_MIDDLE -> MID_FRACTION - Cue.ANCHOR_TYPE_END -> END_FRACTION - Cue.TYPE_UNSET -> // Should never happen. - throw IllegalArgumentException() - - else -> - throw IllegalArgumentException() - } - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/CustomSubtitleDecoderFactory.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/CustomSubtitleDecoderFactory.kt index 61d6f5564..eb20cf6d4 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 @@ -1,12 +1,10 @@ package com.lagradost.cloudstream3.ui.player import android.content.Context -import android.text.Layout import android.util.Log import androidx.annotation.OptIn import androidx.media3.common.Format import androidx.media3.common.MimeTypes -import androidx.media3.common.text.Cue import androidx.media3.common.util.Consumer import androidx.media3.common.util.UnstableApi import androidx.media3.exoplayer.text.SubtitleDecoderFactory @@ -18,6 +16,7 @@ import androidx.media3.extractor.text.SubtitleParser import androidx.media3.extractor.text.dvb.DvbParser import androidx.media3.extractor.text.pgs.PgsParser import androidx.media3.extractor.text.ssa.SsaParser +import androidx.media3.extractor.text.subrip.SubripParser import androidx.media3.extractor.text.ttml.TtmlParser import androidx.media3.extractor.text.tx3g.Tx3gParser import androidx.media3.extractor.text.webvtt.Mp4WebvttParser @@ -25,8 +24,6 @@ import androidx.media3.extractor.text.webvtt.WebvttParser import androidx.preference.PreferenceManager import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.mvvm.logError -import com.lagradost.cloudstream3.ui.subtitles.SaveCaptionStyle -import com.lagradost.cloudstream3.ui.subtitles.SubtitlesFragment import org.mozilla.universalchardet.UniversalDetector import java.lang.ref.WeakReference import java.nio.charset.Charset @@ -34,8 +31,8 @@ import java.nio.charset.Charset /** * @param fallbackFormat used to create a decoder based on mimetype if the subtitle string is not * enough to identify the subtitle format. - */ -@OptIn(UnstableApi::class) + **/ +@UnstableApi class CustomDecoder(private val fallbackFormat: Format?) : SubtitleParser { companion object { fun updateForcedEncoding(context: Context) { @@ -51,24 +48,14 @@ class CustomDecoder(private val fallbackFormat: Format?) : SubtitleParser { } } - private const val DEFAULT_MARGIN: Float = 0.05f - const val SSA_ALIGNMENT_BOTTOM_LEFT = 1 - const val SSA_ALIGNMENT_BOTTOM_CENTER = 2 - const val SSA_ALIGNMENT_BOTTOM_RIGHT = 3 - const val SSA_ALIGNMENT_MIDDLE_LEFT = 4 - const val SSA_ALIGNMENT_MIDDLE_CENTER = 5 - const val SSA_ALIGNMENT_MIDDLE_RIGHT = 6 - const val SSA_ALIGNMENT_TOP_LEFT = 7 - const val SSA_ALIGNMENT_TOP_CENTER = 8 - const val SSA_ALIGNMENT_TOP_RIGHT = 9 - /** Subtitle offset in milliseconds */ var subtitleOffset: Long = 0 private const val UTF_8 = "UTF-8" private const val TAG = "CustomDecoder" private var overrideEncoding: String? = null - val style: SaveCaptionStyle get() = SubtitlesFragment.getCurrentSavedStyle() - private val locationRegex = Regex("""\{\\an(\d+)\}""", RegexOption.IGNORE_CASE) + var regexSubtitlesToRemoveCaptions = false + var regexSubtitlesToRemoveBloat = false + var uppercaseSubtitles = false val bloatRegex = listOf( Regex( @@ -88,6 +75,7 @@ class CustomDecoder(private val fallbackFormat: Format?) : SubtitleParser { RegexOption.IGNORE_CASE ), ) + val captionRegex = listOf(Regex("""(-\s?|)[\[({][\w\s]*?[])}]\s*""")) //https://emptycharacter.com/ //https://www.fileformat.info/info/unicode/char/200b/index.htm @@ -97,100 +85,6 @@ class CustomDecoder(private val fallbackFormat: Format?) : SubtitleParser { " " ) } - - private fun computeDefaultLineOrPosition(@Cue.AnchorType anchor: Int) = when (anchor) { - Cue.ANCHOR_TYPE_START -> DEFAULT_MARGIN - Cue.ANCHOR_TYPE_MIDDLE -> 0.5f - Cue.ANCHOR_TYPE_END -> 1.0f - DEFAULT_MARGIN - Cue.TYPE_UNSET -> Cue.DIMEN_UNSET - else -> Cue.DIMEN_UNSET - } - - /** - * Fixes alignment for cues with {\anX}, - * this is common for .vtt that should be parsed as .srt - * - * ``` - * WEBVTT - * - * 00:00.000 --> 00:01.000 - * {\an1}Label 1 - * - * 00:01.000 --> 00:02.000 - * {\an2}Label 2 - * - * 00:02.000 --> 00:03.000 - * {\an3}Label 3 - * - * 00:03.000 --> 00:04.000 - * {\an4}Label 4 - * - * 00:04.000 --> 00:05.000 - * {\an5}Label 5 - * - * 00:05.000 --> 00:06.000 - * {\an6}Label 6 - * - * 00:06.000 --> 00:07.000 - * {\an7}Label 7 - * - * 00:07.000 --> 00:08.000 - * {\an8}Label 8 - * - * 00:08.000 --> 00:09.000 - * {\an9}Label 9 - * ``` - */ - fun Cue.Builder.fixSubtitleAlignment(): Cue.Builder { - var trimmed = text?.trim() ?: return this - // https://github.com/androidx/media/blob/main/libraries/extractor/src/main/java/androidx/media3/extractor/text/ssa/SsaStyle.java - // exoplayer can already parse this, however for eg webvtt it fails - locationRegex.find(trimmed)?.groupValues?.get(1)?.toIntOrNull()?.let { alignment -> - // toLineAnchor - this.setSubtitleAlignment(alignment) - } - - // remove all matches, so we do not display \anx - trimmed = trimmed.replace(locationRegex, "") - 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 @@ -228,36 +122,28 @@ class CustomDecoder(private val fallbackFormat: Format?) : SubtitleParser { } private fun getSubtitleParser(data: String): SubtitleParser? { - // This way we read the subtitle file and decide what decoder to use instead of relying fully on mimetype - - // First we remove all invisible characters at the start, this is an issue in some subtitle files - // Cntrl is control characters: https://en.wikipedia.org/wiki/Unicode_control_characters - // Cf is formatting characters: https://www.compart.com/en/unicode/category/Cf - val controlCharsRegex = Regex("""[\p{Cntrl}\p{Cf}]""") - val trimmedText = - data.trimStart { it.isWhitespace() || controlCharsRegex.matches(it.toString()) } - + // this way we read the subtitle file and decide what decoder to use instead of relying fully on mimetype //https://github.com/LagradOst/CloudStream-2/blob/ddd774ee66810137ff7bd65dae70bcf3ba2d2489/CloudStreamForms/CloudStreamForms/Script/MainChrome.cs#L388 val subtitleParser = when { // "WEBVTT" can be hidden behind invisible characters not filtered by trim - trimmedText.substring(0, 10).contains("WEBVTT", ignoreCase = true) -> WebvttParser() - trimmedText.startsWith(" TtmlParser() - (trimmedText.startsWith( + data.substring(0, 10).contains("WEBVTT", ignoreCase = true) -> WebvttParser() + data.startsWith(" TtmlParser() + (data.startsWith( "[Script Info]", ignoreCase = true - ) || trimmedText.startsWith( + ) || data.startsWith( "Title:", ignoreCase = true )) -> SsaParser(fallbackFormat?.initializationData) - trimmedText.startsWith("1", ignoreCase = true) -> CustomSubripParser() + data.startsWith("1", ignoreCase = true) -> SubripParser() fallbackFormat != null -> { - when (fallbackFormat.sampleMimeType) { + when (val mimeType = fallbackFormat.sampleMimeType) { MimeTypes.TEXT_VTT -> WebvttParser() MimeTypes.TEXT_SSA -> SsaParser(fallbackFormat.initializationData) MimeTypes.APPLICATION_MP4VTT -> Mp4WebvttParser() MimeTypes.APPLICATION_TTML -> TtmlParser() - MimeTypes.APPLICATION_SUBRIP -> CustomSubripParser() + MimeTypes.APPLICATION_SUBRIP -> SubripParser() MimeTypes.APPLICATION_TX3G -> Tx3gParser(fallbackFormat.initializationData) // These decoders are not converted to parsers yet // TODO @@ -283,7 +169,6 @@ class CustomDecoder(private val fallbackFormat: Format?) : SubtitleParser { val currentSubtitleCues = mutableListOf() - override fun parse( data: ByteArray, offset: Int, @@ -291,26 +176,16 @@ class CustomDecoder(private val fallbackFormat: Format?) : SubtitleParser { outputOptions: SubtitleParser.OutputOptions, output: Consumer ) { - val currentStyle = style val customOutput = Consumer { cue -> - val newCue = - CuesWithTiming(cue.cues, cue.startTimeUs, cue.durationUs) - - // Do not apply the offset to the currentSubtitleCues as those are then used for sync subs currentSubtitleCues.add( SubtitleCue( - newCue.startTimeUs / 1000, - newCue.durationUs / 1000, - newCue.cues.map { it.text.toString() }) + cue.startTimeUs / 1000, + cue.durationUs / 1000, + cue.cues.map { it.text.toString() }) ) - // offset timing for the final val updatedCues = - CuesWithTiming( - newCue.cues, - newCue.startTimeUs - subtitleOffset.times(1000), - newCue.durationUs - ) + CuesWithTiming(cue.cues, cue.startTimeUs - subtitleOffset.times(1000), cue.durationUs) output.accept(updatedCues) } @@ -327,11 +202,15 @@ class CustomDecoder(private val fallbackFormat: Format?) : SubtitleParser { ) realDecoder?.let { decoder -> if (decoder !is SsaParser) { - if (currentStyle.removeBloat) + if (regexSubtitlesToRemoveCaptions) + captionRegex.forEach { rgx -> + str = str.replace(rgx, "\n") + } + if (regexSubtitlesToRemoveBloat) bloatRegex.forEach { rgx -> str = str.replace(rgx, "\n") } - if (currentStyle.upperCase) { + if (uppercaseSubtitles) { str = str.uppercase() } } @@ -391,7 +270,7 @@ class CustomSubtitleDecoderFactory : SubtitleDecoderFactory { /** * Decoders created here persists across reset() * Do not save state in the decoder which you want to reset (e.g subtitle offset) - */ + **/ override fun createDecoder(format: Format): SubtitleDecoder { val parser = CustomDecoder(format) // Allow garbage collection if player releases the decoder @@ -403,8 +282,8 @@ class CustomSubtitleDecoderFactory : SubtitleDecoderFactory { } } -/** We need to convert the newer SubtitleParser to an older SubtitleDecoder */ @OptIn(UnstableApi::class) +/** We need to convert the newer SubtitleParser to an older SubtitleDecoder */ class DelegatingSubtitleDecoder(name: String, private val parser: SubtitleParser) : SimpleSubtitleDecoder(name) { diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/DownloadFileGenerator.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/DownloadFileGenerator.kt index 35f8dcfd8..7d3d18ca9 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/DownloadFileGenerator.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/DownloadFileGenerator.kt @@ -1,25 +1,60 @@ package com.lagradost.cloudstream3.ui.player import android.net.Uri -import com.lagradost.cloudstream3.CloudStreamApp.Companion.context +import com.lagradost.cloudstream3.AcraApplication.Companion.context import com.lagradost.cloudstream3.CommonActivity.activity import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.ui.player.PlayerSubtitleHelper.Companion.toSubtitleMimeType import com.lagradost.cloudstream3.utils.ExtractorLink import com.lagradost.cloudstream3.utils.ExtractorLinkType -import com.lagradost.cloudstream3.utils.SubtitleHelper.fromLanguageToTagIETF import com.lagradost.cloudstream3.utils.SubtitleUtils.cleanDisplayName import com.lagradost.cloudstream3.utils.SubtitleUtils.isMatchingSubtitle -import com.lagradost.cloudstream3.utils.downloader.DownloadFileManagement.getFolder -import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager.getDownloadFileInfo +import com.lagradost.cloudstream3.utils.VideoDownloadManager.getDownloadFileInfoAndUpdateSettings +import com.lagradost.cloudstream3.utils.VideoDownloadManager.getFolder +import kotlin.math.max +import kotlin.math.min class DownloadFileGenerator( - episodes: List -) : VideoGenerator(episodes) { + private val episodes: List, + private var currentIndex: Int = 0 +) : IGenerator { override val hasCache = false override val canSkipLoading = false - override fun getId(index: Int): Int? = this.videos.getOrNull(index)?.id + override fun hasNext(): Boolean { + return currentIndex < episodes.size - 1 + } + + override fun hasPrev(): Boolean { + return currentIndex > 0 + } + + override fun next() { + if (hasNext()) + currentIndex++ + } + + override fun prev() { + if (hasPrev()) + currentIndex-- + } + + override fun goto(index: Int) { + // clamps value + currentIndex = min(episodes.size - 1, max(0, index)) + } + + override fun getCurrentId(): Int? { + return episodes[currentIndex].id + } + + override fun getCurrent(offset: Int): Any? { + return episodes.getOrNull(currentIndex + offset) + } + + override fun getAll(): List? { + return null + } override suspend fun generateLinks( clearCache: Boolean, @@ -29,14 +64,14 @@ class DownloadFileGenerator( offset: Int, isCasting: Boolean ): Boolean { - val meta = videos.getOrNull(offset) ?: return false + val meta = episodes[currentIndex + offset] if (meta.uri == Uri.EMPTY) { // We do this here so that we only load it when // we actually need it as it can be more expensive. val info = meta.id?.let { id -> activity?.let { act -> - getDownloadFileInfo(act, id) + getDownloadFileInfoAndUpdateSettings(act, id) } } @@ -55,19 +90,16 @@ class DownloadFileGenerator( getFolder(ctx, relative, meta.basePath)?.forEach { (name, uri) -> if (isMatchingSubtitle(name, display, cleanDisplay)) { val cleanName = cleanDisplayName(name) - val lastNum = Regex(" ([0-9]+)$") - val nameSuffix = lastNum.find(cleanName)?.groupValues?.get(1) ?: "" - val originalName = cleanName.removePrefix(cleanDisplay).replace(lastNum, "").trim() + val realName = cleanName.removePrefix(cleanDisplay) subtitleCallback( SubtitleData( - originalName.ifBlank { ctx.getString(R.string.default_subtitles) }, - nameSuffix, + realName.ifBlank { ctx.getString(R.string.default_subtitles) }, uri.toString(), SubtitleOrigin.DOWNLOADED_FILE, name.toSubtitleMimeType(), emptyMap(), - fromLanguageToTagIETF(originalName, true) + null ) ) } diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/DownloadedPlayerActivity.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/DownloadedPlayerActivity.kt index a086cc16f..c38160c2d 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 @@ -7,105 +7,74 @@ import android.view.KeyEvent import androidx.appcompat.app.AppCompatActivity import com.lagradost.cloudstream3.CommonActivity import com.lagradost.cloudstream3.R -import com.lagradost.cloudstream3.mvvm.safe +import com.lagradost.cloudstream3.mvvm.normalSafeApiCall import com.lagradost.cloudstream3.ui.player.OfflinePlaybackHelper.playLink import com.lagradost.cloudstream3.ui.player.OfflinePlaybackHelper.playUri import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.attachBackPressedCallback -import com.lagradost.cloudstream3.utils.UIHelper.enableEdgeToEdgeCompat class DownloadedPlayerActivity : AppCompatActivity() { - companion object { - const val TAG = "DownloadedPlayerActivity" + private val dTAG = "DownloadedPlayerAct" + + override fun dispatchKeyEvent(event: KeyEvent): Boolean { + CommonActivity.dispatchKeyEvent(this, event)?.let { + return it + } + return super.dispatchKeyEvent(event) } - override fun dispatchKeyEvent(event: KeyEvent): Boolean = - CommonActivity.dispatchKeyEvent(this, event) ?: super.dispatchKeyEvent(event) + override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean { + CommonActivity.onKeyDown(this, keyCode, event) - override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean = - CommonActivity.onKeyDown(this, keyCode, event) ?: super.onKeyDown(keyCode, event) + return super.onKeyDown(keyCode, event) + } override fun onUserLeaveHint() { super.onUserLeaveHint() CommonActivity.onUserLeaveHint(this) } - override fun onNewIntent(intent: Intent) { - super.onNewIntent(intent) - // Ignore same intent so the player doesnt totally - // reload if you are playing the same thing. - if (isSameIntent(intent)) return - setIntent(intent) - Log.i(TAG, "onNewIntent") - handleIntent(intent) - } - - private fun isSameIntent(newIntent: Intent): Boolean { - val old = intent ?: return false - // Compare URIs first - val oldUri = old.data ?: old.clipData?.getItemAt(0)?.uri - val newUri = newIntent.data ?: newIntent.clipData?.getItemAt(0)?.uri - if (oldUri != null && oldUri == newUri) return true - // Fall back to comparing EXTRA_TEXT links - val oldText = safe { old.getStringExtra(Intent.EXTRA_TEXT) } - val newText = safe { newIntent.getStringExtra(Intent.EXTRA_TEXT) } - return oldText != null && oldText == newText - } - override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) CommonActivity.loadThemes(this) CommonActivity.init(this) - enableEdgeToEdgeCompat() setContentView(R.layout.empty_layout) - Log.i(TAG, "onCreate") - handleIntent(intent) + Log.i(dTAG, "onCreate") - /** - * Use moveTaskToBack instead of finish() so there is always exactly one task - * entry in recents, always reflecting the current file. - * - * finish() destroys the Activity but may leave the task in recents. Each new file - * open can create a new task entry, so recents accumulates stale entries for old - * files. The user then taps a stale entry and gets the wrong file. - * - * moveTaskToBack keeps the Activity alive in the background. There is only ever - * one task entry in recents. New files opened from the file manager arrive via - * onNewIntent on the live instance, updating the player immediately. The single - * recents entry always reflects the current state, ensuring we load the - * correct file. - */ - attachBackPressedCallback("DownloadedPlayerActivity") { moveTaskToBack(true) } - } - - private fun handleIntent(intent: Intent) { val data = intent.data - if (OfflinePlaybackHelper.playIntent(activity = this, intent = intent)) { - return - } - if ( - intent.action == Intent.ACTION_SEND || - intent.action == Intent.ACTION_OPEN_DOCUMENT || - intent.action == Intent.ACTION_VIEW - ) { - val extraText = safe { intent.getStringExtra(Intent.EXTRA_TEXT) } + if (intent?.action == Intent.ACTION_SEND) { + val extraText = normalSafeApiCall { // I dont trust android + intent.getStringExtra(Intent.EXTRA_TEXT) + } val cd = intent.clipData val item = if (cd != null && cd.itemCount > 0) cd.getItemAt(0) else null val url = item?.text?.toString() - when { - item?.uri != null -> playUri(this, item.uri) - url != null -> playLink(this, url) - data != null -> playUri(this, data) - extraText != null -> playLink(this, extraText) - else -> finishAndRemoveTask() + + // idk what I am doing, just hope any of these work + if (item?.uri != null) + playUri(this, item.uri) + else if (url != null) + playLink(this, url) + else if (data != null) + playUri(this, data) + else if (extraText != null) + playLink(this, extraText) + else { + finish() + return } } else if (data?.scheme == "content") { playUri(this, data) - } else finishAndRemoveTask() + } else { + finish() + return + } + + attachBackPressedCallback { finish() } } override fun onResume() { super.onResume() CommonActivity.setActivityInstance(this) } -} +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/ExtractorLinkGenerator.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/ExtractorLinkGenerator.kt index 85db33fc0..794dd762d 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/ExtractorLinkGenerator.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/ExtractorLinkGenerator.kt @@ -6,7 +6,36 @@ import com.lagradost.cloudstream3.utils.ExtractorLinkType class ExtractorLinkGenerator( private val links: List, private val subtitles: List, -) : NoVideoGenerator(null) { +) : IGenerator { + override val hasCache = false + override val canSkipLoading = true + + override fun getCurrentId(): Int? { + return null + } + + override fun hasNext(): Boolean { + return false + } + + override fun getAll(): List? { + return null + } + + override fun hasPrev(): Boolean { + return false + } + + override fun getCurrent(offset: Int): Any? { + return null + } + + override fun goto(index: Int) {} + + override fun next() {} + + override fun prev() {} + override suspend fun generateLinks( clearCache: Boolean, sourceTypes: Set, diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/FixedNextRenderersFactory.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/FixedNextRenderersFactory.kt deleted file mode 100644 index 025267cc9..000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/FixedNextRenderersFactory.kt +++ /dev/null @@ -1,28 +0,0 @@ -package com.lagradost.cloudstream3.ui.player - -import android.content.Context -import android.os.Looper -import androidx.media3.common.util.UnstableApi -import androidx.media3.exoplayer.Renderer -import androidx.media3.exoplayer.text.TextOutput -import androidx.media3.exoplayer.text.TextRenderer -import io.github.anilbeesetti.nextlib.media3ext.ffdecoder.NextRenderersFactory - -@UnstableApi -class FixedNextRenderersFactory(context: Context) : NextRenderersFactory(context) { - /** Somehow the nextlib authors decided that we need a text renderer that causes - * "ERROR_CODE_FAILED_RUNTIME_CHECK". - * - * Core issue: https://github.com/anilbeesetti/nextlib/pull/158 - * Comment: https://github.com/recloudstream/cloudstream/pull/2342#issuecomment-3917751718 - * */ - override fun buildTextRenderers( - context: Context, - output: TextOutput, - outputLooper: Looper, - extensionRendererMode: Int, - out: ArrayList - ) { - out.add(TextRenderer(output, outputLooper)) - } -} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/FullScreenPlayer.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/FullScreenPlayer.kt index 4ba933e13..9325f067f 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,14 +5,16 @@ 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.os.Build import android.os.Bundle +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 @@ -20,17 +22,18 @@ import android.view.Surface import android.view.View import android.view.ViewGroup import android.view.WindowManager -import android.view.animation.AccelerateDecelerateInterpolator +import android.view.WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES import android.view.animation.AlphaAnimation -import android.view.animation.DecelerateInterpolator -import android.widget.LinearLayout +import android.view.animation.Animation +import android.view.animation.AnimationUtils import androidx.annotation.OptIn -import androidx.appcompat.app.AlertDialog +import android.widget.LinearLayout 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 @@ -39,52 +42,69 @@ 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.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 import com.lagradost.cloudstream3.ui.player.source_priority.QualityDataHelper +import com.lagradost.cloudstream3.ui.result.setText +import com.lagradost.cloudstream3.ui.result.txt 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.isUsingMobileData -import com.lagradost.cloudstream3.utils.AppContextUtils.shouldShowPlayerMetadata -import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.attachBackPressedCallback -import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.detachBackPressedCallback import com.lagradost.cloudstream3.utils.DataStoreHelper +import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showDialog import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute import com.lagradost.cloudstream3.utils.UIHelper.dismissSafe -import com.lagradost.cloudstream3.utils.UIHelper.fixSystemBarsPadding +import com.lagradost.cloudstream3.utils.UIHelper.getNavigationBarHeight +import com.lagradost.cloudstream3.utils.UIHelper.getStatusBarHeight import com.lagradost.cloudstream3.utils.UIHelper.hideSystemUI import com.lagradost.cloudstream3.utils.UIHelper.popCurrentPage +import com.lagradost.cloudstream3.utils.UIHelper.showSystemUI import com.lagradost.cloudstream3.utils.UIHelper.toPx -import com.lagradost.cloudstream3.utils.setText -import com.lagradost.cloudstream3.utils.txt -import kotlin.math.roundToInt +import com.lagradost.cloudstream3.utils.UserPreferenceDelegate +import com.lagradost.cloudstream3.utils.Vector2 +import kotlin.math.abs +import kotlin.math.ceil +import kotlin.math.max +import kotlin.math.min +import kotlin.math.round +const val MINIMUM_SEEK_TIME = 7000L // when swipe seeking +const val MINIMUM_VERTICAL_SWIPE = 2.0f // in percentage +const val MINIMUM_HORIZONTAL_SWIPE = 2.0f // in percentage +const val VERTICAL_MULTIPLIER = 2.0f +const val HORIZONTAL_MULTIPLIER = 2.0f +const val DOUBLE_TAB_MAXIMUM_HOLD_TIME = 200L +const val DOUBLE_TAB_MINIMUM_TIME_BETWEEN = 200L // this also affects the UI show response time +const val DOUBLE_TAB_PAUSE_PERCENTAGE = 0.15 // in both directions private const val SUBTITLE_DELAY_BUNDLE_KEY = "subtitle_delay" // All the UI Logic for the player -@OptIn(UnstableApi::class) -open class FullScreenPlayer : AbstractPlayerFragment( - BindingCreator.Bind(FragmentPlayerBinding::bind) -) { - override fun pickLayout(): Int = R.layout.fragment_player +open class FullScreenPlayer : AbstractPlayerFragment() { + private var isVerticalOrientation: Boolean = false protected open var lockRotation = true + protected open var isFullScreenPlayer = true protected var playerBinding: PlayerCustomLayoutBinding? = null + private var durationMode: Boolean by UserPreferenceDelegate("duration_mode", false) + // state of player UI protected var isShowing = false protected var isLocked = false - protected var timestampShowState = false - private var metadataVisibilityToken = 0 + protected var hasEpisodes = false private set + //protected val hasEpisodes + // get() = episodes.isNotEmpty() + + // options for player /** * Default profile 1 @@ -93,13 +113,21 @@ open class FullScreenPlayer : AbstractPlayerFragment( **/ protected var currentQualityProfile = 1 + // protected var currentPrefQuality = +// Qualities.P2160.value // preferred maximum quality, used for ppl w bad internet or on cell + protected var fastForwardTime = 10000L protected var androidTVInterfaceOffSeekTime = 10000L protected var androidTVInterfaceOnSeekTime = 30000L + protected var swipeHorizontalEnabled = false + protected var swipeVerticalEnabled = false protected var playBackSpeedEnabled = false protected var playerResizeEnabled = false + protected var doubleTapEnabled = false + protected var doubleTapPauseEnabled = true protected var playerRotateEnabled = false - protected var rotatedManually = false + protected var autoPlayerRotateEnabled = false private var hideControlsNames = false + protected var subtitleDelay set(value) = try { player.setSubtitleOffset(-value) @@ -113,115 +141,47 @@ open class FullScreenPlayer : AbstractPlayerFragment( 0L } - private var isShowingEpisodeOverlay: Boolean = false - private var previousPlayStatus: Boolean = false + //private var useSystemBrightness = false + protected var useTrueSystemBrightness = true + private val fullscreenNotch = true //TODO SETTING - override fun fixLayout(view: View) = Unit + private var statusBarHeight: Int? = null + private var navigationBarHeight: Int? = null - /** - * Wet code but this can not be made into a function as it is a setter. - * - * The reason for this setter is to fix a bug with the titlecard popup, as we want it to autohide - * when pressing back. - * - * Note that we move the call to autoHide after field assignment with prevField to avoid inf recursion. */ - protected var selectSourceDialog: Dialog? = null - set(value) { - val prevField = field - field = value - if (value == null && prevField != null) { - autoHide() - } - } - protected var selectTrackDialog: Dialog? = null - set(value) { - val prevField = field - field = value - if (value == null && prevField != null) { - autoHide() - } - } - protected var selectSpeedDialog: Dialog? = null - set(value) { - val prevField = field - field = value - if (value == null && prevField != null) { - autoHide() - } - } - protected var selectSubtitlesDialog: Dialog? = null - set(value) { - val prevField = field - field = value - if (value == null && prevField != null) { - autoHide() - } - } + private val brightnessIcons = listOf( + R.drawable.sun_1, + R.drawable.sun_2, + R.drawable.sun_3, + R.drawable.sun_4, + R.drawable.sun_5, + R.drawable.sun_6, + //R.drawable.sun_7, + // R.drawable.ic_baseline_brightness_1_24, + // R.drawable.ic_baseline_brightness_2_24, + // R.drawable.ic_baseline_brightness_3_24, + // R.drawable.ic_baseline_brightness_4_24, + // R.drawable.ic_baseline_brightness_5_24, + // R.drawable.ic_baseline_brightness_6_24, + // R.drawable.ic_baseline_brightness_7_24, + ) - /** Checks if any top level dialog is open and showing */ - fun isDialogOpen() = - selectSourceDialog?.isShowing == true - || selectTrackDialog?.isShowing == true - || selectSpeedDialog?.isShowing == true - || selectSubtitlesDialog?.isShowing == true - || isShowingEpisodeOverlay + private val volumeIcons = listOf( + R.drawable.ic_baseline_volume_mute_24, + R.drawable.ic_baseline_volume_down_24, + R.drawable.ic_baseline_volume_up_24, + ) - private fun scheduleMetadataVisibility() { - val metadataScrim = playerBinding?.playerMetadataScrim ?: return - val ctx = metadataScrim.context ?: return - - if (!ctx.shouldShowPlayerMetadata()) { - metadataScrim.isVisible = false - metadataVisibilityToken++ - return - } - - if (isLayout(PHONE)) { - metadataScrim.isVisible = false - metadataVisibilityToken++ - return - } - - val isPaused = currentPlayerStatus == CSPlayerLoading.IsPaused - val token = ++metadataVisibilityToken - - if (isPaused) { - metadataScrim.postDelayed({ - /** Make sure the user has not interacted with anything */ - if (token != metadataVisibilityToken) return@postDelayed - /** If already visible, then do not rerun the animation */ - if (metadataScrim.isVisible) return@postDelayed - /** Failsafe, as this should only be shown when paused */ - if (currentPlayerStatus != CSPlayerLoading.IsPaused) return@postDelayed - /** We do not want to show the logo in the background when the user is within another screen */ - if (isDialogOpen()) return@postDelayed - - metadataScrim.alpha = 0f - metadataScrim.isVisible = true - metadataScrim.animate() - .alpha(1f) - .setDuration(500L) - .setInterpolator(DecelerateInterpolator()) - .start() - hidePlayerUI() - }, 8000L) - } else { - if (metadataScrim.isVisible) { - metadataScrim.animate() - .alpha(0f) - .setDuration(300L) - .setInterpolator(AccelerateDecelerateInterpolator()) - .withEndAction { - metadataScrim.alpha = 0f // force final state - metadataScrim.isVisible = false - } - .start() - } - } + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { + val root = super.onCreateView(inflater, container, savedInstanceState) ?: return null + playerBinding = PlayerCustomLayoutBinding.bind(root.findViewById(R.id.player_holder)) + return root } override fun onDestroyView() { - playerHostView?.releaseOverlayLayoutListener() playerBinding = null super.onDestroyView() } @@ -242,51 +202,27 @@ open class FullScreenPlayer : AbstractPlayerFragment( throw NotImplementedError() } - open fun showEpisodesOverlay() { - throw NotImplementedError() - } - - open fun isThereEpisodes(): Boolean { - return false + /** Returns false if the touch is on the status bar or navigation bar*/ + private fun isValidTouch(rawX: Float, rawY: Float): Boolean { + val statusHeight = statusBarHeight ?: 0 + // val navHeight = navigationBarHeight ?: 0 + // nav height is removed because screenWidth already takes into account that + return rawY > statusHeight && rawX < screenWidth //- navHeight } override fun exitedPipMode() { animateLayoutChanges() } - private fun animateLayoutChangesForSubtitles() = - // Post here as bottomPlayerBar is gone the first frame => bottomPlayerBar.height = 0 - playerBinding?.bottomPlayerBar?.post { - val sView = subView ?: return@post - val sStyle = CustomDecoder.style - val binding = playerBinding ?: return@post - - val move = if (isShowing) minOf( - // We do not want to drag down subtitles if the subtitle elevation is large - -sStyle.elevation.toPx, - // The lib uses Invisible instead of Gone for no reason - binding.previewFrameLayout.height - binding.bottomPlayerBar.height - ) else -sStyle.elevation.toPx - ObjectAnimator.ofFloat(sView, "translationY", move.toFloat()).apply { - duration = 200 - start() - } - } - protected fun animateLayoutChanges() { - 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?.playerVideoTitleHolder?.let { + playerBinding?.playerVideoTitle?.let { ObjectAnimator.ofFloat(it, "translationY", titleMove).apply { duration = 200 start() @@ -298,19 +234,6 @@ open class FullScreenPlayer : AbstractPlayerFragment( start() } } - playerBinding?.playerVideoInfo?.let { - ObjectAnimator.ofFloat(it, "translationY", titleMove).apply { - duration = 200 - start() - } - } - playerBinding?.playerMetadataScrim?.let { - ObjectAnimator.ofFloat(it, "translationY", 1f).apply { - duration = 200 - start() - } - } - val playerBarMove = if (isShowing) 0f else 50.toPx.toFloat() playerBinding?.bottomPlayerBar?.let { ObjectAnimator.ofFloat(it, "translationY", playerBarMove).apply { @@ -318,22 +241,24 @@ 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) fadeAnimation.duration = 100 fadeAnimation.fillAfter = true - animateLayoutChangesForSubtitles() + @OptIn(UnstableApi::class) + val sView = subView + val sStyle = subStyle + if (sView != null && sStyle != null) { + val move = if (isShowing) -((playerBinding?.bottomPlayerBar?.height?.toFloat() + ?: 0f) + 40.toPx) else -sStyle.elevation.toPx.toFloat() + ObjectAnimator.ofFloat(sView, "translationY", move).apply { + duration = 200 + start() + } + } val playerSourceMove = if (isShowing) 0f else -50.toPx.toFloat() @@ -346,10 +271,24 @@ open class FullScreenPlayer : AbstractPlayerFragment( } if (!isLocked) { - playerHostView?.gestureHelper?.animateCenterControls(fadeTo) + playerFfwdHolder.alpha = 1f + playerRewHolder.alpha = 1f + // player_pause_play_holder?.alpha = 1f shadowOverlay.isVisible = true shadowOverlay.startAnimation(fadeAnimation) - downloadBothHeader.startAnimation(fadeAnimation) + playerFfwdHolder.startAnimation(fadeAnimation) + playerRewHolder.startAnimation(fadeAnimation) + playerPausePlay.startAnimation(fadeAnimation) + + /*if (isBuffering) { + player_pause_play?.isVisible = false + player_pause_play_holder?.isVisible = false + } else { + player_pause_play?.isVisible = true + player_pause_play_holder?.startAnimation(fadeAnimation) + player_pause_play?.startAnimation(fadeAnimation) + }*/ + //player_buffering?.startAnimation(fadeAnimation) } bottomPlayerBar.startAnimation(fadeAnimation) @@ -361,7 +300,7 @@ open class FullScreenPlayer : AbstractPlayerFragment( override fun subtitlesChanged() { val tracks = player.getVideoTracks() val isBuiltinSubtitles = tracks.currentTextTracks.all { track -> - track.sampleMimeType == MimeTypes.APPLICATION_MEDIA3_CUES + track.mimeType == MimeTypes.APPLICATION_MEDIA3_CUES } // Subtitle offset is not possible on built-in media3 tracks playerBinding?.playerSubtitleOffsetBtt?.isGone = @@ -377,7 +316,7 @@ open class FullScreenPlayer : AbstractPlayerFragment( Configuration.ORIENTATION_PORTRAIT -> ActivityInfo.SCREEN_ORIENTATION_SENSOR_PORTRAIT - else -> playerHostView?.dynamicOrientation() ?: return + else -> dynamicOrientation() } activity.requestedOrientation = orientation } @@ -391,14 +330,14 @@ open class FullScreenPlayer : AbstractPlayerFragment( Configuration.ORIENTATION_PORTRAIT -> ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE - else -> playerHostView?.dynamicOrientation() ?: return + else -> dynamicOrientation() } activity.requestedOrientation = orientation } - private fun lockOrientation(activity: Activity) { + open fun lockOrientation(activity: Activity) { + @Suppress("DEPRECATION") val display = if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) - @Suppress("DEPRECATION") (activity.getSystemService(Context.WINDOW_SERVICE) as WindowManager).defaultDisplay else activity.display!! val rotation = display.rotation @@ -419,7 +358,7 @@ open class FullScreenPlayer : AbstractPlayerFragment( else ActivityInfo.SCREEN_ORIENTATION_REVERSE_PORTRAIT - else -> orientation = playerHostView?.dynamicOrientation() ?: return + else -> orientation = dynamicOrientation() } activity.requestedOrientation = orientation } @@ -430,63 +369,53 @@ open class FullScreenPlayer : AbstractPlayerFragment( if (isLocked) { lockOrientation(this) } else { - if (ignoreDynamicOrientation || rotatedManually) { - // Restore when lock is disabled. + if (ignoreDynamicOrientation) { + // restore when lock is disabled restoreOrientationWithSensor(this) } else { - this.requestedOrientation = - playerHostView?.dynamicOrientation() ?: return@apply + this.requestedOrientation = dynamicOrientation() } } } } } - private fun setupKeyEventListener() { - keyEventListener = { (event, hasNavigated) -> - when { - event == null -> false - event.action == KeyEvent.ACTION_DOWN && - (event.keyCode == KeyEvent.KEYCODE_VOLUME_UP || - event.keyCode == KeyEvent.KEYCODE_VOLUME_DOWN) -> - playerHostView?.handleVolumeKey(event.keyCode) ?: false - - player.isActive() -> handleKeyEvent(event, hasNavigated) - else -> false + protected fun enterFullscreen() { + if (isFullScreenPlayer) { + activity?.hideSystemUI() + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P && fullscreenNotch) { + val params = activity?.window?.attributes + params?.layoutInDisplayCutoutMode = LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES + activity?.window?.attributes = params } } + updateOrientation() + } + + protected fun exitFullscreen() { + //if (lockRotation) + activity?.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_USER + + // simply resets brightness and notch settings that might have been overridden + val lp = activity?.window?.attributes + lp?.screenBrightness = WindowManager.LayoutParams.BRIGHTNESS_OVERRIDE_NONE + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + lp?.layoutInDisplayCutoutMode = + WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_DEFAULT + } + activity?.window?.attributes = lp + activity?.showSystemUI() } override fun onResume() { - playerHostView?.enterFullscreen { updateOrientation() } - setupKeyEventListener() - playerHostView?.verifyVolume() - 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") - } - } - playerHostView?.requestUpdateBrightnessOverlayOnNextLayout() + enterFullscreen() super.onResume() } - override fun onStop() { - activity?.detachBackPressedCallback("FullScreenPlayer") - super.onStop() - } - override fun onDestroy() { - playerHostView?.exitFullscreen() + exitFullscreen() + player.release() + player.releaseCallbacks() super.onDestroy() } @@ -519,21 +448,30 @@ open class FullScreenPlayer : AbstractPlayerFragment( val binding = SubtitleOffsetBinding.inflate(LayoutInflater.from(ctx), null, false) // Use dialog as opposed to alertdialog to get fullscreen - val dialog = Dialog(ctx, R.style.DialogFullscreenPlayer).apply { + val dialog = Dialog(ctx, R.style.AlertDialogCustomBlack).apply { setContentView(binding.root) } - this.selectSubtitlesDialog = dialog dialog.show() - val isPortrait = - ctx.resources.configuration.orientation == Configuration.ORIENTATION_PORTRAIT - fixSystemBarsPadding(binding.root, fixIme = isPortrait) + val beforeOffset = subtitleDelay - var currentOffset = subtitleDelay binding.apply { + var subtitleAdapter: SubtitleOffsetItemAdapter? = null + subtitleOffsetInput.doOnTextChanged { text, _, _, _ -> text?.toString()?.toLongOrNull()?.let { time -> - currentOffset = time + subtitleDelay = time + + // Scroll to the first active subtitle + val playerPosition = player.getPosition() ?: 0 + val totalPosition = playerPosition - subtitleDelay + subtitleAdapter?.updateTime(totalPosition) + + subtitleAdapter?.getLatestActiveItem(totalPosition) + ?.let { subtitlePos -> + subtitleOffsetRecyclerview.scrollToPosition(subtitlePos) + } + val str = when { time > 0L -> { txt(R.string.subtitle_offset_extra_hint_later_format, time) @@ -551,27 +489,24 @@ open class FullScreenPlayer : AbstractPlayerFragment( } } subtitleOffsetInput.text = - Editable.Factory.getInstance()?.newEditable(currentOffset.toString()) + Editable.Factory.getInstance()?.newEditable(beforeOffset.toString()) val subtitles = player.getSubtitleCues().toMutableList() subtitleOffsetRecyclerview.isVisible = subtitles.isNotEmpty() noSubtitlesLoadedNotice.isVisible = subtitles.isEmpty() - val initialSubtitlePosition = (player.getPosition() ?: 0) - currentOffset - val subtitleAdapter = - SubtitleOffsetItemAdapter(initialSubtitlePosition) { subtitleCue -> + val initialSubtitlePosition = (player.getPosition() ?: 0) - subtitleDelay + subtitleAdapter = + SubtitleOffsetItemAdapter(initialSubtitlePosition, subtitles) { subtitleCue -> val playerPosition = player.getPosition() ?: 0 subtitleOffsetInput.text = Editable.Factory.getInstance() ?.newEditable((playerPosition - subtitleCue.startTimeMs).toString()) - }.apply { - submitList(subtitles) } subtitleOffsetRecyclerview.adapter = subtitleAdapter // Prevent flashing changes when changing items - (subtitleOffsetRecyclerview.itemAnimator as? SimpleItemAnimator)?.supportsChangeAnimations = - false + (subtitleOffsetRecyclerview.itemAnimator as? SimpleItemAnimator)?.supportsChangeAnimations = false val firstSubtitle = subtitleAdapter.getLatestActiveItem(initialSubtitlePosition) subtitleOffsetRecyclerview.scrollToPosition(firstSubtitle) @@ -599,109 +534,147 @@ open class FullScreenPlayer : AbstractPlayerFragment( } dialog.setOnDismissListener { - selectSubtitlesDialog = null - activity?.hideSystemUI() + if (isFullScreenPlayer) + activity?.hideSystemUI() } applyBtt.setOnClickListener { - selectSubtitlesDialog = null - subtitleDelay = currentOffset dialog.dismissSafe(activity) player.seekTime(1L) } resetBtt.setOnClickListener { - selectSubtitlesDialog = null subtitleDelay = 0 dialog.dismissSafe(activity) player.seekTime(1L) } cancelBtt.setOnClickListener { - selectSubtitlesDialog = null + subtitleDelay = beforeOffset dialog.dismissSafe(activity) } } } - @SuppressLint("SetTextI18n") - fun updateSpeedDialogBinding(binding: SpeedDialogBinding) { - val speed = player.getPlaybackSpeed() - binding.speedText.text = "%.2fx".format(speed).replace(".0x", "x") - // Android crashes if you don't round to an exact step size - binding.speedBar.value = - (speed.coerceIn(0.1f, 2.0f) / binding.speedBar.stepSize).roundToInt() - .toFloat() * binding.speedBar.stepSize - } private fun showSpeedDialog() { - val act = activity ?: return - val isPlaying = player.getIsPlaying() - player.handleEvent(CSPlayerEvent.Pause, PlayerEventSource.UI) + val speedsText = + listOf( + "0.5x", + "0.75x", + "0.85x", + "1x", + "1.15x", + "1.25x", + "1.4x", + "1.5x", + "1.75x", + "2x" + ) + val speedsNumbers = + listOf(0.5f, 0.75f, 0.85f, 1f, 1.15f, 1.25f, 1.4f, 1.5f, 1.75f, 2f) + val speedIndex = speedsNumbers.indexOf(player.getPlaybackSpeed()) - val binding: SpeedDialogBinding = SpeedDialogBinding.inflate( - LayoutInflater.from(act) - ) - - updateSpeedDialogBinding(binding) - for ((view, speed) in arrayOf( - binding.speed25 to 0.25f, - binding.speed100 to 1.0f, - binding.speed125 to 1.25f, - binding.speed150 to 1.5f, - binding.speed200 to 2.0f, - )) { - view.setOnClickListener { - setPlayBackSpeed(speed) - updateSpeedDialogBinding(binding) + activity?.let { act -> + act.showDialog( + speedsText, + speedIndex, + act.getString(R.string.player_speed), + false, + { + if (isFullScreenPlayer) + activity?.hideSystemUI() + }) { index -> + if (isFullScreenPlayer) + activity?.hideSystemUI() + setPlayBackSpeed(speedsNumbers[index]) } } + } - binding.speedMinus.setOnClickListener { - setPlayBackSpeed(maxOf((player.getPlaybackSpeed() - 0.1f), 0.1f)) - updateSpeedDialogBinding(binding) - } + fun resetRewindText() { + playerBinding?.exoRewText?.text = + getString(R.string.rew_text_regular_format).format(fastForwardTime / 1000) + } - binding.speedPlus.setOnClickListener { - setPlayBackSpeed(minOf((player.getPlaybackSpeed() + 0.1f), 2.0f)) - updateSpeedDialogBinding(binding) - } + fun resetFastForwardText() { + playerBinding?.exoFfwdText?.text = + getString(R.string.ffw_text_regular_format).format(fastForwardTime / 1000) + } - binding.speedBar.addOnChangeListener { _, value, fromUser -> - if (fromUser) { - setPlayBackSpeed(value) - updateSpeedDialogBinding(binding) + private fun rewind() { + try { + playerBinding?.apply { + playerCenterMenu.isGone = false + playerRewHolder.alpha = 1f + + val rotateLeft = AnimationUtils.loadAnimation(context, R.anim.rotate_left) + playerRew.startAnimation(rotateLeft) + + val goLeft = AnimationUtils.loadAnimation(context, R.anim.go_left) + goLeft.setAnimationListener(object : Animation.AnimationListener { + override fun onAnimationStart(animation: Animation?) {} + + override fun onAnimationRepeat(animation: Animation?) {} + + override fun onAnimationEnd(animation: Animation?) { + exoRewText.post { + resetRewindText() + playerCenterMenu.isGone = !isShowing + playerRewHolder.alpha = if (isShowing) 1f else 0f + } + } + }) + exoRewText.startAnimation(goLeft) + exoRewText.text = + getString(R.string.rew_text_format).format(fastForwardTime / 1000) } + player.seekTime(-fastForwardTime) + } catch (e: Exception) { + logError(e) } + } - val dismiss = DialogInterface.OnDismissListener { - activity?.hideSystemUI() - if (isPlaying) { - player.handleEvent(CSPlayerEvent.Play, PlayerEventSource.UI) + private fun fastForward() { + try { + playerBinding?.apply { + playerCenterMenu.isGone = false + playerFfwdHolder.alpha = 1f + + val rotateRight = AnimationUtils.loadAnimation(context, R.anim.rotate_right) + playerFfwd.startAnimation(rotateRight) + + val goRight = AnimationUtils.loadAnimation(context, R.anim.go_right) + goRight.setAnimationListener(object : Animation.AnimationListener { + override fun onAnimationStart(animation: Animation?) {} + + override fun onAnimationRepeat(animation: Animation?) {} + + override fun onAnimationEnd(animation: Animation?) { + exoFfwdText.post { + resetFastForwardText() + playerCenterMenu.isGone = !isShowing + playerFfwdHolder.alpha = if (isShowing) 1f else 0f + } + } + }) + exoFfwdText.startAnimation(goRight) + exoFfwdText.text = + getString(R.string.ffw_text_format).format(fastForwardTime / 1000) } - selectSpeedDialog = null + player.seekTime(fastForwardTime) + } catch (e: Exception) { + logError(e) } - - // if (isLayout(PHONE)) { - // val builder = - // BottomSheetDialog(act, R.style.AlertDialogCustom) - // builder.setContentView(binding.root) - // builder.setOnDismissListener(dismiss) - // builder.show() - //} else { - val builder = - AlertDialog.Builder(act, R.style.AlertDialogCustom) - .setView(binding.root) - builder.setOnDismissListener(dismiss) - val dialog = builder.create() - this.selectSpeedDialog = dialog - dialog.show() - //} } private fun onClickChange() { isShowing = !isShowing - if (isShowing) autoHide() - activity?.hideSystemUI() + if (isShowing) { + playerBinding?.playerIntroPlay?.isGone = true + autoHide() + } + if (isFullScreenPlayer) + activity?.hideSystemUI() animateLayoutChanges() - if (playerBinding?.playerEpisodeOverlay?.isGone == true) playerBinding?.playerPausePlay?.requestFocus() + playerBinding?.playerPausePlay?.requestFocus() } private fun toggleLock() { @@ -710,7 +683,6 @@ open class FullScreenPlayer : AbstractPlayerFragment( } isLocked = !isLocked - playerHostView?.isLocked = isLocked updateOrientation(true) // set true to ignore auto rotate to stay in current orientation if (isLocked && isShowing) { @@ -722,37 +694,40 @@ open class FullScreenPlayer : AbstractPlayerFragment( } val fadeTo = if (isLocked) 0f else 1f - playerHostView?.gestureHelper?.animateCenterControls(fadeTo) playerBinding?.apply { - val fadeAnimation = AlphaAnimation(playerVideoTitleHolder.alpha, fadeTo).apply { + val fadeAnimation = AlphaAnimation(playerVideoTitle.alpha, fadeTo).apply { duration = 100 fillAfter = true } updateUIVisibility() - downloadBothHeader.startAnimation(fadeAnimation) + // MENUS + //centerMenu.startAnimation(fadeAnimation) + playerPausePlay.startAnimation(fadeAnimation) + playerFfwdHolder.startAnimation(fadeAnimation) + playerRewHolder.startAnimation(fadeAnimation) - if (hasEpisodes) - playerEpisodesButton.startAnimation(fadeAnimation) - // player_media_route_button?.startAnimation(fadeAnimation) - // video_bar.startAnimation(fadeAnimation) + //if (hasEpisodes) + // player_episodes_button?.startAnimation(fadeAnimation) + //player_media_route_button?.startAnimation(fadeAnimation) + //video_bar.startAnimation(fadeAnimation) - // TITLE + //TITLE playerVideoTitleRez.startAnimation(fadeAnimation) - playerVideoInfo.startAnimation(fadeAnimation) playerEpisodeFiller.startAnimation(fadeAnimation) - playerVideoTitleHolder.startAnimation(fadeAnimation) + playerVideoTitle.startAnimation(fadeAnimation) playerTopHolder.startAnimation(fadeAnimation) // BOTTOM playerLockHolder.startAnimation(fadeAnimation) - // player_go_back_holder?.startAnimation(fadeAnimation) + //player_go_back_holder?.startAnimation(fadeAnimation) + shadowOverlay.isVisible = true shadowOverlay.startAnimation(fadeAnimation) } updateLockUI() } - private fun updateUIVisibility() { + open fun updateUIVisibility() { val isGone = isLocked || !isShowing var togglePlayerTitleGone = isGone context?.let { @@ -763,23 +738,22 @@ open class FullScreenPlayer : AbstractPlayerFragment( } } playerBinding?.apply { + playerLockHolder.isGone = isGone playerVideoBar.isGone = isGone - playerPausePlayHolderHolder.isGone = - isGone || currentPlayerStatus == CSPlayerLoading.IsBuffering + playerPausePlay.isGone = isGone + //player_buffering?.isGone = isGone playerTopHolder.isGone = isGone - val showPlayerEpisodes = !isGone && isThereEpisodes() - playerEpisodesButtonRoot.isVisible = showPlayerEpisodes - playerEpisodesButton.isVisible = showPlayerEpisodes - playerVideoTitleHolder.isGone = togglePlayerTitleGone - playerVideoTitleRez.isGone = isGone || playerVideoTitleRez.text.isBlank() + //player_episodes_button?.isVisible = !isGone && hasEpisodes + playerVideoTitle.isGone = togglePlayerTitleGone +// player_video_title_rez?.isGone = isGone playerEpisodeFiller.isGone = isGone playerCenterMenu.isGone = isGone playerLock.isGone = !isShowing + //player_media_route_button?.isClickable = !isGone playerGoBackHolder.isGone = isGone playerSourcesBtt.isGone = isGone - shadowOverlay.isGone = isGone playerSkipEpisode.isClickable = !isGone } } @@ -787,293 +761,533 @@ open class FullScreenPlayer : AbstractPlayerFragment( private fun updateLockUI() { playerBinding?.apply { playerLock.setIconResource(if (isLocked) R.drawable.video_locked else R.drawable.video_unlocked) - val color = if (isLocked) context?.colorFromAttribute(R.attr.colorPrimary) - else Color.WHITE - if (color != null) { - playerLock.setTextColor(color) - playerLock.iconTint = ColorStateList.valueOf(color) - playerLock.rippleColor = - ColorStateList.valueOf(Color.argb(50, color.red, color.green, color.blue)) + if (layout == R.layout.fragment_player) { + val color = if (isLocked) context?.colorFromAttribute(R.attr.colorPrimary) + else Color.WHITE + if (color != null) { + playerLock.setTextColor(color) + playerLock.iconTint = ColorStateList.valueOf(color) + playerLock.rippleColor = + ColorStateList.valueOf(Color.argb(50, color.red, color.green, color.blue)) + } } } } + private var currentTapIndex = 0 protected fun autoHide() { - metadataVisibilityToken++ - playerHostView?.scheduleAutoHide() - scheduleMetadataVisibility() - } - - override fun onAutoHideUI() { - if (player.getIsPlaying()) onClickChange() - } - - protected fun hidePlayerUI() { - if (isShowing) { - isShowing = false - animateLayoutChanges() - } - } - - /** PlayerView.Callbacks touch overrides */ - - override fun isUIShowing(): Boolean = isShowing - - override fun onSingleTap() { - onClickChange() - } - - override fun onTouchDown() { - if (isShowingEpisodeOverlay) toggleEpisodesOverlay(show = false) - } - - @SuppressLint("SetTextI18n") - override fun onSeekPreviewText(text: String?) { - playerBinding?.playerTimeText?.apply { - isVisible = text != null - if (text != null) this.text = text - } - } - - override fun onHidePlayerUI() { - hidePlayerUI() - } - - override fun onGestureEnd(hadSwipe: Boolean, wasUiShowing: Boolean) { - if (!player.getIsPlaying() && hadSwipe && wasUiShowing && !isShowing) { - isShowing = true - animateLayoutChanges() - } - autoHide() + currentTapIndex++ + delayHide() } override fun playerStatusChanged() { super.playerStatusChanged() - scheduleMetadataVisibility() + delayHide() } - // When the hold-speedup gesture fires, hide controls so the video is unobstructed. - // The speedup button show/hide and speed change are handled by PlayerView. - override fun onHoldSpeedUp(show: Boolean) { - if (show && isShowing) onClickChange() + private fun delayHide() { + val index = currentTapIndex + playerBinding?.playerHolder?.postDelayed({ + if (!isCurrentTouchValid && isShowing && index == currentTapIndex && player.getIsPlaying()) { + onClickChange() + } + }, 2000) } - override fun onConfigurationChanged(newConfig: Configuration) { - super.onConfigurationChanged(newConfig) + // this is used because you don't want to hide UI when double tap seeking + private var currentDoubleTapIndex = 0 + private fun toggleShowDelayed() { + if (doubleTapEnabled || doubleTapPauseEnabled) { + val index = currentDoubleTapIndex + playerBinding?.playerHolder?.postDelayed({ + if (index == currentDoubleTapIndex) { + onClickChange() + } + }, DOUBLE_TAB_MINIMUM_TIME_BETWEEN) + } else { + onClickChange() + } + } - // If we rotate the device we need to recalculate the zoom - val gh = playerHostView?.gestureHelper ?: return - val matrix = gh.zoomMatrix - val animation = gh.matrixAnimation - if ((animation == null || !animation.isRunning) && matrix != null) { - // Ignore if we have no zoom or mid-animation - playerView?.post { - gh.applyZoomMatrix(matrix, true) - playerHostView?.requestUpdateBrightnessOverlayOnNextLayout() + private var isCurrentTouchValid = false + private var currentTouchStart: Vector2? = null + private var currentTouchLast: Vector2? = null + private var currentTouchAction: TouchAction? = null + private var currentLastTouchAction: TouchAction? = null + private var currentTouchStartPlayerTime: Long? = + null // the time in the player when you first click + private var currentTouchStartTime: Long? = null // the system time when you first click + private var currentLastTouchEndTime: Long = 0 // the system time when you released your finger + private var currentClickCount: Int = + 0 // amount of times you have double clicked, will reset when other action is taken + + // requested volume and brightness is used to make swiping smoother + // to make it not jump between values, + // this value is within the range [0,1] + private var currentRequestedVolume: Float = 0.0f + private var currentRequestedBrightness: Float = 1.0f + + enum class TouchAction { + Brightness, + Volume, + Time, + } + + companion object { + private fun forceLetters(inp: Long, letters: Int = 2): String { + val added: Int = letters - inp.toString().length + return if (added > 0) { + "0".repeat(added) + inp.toString() + } else { + inp.toString() + } + } + + private fun convertTimeToString(sec: Long): String { + val rsec = sec % 60L + val min = ceil((sec - rsec) / 60.0).toInt() + val rmin = min % 60L + val h = ceil((min - rmin) / 60.0).toLong() + //int rh = h;// h % 24; + return (if (h > 0) forceLetters(h) + ":" else "") + (if (rmin >= 0 || h >= 0) forceLetters( + rmin + ) + ":" else "") + forceLetters( + rsec + ) + } + } + + private fun calculateNewTime( + startTime: Long?, + touchStart: Vector2?, + touchEnd: Vector2? + ): Long? { + if (touchStart == null || touchEnd == null || startTime == null) return null + val diffX = (touchEnd.x - touchStart.x) * HORIZONTAL_MULTIPLIER / screenWidth.toFloat() + val duration = player.getDuration() ?: return null + return max( + min( + startTime + ((duration * (diffX * diffX)) * (if (diffX < 0) -1 else 1)).toLong(), + duration + ), 0 + ) + } + + private fun getBrightness(): Float? { + return if (useTrueSystemBrightness) { + try { + Settings.System.getInt( + context?.contentResolver, + Settings.System.SCREEN_BRIGHTNESS + ) / 255f + } catch (e: Exception) { + // because true system brightness requires + // permission, this is a lazy way to check + // as it will throw an error if we do not have it + useTrueSystemBrightness = false + return getBrightness() + } + } else { + try { + activity?.window?.attributes?.screenBrightness + } catch (e: Exception) { + logError(e) + null } } } - override fun resize(resize: PlayerResize, showToast: Boolean) { - super.resize(resize, showToast) - playerHostView?.requestUpdateBrightnessOverlayOnNextLayout() + private fun setBrightness(brightness: Float) { + if (useTrueSystemBrightness) { + try { + Settings.System.putInt( + context?.contentResolver, + Settings.System.SCREEN_BRIGHTNESS_MODE, + Settings.System.SCREEN_BRIGHTNESS_MODE_MANUAL + ) + + Settings.System.putInt( + context?.contentResolver, + Settings.System.SCREEN_BRIGHTNESS, (brightness * 255).toInt() + ) + } catch (e: Exception) { + useTrueSystemBrightness = false + setBrightness(brightness) + } + } else { + try { + val lp = activity?.window?.attributes + lp?.screenBrightness = brightness + activity?.window?.attributes = lp + } catch (e: Exception) { + logError(e) + } + } } - private fun handleKeyDownEvent(keyCode: Int): Boolean? { - // adb shell input keyevent [INT] - when (keyCode) { - KeyEvent.KEYCODE_FORWARD, KeyEvent.KEYCODE_D, KeyEvent.KEYCODE_MEDIA_SKIP_FORWARD, KeyEvent.KEYCODE_MEDIA_FAST_FORWARD -> { - player.handleEvent(CSPlayerEvent.SeekForward) - } + @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_A, KeyEvent.KEYCODE_MEDIA_SKIP_BACKWARD, KeyEvent.KEYCODE_MEDIA_REWIND -> { - player.handleEvent(CSPlayerEvent.SeekBack) - } + playerBinding?.apply { + playerIntroPlay.isGone = true - KeyEvent.KEYCODE_MEDIA_NEXT, KeyEvent.KEYCODE_BUTTON_R1, KeyEvent.KEYCODE_N, KeyEvent.KEYCODE_NUMPAD_2, KeyEvent.KEYCODE_CHANNEL_UP -> { - player.handleEvent(CSPlayerEvent.NextEpisode) - } + when (event.action) { + MotionEvent.ACTION_DOWN -> { + // validates if the touch is inside of the player area + isCurrentTouchValid = isValidTouch(currentTouch.x, currentTouch.y) + /*if (isCurrentTouchValid && player_episode_list?.isVisible == true) { + player_episode_list?.isVisible = false + } else*/ if (isCurrentTouchValid) { + currentTouchStartTime = System.currentTimeMillis() + currentTouchStart = currentTouch + currentTouchLast = currentTouch + currentTouchStartPlayerTime = player.getPosition() - KeyEvent.KEYCODE_MEDIA_PREVIOUS, KeyEvent.KEYCODE_BUTTON_L1, KeyEvent.KEYCODE_B, KeyEvent.KEYCODE_NUMPAD_1, KeyEvent.KEYCODE_CHANNEL_DOWN -> { - player.handleEvent(CSPlayerEvent.PrevEpisode) - } + getBrightness()?.let { + currentRequestedBrightness = it + } + (activity?.getSystemService(Context.AUDIO_SERVICE) as? AudioManager)?.let { audioManager -> + val currentVolume = + audioManager.getStreamVolume(AudioManager.STREAM_MUSIC) + val maxVolume = + audioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC) - KeyEvent.KEYCODE_MEDIA_PAUSE -> { - player.handleEvent(CSPlayerEvent.Pause) - } + currentRequestedVolume = currentVolume.toFloat() / maxVolume.toFloat() + } + } + } - KeyEvent.KEYCODE_MEDIA_PLAY, KeyEvent.KEYCODE_BUTTON_START -> { - player.handleEvent(CSPlayerEvent.Play) - } + MotionEvent.ACTION_UP -> { + 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_L, KeyEvent.KEYCODE_NUMPAD_7, KeyEvent.KEYCODE_7 -> { - toggleLock() - } + // 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++ - KeyEvent.KEYCODE_H -> { - onClickChange() - } + 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() + } - KeyEvent.KEYCODE_M, KeyEvent.KEYCODE_VOLUME_MUTE -> { - player.handleEvent(CSPlayerEvent.ToggleMute) - } + currentTouch.x > screenWidth / 2 + (DOUBLE_TAB_PAUSE_PERCENTAGE * screenWidth) -> { + if (doubleTapEnabled) + fastForward() + } - 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) {} + 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 + toggleShowDelayed() + //onClickChange() + } + } else { + currentClickCount = 0 + } + + // call auto hide as it wont hide when you have your finger down + autoHide() + + // reset variables + isCurrentTouchValid = false + currentTouchStart = null + currentLastTouchAction = currentTouchAction + currentTouchAction = null + currentTouchStartPlayerTime = null + currentTouchLast = null + currentTouchStartTime = null + + // resets UI + playerTimeText.isVisible = false + playerProgressbarLeftHolder.isVisible = false + playerProgressbarRightHolder.isVisible = false + + currentLastTouchEndTime = System.currentTimeMillis() + } + + MotionEvent.ACTION_MOVE -> { + // if current touch is valid + 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 + } + } + } + + // 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 + playerProgressbarLeftHolder.isVisible = false + playerProgressbarRightHolder.isVisible = false + + 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 + playerTimeText.apply { + text = + "${convertTimeToString(newMs / 1000)} [${ + (if (abs(skipMs) < 1000) "" else (if (skipMs > 0) "+" else "-")) + }${convertTimeToString(abs(skipMs / 1000))}]" + isVisible = true + } + } + } + } + + TouchAction.Brightness -> { + playerProgressbarRightHolder.isVisible = true + 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 -> { + (activity?.getSystemService(Context.AUDIO_SERVICE) as? AudioManager)?.let { audioManager -> + playerProgressbarLeftHolder.isVisible = true + val maxVolume = + audioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC) + val currentVolume = + audioManager.getStreamVolume(AudioManager.STREAM_MUSIC) + + // clamps volume and adds swipe + currentRequestedVolume = + min( + 1.0f, + max(currentRequestedVolume + verticalAddition, 0.0f) + ) + + // max is set high to make it smooth + playerProgressbarLeft.max = 100_000 + playerProgressbarLeft.progress = + max(2_000, (currentRequestedVolume * 100_000f).toInt()) + + playerProgressbarLeftIcon.setImageResource( + volumeIcons[min( // clamp the value just in case + volumeIcons.size - 1, + max( + 0, + round(currentRequestedVolume * (volumeIcons.size - 1)).toInt() + ) + )] + ) + + // this is used instead of set volume because old devices does not support it + val desiredVolume = + round(currentRequestedVolume * maxVolume).toInt() + if (desiredVolume != currentVolume) { + val newVolumeAdjusted = + if (desiredVolume < currentVolume) AudioManager.ADJUST_LOWER else AudioManager.ADJUST_RAISE + + audioManager.adjustStreamVolume( + AudioManager.STREAM_MUSIC, + newVolumeAdjusted, + 0 + ) + } + } + } + + else -> Unit + } + } + } } } - - KeyEvent.KEYCODE_E, KeyEvent.KEYCODE_NUMPAD_3, KeyEvent.KEYCODE_3 -> { - showSpeedDialog() - } - - KeyEvent.KEYCODE_R, KeyEvent.KEYCODE_NUMPAD_0, KeyEvent.KEYCODE_0 -> { - nextResize() - } - - KeyEvent.KEYCODE_C, KeyEvent.KEYCODE_NUMPAD_4, KeyEvent.KEYCODE_4 -> { - skipOp() - } - - KeyEvent.KEYCODE_V, KeyEvent.KEYCODE_NUMPAD_5, KeyEvent.KEYCODE_5 -> { - player.handleEvent(CSPlayerEvent.SkipCurrentChapter) - } - - KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE, KeyEvent.KEYCODE_P, KeyEvent.KEYCODE_SPACE, KeyEvent.KEYCODE_NUMPAD_ENTER -> { // space is not captured due to navigation - player.handleEvent(CSPlayerEvent.PlayPauseToggle) - } - - // KEYCODE_DPAD_CENTER and KEYCODE_ENTER both act as a "select/confirm" button. - // Some remotes (e.g. LG Magic Remote) send KEYCODE_ENTER instead of KEYCODE_DPAD_CENTER. - // When the player UI or a dialog is visible, we let the event pass through (return null) - // so the focused button/item can handle the click normally, rather than always toggling - // play/pause. Only when the UI is hidden do we treat it as a play/pause toggle. - KeyEvent.KEYCODE_DPAD_CENTER, - KeyEvent.KEYCODE_ENTER -> { - if (isShowing || isDialogOpen()) { - return null - } - // If UI is not shown make click instantly skip to next chapter even if locked - if (timestampShowState) { - player.handleEvent(CSPlayerEvent.SkipCurrentChapter) - } else if (!isLocked) { - player.handleEvent(CSPlayerEvent.PlayPauseToggle) - } - onClickChange() - } - - KeyEvent.KEYCODE_DPAD_DOWN, - KeyEvent.KEYCODE_DPAD_UP -> { - if (isShowing || isShowingEpisodeOverlay) { - return null - } - onClickChange() - } - - KeyEvent.KEYCODE_DPAD_LEFT -> { - if (!isShowing && !isLocked && !isShowingEpisodeOverlay) { - player.seekTime(-androidTVInterfaceOffSeekTime) - return true - } else if (playerBinding?.playerPausePlay?.isFocused == true) { - player.seekTime(-androidTVInterfaceOnSeekTime) - return true - } else { - return null - } - } - - KeyEvent.KEYCODE_DPAD_RIGHT -> { - if (!isShowing && !isLocked && !isShowingEpisodeOverlay) { - player.seekTime(androidTVInterfaceOffSeekTime) - } else if (playerBinding?.playerPausePlay?.isFocused == true) { - player.seekTime(androidTVInterfaceOnSeekTime) - } else { - return null - } - } - - KeyEvent.KEYCODE_VOLUME_DOWN, - KeyEvent.KEYCODE_VOLUME_UP -> { - // Handled entirely by PlayerView.handleVolumeKey (checks PHONE/EMULATOR). - if (playerHostView?.handleVolumeKey(keyCode) != true) { - return null - } - } - - KeyEvent.KEYCODE_MENU, - KeyEvent.KEYCODE_SETTINGS -> { - if (isLocked || !isThereEpisodes()) { - return null - } - toggleEpisodesOverlay(true) - } - else -> return null // Avoid capturing all input } + currentTouchLast = currentTouch return true } + @SuppressLint("GestureBackNavigation") private fun handleKeyEvent(event: KeyEvent, hasNavigated: Boolean): Boolean { if (hasNavigated) { autoHide() - return false - } - val keyCode = event.keyCode + } else { + event.keyCode.let { keyCode -> + when (event.action) { + KeyEvent.ACTION_DOWN -> { + when (keyCode) { + KeyEvent.KEYCODE_DPAD_CENTER -> { + if (!isShowing) { + if (!isLocked) player.handleEvent(CSPlayerEvent.PlayPauseToggle) + onClickChange() + return true + } + } - if (event.action == KeyEvent.ACTION_DOWN) { - val value = handleKeyDownEvent(keyCode) - if (value != null) { - return value - } - } + KeyEvent.KEYCODE_DPAD_DOWN, + KeyEvent.KEYCODE_DPAD_UP -> { + if (!isShowing) { + onClickChange() + return true + } + } - when (keyCode) { - // don't allow dpad move when hidden + 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_DOWN, - KeyEvent.KEYCODE_DPAD_UP, - KeyEvent.KEYCODE_DPAD_DOWN_LEFT, - KeyEvent.KEYCODE_DPAD_DOWN_RIGHT, - KeyEvent.KEYCODE_DPAD_UP_LEFT, - KeyEvent.KEYCODE_DPAD_UP_RIGHT -> { - if (!isShowing) { - return true - } else { - autoHide() + KeyEvent.KEYCODE_DPAD_RIGHT -> { + if (!isShowing && !isLocked) { + player.seekTime(androidTVInterfaceOffSeekTime) + return true + } else if (playerBinding?.playerPausePlay?.isFocused == true) { + player.seekTime(androidTVInterfaceOnSeekTime) + return true + } + } + } + } + } + + when (keyCode) { + // don't allow dpad move when hidden + + KeyEvent.KEYCODE_DPAD_DOWN, + KeyEvent.KEYCODE_DPAD_UP, + KeyEvent.KEYCODE_DPAD_DOWN_LEFT, + KeyEvent.KEYCODE_DPAD_DOWN_RIGHT, + KeyEvent.KEYCODE_DPAD_UP_LEFT, + KeyEvent.KEYCODE_DPAD_UP_RIGHT -> { + if (!isShowing) { + return true + } else { + autoHide() + } + } + + // netflix capture back and hide ~monke + KeyEvent.KEYCODE_BACK -> { + if (isShowing && isLayout(TV or EMULATOR)) { + onClickChange() + return true + } + } } } - - // netflix capture back and hide ~monke - // 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 } 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 - playerGoForwardRoot.isVisible = false + playerGoForward.isVisible = false playerTracksBtt.isVisible = false playerSkipOp.isVisible = false shadowOverlay.isVisible = false @@ -1081,8 +1295,8 @@ open class FullScreenPlayer : AbstractPlayerFragment( updateLockUI() updateUIVisibility() animateLayoutChanges() - playerHostView?.gestureHelper?.resetFastForwardText() - playerHostView?.gestureHelper?.resetRewindText() + resetFastForwardText() + resetRewindText() } override fun onSaveInstanceState(outState: Bundle) { @@ -1091,35 +1305,109 @@ open class FullScreenPlayer : AbstractPlayerFragment( super.onSaveInstanceState(outState) } - override fun onBindingCreated(binding: FragmentPlayerBinding, savedInstanceState: Bundle?) { - // Set up playerBinding before super initializes the player - // (brightness overlay is now injected by PlayerView.initialize()) - playerBinding = - PlayerCustomLayoutBinding.bind(binding.root.findViewById(R.id.player_holder)) - - super.onBindingCreated(binding, savedInstanceState) - - // This player is always full-screen; tell PlayerView so volume-key handling is active. - playerHostView?.isFullScreen = true - - // Wire up the snap-hint outline view and schedule brightness overlay bounds update - playerHostView?.videoOutline = playerBinding?.videoOutline - playerHostView?.requestUpdateBrightnessOverlayOnNextLayout() - - val view = binding.root + @SuppressLint("ClickableViewAccessibility") + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) // init variables setPlayBackSpeed(DataStoreHelper.playBackSpeed) savedInstanceState?.getLong(SUBTITLE_DELAY_BUNDLE_KEY)?.let { subtitleDelay = it } + // handle tv controls + playerEventListener = { eventType -> + when (eventType) { + PlayerEventType.Lock -> { + toggleLock() + } + + PlayerEventType.NextEpisode -> { + player.handleEvent(CSPlayerEvent.NextEpisode) + } + + PlayerEventType.Pause -> { + player.handleEvent(CSPlayerEvent.Pause) + } + + PlayerEventType.PlayPauseToggle -> { + player.handleEvent(CSPlayerEvent.PlayPauseToggle) + } + + PlayerEventType.Play -> { + player.handleEvent(CSPlayerEvent.Play) + } + + PlayerEventType.SkipCurrentChapter -> { + player.handleEvent(CSPlayerEvent.SkipCurrentChapter) + } + + PlayerEventType.Resize -> { + nextResize() + } + + PlayerEventType.PrevEpisode -> { + player.handleEvent(CSPlayerEvent.PrevEpisode) + } + + PlayerEventType.SeekForward -> { + player.handleEvent(CSPlayerEvent.SeekForward) + } + + PlayerEventType.ShowSpeed -> { + showSpeedDialog() + } + + PlayerEventType.SeekBack -> { + player.handleEvent(CSPlayerEvent.SeekBack) + } + + PlayerEventType.Restart -> { + player.handleEvent(CSPlayerEvent.Restart) + } + + PlayerEventType.ToggleMute -> { + player.handleEvent(CSPlayerEvent.ToggleMute) + } + + PlayerEventType.ToggleHide -> { + onClickChange() + } + + PlayerEventType.ShowMirrors -> { + showMirrorsDialogue() + } + + PlayerEventType.SearchSubtitlesOnline -> { + if (subsProvidersIsActive) { + openOnlineSubPicker(view.context, null) {} + } + } + + PlayerEventType.SkipOp -> { + skipOp() + } + } + } + // handle tv controls directly based on player state - setupKeyEventListener() + keyEventListener = { eventNav -> + // Don't hook player keys if player isn't active + if (player.isActive()) { + val (event, hasNavigated) = eventNav + if (event != null) + handleKeyEvent(event, hasNavigated) + else false + } else false + } try { context?.let { ctx -> val settingsManager = PreferenceManager.getDefaultSharedPreferences(ctx) + fastForwardTime = + settingsManager.getInt(ctx.getString(R.string.double_tap_seek_time_key), 10) + .toLong() * 1000L + androidTVInterfaceOffSeekTime = settingsManager.getInt( ctx.getString(R.string.android_tv_interface_off_seek_key), @@ -1133,6 +1421,16 @@ open class FullScreenPlayer : AbstractPlayerFragment( ) .toLong() * 1000L + navigationBarHeight = ctx.getNavigationBarHeight() + statusBarHeight = ctx.getStatusBarHeight() + + swipeHorizontalEnabled = + settingsManager.getBoolean(ctx.getString(R.string.swipe_enabled_key), true) + swipeVerticalEnabled = + settingsManager.getBoolean( + ctx.getString(R.string.swipe_vertical_enabled_key), + true + ) playBackSpeedEnabled = settingsManager.getBoolean( ctx.getString(R.string.playback_speed_enabled_key), false @@ -1141,15 +1439,28 @@ 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 ) - hideControlsNames = settingsManager.getBoolean( - ctx.getString(R.string.hide_player_control_names_key), - false - ) + 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) val profiles = QualityDataHelper.getProfiles() val type = if (ctx.isUsingMobileData()) @@ -1157,15 +1468,20 @@ open class FullScreenPlayer : AbstractPlayerFragment( else QualityDataHelper.QualityProfileType.WiFi currentQualityProfile = - profiles.firstOrNull { it.types.contains(type) }?.id - ?: profiles.firstOrNull()?.id - ?: currentQualityProfile + profiles.firstOrNull { it.type == type }?.id ?: profiles.firstOrNull()?.id + ?: currentQualityProfile + +// currentPrefQuality = settingsManager.getInt( +// ctx.getString(if (ctx.isUsingMobileData()) R.string.quality_pref_mobile_data_key else R.string.quality_pref_key), +// currentPrefQuality +// ) + // useSystemBrightness = + // settingsManager.getBoolean(ctx.getString(R.string.use_system_brightness_key), false) } playerBinding?.apply { playerSpeedBtt.isVisible = playBackSpeedEnabled playerResizeBtt.isVisible = playerResizeEnabled - playerRotateBtt.isVisible = - if (isLayout(TV or EMULATOR)) false else playerRotateEnabled + playerRotateBtt.isVisible = playerRotateEnabled if (hideControlsNames) { hideControlsNames() } @@ -1175,13 +1491,12 @@ 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 + playerGoForward to playerGoForwardText ).forEach { (button, text) -> button.setOnFocusChangeListener { _, hasFocus -> if (!hasFocus) { @@ -1189,17 +1504,25 @@ open class FullScreenPlayer : AbstractPlayerFragment( text.isVisible = false return@setOnFocusChangeListener } - if (button.id == R.id.player_episodes_button) { - toggleEpisodesOverlay(show = true) - } else { - toggleEpisodesOverlay(show = false) - } text.isSelected = true text.isVisible = true } } } + playerPausePlay.setOnClickListener { + autoHide() + player.handleEvent(CSPlayerEvent.PlayPauseToggle) + } + + exoDuration.setOnClickListener { + setRemainingTimeCounter(true) + } + + timeLeft.setOnClickListener { + setRemainingTimeCounter(false) + } + skipChapterButton.setOnClickListener { player.handleEvent(CSPlayerEvent.SkipCurrentChapter) } @@ -1249,8 +1572,18 @@ open class FullScreenPlayer : AbstractPlayerFragment( showSubtitleOffsetDialog() } + playerRew.setOnClickListener { + autoHide() + rewind() + } + + playerFfwd.setOnClickListener { + autoHide() + fastForward() + } + playerGoBack.setOnClickListener { - activity?.popCurrentPage("FullScreenPlayer") + activity?.popCurrentPage() } playerSourcesBtt.setOnClickListener { @@ -1261,21 +1594,20 @@ open class FullScreenPlayer : AbstractPlayerFragment( showTracksDialogue() } - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - playerControlsScroll.setOnScrollChangeListener { _, _, _, _, _ -> - autoHide() - } + // it is !not! a bug that you cant touch the right side, it does not register inputs on navbar or status bar + playerHolder.setOnTouchListener { callView, event -> + return@setOnTouchListener handleMotionEvent(callView, event) } - exoProgress.registerPlayerView(playerView) - - @SuppressLint("ClickableViewAccessibility") exoProgress.setOnTouchListener { _, event -> // this makes the bar not disappear when sliding when (event.action) { - MotionEvent.ACTION_DOWN, + MotionEvent.ACTION_DOWN -> { + currentTapIndex++ + } + MotionEvent.ACTION_MOVE -> { - playerHostView?.cancelAutoHide() + currentTapIndex++ } MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL, MotionEvent.ACTION_BUTTON_RELEASE -> { @@ -1284,9 +1616,11 @@ open class FullScreenPlayer : AbstractPlayerFragment( } return@setOnTouchListener false } - playerEpisodesButton.setOnClickListener { - toggleEpisodesOverlay(show = true) - } + } + // cs3 is peak media center + setRemainingTimeCounter(durationMode || isLayout(TV)) + playerBinding?.exoPosition?.doOnTextChanged { _, _, _, _ -> + updateRemainingTime() } // init UI try { @@ -1296,10 +1630,10 @@ open class FullScreenPlayer : AbstractPlayerFragment( } } + @SuppressLint("SourceLockedOrientationActivity") private fun toggleRotate() { activity?.let { toggleOrientationWithSensor(it) - rotatedManually = true } } @@ -1310,7 +1644,7 @@ open class FullScreenPlayer : AbstractPlayerFragment( it.textSize = 0f it.iconPadding = 0 it.iconGravity = MaterialButton.ICON_GRAVITY_TEXT_START - it.setPadding(0, 0, 0, 0) + it.setPadding(0,0,0,0) } else if (it is LinearLayout) { iterate(it) } @@ -1320,48 +1654,37 @@ open class FullScreenPlayer : AbstractPlayerFragment( } override fun playerDimensionsLoaded(width: Int, height: Int) { - // PlayerView already set isVerticalOrientation; skip rotation on TV (pillarbox instead). - if (isLayout(TV or EMULATOR)) return - // Skip zero-size events emitted when the player transitions to STATE_IDLE, - // acting on them would reset auto-detected orientation to landscape. - if (width <= 0 || height <= 0) return + isVerticalOrientation = height > width updateOrientation() } - private fun toggleEpisodesOverlay(show: Boolean) { - if (show && !isShowingEpisodeOverlay) { - previousPlayStatus = player.getIsPlaying() - player.handleEvent(CSPlayerEvent.Pause) - showEpisodesOverlay() - isShowingEpisodeOverlay = true - animateEpisodesOverlay(true) - } else if (isShowingEpisodeOverlay) { - if (previousPlayStatus) player.handleEvent(CSPlayerEvent.Play) - isShowingEpisodeOverlay = false - animateEpisodesOverlay(false) + private fun updateRemainingTime() { + val duration = player.getDuration() + val position = player.getPosition() + + if (duration != null && duration > 1 && position != null) { + val remainingTimeSeconds = (duration - position + 500) / 1000 + val formattedTime = "-${DateUtils.formatElapsedTime(remainingTimeSeconds)}" + + playerBinding?.timeLeft?.text = formattedTime } } - private fun animateEpisodesOverlay(show: Boolean) { - playerBinding?.playerEpisodeOverlay?.let { overlay -> - overlay.animate().cancel() - (overlay.parent as? ViewGroup)?.layoutTransition = null // Disable layout transitions + private fun setRemainingTimeCounter(showRemaining: Boolean) { + durationMode = showRemaining + playerBinding?.exoDuration?.isInvisible = showRemaining + playerBinding?.timeLeft?.isVisible = showRemaining + } - val offset = 50 * overlay.resources.displayMetrics.density - - overlay.translationX = if (show) offset else 0f - playerBinding?.playerEpisodeOverlay?.isVisible = true - - overlay.animate() - .translationX(if (show) 0f else offset) - .alpha(if (show) 1f else 0f) - .setDuration(300) - .setInterpolator(AccelerateDecelerateInterpolator()).withEndAction { - if (!show) { - playerBinding?.playerEpisodeOverlay?.isGone = true - } - } - .start() + private fun dynamicOrientation(): Int { + return if (autoPlayerRotateEnabled) { + if (isVerticalOrientation) { + ActivityInfo.SCREEN_ORIENTATION_SENSOR_PORTRAIT + } else { + ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE + } + } else { + ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE // default orientation } } } diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/GeneratorPlayer.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/GeneratorPlayer.kt index 17bef3ec0..d4fd047cc 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 @@ -3,207 +3,142 @@ package com.lagradost.cloudstream3.ui.player import android.animation.ValueAnimator import android.annotation.SuppressLint import android.app.Dialog -import android.app.PendingIntent import android.content.Context import android.content.Intent import android.content.res.ColorStateList -import android.graphics.Bitmap import android.os.Build import android.os.Bundle -import android.text.Spanned import android.util.Log import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import android.widget.AbsListView -import android.widget.ArrayAdapter -import android.widget.ImageView -import android.widget.LinearLayout -import android.widget.TextView -import android.widget.Toast +import android.widget.* import androidx.activity.result.contract.ActivityResultContracts -import androidx.annotation.MainThread import androidx.annotation.OptIn 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 import androidx.lifecycle.ViewModelProvider -import androidx.lifecycle.viewModelScope +import androidx.preference.PreferenceManager 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.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.* import com.lagradost.cloudstream3.APIHolder.getApiFromNameNull -import com.lagradost.cloudstream3.CloudStreamApp -import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey +import com.lagradost.cloudstream3.AcraApplication.Companion.setKey import com.lagradost.cloudstream3.CommonActivity.showToast -import com.lagradost.cloudstream3.LoadResponse import com.lagradost.cloudstream3.LoadResponse.Companion.getAniListId import com.lagradost.cloudstream3.LoadResponse.Companion.getImdbId import com.lagradost.cloudstream3.LoadResponse.Companion.getMalId import com.lagradost.cloudstream3.LoadResponse.Companion.getTMDbId -import com.lagradost.cloudstream3.MainActivity -import com.lagradost.cloudstream3.R -import com.lagradost.cloudstream3.TvType -import com.lagradost.cloudstream3.amap import com.lagradost.cloudstream3.databinding.DialogOnlineSubtitlesBinding import com.lagradost.cloudstream3.databinding.FragmentPlayerBinding import com.lagradost.cloudstream3.databinding.PlayerSelectSourceAndSubsBinding import com.lagradost.cloudstream3.databinding.PlayerSelectTracksBinding -import com.lagradost.cloudstream3.isAnimeOp -import com.lagradost.cloudstream3.isEpisodeBased -import com.lagradost.cloudstream3.isLiveStream -import com.lagradost.cloudstream3.isMovieType -import com.lagradost.cloudstream3.mvvm.Resource -import com.lagradost.cloudstream3.mvvm.logError -import com.lagradost.cloudstream3.mvvm.observe -import com.lagradost.cloudstream3.mvvm.observeNullable -import com.lagradost.cloudstream3.mvvm.safe +import com.lagradost.cloudstream3.mvvm.* +import com.lagradost.cloudstream3.subtitles.AbstractSubApi import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities -import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities.SubtitleSearch import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.subtitleProviders -import com.lagradost.cloudstream3.ui.download.DownloadButtonSetup import com.lagradost.cloudstream3.ui.player.CS3IPlayer.Companion.preferredAudioTrackLanguage import com.lagradost.cloudstream3.ui.player.CustomDecoder.Companion.updateForcedEncoding import com.lagradost.cloudstream3.ui.player.PlayerSubtitleHelper.Companion.toSubtitleMimeType -import com.lagradost.cloudstream3.ui.player.source_priority.LinkSource import com.lagradost.cloudstream3.ui.player.source_priority.QualityDataHelper -import com.lagradost.cloudstream3.ui.player.source_priority.QualityDataHelper.getLinkPriority import com.lagradost.cloudstream3.ui.player.source_priority.QualityProfileDialog -import com.lagradost.cloudstream3.ui.result.ACTION_CLICK_DEFAULT -import com.lagradost.cloudstream3.ui.result.EpisodeAdapter -import com.lagradost.cloudstream3.ui.result.FOCUS_SELF -import com.lagradost.cloudstream3.ui.result.ResultEpisode -import com.lagradost.cloudstream3.ui.result.ResultFragment -import com.lagradost.cloudstream3.ui.result.ResultFragment.bindLogo -import com.lagradost.cloudstream3.ui.result.ResultViewModel2 -import com.lagradost.cloudstream3.ui.result.SyncViewModel -import com.lagradost.cloudstream3.ui.result.setLinearListLayout -import com.lagradost.cloudstream3.ui.setRecycledViewPool +import com.lagradost.cloudstream3.ui.result.* import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR import com.lagradost.cloudstream3.ui.settings.Globals.PHONE import com.lagradost.cloudstream3.ui.settings.Globals.TV import com.lagradost.cloudstream3.ui.settings.Globals.isLayout import com.lagradost.cloudstream3.ui.subtitles.SUBTITLE_AUTO_SELECT_KEY -import com.lagradost.cloudstream3.ui.subtitles.SubtitlesFragment -import com.lagradost.cloudstream3.ui.subtitles.SubtitlesFragment.Companion.getAutoSelectLanguageTagIETF -import com.lagradost.cloudstream3.utils.AppContextUtils.getShortSeasonText -import com.lagradost.cloudstream3.utils.AppContextUtils.html +import com.lagradost.cloudstream3.ui.subtitles.SubtitlesFragment.Companion.getAutoSelectLanguageISO639_1 +import com.lagradost.cloudstream3.utils.* import com.lagradost.cloudstream3.utils.AppContextUtils.sortSubs import com.lagradost.cloudstream3.utils.Coroutines.ioSafe import com.lagradost.cloudstream3.utils.Coroutines.runOnMainThread -import com.lagradost.cloudstream3.utils.DataStoreHelper -import com.lagradost.cloudstream3.utils.DataStoreHelper.getViewPos -import com.lagradost.cloudstream3.utils.ExtractorLink -import com.lagradost.cloudstream3.utils.ExtractorLinkType -import com.lagradost.cloudstream3.utils.Qualities import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showDialog -import com.lagradost.cloudstream3.utils.SubtitleHelper.fromTagToEnglishLanguageName -import com.lagradost.cloudstream3.utils.SubtitleHelper.fromTagToLanguageName +import com.lagradost.cloudstream3.utils.SubtitleHelper.fromTwoLettersToLanguage import com.lagradost.cloudstream3.utils.SubtitleHelper.languages -import com.lagradost.cloudstream3.utils.UIHelper.clipboardHelper import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute import com.lagradost.cloudstream3.utils.UIHelper.dismissSafe -import com.lagradost.cloudstream3.utils.UIHelper.fixSystemBarsPadding import com.lagradost.cloudstream3.utils.UIHelper.hideSystemUI import com.lagradost.cloudstream3.utils.UIHelper.popCurrentPage import com.lagradost.cloudstream3.utils.UIHelper.toPx -import com.lagradost.cloudstream3.utils.downloader.DownloadUtils.getImageBitmapFromUrl -import com.lagradost.cloudstream3.utils.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 -import kotlinx.coroutines.isActive -import kotlinx.coroutines.launch import java.io.Serializable -import java.util.Calendar -import java.util.UUID -import java.util.concurrent.ConcurrentHashMap -import java.util.concurrent.atomic.AtomicBoolean +import java.util.* +import kotlin.math.abs -@OptIn(UnstableApi::class) class GeneratorPlayer : FullScreenPlayer() { companion object { - const val NOTIFICATION_ID = 2326 - const val CHANNEL_ID = 7340 - const val STOP_ACTION = "stopcs3" - - private val generators = ConcurrentHashMap>() - fun newInstance( - generator: VideoGenerator<*>, - index: Int, - syncData: HashMap? = null - ): Bundle { + private var lastUsedGenerator: IGenerator? = null + fun newInstance(generator: IGenerator, syncData: HashMap? = null): Bundle { Log.i(TAG, "newInstance = $syncData") - val uuid = UUID.randomUUID().toString() - generators[uuid] = generator + lastUsedGenerator = generator return Bundle().apply { - putString("uuid", uuid) - putInt("index", index) if (syncData != null) putSerializable("syncData", syncData) } } - val subsProviders = subtitleProviders + val subsProviders + get() = subtitleProviders.filter { provider -> + (provider as? AbstractSubApi)?.let { !it.requiresLogin || it.loginInfo() != null } + ?: true + } 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 val currentMeta: Any? get() = viewModel.state.generatorState?.meta - private val nextMeta: Any? get() = viewModel.state.generatorState?.nextMeta - - private var isPlayerActive: AtomicBoolean = AtomicBoolean(false) + private var currentMeta: Any? = null + private var nextMeta: Any? = null + private var isActive: Boolean = false private var isNextEpisode: Boolean = false // this is used to reset the watch time private var preferredAutoSelectSubtitles: String? = null // null means do nothing, "" means none - private val allMeta: List? - get() = viewModel.state.generatorState?.allMeta?.filterIsInstance() - ?.map { episode -> - // Refresh all the episodes watch duration - getViewPos(episode.id)?.let { data -> - episode.copy(position = data.position, duration = data.duration) - } ?: episode - } - private fun setSubtitles(subtitle: SubtitleData?, userInitiated: Boolean): Boolean { - // If subtitle is changed and user initiated -> Save the language - if (subtitle != currentSelectedSubtitles && userInitiated) { - val subtitleLanguageTagIETF = if (subtitle == null) { - "" // -> No Subtitles + private var binding: FragmentPlayerBinding? = null + + private fun startLoading() { + player.release() + currentSelectedSubtitles = null + isActive = false + binding?.overlayLoadingSkipButton?.isVisible = false + binding?.playerLoadingOverlay?.isVisible = true + } + + private fun setSubtitles(subtitle: SubtitleData?): Boolean { + // If subtitle is changed -> Save the language + if (subtitle != currentSelectedSubtitles) { + val subtitleLanguage639 = if (subtitle == null) { + // "" is No Subtitles + "" + } else if (subtitle.languageCode != null) { + // Could be "English 4" which is why it is trimmed. + val trimmedLanguage = subtitle.languageCode.replace(Regex("\\d"), "").trim() + + languages.firstOrNull { language -> + language.languageName.equals(trimmedLanguage, ignoreCase = true) || + language.ISO_639_1 == subtitle.languageCode + }?.ISO_639_1 } else { - subtitle.getIETF_tag() + null } - if (subtitleLanguageTagIETF != null) { - Log.i(TAG, "Set SUBTITLE_AUTO_SELECT_KEY to '$subtitleLanguageTagIETF'") - setKey(SUBTITLE_AUTO_SELECT_KEY, subtitleLanguageTagIETF) - preferredAutoSelectSubtitles = subtitleLanguageTagIETF + if (subtitleLanguage639 != null) { + setKey(SUBTITLE_AUTO_SELECT_KEY, subtitleLanguage639) + preferredAutoSelectSubtitles = subtitleLanguage639 } } @@ -221,11 +156,10 @@ class GeneratorPlayer : FullScreenPlayer() { playerBinding?.playerTracksBtt?.isVisible = tracks.allVideoTracks.size > 1 || tracks.allAudioTracks.size > 1 // Only set the preferred language if it is available. - // Otherwise, it may give some users audio track init failed! + // Otherwise it may give some users audio track init failed! if (tracks.allAudioTracks.any { it.language == preferredAudioTrackLanguage }) { player.setPreferredAudioTrack(preferredAudioTrackLanguage) } - updatePlayerInfo() } override fun playerStatusChanged() { @@ -236,11 +170,11 @@ class GeneratorPlayer : FullScreenPlayer() { } private fun noSubtitles(): Boolean { - return setSubtitles(null, true) + return setSubtitles(null) } private fun getPos(): Long { - val durPos = getViewPos(viewModel.state.generatorState?.id) ?: return 0L + val durPos = DataStoreHelper.getViewPos(viewModel.getId()) ?: return 0L if (durPos.duration == 0L) return 0L if (durPos.position * 100L / durPos.duration > 95L) { return 0L @@ -262,254 +196,17 @@ class GeneratorPlayer : FullScreenPlayer() { } } - // https://github.com/androidx/media/blob/main/libraries/ui/src/main/java/androidx/media3/ui/PlayerNotificationManager.java#L1517 - private fun createBroadcastIntent( - action: String, - context: Context, - instanceId: Int - ): PendingIntent { - val intent: Intent = Intent(action).setPackage(context.packageName) - intent.putExtra(EXTRA_INSTANCE_ID, instanceId) - val pendingFlags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE - } else PendingIntent.FLAG_UPDATE_CURRENT - - return PendingIntent.getBroadcast(context, instanceId, intent, pendingFlags) - } - - private var cachedPlayerNotificationManager: PlayerNotificationManager? = null - - private fun getMediaNotification(context: Context): PlayerNotificationManager { - val cache = cachedPlayerNotificationManager - if (cache != null) return cache - return PlayerNotificationManager.Builder( - context, - NOTIFICATION_ID, - CHANNEL_ID.toString() - ) - .setChannelNameResourceId(R.string.player_notification_channel_name) - .setChannelDescriptionResourceId(R.string.player_notification_channel_description) - .setMediaDescriptionAdapter(object : MediaDescriptionAdapter { - override fun getCurrentContentTitle(player: Player): CharSequence { - return when (val meta = currentMeta) { - is ResultEpisode -> { - meta.headerName - } - - is ExtractorUri -> { - meta.headerName ?: meta.name - } - - else -> null - } ?: "Unknown" - } - - override fun createCurrentContentIntent(player: Player): PendingIntent? { - // Open the app without creating a new task to resume playback seamlessly - return PendingIntentCompat.getActivity( - context, - 0, - Intent(context, MainActivity::class.java), - 0, - false - ) - } - - override fun getCurrentContentText(player: Player): CharSequence? { - return when (val meta = currentMeta) { - is ResultEpisode -> { - meta.name - } - - is ExtractorUri -> { - if (meta.headerName == null) { - null - } else { - meta.name - } - } - - else -> null - } - } - - override fun getCurrentLargeIcon( - player: Player, - callback: PlayerNotificationManager.BitmapCallback - ): Bitmap? { - ioSafe { - val url = when (val meta = currentMeta) { - is ResultEpisode -> { - meta.poster - } - - else -> null - } - // if we have a poster url try with it first - if (url != null) { - val urlBitmap = context.getImageBitmapFromUrl(url) - if (urlBitmap != null) { - callback.onBitmap(urlBitmap) - return@ioSafe - } - } - - // retry several times with a preview in case the preview generator is slow - repeat(10) { - val preview = this@GeneratorPlayer.player.getPreview(0.5f) - if (preview != null) { - callback.onBitmap(preview) - return@repeat - } - delay(1000L) - } - } - - // return null as we want to use the callback - return null - } - }).setCustomActionReceiver(object : PlayerNotificationManager.CustomActionReceiver { - // we have to use a custom action for stop if we want to exit the player instead of just stopping playback - override fun createCustomActions( - context: Context, - instanceId: Int - ): MutableMap { - 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) - ) - ) - } - - override fun getCustomActions(player: Player): MutableList { - return mutableListOf(STOP_ACTION) - } - - override fun onCustomAction(player: Player, action: String, intent: Intent) { - when (action) { - STOP_ACTION -> { - exitPlayer() - } - } - } - }) - .setPlayActionIconResourceId(R.drawable.ic_baseline_play_arrow_24) - .setPauseActionIconResourceId(R.drawable.netflix_pause) - .setSmallIconResourceId(R.drawable.baseline_headphones_24) - .setStopActionIconResourceId(R.drawable.baseline_stop_24) - .setRewindActionIconResourceId(R.drawable.go_back_30) - .setFastForwardActionIconResourceId(R.drawable.go_forward_30) - .setNextActionIconResourceId(R.drawable.ic_baseline_skip_next_24) - .setPreviousActionIconResourceId(R.drawable.baseline_skip_previous_24) - .build().apply { - setColorized(true) // Color - setUseChronometer(true) // Seekbar - - // Don't show the prev episode button - setUsePreviousAction(false) - setUsePreviousActionInCompactView(false) - - // Don't show the next episode button - setUseNextAction(false) - setUseNextActionInCompactView(false) - - // Show the skip 30s in both modes - setUseFastForwardAction(true) - setUseFastForwardActionInCompactView(true) - - // Only show rewind in expanded - setUseRewindAction(true) - setUseFastForwardActionInCompactView(false) - - // Use custom stop action - setUseStopAction(false) - } - .also { cachedPlayerNotificationManager = it } - } - - override fun playerUpdated(player: Any?) { - super.playerUpdated(player) - - // Cancel the notification when released - if (player == null) { - cachedPlayerNotificationManager?.setPlayer(null) - cachedPlayerNotificationManager = null - return - } - - // setup the notification when starting the player - if (player is ExoPlayer) { - val ctx = context ?: return - getMediaNotification(ctx).apply { - setPlayer(player) - mMediaSession?.platformToken?.let { - setMediaSessionToken(it) - } - } - } - } - - override fun onDownload(event: DownloadEvent) { - super.onDownload(event) - showDownloadProgress(event) - } - - private fun showDownloadProgress(event: DownloadEvent) { - activity?.runOnUiThread { - playerBinding?.downloadedProgress?.apply { - val indeterminate = event.totalBytes <= 0 || event.downloadedBytes <= 0 - isIndeterminate = indeterminate - if (!indeterminate) { - max = (event.totalBytes / 1000).toInt() - progress = (event.downloadedBytes / 1000).toInt() - } - } - playerBinding?.downloadedProgressText.setText( - txt( - R.string.download_size_format, - android.text.format.Formatter.formatShortFileSize( - context, - event.downloadedBytes - ), - android.text.format.Formatter.formatShortFileSize(context, event.totalBytes) - ) - ) - val downloadSpeed = - android.text.format.Formatter.formatShortFileSize(context, event.downloadSpeed) - playerBinding?.downloadedProgressSpeedText?.text = - // todo string fmt - event.connections?.let { connections -> - "%s/s - %d Connections".format(downloadSpeed, connections) - } ?: downloadSpeed - - // don't display when done - playerBinding?.downloadedProgressSpeedText?.isGone = - event.downloadedBytes != 0L && event.downloadedBytes - 1024 >= event.totalBytes - } - } - - private fun loadLink(link: VideoLink?, sameEpisode: Boolean) { + private fun loadLink(link: Pair?, sameEpisode: Boolean) { if (link == null) return - isPlayerActive.set(true) + // manage UI binding?.playerLoadingOverlay?.isVisible = false - val isTorrent = - link.first?.type == ExtractorLinkType.MAGNET || link.first?.type == ExtractorLinkType.TORRENT - - playerBinding?.downloadHeader?.isVisible = false - playerBinding?.downloadHeaderToggle?.isVisible = isTorrent - if (!isLayout(PHONE)) { - playerBinding?.downloadBothHeader?.isVisible = isTorrent - } - - showDownloadProgress(DownloadEvent(0, 0, 0, null)) - uiReset() currentSelectedLink = link + currentMeta = viewModel.getMeta() + nextMeta = viewModel.getNextMeta() // setEpisodes(viewModel.getAllMeta() ?: emptyList()) + isActive = true setPlayerDimen(null) setTitle() if (!sameEpisode) @@ -519,7 +216,6 @@ class GeneratorPlayer : FullScreenPlayer() { // load player context?.let { ctx -> val (url, uri) = link - val subtitles = viewModel.state.subtitles player.loadPlayer( ctx, sameEpisode, @@ -528,18 +224,43 @@ class GeneratorPlayer : FullScreenPlayer() { startPosition = if (sameEpisode) null else { if (isNextEpisode) 0L else getPos() }, - subtitles, + currentSubs, (if (sameEpisode) currentSelectedSubtitles else null) ?: getAutoSelectSubtitle( - subtitles, settings = true, downloads = true + currentSubs, settings = true, downloads = true ), - preview = true + preview = isFullScreenPlayer ) } - if (!sameEpisode) { - player.addTimeStamps(emptyList()) // clear stamps - // Resets subtitle delay, as we watch some other content - player.setSubtitleOffset(0) + if (!sameEpisode) + player.addTimeStamps(listOf()) // clear stamps + } + + private fun closestQuality(target: Int?): Qualities { + if (target == null) return Qualities.Unknown + return Qualities.entries.minBy { abs(it.value - target) } + } + + private fun getLinkPriority( + qualityProfile: Int, + link: Pair + ): Int { + val (linkData, _) = link + + val qualityPriority = QualityDataHelper.getQualityPriority( + qualityProfile, + closestQuality(linkData?.quality) + ) + val sourcePriority = + QualityDataHelper.getSourcePriority(qualityProfile, linkData?.source) + + // negative because we want to sort highest quality first + return qualityPriority + sourcePriority + } + + private fun sortLinks(qualityProfile: Int): List> { + return currentLinks.sortedBy { + -getLinkPriority(qualityProfile, it) } } @@ -573,29 +294,28 @@ class GeneratorPlayer : FullScreenPlayer() { return meta } - fun getName(entry: AbstractSubtitleEntities.SubtitleEntity, withLanguage: Boolean): String { - if (entry.lang.isBlank() || !withLanguage) { - return entry.name - } - val language = fromTagToLanguageName(entry.lang.trim()) ?: entry.lang - return "$language ${entry.name}" - } - override fun openOnlineSubPicker( context: Context, loadResponse: LoadResponse?, dismissCallback: (() -> Unit) ) { - val providers = subsProviders.toList() + val providers = subsProviders val isSingleProvider = subsProviders.size == 1 - val dialog = Dialog(context, R.style.DialogFullscreenPlayer) + val dialog = Dialog(context, R.style.AlertDialogCustomBlack) val binding = DialogOnlineSubtitlesBinding.inflate(LayoutInflater.from(context), null, false) dialog.setContentView(binding.root) - fixSystemBarsPadding(binding.root) var currentSubtitles: List = emptyList() var currentSubtitle: AbstractSubtitleEntities.SubtitleEntity? = null + fun getName(entry: AbstractSubtitleEntities.SubtitleEntity, withLanguage: Boolean): String { + if (entry.lang.isBlank() || !withLanguage) { + return entry.name + } + val language = fromTwoLettersToLanguage(entry.lang.trim()) ?: entry.lang + return "$language ${entry.name}" + } + val layout = R.layout.sort_bottom_single_choice_double_text val arrayAdapter = object : ArrayAdapter(dialog.context, layout) { @@ -621,6 +341,7 @@ class GeneratorPlayer : FullScreenPlayer() { imageViewEnd.setImageDrawable(drawableEnd) } + @SuppressLint("SetTextI18n") override fun getView(position: Int, convertView: View?, parent: ViewGroup): View { val view = convertView ?: LayoutInflater.from(context).inflate(layout, null) @@ -633,10 +354,9 @@ class GeneratorPlayer : FullScreenPlayer() { mainTextView?.text = item?.let { getName(it, false) } val language = - item?.let { fromTagToLanguageName(it.lang) ?: it.lang } ?: "" + item?.let { fromTwoLettersToLanguage(it.lang.trim()) ?: it.lang } ?: "" val providerSuffix = if (isSingleProvider || item == null) "" else " · ${item.source}" - @SuppressLint("SetTextI18n") secondaryTextView?.text = language + providerSuffix setHearingImpairedIcon(drawableEnd, position) @@ -656,7 +376,7 @@ class GeneratorPlayer : FullScreenPlayer() { currentSubtitle = currentSubtitles.getOrNull(position) ?: return@setOnItemClickListener } - var currentLanguageTagIETF: String = getAutoSelectLanguageTagIETF() + var currentLanguageTwoLetters: String = getAutoSelectLanguageISO639_1() fun setSubtitlesList(list: List) { @@ -668,8 +388,7 @@ class GeneratorPlayer : FullScreenPlayer() { val currentTempMeta = getMetaData() // bruh idk why it is not correct - val color = - ColorStateList.valueOf(context.colorFromAttribute(androidx.appcompat.R.attr.colorAccent)) + val color = ColorStateList.valueOf(context.colorFromAttribute(R.attr.colorAccent)) binding.searchLoadingBar.progressTintList = color binding.searchLoadingBar.indeterminateTintList = color @@ -713,7 +432,7 @@ class GeneratorPlayer : FullScreenPlayer() { binding.searchLoadingBar.show() ioSafe { val search = - SubtitleSearch( + AbstractSubtitleEntities.SubtitleSearch( query = query ?: return@ioSafe, imdbId = loadResponse?.getImdbId(), tmdbId = loadResponse?.getTMDbId()?.toInt(), @@ -721,27 +440,16 @@ class GeneratorPlayer : FullScreenPlayer() { aniListId = loadResponse?.getAniListId()?.toInt(), epNumber = currentTempMeta.episode, seasonNumber = currentTempMeta.season, - lang = currentLanguageTagIETF.ifBlank { null }, + lang = currentLanguageTwoLetters.ifBlank { null }, year = viewModel.currentSubtitleYear.value ) - - // TODO Make ui a lot better, like search with tabs val results = providers.amap { - when (val response = Resource.fromResult(it.search(search))) { - is Resource.Success -> { - response.value - } - - is Resource.Loading -> { - emptyList() - } - - is Resource.Failure -> { - showToast(response.errorString) - emptyList() - } + try { + it.search(search) + } catch (e: Exception) { + null } - } + }.filterNotNull() val max = results.maxOfOrNull { it.size } ?: return@ioSafe // very ugly @@ -769,22 +477,14 @@ class GeneratorPlayer : FullScreenPlayer() { }) binding.searchFilter.setOnClickListener { view -> - val languagesTagName = - languages - .map { Pair(it.IETF_tag, it.nameNextToFlagEmoji()) } - .sortedBy { - it.second.substringAfter("\u00a0").lowercase() - } // name ignoring flag emoji - val (langTagsIETF, langNames) = languagesTagName.unzip() - - activity?.showDialog( - langNames, - langTagsIETF.indexOf(currentLanguageTagIETF), + val lang639_1 = languages.map { it.ISO_639_1 } + activity?.showDialog(languages.map { it.languageName }, + lang639_1.indexOf(currentLanguageTwoLetters), view?.context?.getString(R.string.subs_subtitle_languages) ?: return@setOnClickListener, true, { }) { index -> - currentLanguageTagIETF = langTagsIETF[index] + currentLanguageTwoLetters = lang639_1[index] binding.subtitlesSearch.setQuery(binding.subtitlesSearch.query, true) } } @@ -793,38 +493,20 @@ class GeneratorPlayer : FullScreenPlayer() { currentSubtitle?.let { currentSubtitle -> providers.firstOrNull { it.idPrefix == currentSubtitle.idPrefix }?.let { api -> ioSafe { - when (val apiResource = - Resource.fromResult(api.resource(currentSubtitle))) { - is Resource.Success -> { - val subtitles = apiResource.value.getSubtitles().map { resource -> - SubtitleData( - originalName = resource.name ?: getName( - currentSubtitle, - true - ), - nameSuffix = "", - url = resource.url, - origin = resource.origin, - mimeType = resource.url.toSubtitleMimeType(), - headers = currentSubtitle.headers, - languageCode = currentSubtitle.lang - ) - } - if (subtitles.isEmpty()) { - showToast(R.string.no_subtitles) - return@ioSafe - } - runOnMainThread { - addAndSelectSubtitles(*subtitles.toTypedArray()) - } + val subtitles = + api.getResource(currentSubtitle).getSubtitles().map { resource -> + SubtitleData( + name = resource.name ?: getName(currentSubtitle, true), + url = resource.url, + origin = resource.origin, + mimeType = resource.url.toSubtitleMimeType(), + headers = currentSubtitle.headers, + currentSubtitle.lang + ) } - - is Resource.Failure -> { - showToast(apiResource.errorString) - } - - is Resource.Loading -> { - // not possible + if (subtitles.isNotEmpty()) { + runOnMainThread { + addAndSelectSubtitles(*subtitles.toTypedArray()) } } } @@ -842,7 +524,7 @@ class GeneratorPlayer : FullScreenPlayer() { //TODO: Set year text from currently loaded movie on Player //dialog.subtitles_search_year?.setText(currentTempMeta.year) } - + @OptIn(UnstableApi::class) private fun openSubPicker() { try { subsPathPicker.launch( @@ -863,26 +545,26 @@ class GeneratorPlayer : FullScreenPlayer() { } } - @MainThread private fun addAndSelectSubtitles( vararg subtitleData: SubtitleData ) { if (subtitleData.isEmpty()) return - val ctx = context ?: return val selectedSubtitle = subtitleData.first() - viewModel.addSubtitles(subtitleData.toSet()) + val ctx = context ?: return + + val subs = currentSubs + subtitleData // this is used instead of observe(viewModel._currentSubs), because observe is too slow - player.setActiveSubtitles(viewModel.state.subtitles) + player.setActiveSubtitles(subs) // Save current time as to not reset player to 00:00 player.saveData() player.reloadPlayer(ctx) - setSubtitles(selectedSubtitle, false) + setSubtitles(selectedSubtitle) + viewModel.addSubtitles(subtitleData.toSet()) selectSourceDialog?.dismissSafe() - selectSourceDialog = null showToast( String.format(ctx.getString(R.string.player_loaded_subtitles), selectedSubtitle.name), @@ -893,10 +575,10 @@ class GeneratorPlayer : FullScreenPlayer() { // Open file picker private val subsPathPicker = registerForActivityResult(ActivityResultContracts.OpenDocument()) { uri -> - safe { + normalSafeApiCall { // It lies, it can be null if file manager quits. - if (uri == null) return@safe - val ctx = context ?: CloudStreamApp.context ?: return@safe + if (uri == null) return@normalSafeApiCall + val ctx = context ?: AcraApplication.context ?: return@normalSafeApiCall // RW perms for the path ctx.contentResolver.takePersistableUriPermission( uri, @@ -911,7 +593,6 @@ class GeneratorPlayer : FullScreenPlayer() { val subtitleData = SubtitleData( name, - "", uri.toString(), SubtitleOrigin.DOWNLOADED_FILE, name.toSubtitleMimeType(), @@ -923,76 +604,8 @@ class GeneratorPlayer : FullScreenPlayer() { } } - /** 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) = - viewModel.viewModelScope.launch { - // async should not have a race condition if they are on the same group - var hasSelectASubtitle = false - - // 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.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 - if (this.isActive) { - showToast("${provider.idPrefix}${result.errorString}") - } - return@amap - } - - is Resource.Loading -> { - // unreachable - return@amap - } - - is Resource.Success -> { - result.value - } - } - - // try to add every subtitle until we have added a new subtitle file - for (subtitleEntry in success) { - if (hasSelectASubtitle || !this.isActive) { - break - } - - val subtitleResources = provider.resource(subtitleEntry).getOrNull() ?: continue - - val subtitles = subtitleResources.getSubtitles().map { resource -> - SubtitleData( - originalName = resource.name ?: getName(subtitleEntry, true), - nameSuffix = "", - url = resource.url, - origin = resource.origin, - mimeType = resource.url.toSubtitleMimeType(), - headers = subtitleEntry.headers, - languageCode = subtitleEntry.lang, - ) - } - - // checks for both a race condition and if any of the subs generated is new - if (this.isActive && !viewModel.state.subtitles.containsAll(subtitles) && !hasSelectASubtitle) { - hasSelectASubtitle = true - runOnMainThread { - addAndSelectSubtitles(*subtitles.toTypedArray()) - } - break - } - } - } - // maybe better error here? - if (!hasSelectASubtitle && this.isActive) { - showToast(R.string.no_subtitles) - } - } - + var selectSourceDialog: Dialog? = null +// var selectTracksDialog: AlertDialog? = null override fun showMirrorsDialogue() { try { @@ -1001,20 +614,18 @@ class GeneratorPlayer : FullScreenPlayer() { context?.let { ctx -> val isPlaying = player.getIsPlaying() player.handleEvent(CSPlayerEvent.Pause, PlayerEventSource.UI) - val currentSubtitles = sortSubs(viewModel.state.subtitles) + val currentSubtitles = sortSubs(currentSubs) - val sourceDialog = Dialog(ctx, R.style.DialogFullscreenPlayer) + val sourceDialog = Dialog(ctx, R.style.AlertDialogCustomBlack) val binding = PlayerSelectSourceAndSubsBinding.inflate(LayoutInflater.from(ctx), null, false) sourceDialog.setContentView(binding.root) - fixSystemBarsPadding(binding.root) selectSourceDialog = sourceDialog sourceDialog.show() val providerList = binding.sortProviders val subtitleList = binding.sortSubtitles - val subtitleOptionList = binding.sortSubtitlesOptions val loadFromFileFooter: TextView = layoutInflater.inflate(R.layout.sort_bottom_footer_add_choice, null) as TextView @@ -1027,14 +638,6 @@ class GeneratorPlayer : FullScreenPlayer() { var shouldDismiss = true - binding.subtitleSettingsBtt.setOnClickListener { - safe { - val subtitlesFragment = SubtitlesFragment() - subtitlesFragment.systemBarsAddPadding = true - subtitlesFragment.show(this.parentFragmentManager, "SubtitleSettings") - } - } - fun dismiss() { if (isPlaying) { player.handleEvent(CSPlayerEvent.Play) @@ -1043,7 +646,7 @@ class GeneratorPlayer : FullScreenPlayer() { } if (subsProvidersIsActive) { - val currentLoadResponse = viewModel.state.generatorState?.response + val currentLoadResponse = viewModel.getLoadResponse() val loadFromOpenSubsFooter: TextView = layoutInflater.inflate( R.layout.sort_bottom_footer_add_choice, null @@ -1055,45 +658,11 @@ class GeneratorPlayer : FullScreenPlayer() { loadFromOpenSubsFooter.setOnClickListener { shouldDismiss = false sourceDialog.dismissSafe(activity) - selectSourceDialog = null openOnlineSubPicker(it.context, currentLoadResponse) { dismiss() } } subtitleList.addFooterView(loadFromOpenSubsFooter) - - // subs from 1 button here - val metadata = getMetaData() - val queryName = metadata.name ?: currentLoadResponse?.name - if (queryName != null) { - val currentLanguageTagIETF: String = getAutoSelectLanguageTagIETF() - val loadFromFirstSubsFooter: TextView = layoutInflater.inflate( - R.layout.sort_bottom_footer_add_choice, null - ) as TextView - - loadFromFirstSubsFooter.text = - ctx.getString(R.string.player_load_one_subtitle_online) - - loadFromFirstSubsFooter.setOnClickListener { - sourceDialog.dismissSafe(activity) - selectSourceDialog = null - showToast(R.string.loading) - addFirstSub( - SubtitleSearch( - query = queryName, - imdbId = currentLoadResponse?.getImdbId(), - tmdbId = currentLoadResponse?.getTMDbId()?.toInt(), - malId = currentLoadResponse?.getMalId()?.toInt(), - aniListId = currentLoadResponse?.getAniListId()?.toInt(), - epNumber = metadata.episode, - seasonNumber = metadata.season, - lang = currentLanguageTagIETF.ifBlank { null }, - year = viewModel.currentSubtitleYear.value - ) - ) - } - subtitleList.addFooterView(loadFromFirstSubsFooter) - } } var sourceIndex = 0 @@ -1101,7 +670,7 @@ class GeneratorPlayer : FullScreenPlayer() { var sortedUrls = emptyList>() fun refreshLinks(qualityProfile: Int) { - sortedUrls = viewModel.state.sortLinks(qualityProfile) + sortedUrls = sortLinks(qualityProfile) if (sortedUrls.isEmpty()) { sourceDialog.findViewById(R.id.sort_sources_holder)?.isGone = true @@ -1126,16 +695,6 @@ class GeneratorPlayer : FullScreenPlayer() { sourceIndex = which providerList.setItemChecked(which, true) } - - providerList.setOnItemLongClickListener { _, _, position, _ -> - sortedUrls.getOrNull(position)?.first?.url?.let { - clipboardHelper( - txt(R.string.video_source), - it - ) - } - true - } } } @@ -1146,74 +705,21 @@ class GeneratorPlayer : FullScreenPlayer() { selectSourceDialog = null } + val subtitleIndexStart = currentSubtitles.indexOf(currentSelectedSubtitles) + 1 + var subtitleIndex = subtitleIndexStart - val subsArrayAdapter = - ArrayAdapter(ctx, R.layout.sort_bottom_single_choice) - subsArrayAdapter.add(ctx.getString(R.string.no_subtitles).html()) - - val subtitlesGrouped = - currentSubtitles.groupBy { it.originalName }.map { (key, value) -> - key to value.sortedBy { it.nameSuffix.toIntOrNull() ?: 0 } - }.toMap() - val subtitlesGroupedList = subtitlesGrouped.entries.toList() - - val subtitles = subtitlesGrouped.map { it.key.html() } - - val subtitleGroupIndexStart = - subtitlesGrouped.keys.indexOf(currentSelectedSubtitles?.originalName) + 1 - var subtitleGroupIndex = subtitleGroupIndexStart - - val subtitleOptionIndexStart = - subtitlesGrouped[currentSelectedSubtitles?.originalName]?.indexOfFirst { it.nameSuffix == currentSelectedSubtitles?.nameSuffix } - ?: 0 - var subtitleOptionIndex = subtitleOptionIndexStart - - subsArrayAdapter.addAll(subtitles) + val subsArrayAdapter = ArrayAdapter(ctx, R.layout.sort_bottom_single_choice) + subsArrayAdapter.add(ctx.getString(R.string.no_subtitles)) + subsArrayAdapter.addAll(currentSubtitles.map { it.name }) subtitleList.adapter = subsArrayAdapter subtitleList.choiceMode = AbsListView.CHOICE_MODE_SINGLE - subtitleList.setSelection(subtitleGroupIndex) - subtitleList.setItemChecked(subtitleGroupIndex, true) - - val subsOptionsArrayAdapter = - ArrayAdapter(ctx, R.layout.sort_bottom_single_choice) - - subtitleOptionList.adapter = subsOptionsArrayAdapter - subtitleOptionList.choiceMode = AbsListView.CHOICE_MODE_SINGLE - - fun updateSubtitleOptionList() { - subsOptionsArrayAdapter.clear() - - val subtitleOptions = - subtitlesGroupedList - .getOrNull(subtitleGroupIndex - 1)?.value?.map { subtitle -> - val nameSuffix = subtitle.nameSuffix.html() - nameSuffix.ifBlank { - when (subtitle.origin) { - SubtitleOrigin.URL -> txt(R.string.subtitles_from_online) - SubtitleOrigin.DOWNLOADED_FILE -> txt(R.string.downloaded) - SubtitleOrigin.EMBEDDED_IN_VIDEO -> txt(R.string.subtitles_from_embedded) - }.asString(ctx).toSpanned() - } - } - ?: emptyList() - - // Show nothing if there is nothing to select - val shouldHide = subtitleOptions.size < 2 - subtitleOptionList.isGone = shouldHide // Make it easier to click - if (shouldHide) return - - subsOptionsArrayAdapter.addAll(subtitleOptions) - - subtitleOptionList.setSelection(subtitleOptionIndex) - subtitleOptionList.setItemChecked(subtitleOptionIndex, true) - } - - updateSubtitleOptionList() + subtitleList.setSelection(subtitleIndex) + subtitleList.setItemChecked(subtitleIndex, true) subtitleList.setOnItemClickListener { _, _, which, _ -> - if (which > subtitlesGrouped.size) { + if (which > currentSubtitles.size) { // Since android TV is funky the setOnItemClickListener will be triggered // instead of setOnClickListener when selecting. To override this we programmatically // click the view when selecting an item outside the list. @@ -1224,35 +730,13 @@ class GeneratorPlayer : FullScreenPlayer() { val child = subtitleList.adapter.getView(which, null, subtitleList) child?.performClick() } else { - if (subtitleGroupIndex != which) { - subtitleGroupIndex = which - subtitleOptionIndex = - if (subtitleGroupIndex == subtitleGroupIndexStart) { - subtitleOptionIndexStart - } else { - 0 - } - } + subtitleIndex = which subtitleList.setItemChecked(which, true) - updateSubtitleOptionList() - } - } - - subtitleOptionList.setOnItemClickListener { _, _, which, _ -> - if (which >= (subtitlesGroupedList.getOrNull(subtitleGroupIndex - 1)?.value?.size - ?: -1) - ) { - val child = subtitleOptionList.adapter.getView(which, null, subtitleList) - child?.performClick() - } else { - subtitleOptionIndex = which - subtitleOptionList.setItemChecked(which, true) } } binding.cancelBtt.setOnClickListener { sourceDialog.dismissSafe(activity) - this.selectSourceDialog = null } fun setProfileName(profile: Int) { @@ -1266,28 +750,16 @@ class GeneratorPlayer : FullScreenPlayer() { binding.profilesClickSettings.setOnClickListener { val activity = activity ?: return@setOnClickListener - val dialog = QualityProfileDialog( + QualityProfileDialog( activity, - R.style.DialogFullscreenPlayer, - viewModel.state.links.mapNotNull { - it.first?.let { extractorLink -> - LinkSource( - extractorLink - ) - } - }, + R.style.AlertDialogCustomBlack, + currentLinks.mapNotNull { it.first }, currentQualityProfile ) { profile -> currentQualityProfile = profile.id setProfileName(profile.id) - } - - dialog.setOnDismissListener { - viewModel.state.clearSortedLinksCache() - refreshLinks(currentQualityProfile) - } - - dialog.show() + refreshLinks(profile.id) + }.show() } binding.subtitlesEncodingFormat.apply { @@ -1303,7 +775,7 @@ class GeneratorPlayer : FullScreenPlayer() { text = prefNames[if (index == -1) 0 else index] } - binding.subtitlesEncodingFormat.setOnClickListener { + binding.subtitlesClickSettings.setOnClickListener { val settingsManager = PreferenceManager.getDefaultSharedPreferences(ctx) val prefNames = ctx.resources.getStringArray(R.array.subtitles_encoding_list) @@ -1315,20 +787,16 @@ class GeneratorPlayer : FullScreenPlayer() { shouldDismiss = false sourceDialog.dismissSafe(activity) - selectSourceDialog = null val index = prefValues.indexOf(currentPrefMedia) - activity?.showDialog( - prefNames.toList(), + activity?.showDialog(prefNames.toList(), if (index == -1) 0 else index, ctx.getString(R.string.subtitles_encoding), true, {}) { - settingsManager.edit { - putString( - ctx.getString(R.string.subtitles_encoding_key), prefValues[it] - ) - } + settingsManager.edit().putString( + ctx.getString(R.string.subtitles_encoding_key), prefValues[it] + ).apply() updateForcedEncoding(ctx) dismiss() player.seekTime(-1) // to update subtitles, a dirty trick @@ -1336,15 +804,16 @@ class GeneratorPlayer : FullScreenPlayer() { } binding.applyBtt.setOnClickListener { - var init = sourceIndex != startSource - if (subtitleGroupIndex != subtitleGroupIndexStart || subtitleOptionIndex != subtitleOptionIndexStart) { - init = init or if (subtitleGroupIndex <= 0) { + var init = false + if (sourceIndex != startSource) { + init = true + } + if (subtitleIndex != subtitleIndexStart) { + init = init || if (subtitleIndex <= 0) { noSubtitles() } else { - subtitlesGroupedList.getOrNull(subtitleGroupIndex - 1)?.value?.getOrNull( - subtitleOptionIndex - )?.let { - setSubtitles(it, true) + currentSubtitles.getOrNull(subtitleIndex - 1)?.let { + setSubtitles(it) } ?: false } } @@ -1354,7 +823,6 @@ class GeneratorPlayer : FullScreenPlayer() { } } sourceDialog.dismissSafe(activity) - selectSourceDialog = null } } } catch (e: Exception) { @@ -1377,14 +845,11 @@ class GeneratorPlayer : FullScreenPlayer() { val currentAudioTracks = tracks.allAudioTracks val binding: PlayerSelectTracksBinding = PlayerSelectTracksBinding.inflate(LayoutInflater.from(ctx), null, false) - val trackDialog = Dialog(ctx, R.style.DialogFullscreenPlayer) - this.selectTrackDialog = trackDialog + val trackDialog = Dialog(ctx, R.style.AlertDialogCustomBlack) trackDialog.setContentView(binding.root) trackDialog.show() - fixSystemBarsPadding(binding.root) - - // selectTracksDialog = tracksDialog +// selectTracksDialog = tracksDialog val videosList = binding.videoTracksList val audioList = binding.autoTracksList @@ -1427,56 +892,22 @@ class GeneratorPlayer : FullScreenPlayer() { trackDialog.setOnDismissListener { dismiss() - // selectTracksDialog = null +// selectTracksDialog = null } - var audioIndexStart = currentAudioTracks.indexOfFirst { track -> - track.id == tracks.currentAudioTrack?.id && - track.formatIndex == tracks.currentAudioTrack?.formatIndex - }.coerceAtLeast(0) + var audioIndexStart = currentAudioTracks.indexOf(tracks.currentAudioTrack).takeIf { + it != -1 + } ?: currentVideoTracks.indexOfFirst { + tracks.currentAudioTrack?.id == it.id + } val audioArrayAdapter = ArrayAdapter(ctx, R.layout.sort_bottom_single_choice) - - audioArrayAdapter.addAll( - currentAudioTracks.mapIndexed { _, track -> - - val language = ( - track.language?.trim()?.let { raw -> - fromTagToLanguageName(raw) - ?: fromTagToLanguageName( - raw.replace('_', '-').substringBefore('-').lowercase() - ) - ?: raw - } - ?: track.label - ?: "Audio" - ).replaceFirstChar { it.uppercaseChar() } - - val codec = audioCodecName(track.sampleMimeType) - - val channelCount = track.channelCount - - val channels = when { - // May be below 1 or null when unknown - channelCount == null || channelCount <= 0 -> "" - channelCount == 1 -> "Mono" - channelCount == 2 -> "Stereo" - channelCount == 6 -> "5.1" - channelCount == 8 -> "7.1" - else -> "${channelCount}ch" - } - - listOfNotNull( - language.takeIf { it.isNotBlank() } - ?.replaceFirstChar { it.uppercaseChar() }, - channels.takeIf { it.isNotBlank() }, - codec.takeIf { it.isNotBlank() }?.uppercase() - ).joinToString(" • ") - - - } - ) +// audioArrayAdapter.add(ctx.getString(R.string.no_subtitles)) + audioArrayAdapter.addAll(currentAudioTracks.mapIndexed { index, format -> + format.label ?: format.language?.let { fromTwoLettersToLanguage(it) } + ?: index.toString() + }) audioList.adapter = audioArrayAdapter audioList.choiceMode = AbsListView.CHOICE_MODE_SINGLE @@ -1491,15 +922,12 @@ class GeneratorPlayer : FullScreenPlayer() { binding.cancelBtt.setOnClickListener { trackDialog.dismissSafe(activity) - this.selectTrackDialog = null } binding.applyBtt.setOnClickListener { val currentTrack = currentAudioTracks.getOrNull(audioIndexStart) player.setPreferredAudioTrack( - currentTrack?.language, - currentTrack?.id, - currentTrack?.formatIndex, + currentTrack?.language, currentTrack?.id ) val currentVideo = currentVideoTracks.getOrNull(videoIndex) @@ -1508,8 +936,8 @@ class GeneratorPlayer : FullScreenPlayer() { if (width != NO_VALUE && height != NO_VALUE) { player.setMaxVideoSize(width, height, currentVideo?.id) } + trackDialog.dismissSafe(activity) - this.selectTrackDialog = null } } } catch (e: Exception) { @@ -1517,21 +945,9 @@ class GeneratorPlayer : FullScreenPlayer() { } } - override fun playerError(exception: Throwable) { - 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 - ) + override fun playerError(exception: Throwable) { + Log.i(TAG, "playerError = $currentSelectedLink") if (!hasNextMirror()) { viewModel.forceClearCache = true } @@ -1546,94 +962,35 @@ class GeneratorPlayer : FullScreenPlayer() { } private fun startPlayer() { - // We don't want double load when you skip loading - if (isPlayerActive.get()) { - return - } + if (isActive) return // we don't want double load when you skip loading - val links = viewModel.state.sortLinks(currentQualityProfile) + val links = sortLinks(currentQualityProfile) if (links.isEmpty()) { noLinksFound() return } - // Atomic operation to prevent double loading - if (!isPlayerActive.compareAndSet(false, true)) { - return - } loadLink(links.first(), false) - showPlayerMetadata() - } - - private fun showPlayerMetadata() { - val overlay = playerBinding?.playerMetadataScrim ?: return - - val titleView = overlay.findViewById(R.id.player_movie_title) - val logoView = overlay.findViewById(R.id.player_movie_logo) - val metaView = overlay.findViewById(R.id.player_movie_meta) - val descView = overlay.findViewById(R.id.player_movie_overview) - - val load = viewModel.state.generatorState?.response ?: return - val episode = currentMeta as? ResultEpisode - titleView.text = load.name - - bindLogo( - url = load.logoUrl, - headers = load.posterHeaders, - titleView = titleView, - logoView = logoView - ) - - val meta = arrayOf( - load.tags?.takeIf { it.isNotEmpty() }?.joinToString(", "), - load.year?.toString(), - if (!load.type.isMovieType()) - context?.getShortSeasonText( - episode = episode?.episode, - season = episode?.season - ) - else null, - load.score?.let { "⭐ $it" } - ).filterNotNull() - .joinToString(" • ") - - metaView.text = meta - metaView.isVisible = meta.isNotBlank() - - - val description = load.plot - - if (!description.isNullOrBlank()) { - descView.isVisible = true - descView.text = description - } else { - descView.isVisible = false - - } } override fun nextEpisode() { - if (viewModel.hasNextEpisode() == true) { - isNextEpisode = true - releasePlayer() - viewModel.loadLinksNext() - } + isNextEpisode = true + player.release() + viewModel.loadLinksNext() } override fun prevEpisode() { - if (viewModel.hasPrevEpisode() == true) { - isNextEpisode = true - releasePlayer() - viewModel.loadLinksPrev() - } + isNextEpisode = true + player.release() + viewModel.loadLinksPrev() } override fun hasNextMirror(): Boolean { - val links = viewModel.state.sortLinks(currentQualityProfile) + val links = sortLinks(currentQualityProfile) return links.isNotEmpty() && links.indexOf(currentSelectedLink) + 1 < links.size } override fun nextMirror() { - val links = viewModel.state.sortLinks(currentQualityProfile) + val links = sortLinks(currentQualityProfile) if (links.isEmpty()) { noLinksFound() return @@ -1677,15 +1034,49 @@ class GeneratorPlayer : FullScreenPlayer() { viewModel.loadStamps(duration) } + viewModel.getId()?.let { + DataStoreHelper.setViewPos(it, position, duration) + } + val percentage = position * 100L / duration - DataStoreHelper.setViewPosAndResume( - viewModel.state.generatorState?.id, - position, - duration, - currentMeta, - nextMeta - ) + val nextEp = percentage >= NEXT_WATCH_EPISODE_PERCENTAGE + val resumeMeta = if (nextEp) nextMeta else currentMeta + if (resumeMeta == null && nextEp) { + // remove last watched as it is the last episode and you have watched too much + when (val newMeta = currentMeta) { + is ResultEpisode -> { + DataStoreHelper.removeLastWatched(newMeta.parentId) + } + + is ExtractorUri -> { + DataStoreHelper.removeLastWatched(newMeta.parentId) + } + } + } else { + // save resume + when (resumeMeta) { + is ResultEpisode -> { + DataStoreHelper.setLastWatched( + resumeMeta.parentId, + resumeMeta.id, + resumeMeta.episode, + resumeMeta.season, + isFromDownload = false + ) + } + + is ExtractorUri -> { + DataStoreHelper.setLastWatched( + resumeMeta.parentId, + resumeMeta.id, + resumeMeta.episode, + resumeMeta.season, + isFromDownload = true + ) + } + } + } var isOpVisible = false when (val meta = currentMeta) { @@ -1714,12 +1105,8 @@ class GeneratorPlayer : FullScreenPlayer() { playerBinding?.playerSkipEpisode?.isVisible = !isOpVisible && viewModel.hasNextEpisode() == true - else -> { - val hasNextEpisode = viewModel.hasNextEpisode() == true - playerBinding?.playerGoForward?.isVisible = hasNextEpisode - playerBinding?.playerGoForwardRoot?.isVisible = hasNextEpisode - } - + else -> + playerBinding?.playerGoForward?.isVisible = viewModel.hasNextEpisode() == true } if (percentage >= PRELOAD_NEXT_EPISODE_PERCENTAGE) { @@ -1731,28 +1118,33 @@ class GeneratorPlayer : FullScreenPlayer() { subtitles: Set, settings: Boolean, downloads: Boolean ): SubtitleData? { val langCode = preferredAutoSelectSubtitles ?: return null + val lang = fromTwoLettersToLanguage(langCode) ?: return null if (downloads) { - sortSubs(subtitles).firstOrNull { - it.origin == SubtitleOrigin.DOWNLOADED_FILE && it.matchesLanguageCode( - langCode - ) - }?.let { return it } + return subtitles.firstOrNull { sub -> + (sub.origin == SubtitleOrigin.DOWNLOADED_FILE && sub.name == context?.getString( + R.string.default_subtitles + )) + } } - if (!settings) return null + sortSubs(subtitles).firstOrNull { sub -> + val t = sub.name.replace(Regex("[^A-Za-z]"), " ").trim() + (settings) && t == lang || t.startsWith(lang) || t == langCode + }?.let { sub -> + return sub + } - return sortSubs(subtitles).firstOrNull { it.matchesLanguageCode(langCode) } + return null } private fun autoSelectFromSettings(): Boolean { - // auto select subtitle based on settings + // auto select subtitle based of settings val langCode = preferredAutoSelectSubtitles val current = player.getCurrentPreferredSubtitle() Log.i(TAG, "autoSelectFromSettings = $current") context?.let { ctx -> - // Only use the player preferred subtitle if it matches the available language - if (current != null && (langCode == null || current.matchesLanguageCode(langCode))) { - if (setSubtitles(current, false)) { + if (current != null) { + if (setSubtitles(current)) { player.saveData() player.reloadPlayer(ctx) player.handleEvent(CSPlayerEvent.Play) @@ -1760,9 +1152,9 @@ class GeneratorPlayer : FullScreenPlayer() { } } else if (!langCode.isNullOrEmpty()) { getAutoSelectSubtitle( - viewModel.state.subtitles, settings = true, downloads = false + currentSubs, settings = true, downloads = false )?.let { sub -> - if (setSubtitles(sub, false)) { + if (setSubtitles(sub)) { player.saveData() player.reloadPlayer(ctx) player.handleEvent(CSPlayerEvent.Play) @@ -1774,39 +1166,31 @@ class GeneratorPlayer : FullScreenPlayer() { return false } - private fun autoSelectFromDownloads() { - if (player.getCurrentPreferredSubtitle() != null) { - return + private fun autoSelectFromDownloads(): Boolean { + if (player.getCurrentPreferredSubtitle() == null) { + getAutoSelectSubtitle(currentSubs, settings = false, downloads = true)?.let { sub -> + context?.let { ctx -> + if (setSubtitles(sub)) { + player.saveData() + player.reloadPlayer(ctx) + player.handleEvent(CSPlayerEvent.Play) + return true + } + } + } } - val sub = - getAutoSelectSubtitle(viewModel.state.subtitles, settings = false, downloads = true) - ?: return - val ctx = context ?: return - if (!setSubtitles(sub, false)) { - return - } - player.saveData() - player.reloadPlayer(ctx) - player.handleEvent(CSPlayerEvent.Play) + return false } private fun autoSelectSubtitles() { //Log.i(TAG, "autoSelectSubtitles") - safe { + normalSafeApiCall { if (!autoSelectFromSettings()) { autoSelectFromDownloads() } } } - private fun getHeaderName(): String? { - return when (val meta = currentMeta) { - is ResultEpisode -> meta.headerName - is ExtractorUri -> meta.headerName - else -> null - } - } - private fun getPlayerVideoTitle(): String { var headerName: String? = null var subName: String? = null @@ -1853,6 +1237,8 @@ class GeneratorPlayer : FullScreenPlayer() { return "" } + + @SuppressLint("SetTextI18n") fun setTitle() { var playerVideoTitle = getPlayerVideoTitle() @@ -1871,105 +1257,29 @@ class GeneratorPlayer : FullScreenPlayer() { playerBinding?.playerEpisodeFillerHolder?.isVisible = isFiller ?: false playerBinding?.playerVideoTitle?.text = playerVideoTitle - playerBinding?.offlinePin?.isVisible = viewModel.generator is DownloadFileGenerator } + @SuppressLint("SetTextI18n") fun setPlayerDimen(widthHeight: Pair?) { - val resolution = widthHeight?.let { "${it.first}x${it.second}" } - val name = currentSelectedLink?.first?.name ?: currentSelectedLink?.second?.name - val title = getHeaderName() - - val result = listOfNotNull( - title?.takeIf { showTitle && it.isNotBlank() }, - name?.takeIf { showName && it.isNotBlank() }, - resolution?.takeIf { showResolution && it.isNotBlank() }, - ).joinToString(" - ") - - playerBinding?.playerVideoTitleRez?.apply { - text = result - isVisible = result.isNotBlank() + val extra = if (widthHeight != null) { + val (width, height) = widthHeight + "- ${width}x${height}" + } else { + "" } - } + val source = currentSelectedLink?.first?.name ?: currentSelectedLink?.second?.name ?: "NULL" - private fun videoCodecName(mime: String?): String? { - val m = mime?.lowercase() ?: return null - return when { - m.contains("avc") || m.contains("h264") -> "AVC" - m.contains("hevc") || m.contains("h265") -> "HEVC" - m.contains("av1") -> "AV1" - m.contains("vp9") -> "VP9" - m.contains("vp8") -> "VP8" - "/" in m -> m.substringAfter("/").uppercase() - else -> m.uppercase() - } - } - - private fun audioCodecName(mime: String?): String { - val m = mime?.lowercase()?.trim().orEmpty() - if (m.isBlank()) return "" - return when { - m.contains("eac3-joc") -> "Dolby Atmos" - m.contains("truehd") -> "TrueHD" - m.contains("eac3") -> "E-AC3" - m.contains("ac-3") || m.contains("ac3") -> "AC3" - m.contains("aac") || m.contains("mp4a") -> "AAC" - m.contains("opus") -> "Opus" - m.contains("vorbis") -> "Vorbis" - m.contains("mp3") -> "MP3" - m.contains("flac") -> "FLAC" - m.contains("dts") -> "DTS" - m.contains("pcm") -> "PCM" - m.contains("alac") -> "ALAC" - m.contains("amr") -> "AMR" - m.contains("/") -> m.substringAfter("/").uppercase().takeIf { it.isNotBlank() } ?: "" + val title = when (titleRez) { + 0 -> "" + 1 -> extra + 2 -> source + 3 -> "$source $extra" else -> "" } - } - - private fun updatePlayerInfo() { - val tracks = player.getVideoTracks() - - val videoTrack = tracks.currentVideoTrack - val audioTrack = tracks.currentAudioTrack - - val ctx = context ?: return - val prefs = PreferenceManager.getDefaultSharedPreferences(ctx) - showMediaInfo = prefs.getBoolean(ctx.getString(R.string.show_media_info_key), false) - - val videoCodec = videoCodecName(videoTrack?.sampleMimeType) - val audioCodec = audioCodecName(audioTrack?.sampleMimeType) - val languageName = fromTagToLanguageName(audioTrack?.language) - val label = audioTrack?.label - - val channelCount = audioTrack?.channelCount - - val channels = when { - // May be below 1 or null when unknown - channelCount == null || channelCount <= 0 -> "" - channelCount == 1 -> "Mono" - channelCount == 2 -> "Stereo" - channelCount == 6 -> "5.1" - channelCount == 8 -> "7.1" - else -> "${channelCount}ch" - } - - val language = languageName?.takeIf { it.isNotBlank() }?.let { lang -> - label?.takeIf { it.isNotBlank() && !it.equals(lang, true) } - ?.let { lang } - ?: lang - } ?: label?.takeIf { it.isNotBlank() } - - val stats = arrayOf( - videoCodec, - language, - channels, - audioCodec - ).filter { !it.isNullOrBlank() }.joinToString(" • ") - - playerBinding?.playerVideoInfo?.apply { - text = stats - isVisible = showMediaInfo && stats.isNotBlank() + playerBinding?.playerVideoTitleRez?.apply { + text = title + isVisible = title.isNotBlank() } } @@ -1985,13 +1295,31 @@ class GeneratorPlayer : FullScreenPlayer() { } } - /** - * This is used instead of layout-television to follow the - * settings and some TV devices are not classified as TV - * for some reason. - */ - override fun pickLayout(): Int = - if (isLayout(TV or EMULATOR)) R.layout.fragment_player_tv else R.layout.fragment_player + override fun onCreateView( + inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? + ): View? { + // this is used instead of layout-television to follow the settings and some TV devices are not classified as TV for some reason + layout = + if (isLayout(TV or EMULATOR)) R.layout.fragment_player_tv else R.layout.fragment_player + + viewModel = ViewModelProvider(this)[PlayerGeneratorViewModel::class.java] + sync = ViewModelProvider(this)[SyncViewModel::class.java] + + viewModel.attachGenerator(lastUsedGenerator) + unwrapBundle(savedInstanceState) + unwrapBundle(arguments) + + val root = super.onCreateView(inflater, container, savedInstanceState) ?: return null + binding = FragmentPlayerBinding.bind(root) + return root + } + + override fun onDestroyView() { + binding = null + super.onDestroyView() + } + + var timestampShowState = false var skipAnimator: ValueAnimator? = null var skipIndex = 0 @@ -2012,12 +1340,6 @@ class GeneratorPlayer : FullScreenPlayer() { skipAnimator?.cancel() isVisible = true - /** Focus instantly to make the focus color appear instantly */ - if (show && !isShowing) { - // Automatically request focus if the menu is not opened - playerBinding?.skipChapterButton?.requestFocus() - } - // just in case val lay = layoutParams lay.width = from @@ -2026,7 +1348,12 @@ class GeneratorPlayer : FullScreenPlayer() { from, to ).apply { addListener(onEnd = { - if (!show) { + if (show) { + if (!isShowing) { + // Automatically request focus if the menu is not opened + playerBinding?.skipChapterButton?.requestFocus() + } + } else { playerBinding?.skipChapterButton?.isVisible = false if (!isShowing) { // Automatically return focus to play pause @@ -2046,11 +1373,11 @@ class GeneratorPlayer : FullScreenPlayer() { } } - override fun onTimestampSkipped(timestamp: VideoSkipStamp) { + override fun onTimestampSkipped(timestamp: EpisodeSkip.SkipStamp) { displayTimeStamp(false) } - override fun onTimestamp(timestamp: VideoSkipStamp?) { + override fun onTimestamp(timestamp: EpisodeSkip.SkipStamp?) { if (timestamp != null) { playerBinding?.skipChapterButton?.setText(timestamp.uiText) displayTimeStamp(true) @@ -2064,143 +1391,25 @@ class GeneratorPlayer : FullScreenPlayer() { } } - override fun isThereEpisodes(): Boolean { - // Checks if there is a second episode of type ResultEpisode - // => There exists more than 1 episode, and they are all ResultEpisode - return viewModel.state.generatorState?.allMeta?.getOrNull(1) as? ResultEpisode != null - } - - override fun showEpisodesOverlay() { - try { - playerBinding?.apply { - playerEpisodeList.setRecycledViewPool(EpisodeAdapter.sharedPool) - playerEpisodeList.adapter = EpisodeAdapter( - false, - { episodeClick -> - if (episodeClick.action == ACTION_CLICK_DEFAULT) { - isNextEpisode = false - releasePlayer() - playerEpisodeOverlay.isGone = true - episodeClick.position?.let { viewModel.loadThisEpisode(it) } - } - }, - { downloadClickEvent -> - DownloadButtonSetup.handleDownloadClick(downloadClickEvent) - } - ) - playerEpisodeList.setLinearListLayout( - isHorizontal = false, - nextUp = FOCUS_SELF, - nextDown = FOCUS_SELF, - nextRight = FOCUS_SELF, - ) - val episodes = allMeta ?: emptyList() - (playerEpisodeList.adapter as? EpisodeAdapter)?.submitList(episodes) - - // Scroll to current episode - viewModel.state.generatorState?.index?.let { index -> - playerEpisodeList.scrollToPosition(index) - // Ensure focus on tv - if (isLayout(TV)) { - playerEpisodeList.post { - val viewHolder = - playerEpisodeList.findViewHolderForAdapterPosition(index) - viewHolder?.itemView?.requestFocus() - viewHolder?.itemView?.let { itemView -> - itemView.isFocusableInTouchMode = true - itemView.requestFocus() - } - } - } - } - - // update overlay season title - var lastTopIndex = -1 - playerEpisodeList.addOnScrollListener(object : RecyclerView.OnScrollListener() { - override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { - val layoutManager = - recyclerView.layoutManager as? LinearLayoutManager ?: return - val topIndex = layoutManager.findFirstCompletelyVisibleItemPosition() - if (topIndex != RecyclerView.NO_POSITION && topIndex != lastTopIndex) { - @Suppress("AssignedValueIsNeverRead") - lastTopIndex = topIndex - val topItem = episodes.getOrNull(topIndex) - topItem?.let { - playerEpisodeOverlayTitle.setText( - ResultViewModel2.seasonToTxt( - topItem.seasonData, - topItem.seasonIndex - ) - ) - } - } - } - }) - } - } catch (e: Exception) { - logError(e) - } - } - - @MainThread - fun releasePlayer() { - player.release() - currentSelectedSubtitles = null - currentSelectedLink = null - isPlayerActive.set(false) - binding?.overlayLoadingSkipButton?.isVisible = false - binding?.playerLoadingOverlay?.isVisible = true - uiReset() - } - - fun exitPlayer() { - playerHostView?.exitFullscreen() - player.release() - activity?.popCurrentPage() - } - - override fun onSaveInstanceState(outState: Bundle) { - outState.putInt("index", viewModel.episodeIndex) - super.onSaveInstanceState(outState) - } - - override fun onBindingCreated(binding: FragmentPlayerBinding, savedInstanceState: Bundle?) { - viewModel = ViewModelProvider(this)[PlayerGeneratorViewModel::class.java] - sync = ViewModelProvider(this)[SyncViewModel::class.java] - - val uuid = savedInstanceState?.getString("uuid") ?: arguments?.getString("uuid") - val index = savedInstanceState?.getInt("index") ?: arguments?.getInt("index") - val generator = generators[uuid] - - unwrapBundle(savedInstanceState) - unwrapBundle(arguments) - - super.onBindingCreated(binding, savedInstanceState) - - // Avoid showing no links found - if (generator == null || index == null) { - exitPlayer() - return - } - viewModel.attachGenerator(generator, index) + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + var langFilterList = listOf() + var filterSubByLang = false context?.let { ctx -> val settingsManager = PreferenceManager.getDefaultSharedPreferences(ctx) - showName = settingsManager.getBoolean(ctx.getString(R.string.show_name_key), true) - showResolution = - settingsManager.getBoolean(ctx.getString(R.string.show_resolution_key), true) - showMediaInfo = - settingsManager.getBoolean(ctx.getString(R.string.show_media_info_key), false) - limitTitle = settingsManager.getInt(ctx.getString(R.string.prefer_title_limit_key), 0) + titleRez = settingsManager.getInt(ctx.getString(R.string.prefer_limit_title_rez_key), 3) + limitTitle = settingsManager.getInt(ctx.getString(R.string.prefer_limit_title_key), 0) updateForcedEncoding(ctx) - viewModel.filterSubByLang = + + filterSubByLang = settingsManager.getBoolean(getString(R.string.filter_sub_lang_key), false) - if (viewModel.filterSubByLang) { + if (filterSubByLang) { val langFromPrefMedia = settingsManager.getStringSet( this.getString(R.string.provider_lang_key), mutableSetOf("en") ) - viewModel.langFilterList = langFromPrefMedia?.mapNotNull { - fromTagToEnglishLanguageName(it)?.lowercase() ?: return@mapNotNull null + langFilterList = langFromPrefMedia?.mapNotNull { + fromTwoLettersToLanguage(it)?.lowercase() ?: return@mapNotNull null } ?: listOf() } } @@ -2210,60 +1419,30 @@ class GeneratorPlayer : FullScreenPlayer() { sync.updateUserData() - preferredAutoSelectSubtitles = getAutoSelectLanguageTagIETF() + preferredAutoSelectSubtitles = getAutoSelectLanguageISO639_1() - val selectedLink = currentSelectedLink - if (selectedLink == null) { + if (currentSelectedLink == null) { viewModel.loadLinks() - } else { - // Recreated view, so we need to recreate the - loadLink(selectedLink, true) } - binding.overlayLoadingSkipButton.setOnClickListener { - // Mark as "success" early - viewModel.modifyState { - copy(loading = Resource.Success(Unit)) - } + binding?.overlayLoadingSkipButton?.setOnClickListener { + startPlayer() } - binding.playerLoadingGoBack.setOnClickListener { - exitPlayer() + binding?.playerLoadingGoBack?.setOnClickListener { + exitFullscreen() + player.release() + activity?.popCurrentPage() } - playerBinding?.downloadHeader?.setOnClickListener { - it?.isVisible = false - } - - playerBinding?.downloadHeaderToggle?.setOnClickListener { - playerBinding?.downloadHeader?.let { - it.isVisible = !it.isVisible - } - } - - observe(viewModel.currentStamps) { (stamps, instance) -> - if (instance != viewModel.state.instance) return@observe // Outdated observe + observe(viewModel.currentStamps) { stamps -> player.addTimeStamps(stamps) } - observe(viewModel.currentSubtitles) { (subtitles, instance) -> - if (instance != viewModel.state.instance) return@observe // Outdated observe - player.setActiveSubtitles(subtitles) - - // If the file is downloaded then do not select auto select the subtitles - // Downloaded subtitles cannot be selected immediately after loading since - // player.getCurrentPreferredSubtitle() cannot fetch data from non-loaded subtitles - // Resulting in unselecting the downloaded subtitle - if (subtitles.lastOrNull()?.origin != SubtitleOrigin.DOWNLOADED_FILE) { - autoSelectSubtitles() - } - } - observe(viewModel.loadingLinks) { (loading, instance) -> - if (instance != viewModel.state.instance) return@observe // Outdated observe - - when (loading) { + observe(viewModel.loadingLinks) { + when (it) { is Resource.Loading -> { - releasePlayer() + startLoading() } is Resource.Success -> { @@ -2275,31 +1454,21 @@ class GeneratorPlayer : FullScreenPlayer() { } is Resource.Failure -> { - showToast(loading.errorString, Toast.LENGTH_LONG) + showToast(it.errorString, Toast.LENGTH_LONG) startPlayer() } } } - observe(viewModel.currentLinks) { (links, instance) -> - if (instance != viewModel.state.instance) return@observe // Outdated observe + observe(viewModel.currentLinks) { + currentLinks = it + val turnVisible = it.isNotEmpty() && lastUsedGenerator?.canSkipLoading == true + val wasGone = binding?.overlayLoadingSkipButton?.isGone == true + binding?.overlayLoadingSkipButton?.isVisible = turnVisible - val turnVisible = links.isNotEmpty() && viewModel.generator?.canSkipLoading == true - val wasGone = binding.overlayLoadingSkipButton.isGone - - binding.overlayLoadingSkipButton.apply { - isVisible = turnVisible - if (links.isEmpty()) { - setText(R.string.skip_loading) - } else { - @SuppressLint("SetTextI18n") - text = "${context.getString(R.string.skip_loading)} (${links.size})" - } - } - - safe { - if (!isPlayerActive.get() && viewModel.state.links.any { link -> - getLinkPriority(currentQualityProfile, link.first) >= + normalSafeApiCall { + if (currentLinks.any { link -> + getLinkPriority(currentQualityProfile, link) >= QualityDataHelper.AUTO_SKIP_PRIORITY } ) { @@ -2308,15 +1477,37 @@ class GeneratorPlayer : FullScreenPlayer() { } if (turnVisible && wasGone) { - binding.overlayLoadingSkipButton.requestFocus() + binding?.overlayLoadingSkipButton?.requestFocus() + } + } + + observe(viewModel.currentSubs) { set -> + val setOfSub = mutableSetOf() + if (langFilterList.isNotEmpty() && filterSubByLang) { + Log.i("subfilter", "Filtering subtitle") + langFilterList.forEach { lang -> + Log.i("subfilter", "Lang: $lang") + setOfSub += set.filter { + it.name.contains(lang, ignoreCase = true) || + it.origin != SubtitleOrigin.URL + } + } + currentSubs = setOfSub + } else { + currentSubs = set + } + player.setActiveSubtitles(set) + + // If the file is downloaded then do not select auto select the subtitles + // Downloaded subtitles cannot be selected immediately after loading since + // player.getCurrentPreferredSubtitle() cannot fetch data from non-loaded subtitles + // Resulting in unselecting the downloaded subtitle + if (set.lastOrNull()?.origin != SubtitleOrigin.DOWNLOADED_FILE) { + autoSelectSubtitles() } } } } @Suppress("DEPRECATION") -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 - ) +inline fun Bundle.getSafeSerializable(key: String) : T? = if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) getSerializable(key) as? T else getSerializable(key, T::class.java) \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/IGenerator.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/IGenerator.kt index 3ab46ce21..31cf0c70f 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 @@ -6,9 +6,7 @@ import com.lagradost.cloudstream3.utils.ExtractorLinkType val LOADTYPE_INAPP = setOf( ExtractorLinkType.VIDEO, ExtractorLinkType.DASH, - ExtractorLinkType.M3U8, - ExtractorLinkType.TORRENT, - ExtractorLinkType.MAGNET + ExtractorLinkType.M3U8 ) val LOADTYPE_INAPP_DOWNLOAD = setOf( @@ -25,27 +23,27 @@ val LOADTYPE_CHROMECAST = setOf( val LOADTYPE_ALL = ExtractorLinkType.entries.toSet() -abstract class NoVideoGenerator(val id : Int?) : VideoGenerator(emptyList()) { - override val hasCache = false - override val canSkipLoading = false - override fun getId(index: Int): Int? = id -} +interface IGenerator { + val hasCache: Boolean + val canSkipLoading: Boolean -abstract class VideoGenerator(val videos: List) { - abstract val hasCache: Boolean - abstract val canSkipLoading: Boolean - abstract fun getId(index : Int) : Int? + fun hasNext(): Boolean + fun hasPrev(): Boolean + fun next() + fun prev() + fun goto(index: Int) - fun hasNext(videoIndex : Int): Boolean = videoIndex < videos.lastIndex - fun hasPrev(videoIndex : Int): Boolean = videoIndex > 0 + fun getCurrentId(): Int? // this is used to save data or read data about this id + fun getCurrent(offset: Int = 0): Any? // this is used to get metadata about the current playing, can return null + fun getAll(): List? // this us used to get the metadata about all entries, not needed - @Throws - abstract suspend fun generateLinks( + /* not safe, must use try catch */ + suspend fun generateLinks( clearCache: Boolean, sourceTypes: Set, callback: (Pair) -> Unit, subtitleCallback: (SubtitleData) -> Unit, - offset: Int, - isCasting: Boolean + offset: Int = 0, + isCasting: Boolean = false ): Boolean -} +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/IPlayer.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/IPlayer.kt index 034237266..709a4a600 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/IPlayer.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/IPlayer.kt @@ -3,11 +3,30 @@ package com.lagradost.cloudstream3.ui.player import android.content.Context import android.graphics.Bitmap import android.util.Rational -import androidx.annotation.AnyThread -import androidx.annotation.MainThread import com.lagradost.cloudstream3.ui.subtitles.SaveCaptionStyle +import com.lagradost.cloudstream3.utils.EpisodeSkip import com.lagradost.cloudstream3.utils.ExtractorLink -import com.lagradost.cloudstream3.utils.videoskip.VideoSkipStamp + +enum class PlayerEventType(val value: Int) { + Pause(0), + Play(1), + SeekForward(2), + SeekBack(3), + + SkipCurrentChapter(4), + NextEpisode(5), + PrevEpisode(6), + PlayPauseToggle(7), + ToggleMute(8), + Lock(9), + ToggleHide(10), + ShowSpeed(11), + ShowMirrors(12), + Resize(13), + SearchSubtitlesOnline(14), + SkipOp(15), + Restart(16), +} enum class CSPlayerEvent(val value: Int) { Pause(0), @@ -21,14 +40,12 @@ enum class CSPlayerEvent(val value: Int) { PlayPauseToggle(7), ToggleMute(8), Restart(9), - PlayAsAudio(10), } enum class CSPlayerLoading { IsPaused, IsPlaying, IsBuffering, - IsEnded, } enum class PlayerEventSource { @@ -67,13 +84,13 @@ data class ErrorEvent( /** Event when timestamps appear, null when it should disappear */ data class TimestampInvokedEvent( - val timestamp: VideoSkipStamp, + val timestamp: EpisodeSkip.SkipStamp, override val source: PlayerEventSource = PlayerEventSource.Player, ) : PlayerEvent() /** Event for when a chapter is skipped, aka when event is handled (or for future use when skip automatically ads/sponsor) */ data class TimestampSkippedEvent( - val timestamp: VideoSkipStamp, + val timestamp: EpisodeSkip.SkipStamp, override val source: PlayerEventSource = PlayerEventSource.Player, ) : PlayerEvent() @@ -144,17 +161,6 @@ data class VideoEndedEvent( override val source: PlayerEventSource = PlayerEventSource.Player ) : PlayerEvent() -/** Used for torrent to pre-download a video before playing it */ -data class DownloadEvent( - val downloadedBytes: Long, - val totalBytes: Long, - /** bytes / sec */ - val downloadSpeed: Long, - val connections: Int?, - - override val source: PlayerEventSource = PlayerEventSource.Player -) : PlayerEvent() - interface Track { /** * Unique among the class, used to check which track is used. @@ -163,7 +169,6 @@ interface Track { val id: String? val label: String? val language: String? - val sampleMimeType : String? } data class VideoTrack( @@ -172,23 +177,19 @@ data class VideoTrack( override val language: String?, val width: Int?, val height: Int?, - override val sampleMimeType: String?, ) : Track data class AudioTrack( override val id: String?, override val label: String?, override val language: String?, - override val sampleMimeType: String?, - val channelCount: Int?, - val formatIndex: Int?, ) : Track data class TextTrack( override val id: String?, override val label: String?, override val language: String?, - override val sampleMimeType: String?, + val mimeType: String?, ) : Track @@ -201,6 +202,8 @@ data class CurrentTracks( val allTextTracks: List, ) +class InvalidFileException(msg: String) : Exception(msg) + //http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4 const val ACTION_MEDIA_CONTROL = "media_control" const val EXTRA_CONTROL_TYPE = "control_type" @@ -222,9 +225,8 @@ interface IPlayer { fun getSubtitleOffset(): Long // in ms fun setSubtitleOffset(offset: Long) // in ms - @AnyThread fun initCallbacks( - @MainThread eventHandler: ((PlayerEvent) -> Unit), + eventHandler: ((PlayerEvent) -> Unit), /** this is used to request when the player should report back view percentage */ requestedListeningPercentages: List? = null, ) @@ -234,7 +236,7 @@ interface IPlayer { fun updateSubtitleStyle(style: SaveCaptionStyle) fun saveData() - fun addTimeStamps(timeStamps: List) + fun addTimeStamps(timeStamps: List) fun loadPlayer( context: Context, @@ -287,8 +289,8 @@ interface IPlayer { fun setMaxVideoSize(width: Int = Int.MAX_VALUE, height: Int = Int.MAX_VALUE, id: String? = null) /** If no trackLanguage is set it'll default to first track. Specifying the id allows for track overrides as the language can be identical. */ - fun setPreferredAudioTrack(trackLanguage: String?, id: String? = null, formatIndex: Int? = null) + fun setPreferredAudioTrack(trackLanguage: String?, id: String? = null) /** Get the current subtitle cues, for use with syncing */ fun getSubtitleCues(): List -} +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/LinkGenerator.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/LinkGenerator.kt index db06e26e9..109e3137b 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 @@ -2,14 +2,12 @@ package com.lagradost.cloudstream3.ui.player import android.net.Uri import com.lagradost.cloudstream3.TvType -import com.lagradost.cloudstream3.actions.temp.CloudStreamPackage import com.lagradost.cloudstream3.amap import com.lagradost.cloudstream3.utils.ExtractorLink import com.lagradost.cloudstream3.utils.ExtractorLinkType import com.lagradost.cloudstream3.utils.INFER_TYPE import com.lagradost.cloudstream3.utils.Qualities import com.lagradost.cloudstream3.utils.loadExtractor -import com.lagradost.cloudstream3.utils.newExtractorLink import com.lagradost.cloudstream3.utils.unshortenLinkSafe data class ExtractorUri( @@ -35,13 +33,41 @@ data class BasicLink( val url: String, val name: String? = null, ) - class LinkGenerator( private val links: List, private val extract: Boolean = true, - private val refererUrl: String? = null, - id: Int? -) : NoVideoGenerator(id) { + private val referer: String? = null, + private val isM3u8: Boolean? = null +) : IGenerator { + override val hasCache = false + override val canSkipLoading = true + + override fun getCurrentId(): Int? { + return null + } + + override fun hasNext(): Boolean { + return false + } + + override fun getAll(): List? { + return null + } + + override fun hasPrev(): Boolean { + return false + } + + override fun getCurrent(offset: Int): Any? { + return null + } + + override fun goto(index: Int) {} + + override fun next() {} + + override fun prev() {} + override suspend fun generateLinks( clearCache: Boolean, sourceTypes: Set, @@ -51,7 +77,7 @@ class LinkGenerator( isCasting: Boolean ): Boolean { links.amap { link -> - if (!extract || !loadExtractor(link.url, refererUrl, { + if (!extract || !loadExtractor(link.url, referer, { subtitleCallback(PlayerSubtitleHelper.getSubtitleData(it)) }) { callback(it to null) @@ -59,43 +85,18 @@ class LinkGenerator( // if don't extract or if no extractor found simply return the link callback( - newExtractorLink( + ExtractorLink( "", link.name ?: link.url, unshortenLinkSafe(link.url), // unshorten because it might be a raw link + referer ?: "", + Qualities.Unknown.value, type = INFER_TYPE, - ) { - this.referer = refererUrl ?: "" - this.quality = Qualities.Unknown.value - } to null + ) to null ) } } - return true - } -} - -class MinimalLinkGenerator( - private val links: List, - private val subs: List, - id: Int? -) : NoVideoGenerator(id) { - override suspend fun generateLinks( - clearCache: Boolean, - sourceTypes: Set, - callback: (Pair) -> Unit, - subtitleCallback: (SubtitleData) -> Unit, - offset: Int, - isCasting: Boolean - ): Boolean { - for (link in links) { - callback(link.toExtractorLink()) - } - for (link in subs) { - subtitleCallback(link.toSubtitleData()) - } - return true } } \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/OfflinePlaybackHelper.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/OfflinePlaybackHelper.kt index dcf976612..f00f8a61b 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,79 +1,28 @@ package com.lagradost.cloudstream3.ui.player import android.app.Activity -import android.content.Intent +import android.content.ContentUris import android.net.Uri import androidx.core.content.ContextCompat.getString -import androidx.navigation.NavOptions +import androidx.media3.common.util.UnstableApi import com.lagradost.cloudstream3.R -import com.lagradost.cloudstream3.actions.temp.CloudStreamPackage -import com.lagradost.cloudstream3.utils.AppUtils.tryParseJson -import com.lagradost.cloudstream3.utils.DataStoreHelper import com.lagradost.cloudstream3.utils.UIHelper.navigate import com.lagradost.safefile.SafeFile object OfflinePlaybackHelper { - /** - * Pop any existing player off the nav back stack before pushing the new one, - * keeping the stack flat (at most one player at a time). This prevents an - * OOM when many files are opened in sequence via DownloadedPlayerActivity. - */ - private val replacePlayerNavOptions = NavOptions.Builder() - .setPopUpTo(R.id.navigation_player, inclusive = true, saveState = false) - .build() - fun playLink(activity: Activity, url: String) { activity.navigate( R.id.global_to_navigation_player, GeneratorPlayer.newInstance( LinkGenerator( listOf( BasicLink(url) - ), id = url.hashCode() - ), 0 - ), - replacePlayerNavOptions + ) + ) + ) ) } - // See CloudStreamPackage - fun playIntent(activity: Activity, intent: Intent?): Boolean { - if (intent == null) return false - val links = intent.getStringArrayExtra(CloudStreamPackage.LINKS_EXTRA) - ?.mapNotNull { tryParseJson(it) } ?: emptyList() - if (links.isEmpty()) return false - val subs = intent.getStringArrayExtra(CloudStreamPackage.SUBTITLE_EXTRA) - ?.mapNotNull { tryParseJson(it) } ?: emptyList() - - val id = intent.getIntExtra(CloudStreamPackage.ID_EXTRA, -1) - //val title = intent.getStringExtra(CloudStreamPackage.TITLE_EXTRA) // unused - val pos = intent.getLongExtra(CloudStreamPackage.POSITION_EXTRA, -1L) - val dur = intent.getLongExtra(CloudStreamPackage.DURATION_EXTRA, -1L) - - if (id != -1 && pos != -1L) { - val duration = if (dur != -1L) { - dur - } else DataStoreHelper.getViewPos(id)?.duration ?: pos - DataStoreHelper.setViewPos(id, pos, duration) - } - - activity.navigate( - R.id.global_to_navigation_player, GeneratorPlayer.newInstance( - MinimalLinkGenerator( - links, - subs, - if (id != -1) id else null, - ), 0 - ), - replacePlayerNavOptions - ) - return true - } - fun playUri(activity: Activity, uri: Uri) { - if (uri.scheme == "magnet") { - playLink(activity, uri.toString()) - return - } val name = SafeFile.fromUri(activity, uri)?.name() activity.navigate( R.id.global_to_navigation_player, GeneratorPlayer.newInstance( @@ -84,12 +33,11 @@ object OfflinePlaybackHelper { name = name ?: getString(activity, R.string.downloaded_file), // well not the same as a normal id, but we take it as users may want to // play downloaded files and save the location - id = uri.lastPathSegment?.toLongOrNull()?.hashCode() ?: uri.lastPathSegment?.hashCode() + id = kotlin.runCatching { ContentUris.parseId(uri) }.getOrNull()?.hashCode() ) ) - ), 0 - ), - replacePlayerNavOptions + ) + ) ) } } \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/OutlineSpan.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/OutlineSpan.kt deleted file mode 100644 index f011ef37b..000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/OutlineSpan.kt +++ /dev/null @@ -1,10 +0,0 @@ -package com.lagradost.cloudstream3.ui.player - -import android.text.TextPaint -import android.text.style.CharacterStyle -import androidx.annotation.Px - -// source: https://github.com/androidx/media/pull/1840 -class OutlineSpan(@Px val outlineWidth : Float) : CharacterStyle() { - override fun updateDrawState(tp: TextPaint?) { tp?.strokeWidth = outlineWidth } -} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerGeneratorViewModel.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerGeneratorViewModel.kt index e3c390d50..67cd9de6d 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerGeneratorViewModel.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerGeneratorViewModel.kt @@ -9,188 +9,34 @@ import com.lagradost.cloudstream3.LoadResponse import com.lagradost.cloudstream3.mvvm.Resource import com.lagradost.cloudstream3.mvvm.launchSafe import com.lagradost.cloudstream3.mvvm.logError +import com.lagradost.cloudstream3.mvvm.normalSafeApiCall import com.lagradost.cloudstream3.mvvm.safeApiCall -import com.lagradost.cloudstream3.ui.player.source_priority.QualityDataHelper.getLinkPriority import com.lagradost.cloudstream3.ui.result.ResultEpisode import com.lagradost.cloudstream3.utils.Coroutines.ioSafe +import com.lagradost.cloudstream3.utils.EpisodeSkip import com.lagradost.cloudstream3.utils.ExtractorLink import com.lagradost.cloudstream3.utils.ExtractorLinkType -import com.lagradost.cloudstream3.utils.videoskip.SkipAPI -import com.lagradost.cloudstream3.utils.videoskip.VideoSkipStamp -import kotlinx.collections.immutable.PersistentList -import kotlinx.collections.immutable.PersistentSet -import kotlinx.collections.immutable.persistentListOf -import kotlinx.collections.immutable.persistentSetOf -import kotlinx.collections.immutable.toPersistentList -import kotlinx.collections.immutable.toPersistentSet import kotlinx.coroutines.Job -import kotlinx.coroutines.isActive import kotlinx.coroutines.launch -import org.jetbrains.annotations.Contract -import java.util.concurrent.ConcurrentHashMap - -typealias VideoLink = Pair - -data class GeneratorState( - val meta: Any?, - val nextMeta: Any?, - val allMeta: List<*>?, - val response: LoadResponse?, - val index: Int, - val id: Int?, -) - -/** Immutable state of all current links relevant to displaying the video */ -// @MustUseReturnValues -// @Immutable -data class VideoState( - val subtitles: PersistentSet = persistentSetOf(), - val links: PersistentSet = persistentSetOf(), - val stamps: PersistentList = persistentListOf(), - val loading: Resource = Resource.Loading(), - val generatorState: GeneratorState? = null, - val instance: Int, -) { - /** - * This acts as a local cache for sorted links that are not copied over by the copy constructor. - * - * sortedBy is not exactly expensive, but each hasNextMirror does it again, so this alleviates unnecessary recomputation - * */ - private val sortedLinks: ConcurrentHashMap> = ConcurrentHashMap() - - fun clearSortedLinksCache() = sortedLinks.clear() - - // Modifying sortedLinks is not considered a "visible" side effect, and rerunning it does not change the result - // It is by all standards, idempotent and by extension also pure as it has no "visible" side effect - /** Returns .links in the sorted order according to the qualityProfile. - * Use .links if order is not needed */ - @Contract(pure = true) - fun sortLinks(qualityProfile: Int): List { - return sortedLinks[qualityProfile] ?: links.sortedBy { link -> - // negative because we want to sort highest quality first - -getLinkPriority(qualityProfile, link.first) - }.also { value -> sortedLinks[qualityProfile] = value } - } - - @Contract(pure = true) - fun add(item: SubtitleData): VideoState = copy(subtitles = subtitles.add(item)) - - @Contract(pure = true) - fun add(item: VideoLink): VideoState = copy(links = links.add(item)) - - @Contract(pure = true) - fun add(item: VideoSkipStamp): VideoState = copy(stamps = stamps.add(item)) - - @JvmName("addSubtitleData") - @Contract(pure = true) - fun add(items: Collection): VideoState = copy(subtitles = subtitles.addAll(items)) - - @JvmName("addVideoLink") - @Contract(pure = true) - fun add(items: Collection): VideoState = copy(links = links.addAll(items)) - - @JvmName("addVideoSkipStamp") - @Contract(pure = true) - fun add(items: Collection): VideoState = copy(stamps = stamps.addAll(items)) - - @Contract(pure = true) - fun set(item: SubtitleData): VideoState = copy(subtitles = persistentSetOf(item)) - - @Contract(pure = true) - fun set(item: VideoLink): VideoState = copy(links = persistentSetOf(item)) - - @Contract(pure = true) - fun set(item: VideoSkipStamp): VideoState = copy(stamps = persistentListOf(item)) - - @JvmName("setSubtitleData") - @Contract(pure = true) - fun set(items: Collection): VideoState = copy(subtitles = items.toPersistentSet()) - - @JvmName("setVideoLink") - @Contract(pure = true) - fun set(items: Collection): VideoState = copy(links = items.toPersistentSet()) - - @JvmName("setVideoSkipStamp") - @Contract(pure = true) - fun set(items: Collection): VideoState = copy(stamps = items.toPersistentList()) -} - -data class VideoLive( - val value: T, - val instance: Int, -) class PlayerGeneratorViewModel : ViewModel() { companion object { const val TAG = "PlayViewGen" } - @Volatile - var generator: VideoGenerator<*>? = null + private var generator: IGenerator? = null - @Volatile - var episodeIndex: Int = 0 + private val _currentLinks = MutableLiveData>>(setOf()) + val currentLinks: LiveData>> = _currentLinks - /** - * The state of the video player, only modify it by modifyState to make sure observe is called, - * and avoid concurrency issues. - * - * This value can be used without Synchronized or locking when reading, as all fields are immutable. - * */ - @Volatile - var state = VideoState(instance = 0) - private set + private val _currentSubs = MutableLiveData>(setOf()) + val currentSubs: LiveData> = _currentSubs - private val _currentLinks = - MutableLiveData>>>(null) - val currentLinks: LiveData>>> = _currentLinks + private val _loadingLinks = MutableLiveData>() + val loadingLinks: LiveData> = _loadingLinks - private val _currentSubtitles = MutableLiveData>>(null) - val currentSubtitles: LiveData>> = _currentSubtitles - - private val _loadingLinks = MutableLiveData>>() - val loadingLinks: LiveData>> = _loadingLinks - - private val _currentStamps = MutableLiveData>>(null) - val currentStamps: LiveData>> = _currentStamps - - /** - * Modifies the `state` variable safely, and with the correct observe behavior. - * - * Synchronized to avoid concurrency issues, and make this operation atomic. - * Otherwise, one update may be lost if they are done in parallel. - * */ - @Synchronized - fun modifyState(op: VideoState.() -> VideoState) { - val oldState = state - state = op.invoke(oldState) - - /** New instance, always push state */ - if (state.instance != oldState.instance) { - _currentSubtitles.postValue(VideoLive(state.subtitles, state.instance)) - _currentStamps.postValue(VideoLive(state.stamps, state.instance)) - _currentLinks.postValue(VideoLive(state.links, state.instance)) - _loadingLinks.postValue(VideoLive(state.loading, state.instance)) - return - } - - /** - * Only post the changed values, this makes sure we do not invoke the "observe" - * - * We do this by "Referential equality" https://kotlinlang.org/docs/equality.html#referential-equality - * to avoid comparing the entire set or list as "Persistent" classes will hold the same reference if they are unchanged. - * */ - if (state.links !== oldState.links) - _currentLinks.postValue(VideoLive(state.links, state.instance)) - if (state.stamps !== oldState.stamps) - _currentStamps.postValue(VideoLive(state.stamps, state.instance)) - if (state.subtitles !== oldState.subtitles) - _currentSubtitles.postValue(VideoLive(state.subtitles, state.instance)) - - /** Normal equality here as it is not a collection */ - if (state.loading != oldState.loading) - _loadingLinks.postValue(VideoLive(state.loading, state.instance)) - } + private val _currentStamps = MutableLiveData>(emptyList()) + val currentStamps: LiveData> = _currentStamps private val _currentSubtitleYear = MutableLiveData(null) val currentSubtitleYear: LiveData = _currentSubtitleYear @@ -206,32 +52,37 @@ class PlayerGeneratorViewModel : ViewModel() { _currentSubtitleYear.postValue(year) } + fun getId(): Int? { + return generator?.getCurrentId() + } + + fun loadLinks(episode: Int) { + generator?.goto(episode) + loadLinks() + } + fun loadLinksPrev() { Log.i(TAG, "loadLinksPrev") - if (generator?.hasPrev(episodeIndex) == true) { - episodeIndex += 1 + if (generator?.hasPrev() == true) { + generator?.prev() loadLinks() } } fun loadLinksNext() { Log.i(TAG, "loadLinksNext") - if (generator?.hasNext(episodeIndex) == true) { - episodeIndex += 1 + if (generator?.hasNext() == true) { + generator?.next() loadLinks() } } fun hasNextEpisode(): Boolean? { - return generator?.hasNext(episodeIndex) - } - - fun hasPrevEpisode(): Boolean? { - return generator?.hasPrev(episodeIndex) + return generator?.hasNext() } fun preLoadNextLinks() { - val id = generator?.getId(episodeIndex) + val id = getId() // Do not preload if already loading if (id == currentLoadingEpisodeId) return @@ -241,15 +92,14 @@ class PlayerGeneratorViewModel : ViewModel() { currentJob = viewModelScope.launch { try { - if (generator?.hasCache == true && generator?.hasNext(episodeIndex) == true) { + if (generator?.hasCache == true && generator?.hasNext() == true) { safeApiCall { generator?.generateLinks( sourceTypes = LOADTYPE_INAPP, clearCache = false, - isCasting = false, callback = {}, subtitleCallback = {}, - offset = episodeIndex + 1 + offset = 1 ) } } @@ -262,138 +112,104 @@ class PlayerGeneratorViewModel : ViewModel() { } } } - - fun loadThisEpisode(index: Int) { - episodeIndex = index - loadLinks() + fun getLoadResponse(): LoadResponse? { + return normalSafeApiCall { (generator as? RepoLinkGenerator?)?.page } } - fun attachGenerator(newGenerator: VideoGenerator<*>, index: Int) { - Log.i(TAG, "attachGenerator with generator=$newGenerator and index=$index") - generator = newGenerator - episodeIndex = index + fun getMeta(): Any? { + return normalSafeApiCall { generator?.getCurrent() } + } + + fun getAllMeta(): List? { + return normalSafeApiCall { generator?.getAll() } + } + + fun getNextMeta(): Any? { + return normalSafeApiCall { + if (generator?.hasNext() == false) return@normalSafeApiCall null + generator?.getCurrent(offset = 1) + } + } + + fun attachGenerator(newGenerator: IGenerator?) { + if (generator == null) { + generator = newGenerator + } } /** * If duplicate nothing will happen * */ fun addSubtitles(file: Set) { - val validFile = file.filter(::isValidSubtitle) - if (validFile.isNotEmpty()) - modifyState { - add(validFile) - } + val currentSubs = _currentSubs.value ?: emptySet() + // Prevent duplicates + val allSubs = (currentSubs + file).distinct().toSet() + // Do not post if there's nothing new + // Posting will refresh subtitles which will in turn + // make the subs to english if previously unselected + if (allSubs != currentSubs) { + _currentSubs.postValue(allSubs) + } } private var currentJob: Job? = null private var currentStampJob: Job? = null fun loadStamps(duration: Long) { + //currentStampJob?.cancel() currentStampJob = ioSafe { - val genState = state.generatorState ?: return@ioSafe - val meta = genState.meta - val page = genState.response - val id = genState.id - if (page == null || meta !is ResultEpisode) { - return@ioSafe + val meta = generator?.getCurrent() + val page = (generator as? RepoLinkGenerator?)?.page + if (page != null && meta is ResultEpisode) { + _currentStamps.postValue(listOf()) + _currentStamps.postValue( + EpisodeSkip.getStamps( + page, + meta, + duration, + hasNextEpisode() ?: false + ) + ) } - val stamps = SkipAPI.videoStamps( - page, - meta, - duration, - hasNextEpisode() ?: false - ) - - /** Avoid adding stamps to the wrong video */ - modifyState { - if (id != this.generatorState?.id) { - this - } else { - set(stamps) - } - } - } - } - - var langFilterList = listOf() - var filterSubByLang = false - - fun isValidSubtitle(subtitle: SubtitleData): Boolean { - if (langFilterList.isEmpty() || !filterSubByLang) { - return true - } - - /** Only filter out subtitles fetched online */ - if (subtitle.origin != SubtitleOrigin.URL) { - return true - } - - return langFilterList.any { lang -> - subtitle.originalName.contains(lang, ignoreCase = true) } } fun loadLinks(sourceTypes: Set = LOADTYPE_INAPP) { - Log.i(TAG, "loadLinks with generator=$generator and index=$episodeIndex") + Log.i(TAG, "loadLinks") currentJob?.cancel() - val index = episodeIndex - // Clear old data and reset the state - modifyState { - VideoState( - loading = Resource.Loading(), - generatorState = generator?.let { gen -> - GeneratorState( - meta = gen.videos.getOrNull(index), - nextMeta = gen.videos.getOrNull(index + 1), - id = gen.getId(index), - response = (gen as? RepoLinkGenerator)?.page, - index = index, - allMeta = gen.videos - ) - }, - instance = instance + 1 + currentJob = viewModelScope.launchSafe { + val currentLinks = mutableSetOf>() + val currentSubs = mutableSetOf() + + // clear old data + _currentSubs.postValue(emptySet()) + _currentLinks.postValue(emptySet()) + + // load more data + _loadingLinks.postValue(Resource.Loading()) + val loadingState = safeApiCall { + generator?.generateLinks(sourceTypes = sourceTypes, clearCache = forceClearCache, callback = { + currentLinks.add(it) + // Clone to prevent ConcurrentModificationException + normalSafeApiCall { + // Extra normalSafeApiCall since .toSet() iterates. + _currentLinks.postValue(currentLinks.toSet()) + } + }, subtitleCallback = { + currentSubs.add(it) + normalSafeApiCall { + _currentSubs.postValue(currentSubs.toSet()) + } + }) + } + + _loadingLinks.postValue(loadingState) + _currentLinks.postValue(currentLinks) + _currentSubs.postValue( + currentSubs.union(_currentSubs.value ?: emptySet()) ) } - currentJob = viewModelScope.launchSafe { - // Load more data - val loadingState = safeApiCall { - generator?.generateLinks( - sourceTypes = sourceTypes, - clearCache = forceClearCache, - callback = { link -> - if (isActive) - modifyState { - add(link) - } - }, - isCasting = false, - offset = index, - subtitleCallback = { link -> - if (isActive && isValidSubtitle(link)) - modifyState { - add(link) - } - }) - Unit - } - - if (!isActive) { - return@launchSafe - } - - /** Only mark as success if we have not skipped loading */ - modifyState { - if (!isActive) { - this - } else { - when (loading) { - is Resource.Loading -> copy(loading = loadingState) - else -> this - } - } - } - } } } \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerGestureHelper.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerGestureHelper.kt deleted file mode 100644 index 1c7086d12..000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerGestureHelper.kt +++ /dev/null @@ -1,1220 +0,0 @@ -package com.lagradost.cloudstream3.ui.player - -import android.animation.ValueAnimator -import android.annotation.SuppressLint -import android.app.Activity -import android.content.Context -import android.content.res.ColorStateList -import android.graphics.Matrix -import android.media.AudioManager -import android.media.audiofx.LoudnessEnhancer -import android.os.Build -import android.os.Handler -import android.os.Looper -import android.provider.Settings -import android.view.KeyEvent -import android.view.LayoutInflater -import android.view.MotionEvent -import android.view.ScaleGestureDetector -import android.view.View -import android.view.ViewGroup -import android.view.WindowInsets -import android.view.animation.AlphaAnimation -import android.view.animation.Animation -import android.view.animation.AnimationUtils -import androidx.annotation.OptIn -import androidx.core.content.ContextCompat -import androidx.core.view.isGone -import androidx.core.view.isVisible -import androidx.media3.common.util.UnstableApi -import androidx.media3.exoplayer.ExoPlayer -import androidx.media3.ui.AspectRatioFrameLayout -import androidx.preference.PreferenceManager -import com.lagradost.cloudstream3.CommonActivity.keyEventListener -import com.lagradost.cloudstream3.CommonActivity.screenHeightWithOrientation -import com.lagradost.cloudstream3.CommonActivity.screenWidthWithOrientation -import com.lagradost.cloudstream3.CommonActivity.showToast -import com.lagradost.cloudstream3.R -import com.lagradost.cloudstream3.mvvm.logError -import com.lagradost.cloudstream3.mvvm.safe -import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR -import com.lagradost.cloudstream3.ui.settings.Globals.PHONE -import com.lagradost.cloudstream3.ui.settings.Globals.isLayout -import com.lagradost.cloudstream3.utils.DataStoreHelper -import com.lagradost.cloudstream3.utils.UIHelper.getStatusBarHeight -import com.lagradost.cloudstream3.utils.Vector2 -import kotlin.math.abs -import kotlin.math.absoluteValue -import kotlin.math.ceil -import kotlin.math.max -import kotlin.math.min -import kotlin.math.round -import kotlin.math.roundToInt - -/** - * Handles all gesture, volume, brightness, speed-up, zoom, and hardware-key-event input for a - * [PlayerView]. Keeps these separate from the player-view setup and lifecycle - * code in [PlayerView] itself. - * - * Instantiated and owned by [PlayerView]; accessed from host fragments via the delegate - * properties [PlayerView] exposes. - */ -@OptIn(UnstableApi::class) -class PlayerGestureHelper(private val playerView: PlayerView) { - - companion object { - /** Swipe-seek constants */ - const val MINIMUM_SEEK_TIME = 7000L - const val MINIMUM_VERTICAL_SWIPE = 2.0f // % of screen height - const val MINIMUM_HORIZONTAL_SWIPE = 2.0f // % of screen height - const val VERTICAL_MULTIPLIER = 2.0f - const val HORIZONTAL_MULTIPLIER = 2.0f - - /** Double-tap constants */ - /** Maximum finger-hold time (ms) for a tap to qualify as a double-tap seek. */ - const val DOUBLE_TAP_MAXIMUM_HOLD_TIME = 200L - /** Time window (ms) between taps to count as a double-tap. - * Also determines how long a single-tap is delayed before firing. */ - const val DOUBLE_TAP_MINIMUM_TIME_BETWEEN = 200L - /** Fraction of view width on each side that counts as "left" / "right" seek zone. */ - const val DOUBLE_TAP_PAUSE_PERCENTAGE = 0.15 - - /** Zoom constants */ - /** Minimum zoom; allows zooming out past 100% but snaps back. */ - const val MINIMUM_ZOOM = 0.95f - /** Sensitivity for the auto-snap to 100% at the minimum zoom boundary. */ - const val ZOOM_SNAP_SENSITIVITY = 0.07f - /** Maximum zoom to prevent the user from getting lost. */ - const val MAXIMUM_ZOOM = 4.0f - - /** Extracts translation and uniform scale from a matrix with no rotation. */ - fun matrixToTranslationAndScale(matrix: Matrix): Triple { - val points = floatArrayOf(0f, 0f, 1f, 1f) - matrix.mapPoints(points) - val translationX = points[0] - val translationY = points[1] - val scale = points[2] - translationX - return Triple(translationX, translationY, scale) - } - } - - private val context: Context get() = playerView.context - - /** Set true by the host when the player occupies the full screen. - * Controls whether hardware volume-key overrides are active (phones/emulators only). */ - var isFullScreen: Boolean = false - - /** Volume state */ - var currentRequestedVolume: Float = 0.0f - var isVolumeLocked: Boolean = false - var hasShownVolumeToast: Boolean = false - private var loudnessEnhancer: LoudnessEnhancer? = null - private var progressBarLeftHideRunnable: Runnable? = null - - /** Brightness state */ - var currentRequestedBrightness: Float = 1.0f - var currentExtraBrightness: Float = 0.0f - var isBrightnessLocked: Boolean = false - var hasShownBrightnessToast: Boolean = false - /** When true, read/write system brightness via [Settings.System.SCREEN_BRIGHTNESS]. - * Automatically falls back to window-attribute brightness if the permission is missing. */ - var useTrueSystemBrightness: Boolean = true - /** White overlay inflated into exo_content_frame; alpha encodes extra brightness (0–1). */ - var brightnessOverlay: View? = null - private var progressBarRightHideRunnable: Runnable? = null - - /** Gesture settings (read from prefs in initialize) */ - var swipeVerticalEnabled: Boolean = true - var swipeHorizontalEnabled: Boolean = false - var extraBrightnessEnabled: Boolean = false - var speedupEnabled: Boolean = false - - /** Hold / speed-up */ - val holdHandler = Handler(Looper.getMainLooper()) - var hasTriggeredSpeedUp = false - val holdRunnable = Runnable { - playerView.player.setPlaybackSpeed(2.0f) - showOrHideSpeedUp(true) - playerView.callbacks?.onHoldSpeedUp(true) - hasTriggeredSpeedUp = true - } - - enum class TouchAction { Brightness, Volume, Time } - - /** Mirrors the host's lock state; suppresses gesture interactions when true. */ - var isLocked: Boolean = false - - /** Touch tracking */ - var isCurrentTouchValid = false - private set - private var currentTouchStart: Vector2? = null - private var currentTouchLast: Vector2? = null - /** Current in-progress swipe action, null when no swipe is active. */ - var currentTouchAction: TouchAction? = null - /** Action from the previous touch sequence; guards against mis-detected double-taps after swipes. */ - var currentLastTouchAction: TouchAction? = null - /** The time in the player when you first click. */ - private var currentTouchStartPlayerTime: Long? = null - /** The system time when you first click. */ - private var currentTouchStartTime: Long? = null - /** Whether the player UI was visible when the current swipe gesture began. */ - var uiShowingBeforeGesture: Boolean = false - - /** Icons */ - private val brightnessIcons = listOf( - R.drawable.sun_1, R.drawable.sun_2, R.drawable.sun_3, - R.drawable.sun_4, R.drawable.sun_5, R.drawable.sun_6, R.drawable.sun_7, - ) - private val volumeIcons = listOf( - R.drawable.ic_baseline_volume_mute_24, - R.drawable.ic_baseline_volume_down_24, - R.drawable.ic_baseline_volume_up_24, - ) - - /** Double-tap / tap state */ - - /** Whether double-tapping left/right seeks backward/forward. */ - var doubleTapEnabled: Boolean = false - - /** Whether double-tapping the center of the screen pauses (left/right still seeks if [doubleTapEnabled]). */ - var doubleTapPauseEnabled: Boolean = false - - /** Seek distance (ms) for each double-tap seek. Read from prefs in [initialize]. */ - var fastForwardTime: Long = 10_000L - - /** Monotonically-incremented token; cancels any pending single-tap runnable when a double-tap arrives. */ - private var doubleTapToken = 0 - - /** Number of consecutive taps in the current double-tap window. */ - private var tapCount = 0 - - /** System time of the most-recent touch end. Updated by callers at the end of every ACTION_UP. */ - var lastTouchEndTime: Long = 0L - - /** Zoom state */ - - /** Optional view for showing the snap-hint outline during zoom (set by FullScreenPlayer). */ - var videoOutline: View? = null - - /** Current zoom+pan matrix, or null when no zoom is active. */ - var zoomMatrix: Matrix? = null - - /** The matrix the zoom will animate to after the user lifts fingers. */ - var desiredMatrix: Matrix? = null - - /** Running snap-back animation, or null. */ - var matrixAnimation: ValueAnimator? = null - - private var scaleGestureDetector: ScaleGestureDetector? = null - - /** Midpoint of the two-finger pan, null when no pan is active. */ - var lastPan: Vector2? = null - - private var overlayLayoutListener: View.OnLayoutChangeListener? = null - - /** Called from [PlayerView.initialize] after views are bound. */ - fun initialize() { - try { - val sm = PreferenceManager.getDefaultSharedPreferences(context) - swipeVerticalEnabled = sm.getBoolean(context.getString(R.string.swipe_vertical_enabled_key), true) - swipeHorizontalEnabled = sm.getBoolean(context.getString(R.string.swipe_enabled_key), true) - extraBrightnessEnabled = sm.getBoolean(context.getString(R.string.extra_brightness_key), false) - speedupEnabled = sm.getBoolean(context.getString(R.string.speedup_key), false) - doubleTapEnabled = sm.getBoolean(context.getString(R.string.double_tap_enabled_key), false) - doubleTapPauseEnabled = sm.getBoolean(context.getString(R.string.double_tap_pause_enabled_key), false) - fastForwardTime = sm.getInt(context.getString(R.string.double_tap_seek_time_key), 10).toLong() * 1000L - } catch (_: Exception) { - } - - // Inject the brightness overlay into the ExoPlayer content frame so it sits - // directly on top of the video surface. Alpha is set by handleBrightnessAdjustment. - safe { - val pkg = context.packageName - @SuppressLint("DiscouragedApi") - val contentId = context.resources.getIdentifier("exo_content_frame", "id", pkg) - val contentFrame = playerView.exoPlayerView?.findViewById(contentId) - if (contentFrame != null) { - brightnessOverlay?.let { - (it.parent as? ViewGroup)?.removeView(it) - } - brightnessOverlay = LayoutInflater.from(context) - .inflate(R.layout.extra_brightness_overlay, contentFrame, false) - contentFrame.addView(brightnessOverlay) - } - } - - setupTouchGestures() - } - - /** Called from [PlayerView.release]. */ - fun release() { - safe { - brightnessOverlay?.let { - (it.parent as? ViewGroup)?.removeView(it) - } - } - brightnessOverlay = null - loudnessEnhancer?.release() - loudnessEnhancer = null - holdHandler.removeCallbacksAndMessages(null) - clearZoomState() - releaseOverlayLayoutListener() - } - - /** Key-event listener */ - - /** - * Registers the basic volume-key listener on [keyEventListener]. - * Called from [PlayerView.initialize] and from the host fragment's onResume. - */ - fun setupKeyEventListener() { - keyEventListener = { (event, _) -> - if (event != null && event.action == KeyEvent.ACTION_DOWN) - handleVolumeKey(event.keyCode) - else false - } - } - - /** Nulls [keyEventListener]. Called from the host fragment's onPause. */ - fun releaseKeyEventListener() { - keyEventListener = null - } - - /** Speed-up */ - - fun showOrHideSpeedUp(show: Boolean) { - playerView.playerSpeedupButton?.let { btn -> - btn.clearAnimation() - btn.alpha = if (show) 0f else 1f - btn.isVisible = show - btn.animate() - .alpha(if (show) 1f else 0f) - .setDuration(200L) - .withEndAction { if (!show) btn.isVisible = false } - .start() - } - } - - /** Volume helpers */ - - /** - * Syncs [currentRequestedVolume] with the current system stream volume. - * - * This is here to make returning to the player less jarring, if we change the volume outside - * the app. Note that this will make it a bit wierd when using loudness in PiP, then returning - * however that is the cost of correctness. - */ - fun verifyVolume() { - ((context as? Activity)?.getSystemService(Context.AUDIO_SERVICE) as? AudioManager)?.let { am -> - val cur = am.getStreamVolume(AudioManager.STREAM_MUSIC) - val max = am.getStreamMaxVolume(AudioManager.STREAM_MUSIC) - if (cur < max || currentRequestedVolume <= 1.0f) { - currentRequestedVolume = cur.toFloat() / max.toFloat() - loudnessEnhancer?.release() - loudnessEnhancer = null - } - } - } - - /** - * Handles a hardware volume key press. - * Only active on phones/emulators when [isFullScreen] is true. - * - * @return true if the key was consumed (suppresses the system volume UI). - */ - fun handleVolumeKey(keyCode: Int): Boolean { - /** - * Some TVs do not support volume boosting, and overriding - * the volume buttons can be inconvenient for TV users. - * Since boosting volume is mainly useful on phones and emulators, - * we limit this feature to those devices. - */ - if (!isLayout(PHONE or EMULATOR) || !isFullScreen) return false - if (keyCode != KeyEvent.KEYCODE_VOLUME_UP && keyCode != KeyEvent.KEYCODE_VOLUME_DOWN) return false - verifyVolume() - if (currentRequestedVolume <= 1.0f) hasShownVolumeToast = false - isVolumeLocked = currentRequestedVolume < 1.0f - // +- 5% - handleVolumeAdjustment(if (keyCode == KeyEvent.KEYCODE_VOLUME_UP) 0.05f else -0.05f, fromButton = true) - return true - } - - fun handleVolumeAdjustment(delta: Float, fromButton: Boolean) { - val am = (context as? Activity)?.getSystemService(Context.AUDIO_SERVICE) as? AudioManager ?: return - val curStep = am.getStreamVolume(AudioManager.STREAM_MUSIC) - val maxStep = am.getStreamMaxVolume(AudioManager.STREAM_MUSIC) - - val cur = currentRequestedVolume - val locked = isVolumeLocked - val next = (cur + delta).coerceIn(0.0f, if (locked) 1.0f else 2.0f) - val nextStep = (next * maxStep.toFloat()).roundToInt().coerceIn(0, maxStep) - - // Show toast - if (fromButton) { - // For button related request we only show a toast when we exceeded the volume. - if (cur <= 1.0f && next > 1.0f && !hasShownVolumeToast) { - showToast(R.string.volume_exceeded_100) - hasShownVolumeToast = true - } - } else { - val raw = cur + delta - // For swipes, we show toast that we need to swipe again. - if (raw > 1.0 && locked && !hasShownVolumeToast) { - showToast(R.string.slide_up_again_to_exceed_100) - hasShownVolumeToast = true - } - } - - // Set the current volume step. - if (nextStep != curStep) am.setStreamVolume(AudioManager.STREAM_MUSIC, nextStep, 0) - - var hasBoostError = false - // Apply loudness enhancer for volumes > 100%, removes it if less. - if (next > 1.0f) { - val boost = ((next - 1.0f) * 1000).toInt() - val existing = loudnessEnhancer - if (existing != null) { - existing.setTargetGain(boost) - } else { - val sessionId = (playerView.exoPlayerView?.player as? ExoPlayer)?.audioSessionId - if (sessionId != null && sessionId != AudioManager.ERROR) { - try { - loudnessEnhancer = LoudnessEnhancer(sessionId).apply { - setTargetGain(boost); enabled = true - } - } catch (t: Throwable) { logError(t); hasBoostError = true } - } - } - } else { - loudnessEnhancer?.release(); loudnessEnhancer = null - } - - currentRequestedVolume = next - - val leftHolder = playerView.playerProgressbarLeftHolder ?: return - val level1 = playerView.playerProgressbarLeftLevel1 ?: return - val level2 = playerView.playerProgressbarLeftLevel2 ?: return - val icon = playerView.playerProgressbarLeftIcon ?: return - - if (next > 1.0f) { - // Change color to show that LoudnessEnhancer broke - // this is not a real fix, but solves the crash issue. - level2.progressTintList = ColorStateList.valueOf( - ContextCompat.getColor(context, if (hasBoostError) R.color.colorPrimaryRed else R.color.colorPrimaryOrange) - ) - } - // Max is set high to make it smooth. - level1.max = 100_000 - level1.progress = (next * 100_000f).toInt().coerceIn(2_000, 100_000) - level2.max = 100_000 - level2.progress = if (next > 1.0f) ((next - 1.0) * 100_000f).toInt().coerceIn(2_000, 100_000) else 0 - level2.isVisible = next > 1.0f - // Calculate the clamped index for the volume icon based on the requested volume. - val iconIdx = (next * volumeIcons.lastIndex).roundToInt().coerceIn(0, volumeIcons.lastIndex) - icon.setImageResource(volumeIcons[iconIdx]) - - if (!leftHolder.isVisible || leftHolder.alpha < 1f) { - leftHolder.animate().cancel(); leftHolder.alpha = 1f; leftHolder.isVisible = true - } - progressBarLeftHideRunnable?.let { leftHolder.removeCallbacks(it) } - progressBarLeftHideRunnable = Runnable { - leftHolder.animate().cancel() - leftHolder.animate().alpha(0f).setDuration(300).withEndAction { leftHolder.isVisible = false }.start() - } - // Show the progress bar for 1.5 seconds. - leftHolder.postDelayed(progressBarLeftHideRunnable, 1500) - } - - /** Brightness helpers */ - - /** - * Reads from [Settings.System.SCREEN_BRIGHTNESS], falling back to the window - * attribute if the permission is absent. - */ - fun getBrightness(): Float? { - return if (useTrueSystemBrightness) { - try { - Settings.System.getInt( - (context as? Activity)?.contentResolver, - Settings.System.SCREEN_BRIGHTNESS - ) / 255f - } catch (_: Exception) { - // Because true system brightness requires - // permission, this is a lazy way to check - // as it will throw an error if we do not have it. - useTrueSystemBrightness = false - getBrightness() - } - } else { - try { - (context as? Activity)?.window?.attributes?.screenBrightness?.takeIf { it >= 0f } - } catch (e: Exception) { - logError(e) - null - } - } - } - - /** - * Sets [Settings.System.SCREEN_BRIGHTNESS], falling back to the window - * attribute if the permission is absent. - */ - fun setBrightness(brightness: Float) { - if (useTrueSystemBrightness) { - try { - Settings.System.putInt( - (context as? Activity)?.contentResolver, - Settings.System.SCREEN_BRIGHTNESS_MODE, - Settings.System.SCREEN_BRIGHTNESS_MODE_MANUAL - ) - Settings.System.putInt( - (context as? Activity)?.contentResolver, - Settings.System.SCREEN_BRIGHTNESS, - min(1, (brightness.coerceIn(0.0f, 1.0f) * 255).toInt()) - ) - } catch (_: Exception) { - useTrueSystemBrightness = false - setBrightness(brightness) - } - } else { - try { - val lp = (context as? Activity)?.window?.attributes ?: return - // Use 0.004f instead of 0: on some devices a value too close to 0 causes the - // system to override with its own brightness, making fine-tuning impossible. - lp.screenBrightness = brightness.coerceIn(0.004f, 1.0f) - (context as? Activity)?.window?.attributes = lp - } catch (e: Exception) { - logError(e) - } - } - } - - fun handleBrightnessAdjustment(verticalAddition: Float) { - val lastBrightness = currentRequestedBrightness - val raw = currentRequestedBrightness + verticalAddition - val next = raw.coerceIn(0.0f, if (extraBrightnessEnabled && !isBrightnessLocked) 2.0f else 1.0f) - - if (extraBrightnessEnabled && isBrightnessLocked && raw > 1.0f && !hasShownBrightnessToast) { - showToast(R.string.slide_up_again_to_exceed_100) - hasShownBrightnessToast = true - } - - currentRequestedBrightness = next - if (lastBrightness != currentRequestedBrightness) setBrightness(currentRequestedBrightness) - - currentExtraBrightness = if (extraBrightnessEnabled && next > 1.0f) min(2.0f, next) - 1.0f else 0.0f - brightnessOverlay?.alpha = currentExtraBrightness - playerView.callbacks?.onBrightnessExtra(currentExtraBrightness) - - val rightHolder = playerView.playerProgressbarRightHolder ?: return - val level1 = playerView.playerProgressbarRightLevel1 ?: return - val level2 = playerView.playerProgressbarRightLevel2 ?: return - val icon = playerView.playerProgressbarRightIcon ?: return - - level1.max = 100_000 - level1.progress = max(2_000, (min(1.0f, next) * 100_000f).toInt()) - - if (extraBrightnessEnabled) { - level2.max = 100_000 - level2.progress = (currentExtraBrightness * 100_000f).toInt().coerceIn(2_000, 100_000) - level2.isVisible = next > 1.0f - } - - icon.setImageResource( - // Clamp the value in case of extra brightness. - brightnessIcons[min(brightnessIcons.lastIndex, max(0, round(next * brightnessIcons.lastIndex).toInt()))] - ) - - if (!rightHolder.isVisible || rightHolder.alpha < 1f) { - rightHolder.animate().cancel(); rightHolder.alpha = 1f; rightHolder.isVisible = true - } - progressBarRightHideRunnable?.let { rightHolder.removeCallbacks(it) } - progressBarRightHideRunnable = Runnable { - rightHolder.animate().cancel() - rightHolder.animate().alpha(0f).setDuration(300).withEndAction { rightHolder.isVisible = false }.start() - } - rightHolder.postDelayed(progressBarRightHideRunnable, 1500) - } - - /** Zoom helpers */ - - /** - * Returns the current zoom matrix, accounting for RESIZE_MODE_ZOOM which already has - * an implicit zoom applied. - * - * This is different from `zoomMatrix ?: Matrix()` - * because it allows used to start zooming at different resizeModes. - * - * The main issue is that RESIZE_MODE_FIT = 100% zoom, but if you are in RESIZE_MODE_ZOOM - * 100% will make the zoom snap to less zoomed in then you already are. - */ - fun currentZoomMatrix(): Matrix { - val current = zoomMatrix - if (current != null) return current - - val exoView = playerView.exoPlayerView - val videoView = exoView?.videoSurfaceView - - if (exoView == null || videoView == null || - exoView.resizeMode != AspectRatioFrameLayout.RESIZE_MODE_ZOOM) { - return Matrix() - } - - val videoWidth = videoView.width.toFloat() - val videoHeight = videoView.height.toFloat() - val playerWidth = screenWidthWithOrientation.toFloat() - val playerHeight = screenHeightWithOrientation.toFloat() - - if (videoWidth <= 1f || videoHeight <= 1f || playerWidth <= 1f || playerHeight <= 1f) { - return Matrix() - } - - val initAspect = (playerHeight * videoWidth) / (playerWidth * videoHeight) - val aspect = max(initAspect, 1f / initAspect) - return Matrix().apply { postScale(aspect, aspect) } - } - - /** - * Applies [newMatrix] (scale + translation only) to the video surface view. - * - * @param newMatrix The new zoom matrix - * @param animation If this zoom is part of an animation, as then it will not auto zoom after we are done. - */ - fun applyZoomMatrix(newMatrix: Matrix, animation: Boolean) { - val exoView = playerView.exoPlayerView ?: return - if (!animation) { - matrixAnimation?.cancel() - matrixAnimation = null - } - val (translationX, translationY, scale) = matrixToTranslationAndScale(newMatrix) - - if (exoView.resizeMode == AspectRatioFrameLayout.RESIZE_MODE_FIT) { - exoView.resizeMode = AspectRatioFrameLayout.RESIZE_MODE_ZOOM - } - - val videoView = exoView.videoSurfaceView ?: return - val videoWidth = videoView.width.toFloat() - val videoHeight = videoView.height.toFloat() - val playerWidth = screenWidthWithOrientation.toFloat() - val playerHeight = screenHeightWithOrientation.toFloat() - - // Sanity check - if (videoWidth <= 1f || videoHeight <= 1f || playerWidth <= 1f || playerHeight <= 1f || scale <= 0.01f) return - - // Calculate the scaled aspect ratio as the view height is not real, check the debugger - // and you will see videoView.height > screen.height. - val initAspect = (playerHeight * videoWidth) / (playerWidth * videoHeight) - val aspect = min(initAspect, 1f / initAspect) - val scaledAspect = scale * aspect - - // Calculate clamp, this is very weird because we need to use aspect here as videoHeight > playerHeight. - val maxTransX = max(0f, videoWidth * scaledAspect - playerWidth) * 0.5f - val maxTransY = max(0f, videoHeight * scaledAspect - playerHeight) * 0.5f - - // Correct the translation to clamp within the viewing area. - val expectedTranslationX = translationX.coerceIn(-maxTransX, maxTransX) - val expectedTranslationY = translationY.coerceIn(-maxTransY, maxTransY) - - // Set the transform to the correct x and y. - newMatrix.postTranslate( - expectedTranslationX - translationX, - expectedTranslationY - translationY - ) - zoomMatrix = newMatrix - - if (!animation) { - // If we are not in an animation, set up the values for the animation. - if ((scaledAspect - 1f).absoluteValue < ZOOM_SNAP_SENSITIVITY) { - // We are within the correct scaling, so center and fit it. - videoOutline?.isVisible = true - val desired = Matrix() - desired.setScale(1f / aspect, 1f / aspect) - desiredMatrix = desired - } else if (scale < 1f) { - // We have zoomed too far, zoom to 100%. - videoOutline?.isVisible = false - desiredMatrix = Matrix() - } else { - // Keep the same scaling after zoom. - videoOutline?.isVisible = false - desiredMatrix = null - } - } - - // Finally set the actual scale + translation. - videoView.scaleX = scaledAspect - videoView.scaleY = scaledAspect - videoView.translationX = expectedTranslationX - videoView.translationY = expectedTranslationY - updateBrightnessOverlayBounds() - } - - /** - * Clears all zoom state and resets the video surface view to 1:1 scale. - * Does NOT change the ExoPlayer resize mode - call [PlayerView.resize] separately. - */ - fun clearZoomState() { - matrixAnimation?.cancel() - matrixAnimation = null - zoomMatrix = null - desiredMatrix = null - scaleGestureDetector = null - lastPan = null - playerView.exoPlayerView?.videoSurfaceView?.apply { - scaleX = 1f - scaleY = 1f - translationX = 0f - translationY = 0f - } - } - - /** - * Resets zoom to fit mode if any zoom is currently active. - * Calls [PlayerView.resize] to update the ExoPlayer resize mode. - */ - fun resetZoomToDefault() { - if (zoomMatrix != null) { - clearZoomState() - playerView.resize(PlayerResize.Fit, false) - } - } - - private fun createScaleGestureDetector(ctx: Context) { - scaleGestureDetector = ScaleGestureDetector( - ctx, - object : ScaleGestureDetector.SimpleOnScaleGestureListener() { - override fun onScale(detector: ScaleGestureDetector): Boolean { - val matrix = currentZoomMatrix() - val (_, _, scale) = matrixToTranslationAndScale(matrix) - // Clamp scale of the zoom, do it here as it is easier than doing it within applyZoomMatrix. - val newScale = (scale * detector.scaleFactor).coerceIn(MINIMUM_ZOOM, MAXIMUM_ZOOM) - // This is how much we should scale it with to prevent infinite scaling. - val actualScaleFactor = newScale / scale - // Scale around the focus point, this is more natural than just zoom. - val pivotX = detector.focusX - screenWidthWithOrientation.toFloat() * 0.5f - val pivotY = detector.focusY - screenHeightWithOrientation.toFloat() * 0.5f - matrix.postScale(actualScaleFactor, actualScaleFactor, pivotX, pivotY) - applyZoomMatrix(matrix, false) - return true - } - } - ) - } - - /** - * Processes a two-finger zoom/pan gesture event. - * Handles scale detection, panning, and the snap-back animation after finger lift. - * - * @param event The motion event (should have pointerCount >= 2 or [lastPan] != null). - * @param ctx Context used to create the [ScaleGestureDetector] on first call. - * @param onFirstPointerDown Called on [MotionEvent.ACTION_POINTER_DOWN] (e.g. hide player UI). - * @param onGestureEnd Called when the gesture ends (e.g. reset caller touch state). - * @return Always true (event consumed). - */ - fun handleZoomPanGesture( - event: MotionEvent, - ctx: Context, - onFirstPointerDown: () -> Unit, - onGestureEnd: () -> Unit - ): Boolean { - if (scaleGestureDetector == null) createScaleGestureDetector(ctx) - scaleGestureDetector?.onTouchEvent(event) - - when (event.actionMasked) { - MotionEvent.ACTION_POINTER_DOWN -> { - onFirstPointerDown() - } - - MotionEvent.ACTION_MOVE -> { - if (event.pointerCount >= 2) { - val newPan = Vector2( - (event.getX(0) + event.getX(1)) / 2f, - (event.getY(0) + event.getY(1)) / 2f - ) - val oldPan = lastPan - if (oldPan != null) { - val matrix = currentZoomMatrix() - matrix.postTranslate(newPan.x - oldPan.x, newPan.y - oldPan.y) - applyZoomMatrix(matrix, false) - } - lastPan = newPan - } - } - - MotionEvent.ACTION_CANCEL, - MotionEvent.ACTION_POINTER_UP, - MotionEvent.ACTION_UP -> { - lastPan = null - videoOutline?.isVisible = false - matrixAnimation?.cancel() - matrixAnimation = null - - // Snap to desired matrix after zoom gesture ends - matrixAnimation = ValueAnimator.ofFloat(0f, 1f).apply { - startDelay = 0 - duration = 200 - val startMatrix = currentZoomMatrix() - val endMatrix = desiredMatrix ?: return@apply - val (startX, startY, startScale) = matrixToTranslationAndScale(startMatrix) - val (endX, endY, endScale) = matrixToTranslationAndScale(endMatrix) - addUpdateListener { anim -> - val v = anim.animatedValue as Float - val vInv = 1f - v - val m = Matrix() - m.setScale(startScale * vInv + endScale * v, startScale * vInv + endScale * v) - m.postTranslate(startX * vInv + endX * v, startY * vInv + endY * v) - applyZoomMatrix(m, true) - } - start() - } - - onGestureEnd() - } - } - return true - } - - /** - * Resizes and repositions [brightnessOverlay] to exactly match the visible video surface, - * accounting for zoom scale and translation. - */ - fun updateBrightnessOverlayBounds() { - val overlay = brightnessOverlay ?: return - val pv = playerView.exoPlayerView ?: return - val video = pv.videoSurfaceView ?: return - - // Compute accurate transformed bounding box of the video view after scale+translation. - val vw = video.width.toFloat() - val vh = video.height.toFloat() - val sx = video.scaleX - val sy = video.scaleY - if (vw <= 0f || vh <= 0f) return - - // Pivot defaults to center if not set. - val pivotX = if (video.pivotX != 0f) video.pivotX else vw * 0.5f - val pivotY = if (video.pivotY != 0f) video.pivotY else vh * 0.5f - // Use view position (includes translation) as base; avoid double-counting translation. - val tx = video.x - val ty = video.y - - // Transform function for a local point (lx,ly). - fun transform(lx: Float, ly: Float): Pair { - val gx = tx + pivotX + (lx - pivotX) * sx - val gy = ty + pivotY + (ly - pivotY) * sy - return Pair(gx, gy) - } - - val p0 = transform(0f, 0f); val p1 = transform(vw, 0f) - val p2 = transform(0f, vh); val p3 = transform(vw, vh) - - val minX = min(min(p0.first, p1.first), min(p2.first, p3.first)) - val maxX = max(max(p0.first, p1.first), max(p2.first, p3.first)) - val minY = min(min(p0.second, p1.second), min(p2.second, p3.second)) - val maxY = max(max(p0.second, p1.second), max(p2.second, p3.second)) - - val newW = ceil(maxX - minX).toInt().coerceAtLeast(0) - val newH = ceil(maxY - minY).toInt().coerceAtLeast(0) - - val lp = overlay.layoutParams - if (lp == null) { - overlay.layoutParams = ViewGroup.LayoutParams(newW, newH) - } else if (lp.width != newW || lp.height != newH) { - lp.width = newW; lp.height = newH - overlay.layoutParams = lp - } - - overlay.scaleX = 1f; overlay.scaleY = 1f - overlay.x = minX; overlay.y = minY - } - - /** - * Attaches a persistent layout-change listener to the ExoPlayer view so - * [updateBrightnessOverlayBounds] is called on every layout pass (orientation change, - * aspect-ratio change, zoom, PiP transition, etc.). - */ - fun requestUpdateBrightnessOverlayOnNextLayout() { - val exoView = playerView.exoPlayerView ?: return - overlayLayoutListener?.let { exoView.removeOnLayoutChangeListener(it) } - val listener = View.OnLayoutChangeListener { _, _, _, _, _, _, _, _, _ -> - safe { updateBrightnessOverlayBounds() } - } - overlayLayoutListener = listener - exoView.addOnLayoutChangeListener(listener) - } - - /** Removes the overlay layout listener registered by [requestUpdateBrightnessOverlayOnNextLayout]. */ - fun releaseOverlayLayoutListener() { - overlayLayoutListener?.let { playerView.exoPlayerView?.removeOnLayoutChangeListener(it) } - overlayLayoutListener = null - } - - /** Rewind / fast-forward animations */ - - /** Resets the rewind button label to the standard "–Xs" format. */ - fun resetRewindText() { - playerView.exoRewText?.text = context.getString(R.string.rew_text_regular_format) - .format(fastForwardTime / 1000) - } - - /** Resets the fast-forward button label to the standard "+Xs" format. */ - fun resetFastForwardText() { - playerView.exoFfwdText?.text = context.getString(R.string.ffw_text_regular_format) - .format(fastForwardTime / 1000) - } - - /** - * Fades playerRewHolder, playerFfwdHolder, and playerPausePlay to [fadeTo] (0f or 1f). - * Always resets the holder alphas to 1f first so any stale fillAfter state is cleared. - * Called from host fragments' show/hide control animations so both GeneratorPlayer and trailer share - * the same fade logic. - */ - fun animateCenterControls(fadeTo: Float) { - val from = if (fadeTo > 0.5f) 0f else 1f - fun makeAnim() = AlphaAnimation(from, fadeTo).apply { duration = 100; fillAfter = true } - // Each view needs its own Animation instance; sharing one causes fillAfter to - // not hold reliably across all views once any of them restarts the animation. - playerView.playerRewHolder?.let { it.alpha = 1f; it.startAnimation(makeAnim()) } - playerView.playerFfwdHolder?.let { it.alpha = 1f; it.startAnimation(makeAnim()) } - playerView.playerPausePlay?.startAnimation(makeAnim()) - } - - /** Plays the rewind animation and seeks back by [fastForwardTime]. */ - fun rewind() { - try { - val rewHolder = playerView.playerRewHolder ?: return - val rew = playerView.playerRew - val rewText = playerView.exoRewText - val wasShowing = playerView.callbacks?.isUIShowing() ?: false - - // Only expose the parent chain when controls are currently hidden. - val prevCenterMenuGone = playerView.playerCenterMenu?.isGone ?: false - val prevVideoHolderVisible = playerView.playerVideoHolder?.isVisible ?: true - if (!wasShowing) { - playerView.playerCenterMenu?.isGone = false - playerView.playerVideoHolder?.isVisible = true - } - // Always clear any stale fillAfter alpha so the button is visible during animation. - rewHolder.alpha = 1f - - rew?.startAnimation(AnimationUtils.loadAnimation(context, R.anim.rotate_left)) - val goLeft = AnimationUtils.loadAnimation(context, R.anim.go_left) - goLeft.setAnimationListener(object : Animation.AnimationListener { - override fun onAnimationStart(animation: Animation?) {} - override fun onAnimationRepeat(animation: Animation?) {} - override fun onAnimationEnd(animation: Animation?) { - rewText?.post { - resetRewindText() - // Restore parent chain only if we changed it and controls are still hidden. - if (!wasShowing && !(playerView.callbacks?.isUIShowing() ?: false)) { - playerView.playerCenterMenu?.isGone = prevCenterMenuGone - playerView.playerVideoHolder?.isVisible = prevVideoHolderVisible - rewHolder.alpha = 0f - } - } - } - }) - rewText?.startAnimation(goLeft) - rewText?.text = context.getString(R.string.rew_text_format).format(fastForwardTime / 1000) - playerView.player.seekTime(-fastForwardTime) - } catch (e: Exception) { logError(e) } - } - - /** Plays the fast-forward animation and seeks forward by [fastForwardTime]. */ - fun fastForward() { - try { - val ffwdHolder = playerView.playerFfwdHolder ?: return - val ffwd = playerView.playerFfwd - val ffwdText = playerView.exoFfwdText - val wasShowing = playerView.callbacks?.isUIShowing() ?: false - - val prevCenterMenuGone = playerView.playerCenterMenu?.isGone ?: false - val prevVideoHolderVisible = playerView.playerVideoHolder?.isVisible ?: true - if (!wasShowing) { - playerView.playerCenterMenu?.isGone = false - playerView.playerVideoHolder?.isVisible = true - } - // Always clear any stale fillAfter alpha so the button is visible during animation. - ffwdHolder.alpha = 1f - - ffwd?.startAnimation(AnimationUtils.loadAnimation(context, R.anim.rotate_right)) - val goRight = AnimationUtils.loadAnimation(context, R.anim.go_right) - goRight.setAnimationListener(object : Animation.AnimationListener { - override fun onAnimationStart(animation: Animation?) {} - override fun onAnimationRepeat(animation: Animation?) {} - override fun onAnimationEnd(animation: Animation?) { - ffwdText?.post { - resetFastForwardText() - if (!wasShowing && !(playerView.callbacks?.isUIShowing() ?: false)) { - playerView.playerCenterMenu?.isGone = prevCenterMenuGone - playerView.playerVideoHolder?.isVisible = prevVideoHolderVisible - ffwdHolder.alpha = 0f - } - } - } - }) - ffwdText?.startAnimation(goRight) - ffwdText?.text = context.getString(R.string.ffw_text_format).format(fastForwardTime / 1000) - playerView.player.seekTime(fastForwardTime) - } catch (e: Exception) { logError(e) } - } - - /** Double-tap detection */ - - /** - * Call when a valid tap is detected (short hold, minimal movement, valid touch area). - * Routes to double-tap seeking/pausing or schedules a delayed single-tap callback. - * - * Updates [lastTouchEndTime] when a confirmed tap (single or double) is recorded. - * - * @param x X coordinate of the tap in the view's coordinate space. - * @param viewWidth Width of the view (used to compute left/center/right zones). - * @param isLocked Whether player controls are locked (suppresses double-tap seek). - * @param onSingleTap Invoked when it is determined to be a single tap; may be deferred. - * @return true if a double-tap action was performed. - */ - fun onTapDetected(x: Float, viewWidth: Int, isLocked: Boolean, onSingleTap: () -> Unit): Boolean { - val anyDoubleTap = doubleTapEnabled || doubleTapPauseEnabled - if (!anyDoubleTap) { - onSingleTap() - return false - } - - val timeSinceLast = System.currentTimeMillis() - lastTouchEndTime - return if (!isLocked && timeSinceLast < DOUBLE_TAP_MINIMUM_TIME_BETWEEN) { - /** Double-tap */ - tapCount++ - doubleTapToken++ // cancel any pending single-tap runnable - if (doubleTapPauseEnabled) { - when { - x < viewWidth / 2f - (DOUBLE_TAP_PAUSE_PERCENTAGE * viewWidth) -> { - if (doubleTapEnabled) rewind() - } - x > viewWidth / 2f + (DOUBLE_TAP_PAUSE_PERCENTAGE * viewWidth) -> { - if (doubleTapEnabled) fastForward() - } - else -> { - playerView.player.handleEvent(CSPlayerEvent.PlayPauseToggle, PlayerEventSource.UI) - } - } - } else if (doubleTapEnabled) { - if (x < viewWidth / 2f) rewind() else fastForward() - } - true - } else { - /** Single tap (first tap, or too slow for double-tap) */ - tapCount = 0 - val token = ++doubleTapToken - playerView.playerHolder?.postDelayed({ - if (token == doubleTapToken) { - onSingleTap() - } - }, DOUBLE_TAP_MINIMUM_TIME_BETWEEN) - false - } - } - - /** Seek time helpers */ - - private fun calculateNewTime(startTime: Long?, touchStart: Vector2?, touchEnd: Vector2?): Long? { - if (touchStart == null || touchEnd == null || startTime == null) return null - val diffX = (touchEnd.x - touchStart.x) * HORIZONTAL_MULTIPLIER / screenWidthWithOrientation.toFloat() - val duration = playerView.player.getDuration() ?: return null - return max(min(startTime + ((duration * (diffX * diffX)) * (if (diffX < 0) -1 else 1)).toLong(), duration), 0) - } - - private fun forceLetters(inp: Long, letters: Int = 2): String { - val added = letters - inp.toString().length - return if (added > 0) "0".repeat(added) + inp.toString() else inp.toString() - } - - private fun convertTimeToString(sec: Long): String { - val rsec = sec % 60L - val min = ceil((sec - rsec) / 60.0).toInt() - val rmin = min % 60L - val h = ceil((min - rmin) / 60.0).toLong() - // int rh = h;// h % 24; - return (if (h > 0) forceLetters(h) + ":" else "") + - (if (rmin >= 0 || h >= 0) forceLetters(rmin) + ":" else "") + - forceLetters(rsec) - } - - /** Touch gestures */ - - fun setupTouchGestures() { - val holder = playerView.playerHolder ?: return - @SuppressLint("ClickableViewAccessibility") - holder.setOnTouchListener(::handleGesture) - } - - private fun isValidTouch(rawX: Float, rawY: Float): Boolean { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - val holder = playerView.playerHolder ?: return true - val insets = holder.rootWindowInsets.getInsetsIgnoringVisibility(WindowInsets.Type.systemBars()) - val validHeight = rawY > insets.top && rawY < screenHeightWithOrientation - insets.bottom - val validWidth = rawX > insets.left && rawX < screenWidthWithOrientation - insets.right - return validHeight && validWidth - } - - return rawY > context.getStatusBarHeight() && rawX < screenWidthWithOrientation - } - - private fun handleGesture(view: View, event: MotionEvent): Boolean { - val currentTouch = Vector2(event.x, event.y) - val startTouch = currentTouchStart - - /** Two-finger zoom/pan (fullscreen, unlocked) */ - if ((event.pointerCount >= 2 || lastPan != null) && isFullScreen && !isLocked - && !hasTriggeredSpeedUp && currentTouchAction == null) { - holdHandler.removeCallbacks(holdRunnable) // Remove 2x speed. - isCurrentTouchValid = false // Prevent other touches - return handleZoomPanGesture( - event = event, - ctx = view.context, - onFirstPointerDown = { - uiShowingBeforeGesture = playerView.callbacks?.isUIShowing() ?: false - playerView.callbacks?.onHidePlayerUI() - }, - onGestureEnd = { - currentTouchStart = null - currentLastTouchAction = null - currentTouchAction = null - currentTouchStartPlayerTime = null - currentTouchLast = null - currentTouchStartTime = null - } - ) - } - - when (event.actionMasked) { - MotionEvent.ACTION_DOWN -> { - isCurrentTouchValid = isValidTouch(event.rawX, event.rawY) - if (isCurrentTouchValid) { - playerView.callbacks?.onTouchDown() - hasTriggeredSpeedUp = false - if (speedupEnabled && playerView.player.getIsPlaying() && !isLocked) { - holdHandler.postDelayed(holdRunnable, 500) - } - isVolumeLocked = currentRequestedVolume < 1.0f - if (currentRequestedVolume <= 1.0f) hasShownVolumeToast = false - isBrightnessLocked = currentRequestedBrightness < 1.0f - if (currentRequestedBrightness <= 1.0f) hasShownBrightnessToast = false - currentTouchStartTime = System.currentTimeMillis() - currentTouchStart = currentTouch - currentTouchLast = currentTouch - currentTouchStartPlayerTime = playerView.player.getPosition() - getBrightness()?.let { currentRequestedBrightness = it + currentExtraBrightness } - verifyVolume() - } - return true - } - - MotionEvent.ACTION_MOVE -> { - if (hasTriggeredSpeedUp) return true - if (!isCurrentTouchValid) return true - - if (currentTouchAction == null && startTouch != null) { - val diffFromStart = startTouch - currentTouch - if (swipeVerticalEnabled) { - if (abs(diffFromStart.y * 100 / screenHeightWithOrientation) > MINIMUM_VERTICAL_SWIPE) { - holdHandler.removeCallbacks(holdRunnable) - uiShowingBeforeGesture = playerView.callbacks?.isUIShowing() ?: false - playerView.callbacks?.onHidePlayerUI() - currentTouchAction = if ((startTouch.x) >= view.width / 2f) - TouchAction.Volume else TouchAction.Brightness - } - } - if (swipeHorizontalEnabled && !isLocked) { - if (abs(diffFromStart.x * 100 / screenHeightWithOrientation) > MINIMUM_HORIZONTAL_SWIPE) { - holdHandler.removeCallbacks(holdRunnable) - currentTouchAction = TouchAction.Time - } - } - } - - val lastTouch = currentTouchLast - if (lastTouch != null) { - val diffFromLast = lastTouch - currentTouch - val verticalAddition = diffFromLast.y * VERTICAL_MULTIPLIER / view.height.toFloat() - when (currentTouchAction) { - TouchAction.Time -> { - // This simply updates UI as the seek logic happens on release - // startTime is rounded to make the UI sync in a nice way. - val startTime = currentTouchStartPlayerTime?.div(1000L)?.times(1000L) - if (startTime != null) { - calculateNewTime(startTime, startTouch, currentTouch)?.let { newMs -> - val skipMs = newMs - startTime - playerView.callbacks?.onSeekPreviewText( - "${convertTimeToString(newMs / 1000)} [${ - if (abs(skipMs) < 1000) "" else if (skipMs > 0) "+" else "-" - }${convertTimeToString(abs(skipMs / 1000))}]" - ) - } - } - } - TouchAction.Brightness -> if (!isLocked) handleBrightnessAdjustment(verticalAddition) - TouchAction.Volume -> if (!isLocked) handleVolumeAdjustment(verticalAddition, false) - null -> Unit - } - if (currentTouchAction != TouchAction.Time) { - playerView.callbacks?.onSeekPreviewText(null) - } - } - currentTouchLast = currentTouch - return true - } - - MotionEvent.ACTION_CANCEL, MotionEvent.ACTION_UP -> { - holdHandler.removeCallbacks(holdRunnable) - if (hasTriggeredSpeedUp) { - playerView.player.setPlaybackSpeed(DataStoreHelper.playBackSpeed) - showOrHideSpeedUp(false) - playerView.callbacks?.onHoldSpeedUp(false) - hasTriggeredSpeedUp = false - } - - if (isCurrentTouchValid) { - // Horizontal seek on release - if (swipeHorizontalEnabled && currentTouchAction == TouchAction.Time && !isLocked) { - val startTime = currentTouchStartPlayerTime - if (startTime != null) { - calculateNewTime(startTime, startTouch, currentTouch)?.let { seekTo -> - if (abs(seekTo - startTime) > MINIMUM_SEEK_TIME) { - playerView.player.seekTo(seekTo, PlayerEventSource.UI) - } - } - } - } - // Tap detection: only fire if the finger was held briefly (not a long-press). - val holdTime = currentTouchStartTime?.let { System.currentTimeMillis() - it } - if (currentTouchAction == null && currentLastTouchAction == null - && !hasTriggeredSpeedUp - && (holdTime == null || holdTime < DOUBLE_TAP_MAXIMUM_HOLD_TIME)) { - onTapDetected( - x = currentTouch.x, - viewWidth = view.width, - isLocked = isLocked, - onSingleTap = { playerView.callbacks?.onSingleTap() } - ) - } - } - - playerView.callbacks?.onSeekPreviewText(null) - val hadSwipe = currentTouchAction != null || currentLastTouchAction != null - playerView.callbacks?.onGestureEnd(hadSwipe, uiShowingBeforeGesture) - - // Reset touch - lastTouchEndTime = System.currentTimeMillis() - isCurrentTouchValid = false - currentTouchStart = null - currentLastTouchAction = currentTouchAction - currentTouchAction = null - currentTouchStartPlayerTime = null - currentTouchLast = null - currentTouchStartTime = null - uiShowingBeforeGesture = false - return true - } - } - return false - } -} diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerPipHelper.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerPipHelper.kt index 0db06499e..938572349 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerPipHelper.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerPipHelper.kt @@ -1,205 +1,113 @@ 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 com.lagradost.cloudstream3.mvvm.normalSafeApiCall import kotlin.math.roundToInt -object PlayerPipHelper { - /** Is pip (Player in Player) supported, and enabled? */ - fun Context.isPIPPossible() : Boolean { - return try { - this.hasPIPEnabled() && this.hasPIPFeature() - } catch (t : Throwable) { - // While both hasPIPEnabled and hasPIPFeature should never throw, this catches it just in case - logError(t) - false - } - } - - /** Is pip enabled in app settings? */ - private fun Context.hasPIPEnabled(): Boolean { - return try { - val settingsManager = PreferenceManager.getDefaultSharedPreferences(this) - settingsManager?.getBoolean( - getString(R.string.pip_enabled_key), - true - ) ?: true - } catch (e: Exception) { - logError(e) - false - } - } - - - /** - * Is pip supported by the OS? - * - * Source: - * https://stackoverflow.com/questions/52594181/how-to-know-if-user-has-disabled-picture-in-picture-feature-permission - * https://developer.android.com/guide/topics/ui/picture-in-picture - * */ - private fun Context.hasPIPFeature(): Boolean = - // OS Support - Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && - // Might have the feature, but OS blocked due to power drain - this.packageManager.hasSystemFeature(PackageManager.FEATURE_PICTURE_IN_PICTURE) && - // Might have been disabled by the user - this.hasPIPPermission() - - /** Is pip enabled in the OS settings? */ - private fun Context.hasPIPPermission(): Boolean { - val appOps = - getSystemService(Context.APP_OPS_SERVICE) as AppOpsManager - return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - appOps.checkOpNoThrow( - AppOpsManager.OPSTR_PICTURE_IN_PICTURE, - android.os.Process.myUid(), - packageName - ) == AppOpsManager.MODE_ALLOWED - } else true - } - - @RequiresApi(Build.VERSION_CODES.O) - private fun getPen(activity: Activity, code: Int): PendingIntent { - return PendingIntent.getBroadcast( - activity, - code, - Intent(ACTION_MEDIA_CONTROL).putExtra(EXTRA_CONTROL_TYPE, code), - PendingIntent.FLAG_IMMUTABLE - ) - } - - @RequiresApi(Build.VERSION_CODES.O) - private fun getRemoteAction( - activity: Activity, - id: Int, - @StringRes title: Int, - event: CSPlayerEvent - ): RemoteAction { - val text = activity.getString(title) - return RemoteAction( - Icon.createWithResource(activity, id), - text, - text, - getPen(activity, event.value) - ) - } - - fun updatePIPModeActions( - activity: Activity?, - status: CSPlayerLoading, - pipEnabled: Boolean, - aspectRatio: Rational? - ) { - // Is it even desired to enter pip mode right now if we ignore all settings? - // This does not check for isPIPPossible as that is deferred to later - val isPipDesired = when (status) { - CSPlayerLoading.IsBuffering, CSPlayerLoading.IsPlaying -> pipEnabled - else -> false - } - - // On lower api ver setPictureInPictureParams is not supported, - // so we enter pip manually in onUserLeaveHint - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { - CommonActivity.isPipDesired = isPipDesired - return - } - - if(activity == null) return - - val actions: ArrayList = ArrayList() - actions.add( - getRemoteAction( +class PlayerPipHelper { + companion object { + @RequiresApi(Build.VERSION_CODES.O) + private fun getPen(activity: Activity, code: Int): PendingIntent { + return PendingIntent.getBroadcast( activity, - R.drawable.baseline_headphones_24, - R.string.audio_singular, - CSPlayerEvent.PlayAsAudio + code, + Intent(ACTION_MEDIA_CONTROL).putExtra(EXTRA_CONTROL_TYPE, code), + PendingIntent.FLAG_IMMUTABLE ) - ) - /*actions.add( - getRemoteAction( - activity, - R.drawable.go_back_30, - R.string.go_back_30, - CSPlayerEvent.SeekBack - ) - )*/ + } - if (status == CSPlayerLoading.IsPlaying) { + @RequiresApi(Build.VERSION_CODES.O) + private fun getRemoteAction( + activity: Activity, + id: Int, + @StringRes title: Int, + event: CSPlayerEvent + ): RemoteAction { + val text = activity.getString(title) + return RemoteAction( + Icon.createWithResource(activity, id), + text, + text, + getPen(activity, event.value) + ) + } + + @RequiresApi(Build.VERSION_CODES.O) + fun updatePIPModeActions(activity: Activity, isPlaying: Boolean, aspectRatio: Rational?) { + val actions: ArrayList = ArrayList() actions.add( getRemoteAction( activity, - R.drawable.netflix_pause, - R.string.pause, - CSPlayerEvent.Pause + R.drawable.go_back_30, + R.string.go_back_30, + CSPlayerEvent.SeekBack ) ) - } else { - actions.add( - getRemoteAction( - activity, - R.drawable.ic_baseline_play_arrow_24, - R.string.pause, - CSPlayerEvent.Play + + 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 + ) ) - ) - } - - actions.add( - getRemoteAction( - activity, - R.drawable.go_forward_30, - R.string.go_forward_30, - CSPlayerEvent.SeekForward - ) - ) - - // Necessary to prevent crashing. - val mixAspectRatio = 0.41841f // ~1/2.39 - val maxAspectRatio = 2.39f // widescreen standard - val ratioAccuracy = 100000 // To convert the float to int - - // java.lang.IllegalArgumentException: setPictureInPictureParams: Aspect ratio is too extreme - // (must be between 0.418410 and 2.390000) - val fixedRational = - aspectRatio?.toFloat()?.coerceIn(mixAspectRatio, maxAspectRatio)?.let { - Rational((it * ratioAccuracy).roundToInt(), ratioAccuracy) } - safe { - activity.setPictureInPictureParams( - PictureInPictureParams.Builder() - .apply { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - setSeamlessResizeEnabled(true) - setAutoEnterEnabled(isPipDesired && activity.isPIPPossible()) - } else { - // We enter pip manually in onUserLeaveHint as the smooth transition - // is not supported yet - CommonActivity.isPipDesired = isPipDesired - } - } - .setAspectRatio(fixedRational) - .setActions(actions) - .build() + actions.add( + getRemoteAction( + activity, + R.drawable.go_forward_30, + R.string.go_forward_30, + CSPlayerEvent.SeekForward + ) ) + + // Nessecary to prevent crashing. + val mixAspectRatio = 0.41841f // ~1/2.39 + val maxAspectRatio = 2.39f // widescreen standard + val ratioAccuracy = 100000 // To convert the float to int + + // java.lang.IllegalArgumentException: setPictureInPictureParams: Aspect ratio is too extreme (must be between 0.418410 and 2.390000) + val fixedRational = + aspectRatio?.toFloat()?.coerceIn(mixAspectRatio, maxAspectRatio)?.let { + Rational((it * ratioAccuracy).roundToInt(), ratioAccuracy) + } + + normalSafeApiCall { + activity.setPictureInPictureParams( + PictureInPictureParams.Builder() + .apply { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + setSeamlessResizeEnabled(true) + setAutoEnterEnabled(isPlaying) + } + } + .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 ee6170aa5..82d88b93f 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 @@ -9,9 +9,11 @@ import androidx.media3.common.MimeTypes import androidx.media3.common.util.UnstableApi import androidx.media3.ui.SubtitleView import com.lagradost.cloudstream3.SubtitleFile +import com.lagradost.cloudstream3.ui.player.CustomDecoder.Companion.regexSubtitlesToRemoveBloat +import com.lagradost.cloudstream3.ui.player.CustomDecoder.Companion.regexSubtitlesToRemoveCaptions +import com.lagradost.cloudstream3.ui.player.CustomDecoder.Companion.uppercaseSubtitles 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.ui.subtitles.SubtitlesFragment.Companion.fromSaveToStyle import com.lagradost.cloudstream3.utils.UIHelper.toPx enum class SubtitleStatus { @@ -27,20 +29,18 @@ enum class SubtitleOrigin { } /** - * @param originalName the start of the name to be displayed in the player - * @param nameSuffix An extra suffix added to the subtitle to make sure it is unique + * @param name To be displayed in the player * @param url Url for the subtitle, when EMBEDDED_IN_VIDEO this variable is used as the real backend id * @param headers if empty it will use the base onlineDataSource headers else only the specified headers - * @param languageCode usually, tags such as "en", "es-mx", or "zh-hant-TW". But it could be something like "English 4" + * @param languageCode Not guaranteed to follow any standard. Could be something like "English 4" or "en". * */ data class SubtitleData( - val originalName: String, - val nameSuffix: String, + val name: String, val url: String, val origin: SubtitleOrigin, val mimeType: String, val headers: Map, - val languageCode: String?, + val languageCode: String? ) { /** Internal ID for exoplayer, unique for each link*/ fun getId(): String { @@ -48,18 +48,6 @@ data class SubtitleData( else "$url|$name" } - /** Returns true if langCode is the same as the IETF tag */ - fun matchesLanguageCode(langCode: String): Boolean { - return getIETF_tag() == langCode - } - - /** Tries hard to figure out a valid IETF tag based on language code and name. Will return null if not found. */ - fun getIETF_tag(): String? { - return fromLanguageToTagIETF(this.languageCode) ?: fromLanguageToTagIETF(this.originalName, halfMatch = true) - } - - val name = "$originalName $nameSuffix" - /** * Gets the URL, but tries to fix it if it is malformed. */ @@ -91,7 +79,8 @@ class PlayerSubtitleHelper { allSubtitles = list } - var subtitleView: SubtitleView? = null + private var subStyle: SaveCaptionStyle? = null + private var subtitleView: SubtitleView? = null companion object { fun String.toSubtitleMimeType(): String { @@ -105,13 +94,12 @@ class PlayerSubtitleHelper { fun getSubtitleData(subtitleFile: SubtitleFile): SubtitleData { return SubtitleData( - originalName = subtitleFile.lang, - nameSuffix = "", + name = subtitleFile.lang, url = subtitleFile.url, origin = SubtitleOrigin.URL, mimeType = subtitleFile.url.toSubtitleMimeType(), - headers = subtitleFile.headers ?: emptyMap(), - languageCode = subtitleFile.langTag ?: subtitleFile.lang + headers = emptyMap(), + languageCode = subtitleFile.lang ) } } @@ -127,9 +115,21 @@ class PlayerSubtitleHelper { } fun setSubStyle(style: SaveCaptionStyle) { - Log.i(TAG, "SET STYLE = $style") - subtitleView?.translationY = -style.elevation.toPx.toFloat() - setSubtitleViewStyle(subtitleView, style, true) + regexSubtitlesToRemoveBloat = style.removeBloat + uppercaseSubtitles = style.upperCase + regexSubtitlesToRemoveCaptions = style.removeCaptions + subtitleView?.context?.let { ctx -> + subStyle = style + Log.i(TAG, "SET STYLE = $style") + subtitleView?.setStyle(ctx.fromSaveToStyle(style)) + subtitleView?.translationY = -style.elevation.toPx.toFloat() + val size = style.fixedTextSize + if (size != null) { + subtitleView?.setFixedTextSize(TypedValue.COMPLEX_UNIT_SP, size) + } else { + subtitleView?.setUserDefaultTextSize() + } + } } fun initSubtitles(subView: SubtitleView?, subHolder: FrameLayout?, style: SaveCaptionStyle?) { diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerView.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerView.kt deleted file mode 100644 index 0e6f1a367..000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerView.kt +++ /dev/null @@ -1,842 +0,0 @@ -package com.lagradost.cloudstream3.ui.player - -import android.annotation.SuppressLint -import android.app.Activity -import android.content.BroadcastReceiver -import android.content.Context -import android.content.Intent -import android.content.IntentFilter -import android.content.pm.ActivityInfo -import android.graphics.drawable.AnimatedImageDrawable -import android.graphics.drawable.AnimatedVectorDrawable -import android.media.metrics.PlaybackErrorEvent -import android.os.Build -import android.os.Handler -import android.os.Looper -import android.text.format.DateUtils -import android.util.AttributeSet -import android.util.Log -import android.view.View -import android.view.WindowManager -import android.view.WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES -import android.widget.FrameLayout -import android.widget.ImageView -import android.widget.ProgressBar -import android.widget.RelativeLayout -import android.widget.TextView -import android.widget.Toast -import androidx.annotation.MainThread -import androidx.annotation.OptIn -import androidx.core.view.isGone -import androidx.core.view.isInvisible -import androidx.core.view.isVisible -import androidx.core.widget.doOnTextChanged -import androidx.fragment.app.FragmentActivity -import androidx.media3.common.PlaybackException -import androidx.media3.common.util.UnstableApi -import androidx.media3.exoplayer.ExoPlayer -import androidx.media3.session.MediaSession -import androidx.media3.ui.AspectRatioFrameLayout -import androidx.media3.ui.DefaultTimeBar -import androidx.media3.ui.SubtitleView -import androidx.media3.ui.TimeBar -import androidx.preference.PreferenceManager -import androidx.vectordrawable.graphics.drawable.AnimatedVectorDrawableCompat -import com.github.rubensousa.previewseekbar.PreviewBar -import com.github.rubensousa.previewseekbar.media3.PreviewTimeBar -import com.lagradost.cloudstream3.CommonActivity.isInPIPMode -import com.lagradost.cloudstream3.CommonActivity.screenWidth -import com.lagradost.cloudstream3.CommonActivity.showToast -import com.lagradost.cloudstream3.ErrorLoadingException -import com.lagradost.cloudstream3.R -import com.lagradost.cloudstream3.mvvm.logError -import com.lagradost.cloudstream3.mvvm.safe -import com.lagradost.cloudstream3.ui.player.live.LivePreviewTimeBar -import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR -import com.lagradost.cloudstream3.ui.settings.Globals.PHONE -import com.lagradost.cloudstream3.ui.settings.Globals.TV -import com.lagradost.cloudstream3.ui.settings.Globals.isLayout -import com.lagradost.cloudstream3.ui.subtitles.SaveCaptionStyle -import com.lagradost.cloudstream3.ui.subtitles.SubtitlesFragment -import com.lagradost.cloudstream3.utils.AppContextUtils -import com.lagradost.cloudstream3.utils.AppContextUtils.requestLocalAudioFocus -import com.lagradost.cloudstream3.utils.DataStoreHelper -import com.lagradost.cloudstream3.utils.UIHelper.hideKeyboard -import com.lagradost.cloudstream3.utils.UIHelper.hideSystemUI -import com.lagradost.cloudstream3.utils.UIHelper.popCurrentPage -import com.lagradost.cloudstream3.utils.UIHelper.showSystemUI -import com.lagradost.cloudstream3.utils.UserPreferenceDelegate -import com.lagradost.cloudstream3.utils.videoskip.VideoSkipStamp -import java.net.SocketTimeoutException - -/** - * Shared player view - manages ExoPlayer setup, view binding, lifecycle, and event - * dispatching. Gesture/volume/brightness/key-event input is handled by [gestureHelper] - * ([PlayerGestureHelper]), which is exposed via delegate properties for easier access. - */ -@OptIn(UnstableApi::class) -class PlayerView @JvmOverloads constructor( - context: Context, - attrs: AttributeSet? = null -) : FrameLayout(context, attrs) { - - companion object { - private const val TAG = "PlayerView" - } - - /** All gesture, volume, brightness and key-event logic lives here. */ - val gestureHelper = PlayerGestureHelper(this) - - /** Delegate properties (forwarded to gestureHelper for external callers to have easier access) */ - var isFullScreen: Boolean - get() = gestureHelper.isFullScreen - set(value) { gestureHelper.isFullScreen = value } - - var isLocked: Boolean - get() = gestureHelper.isLocked - set(value) { gestureHelper.isLocked = value } - - var videoOutline: View? - get() = gestureHelper.videoOutline - set(value) { gestureHelper.videoOutline = value } - - /** Delegate methods */ - fun handleVolumeKey(keyCode: Int) = gestureHelper.handleVolumeKey(keyCode) - fun verifyVolume() = gestureHelper.verifyVolume() - fun setupKeyEventListener() = gestureHelper.setupKeyEventListener() - fun releaseKeyEventListener() = gestureHelper.releaseKeyEventListener() - fun requestUpdateBrightnessOverlayOnNextLayout() = gestureHelper.requestUpdateBrightnessOverlayOnNextLayout() - fun releaseOverlayLayoutListener() = gestureHelper.releaseOverlayLayoutListener() - - /** Callbacks */ - - /** Host-fragment-level callbacks invoked by [mainCallback]. */ - interface Callbacks { - fun nextEpisode() {} - fun prevEpisode() {} - fun playerPositionChanged(position: Long, duration: Long) {} - fun playerStatusChanged() {} - fun playerDimensionsLoaded(width: Int, height: Int) {} - fun subtitlesChanged() {} - fun embeddedSubtitlesFetched(subtitles: List) {} - fun onTracksInfoChanged() {} - fun onTimestamp(timestamp: VideoSkipStamp?) {} - fun onTimestampSkipped(timestamp: VideoSkipStamp) {} - fun exitedPipMode() {} - fun hasNextMirror(): Boolean = false - fun nextMirror() {} - fun onDownload(event: DownloadEvent) {} - fun playerError(exception: Throwable) {} - /** Called after [PlayerView] finishes its own player-attached setup (MediaSession, ExoPlayer view). */ - fun playerUpdated(player: Any?) {} - /** Called on a short single-tap on empty player area (no swipe, no double-tap). */ - fun onSingleTap() {} - /** Called when the hold-for-speedup gesture starts (show=true) or ends (show=false). */ - fun onHoldSpeedUp(show: Boolean) {} - /** Called during brightness swipe with the current extra-brightness alpha (0–1). */ - fun onBrightnessExtra(alpha: Float) {} - - /** Touch event callbacks */ - - /** Returns whether the player UI (controls overlay) is currently visible. */ - fun isUIShowing(): Boolean = false - /** Called on a valid ACTION_DOWN; use for e.g. dismissing an episode overlay. */ - fun onTouchDown() {} - /** Called with seek-preview text during a horizontal-swipe, or null to clear it. */ - fun onSeekPreviewText(text: String?) {} - /** Called when a swipe gesture begins; hide the player UI if desired. */ - fun onHidePlayerUI() {} - /** - * Called at the end of each touch sequence. - * @param hadSwipe true if a swipe (brightness/volume/time) was in progress. - * @param wasUiShowing true if the UI was visible when the swipe began. - */ - fun onGestureEnd(hadSwipe: Boolean, wasUiShowing: Boolean) {} - /** - * Called when the auto-hide timer fires: UI is showing, no touch is active. - * Implement to hide the player controls. - */ - fun onAutoHideUI() {} - } - - var callbacks: Callbacks? = null - - /** Player state */ - - var player: IPlayer = CS3IPlayer() - var resizeMode: Int = 0 - var hasPipModeSupport: Boolean = true - var currentPlayerStatus: CSPlayerLoading = CSPlayerLoading.IsBuffering - var mMediaSession: MediaSession? = null - private var pipReceiver: BroadcastReceiver? = null - - /** Auto-hide */ - private var autoHideToken = 0 - private val autoHideHandler = Handler(Looper.getMainLooper()) - - /** View references (populated by bindViews) */ - - var subView: SubtitleView? = null - var playerPausePlayHolderHolder: FrameLayout? = null - var playerPausePlay: ImageView? = null - var playerBuffering: ProgressBar? = null - /** The Media3/ExoPlayer [androidx.media3.ui.PlayerView] widget. */ - var exoPlayerView: androidx.media3.ui.PlayerView? = null - var piphide: FrameLayout? = null - var subtitleHolder: FrameLayout? = null - internal var playerRew: View? = null - internal var playerFfwd: View? = null - internal var exoRewText: TextView? = null - internal var exoFfwdText: TextView? = null - internal var playerCenterMenu: View? = null - internal var playerRewHolder: View? = null - internal var playerFfwdHolder: View? = null - internal var playerVideoHolder: View? = null - var playerProgressbarLeftHolder: RelativeLayout? = null - var playerProgressbarLeftIcon: ImageView? = null - var playerProgressbarLeftLevel1: ProgressBar? = null - var playerProgressbarLeftLevel2: ProgressBar? = null - var playerProgressbarRightHolder: RelativeLayout? = null - var playerProgressbarRightIcon: ImageView? = null - var playerProgressbarRightLevel1: ProgressBar? = null - var playerProgressbarRightLevel2: ProgressBar? = null - /** Accessed by [PlayerGestureHelper.showOrHideSpeedUp]. */ - internal var playerSpeedupButton: View? = null - var playerHolder: FrameLayout? = null - private var exoDuration: TextView? = null - private var timeLeft: TextView? = null - private var exoPosition: TextView? = null - private var timeLive: View? = null - private var exoProgress: LivePreviewTimeBar? = null - - /** Seek delta used by the basic rew/ffwd click listeners. Read from settings in [initialize]. */ - var seekTime: Long = 10_000L - - /** True when the current video is taller than it is wide. Set by [mainCallback] on [ResizedEvent]. */ - var isVerticalOrientation: Boolean = false - - /** When true, [dynamicOrientation] returns portrait for portrait videos. Read from settings in [initialize]. */ - var autoPlayerRotateEnabled: Boolean = false - - var durationMode: Boolean by UserPreferenceDelegate("duration_mode", false) - - // Kept so SubtitlesFragment can unsubscribe the exact same reference. - private val subStyleListener: (SaveCaptionStyle) -> Unit = ::onSubStyleChanged - - /** View discovery */ - - /** - * Discovers player-related views from [root]. IDs absent in compact layouts (e.g. trailer) simply - * remain null, all usage is null-safe. - */ - fun bindViews(root: View) { - exoDuration = root.findViewById(androidx.media3.ui.R.id.exo_duration) - exoFfwdText = root.findViewById(R.id.exo_ffwd_text) - exoPlayerView = root.findViewById(R.id.player_view) - exoPosition = root.findViewById(R.id.exo_position) - exoRewText = root.findViewById(R.id.exo_rew_text) - piphide = root.findViewById(R.id.piphide) - playerBuffering = root.findViewById(R.id.player_buffering) - playerCenterMenu = root.findViewById(R.id.player_center_menu) - playerFfwd = root.findViewById(R.id.player_ffwd) - playerFfwdHolder = root.findViewById(R.id.player_ffwd_holder) - playerHolder = root.findViewById(R.id.player_holder) - playerPausePlay = root.findViewById(R.id.player_pause_play) - playerPausePlayHolderHolder = root.findViewById(R.id.player_pause_play_holder_holder) - playerProgressbarLeftHolder = root.findViewById(R.id.player_progressbar_left_holder) - playerProgressbarLeftIcon = root.findViewById(R.id.player_progressbar_left_icon) - playerProgressbarLeftLevel1 = root.findViewById(R.id.player_progressbar_left_level1) - playerProgressbarLeftLevel2 = root.findViewById(R.id.player_progressbar_left_level2) - playerProgressbarRightHolder = root.findViewById(R.id.player_progressbar_right_holder) - playerProgressbarRightIcon = root.findViewById(R.id.player_progressbar_right_icon) - playerProgressbarRightLevel1 = root.findViewById(R.id.player_progressbar_right_level1) - playerProgressbarRightLevel2 = root.findViewById(R.id.player_progressbar_right_level2) - playerRew = root.findViewById(R.id.player_rew) - playerRewHolder = root.findViewById(R.id.player_rew_holder) - playerSpeedupButton = root.findViewById(R.id.player_speedup_button) - playerVideoHolder = root.findViewById(R.id.player_video_holder) - subtitleHolder = root.findViewById(R.id.subtitle_holder) - timeLeft = root.findViewById(R.id.time_left) - timeLive = root.findViewById(R.id.time_live) - } - - /** - * Called once after [bindViews]. Sets up the preview seek-bar, subtitle style listener, - * player callbacks and basic controls; then delegates gesture/input setup to [gestureHelper]. - */ - fun initialize() { - resizeMode = DataStoreHelper.resizeMode - resize(resizeMode, false) - - player.releaseCallbacks() - player.initCallbacks( - eventHandler = ::mainCallback, - requestedListeningPercentages = listOf( - SKIP_OP_VIDEO_PERCENTAGE, - PRELOAD_NEXT_EPISODE_PERCENTAGE, - NEXT_WATCH_EPISODE_PERCENTAGE, - UPDATE_SYNC_PROGRESS_PERCENTAGE, - ), - ) - - if (player is CS3IPlayer) { - // Preview bar - val progressBar: PreviewTimeBar? = exoPlayerView?.findViewById(R.id.exo_progress) - exoProgress = progressBar as? LivePreviewTimeBar - val previewImageView: ImageView? = exoPlayerView?.findViewById(R.id.previewImageView) - val previewFrameLayout: FrameLayout? = - exoPlayerView?.findViewById(R.id.previewFrameLayout) - - /** Hide the previewFrameLayout on TV to make the skip op button not float, - * as previewFrameLayout is normally invisible */ - if(isLayout(TV)) { - previewFrameLayout?.isVisible = false - } - - if (progressBar != null && previewImageView != null && previewFrameLayout != null && !isLayout(TV)) { - var resume = false - progressBar.addOnScrubListener(object : PreviewBar.OnScrubListener { - override fun onScrubStart(previewBar: PreviewBar?) { - val cs3 = player as? CS3IPlayer ?: return - val hasPreview = cs3.hasPreview() - progressBar.isPreviewEnabled = hasPreview - resume = cs3.getIsPlaying() - if (resume) cs3.handleEvent(CSPlayerEvent.Pause, PlayerEventSource.Player) - // No clashing UI - if (hasPreview) subView?.isVisible = false - } - - override fun onScrubMove(previewBar: PreviewBar?, progress: Int, fromUser: Boolean) {} - - override fun onScrubStop(previewBar: PreviewBar?) { - val cs3 = player as? CS3IPlayer ?: return - if (resume) cs3.handleEvent(CSPlayerEvent.Play, PlayerEventSource.Player) - // Delay to prevent the small flicker of subtitle before seeking. - subView?.postDelayed({ - // If we are not scrubbing then show subtitles again. - if (previewBar == null || !previewBar.isPreviewEnabled || !previewBar.isShowingPreview) { - subView?.isVisible = true - } - }, 200) - } - }) - progressBar.attachPreviewView(previewFrameLayout) - progressBar.setPreviewLoader { currentPosition, max -> - val cs3 = player as? CS3IPlayer ?: return@setPreviewLoader - val bitmap = cs3.getPreview(currentPosition.toFloat().div(max.toFloat())) - previewImageView.isGone = bitmap == null - previewImageView.setImageBitmap(bitmap) - } - } - - subView = exoPlayerView?.findViewById(androidx.media3.ui.R.id.exo_subtitles) - (player as? CS3IPlayer)?.initSubtitles(subView, subtitleHolder, CustomDecoder.style) - (player as? CS3IPlayer)?.let { - (it.imageGenerator as? PreviewGenerator)?.params = - ImageParams.new16by9(screenWidth) - } - - /** - * This might seam a bit fucky and that is because it is, the seek event is captured twice, once by the player - * and once by the UI even if it should only be registered once by the UI. - */ - exoPlayerView?.findViewById(R.id.exo_progress) - ?.addListener(object : TimeBar.OnScrubListener { - override fun onScrubStart(timeBar: TimeBar, position: Long) = Unit - override fun onScrubMove(timeBar: TimeBar, position: Long) = Unit - override fun onScrubStop(timeBar: TimeBar, position: Long, canceled: Boolean) { - if (canceled) return - val playerDuration = player.getDuration() ?: return - val playerPosition = player.getPosition() ?: return - mainCallback( - PositionEvent( - source = PlayerEventSource.UI, - durationMs = playerDuration, - fromMs = playerPosition, - toMs = position - ) - ) - } - }) - - // Read seek time and rotation settings. - try { - val sm = PreferenceManager.getDefaultSharedPreferences(context) - seekTime = sm.getInt(context.getString(R.string.double_tap_seek_time_key), 10) - .toLong() * 1000L - autoPlayerRotateEnabled = sm.getBoolean( - context.getString(R.string.auto_rotate_video_key), true - ) - } catch (_: Exception) { - } - - val seekSecs = (seekTime / 1000).toInt() - exoRewText?.text = context.getString(R.string.rew_text_regular_format).format(seekSecs) - exoFfwdText?.text = context.getString(R.string.ffw_text_regular_format).format(seekSecs) - - playerPausePlay?.setOnClickListener { - scheduleAutoHide() - if (currentPlayerStatus == CSPlayerLoading.IsEnded && isLayout(PHONE)) { - player.handleEvent(CSPlayerEvent.Restart, PlayerEventSource.UI) - } else { - player.handleEvent(CSPlayerEvent.PlayPauseToggle, PlayerEventSource.UI) - } - } - playerRew?.setOnClickListener { - scheduleAutoHide() - gestureHelper.rewind() - } - playerFfwd?.setOnClickListener { - scheduleAutoHide() - gestureHelper.fastForward() - } - - SubtitlesFragment.applyStyleEvent += subStyleListener - - try { - val ctx = context - val settingsManager = PreferenceManager.getDefaultSharedPreferences(ctx) - val cs3 = player as? CS3IPlayer ?: return - cs3.cacheSize = - settingsManager.getInt(context.getString(R.string.video_buffer_size_key), 0) * 1024L * 1024L - cs3.simpleCacheSize = - settingsManager.getInt(context.getString(R.string.video_buffer_disk_key), 0) * 1024L * 1024L - cs3.videoBufferMs = - settingsManager.getInt(context.getString(R.string.video_buffer_length_key), 0) * 1000L - } catch (e: Exception) { - logError(e) - } - - // Duration toggle click listeners - exoDuration?.setOnClickListener { setRemainingTimeCounter(true) } - timeLeft?.setOnClickListener { setRemainingTimeCounter(false) } - // Keep remaining-time text in sync with playback position - exoPosition?.doOnTextChanged { _, _, _, _ -> updateRemainingTime() } - - // Delegate gesture/input setup (settings, brightness overlay, touch gestures, key listener) - gestureHelper.initialize() - setupKeyEventListener() - - // Apply duration-mode display (remaining time vs elapsed); TV always shows remaining - setRemainingTimeCounter(durationMode || isLayout(TV)) - } - } - - /** Lifecycle delegation */ - - var fullscreenNotch: Boolean = true // TODO SETTING - - fun enterFullscreen(updateOrientation: () -> Unit = {}) { - val activity = context as? Activity - if (isFullScreen) { - activity?.hideSystemUI() - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P && fullscreenNotch) { - val params = activity?.window?.attributes - params?.layoutInDisplayCutoutMode = LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES - activity?.window?.attributes = params - } - } - updateOrientation() - } - - fun exitFullscreen() { - val activity = context as? Activity - gestureHelper.resetZoomToDefault() - activity?.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_USER - // Simply resets brightness and notch settings that might have been overridden. - val lp = activity?.window?.attributes - lp?.screenBrightness = WindowManager.LayoutParams.BRIGHTNESS_OVERRIDE_NONE - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { - lp?.layoutInDisplayCutoutMode = WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_DEFAULT - } - activity?.window?.attributes = lp - activity?.showSystemUI() - } - - fun onStop() { - player.onStop() - } - - fun onResume(ctx: Context) { - player.onResume(ctx) - } - - /** Releases all player resources. */ - fun release() { - player.release() - player.releaseCallbacks() - player = CS3IPlayer() - - // keyEventListener is deregistered in onPause so that the incoming player's - // onResume can register its own listener without racing against release(). - - PlayerPipHelper.updatePIPModeActions( - context as? Activity, - CSPlayerLoading.IsPaused, - false, - null - ) - - mMediaSession?.release() - mMediaSession = null - exoPlayerView?.player = null - - SubtitlesFragment.applyStyleEvent -= subStyleListener - - gestureHelper.release() - autoHideHandler.removeCallbacksAndMessages(null) - - keepScreenOn(false) - } - - fun onPictureInPictureModeChanged( - isInPictureInPictureMode: Boolean, - activity: Activity? - ) { - try { - isInPIPMode = isInPictureInPictureMode - if (isInPictureInPictureMode) { - // Hide the full-screen UI (controls, etc.) while in picture-in-picture mode. - piphide?.isVisible = false - pipReceiver = object : BroadcastReceiver() { - override fun onReceive(context: Context, intent: Intent) { - if (ACTION_MEDIA_CONTROL != intent.action) return - player.handleEvent( - CSPlayerEvent.entries[intent.getIntExtra(EXTRA_CONTROL_TYPE, 0)], - source = PlayerEventSource.UI - ) - } - } - val filter = IntentFilter().apply { addAction(ACTION_MEDIA_CONTROL) } - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - activity?.registerReceiver(pipReceiver, filter, Context.RECEIVER_EXPORTED) - } else { - @SuppressLint("UnspecifiedRegisterReceiverFlag") - activity?.registerReceiver(pipReceiver, filter) - } - val isPlaying = player.getIsPlaying() - val status = if (isPlaying) CSPlayerLoading.IsPlaying else CSPlayerLoading.IsPaused - updateIsPlaying(status, status) - } else { - // Restore the full-screen UI. - piphide?.isVisible = true - callbacks?.exitedPipMode() - pipReceiver?.let { - // Prevents java.lang.IllegalArgumentException: Receiver not registered - safe { activity?.unregisterReceiver(it) } - } - activity?.hideSystemUI() - hideKeyboard(this) - } - } catch (e: Exception) { - logError(e) - } - } - - /** Player UI helpers */ - - private fun keepScreenOn(on: Boolean) { - val window = (context as? Activity)?.window ?: return - if (on) window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) - else window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) - } - - fun updateIsPlaying(wasPlaying: CSPlayerLoading, isPlaying: CSPlayerLoading) { - val isPlayingRightNow = CSPlayerLoading.IsPlaying == isPlaying - val isBuffering = CSPlayerLoading.IsBuffering == isPlaying - currentPlayerStatus = isPlaying - - keepScreenOn(isPlayingRightNow || isBuffering) - - if (isBuffering) { - playerPausePlayHolderHolder?.isVisible = false - playerBuffering?.isVisible = true - } else { - playerPausePlayHolderHolder?.isVisible = true - playerBuffering?.isVisible = false - - if (isPlaying == CSPlayerLoading.IsEnded && isLayout(PHONE)) { - playerPausePlay?.setImageResource(R.drawable.ic_baseline_replay_24) - } else if (wasPlaying != isPlaying) { - playerPausePlay?.setImageResource( - if (isPlayingRightNow) R.drawable.play_to_pause else R.drawable.pause_to_play - ) - val drawable = playerPausePlay?.drawable - var startedAnimation = false - if (Build.VERSION.SDK_INT > Build.VERSION_CODES.P) { - if (drawable is AnimatedImageDrawable) { drawable.start(); startedAnimation = true } - } - if (drawable is AnimatedVectorDrawable) { drawable.start(); startedAnimation = true } - if (drawable is AnimatedVectorDrawableCompat) { drawable.start(); startedAnimation = true } - // Somehow the phone is wacked - if (!startedAnimation) { - playerPausePlay?.setImageResource( - if (isPlayingRightNow) R.drawable.netflix_pause else R.drawable.netflix_play - ) - } - } else { - playerPausePlay?.setImageResource( - if (isPlayingRightNow) R.drawable.netflix_pause else R.drawable.netflix_play - ) - } - } - - PlayerPipHelper.updatePIPModeActions( - context as? Activity, - isPlaying, - hasPipModeSupport, - player.getAspectRatio() - ) - } - - private fun requestAudioFocus() { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - (context as? Activity)?.requestLocalAudioFocus(AppContextUtils.getFocusRequest()) - } - } - - private fun playerUpdated(player: Any?) { - if (player is ExoPlayer) { - mMediaSession?.release() - mMediaSession = MediaSession.Builder(context, player) - // Ensure unique ID for concurrent players. - .setId(System.currentTimeMillis().toString()) - .build() - - // Necessary for multiple combined videos. - @Suppress("DEPRECATION") - exoPlayerView?.setShowMultiWindowTimeBar(true) - exoPlayerView?.player = player - exoPlayerView?.performClick() - } - callbacks?.playerUpdated(player) - } - - private fun onSubStyleChanged(style: SaveCaptionStyle) { - player.updateSubtitleStyle(style) - // Forcefully update the subtitle encoding in case the edge size is changed. - player.seekTime(-1) - } - - /** Error handling */ - - @MainThread - fun playerError(exception: Throwable) { - fun showErrorToast(message: String) { - if (callbacks?.hasNextMirror() == true) { - showToast(message, Toast.LENGTH_SHORT) - callbacks?.nextMirror() - } else { - showToast( - context.getString(R.string.no_links_found_toast) + "\n" + message, - Toast.LENGTH_LONG - ) - (context as? FragmentActivity)?.popCurrentPage() - } - } - - when (exception) { - is PlaybackException -> { - val msg = exception.message ?: "" - val errorName = exception.errorCodeName - when (val code = exception.errorCode) { - PlaybackException.ERROR_CODE_IO_FILE_NOT_FOUND, - PlaybackException.ERROR_CODE_IO_NO_PERMISSION, - PlaybackException.ERROR_CODE_IO_READ_POSITION_OUT_OF_RANGE, - PlaybackException.ERROR_CODE_IO_UNSPECIFIED, - PlaybackException.ERROR_CODE_PARSING_CONTAINER_UNSUPPORTED -> - showErrorToast("${context.getString(R.string.source_error)}\n$errorName ($code)\n$msg") - - PlaybackException.ERROR_CODE_REMOTE_ERROR, - PlaybackException.ERROR_CODE_IO_BAD_HTTP_STATUS, - PlaybackException.ERROR_CODE_TIMEOUT, - PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_FAILED, - PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_TIMEOUT, - PlaybackException.ERROR_CODE_IO_INVALID_HTTP_CONTENT_TYPE -> - showErrorToast("${context.getString(R.string.remote_error)}\n$errorName ($code)\n$msg") - - PlaybackErrorEvent.ERROR_AUDIO_TRACK_INIT_FAILED, - PlaybackErrorEvent.ERROR_AUDIO_TRACK_OTHER, - PlaybackException.ERROR_CODE_DECODING_FAILED, - PlaybackException.ERROR_CODE_AUDIO_TRACK_WRITE_FAILED, - PlaybackException.ERROR_CODE_DECODER_INIT_FAILED, - PlaybackException.ERROR_CODE_DECODER_QUERY_FAILED -> - showErrorToast("${context.getString(R.string.render_error)}\n$errorName ($code)\n$msg") - - PlaybackException.ERROR_CODE_DECODING_FORMAT_UNSUPPORTED, - PlaybackException.ERROR_CODE_DECODING_FORMAT_EXCEEDS_CAPABILITIES -> - showErrorToast("${context.getString(R.string.unsupported_error)}\n$errorName ($code)\n$msg") - - PlaybackException.ERROR_CODE_PARSING_CONTAINER_MALFORMED, - PlaybackException.ERROR_CODE_PARSING_MANIFEST_MALFORMED -> - showErrorToast("${context.getString(R.string.encoding_error)}\n$errorName ($code)\n$msg") - - else -> - showErrorToast("${context.getString(R.string.unexpected_error)}\n$errorName ($code)\n$msg") - } - } - - is SocketTimeoutException -> - showErrorToast("${context.getString(R.string.remote_error)}\n${exception.message}") - - is ErrorLoadingException -> - exception.message?.let { showErrorToast(it) } - ?: showErrorToast(exception.toString()) - - else -> - exception.message?.let { showErrorToast(it) } - ?: showErrorToast(exception.toString()) - } - } - - /** Resize */ - - fun nextResize() { - resizeMode = (resizeMode + 1) % PlayerResize.entries.size - resize(resizeMode, true) - } - - fun resize(resize: Int, showToast: Boolean) { - // Clear all zoom state before applying the new resize mode - gestureHelper.clearZoomState() - resize(PlayerResize.entries[resize], showToast) - } - - fun resize(resize: PlayerResize, showToast: Boolean) { - DataStoreHelper.resizeMode = resize.ordinal - val type = when (resize) { - PlayerResize.Fill -> AspectRatioFrameLayout.RESIZE_MODE_FILL - PlayerResize.Fit -> AspectRatioFrameLayout.RESIZE_MODE_FIT - PlayerResize.Zoom -> AspectRatioFrameLayout.RESIZE_MODE_ZOOM - } - exoPlayerView?.resizeMode = type - if (showToast) showToast(resize.nameRes, Toast.LENGTH_SHORT) - } - - /** Orientation */ - - /** - * Returns the desired [ActivityInfo] orientation constant based on [isVerticalOrientation] - * and [autoPlayerRotateEnabled]. TV/emulator always returns sensor-landscape. - * Host fragments call this from [Callbacks.playerDimensionsLoaded] to apply rotation. - */ - fun dynamicOrientation(): Int { - if (isLayout(TV or EMULATOR)) return ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE - return if (autoPlayerRotateEnabled && isVerticalOrientation) - ActivityInfo.SCREEN_ORIENTATION_SENSOR_PORTRAIT - else ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE - } - - /** Event dispatch */ - - /** - * This receives the events from the player, if you want to append functionality - * you do it here, do note that this only receives events for UI changes, - * and returning early WON'T stop it from changing in e.g. the player time - * or pause status. - */ - @MainThread - fun mainCallback(event: PlayerEvent) { - // We don't want to spam DownloadEvent. - if (event !is DownloadEvent) Log.i(TAG, "Handle event: $event") - when (event) { - is DownloadEvent -> callbacks?.onDownload(event) - is ResizedEvent -> { - // Skip 0x0 dimensions that the player emits when going to STATE_IDLE - // to avoid incorrectly resetting the auto-detected orientation. - if (event.width > 0 && event.height > 0) { - // TV never rotates; otherwise track whether the video is portrait. - isVerticalOrientation = !isLayout(TV or EMULATOR) && event.height > event.width - } - callbacks?.playerDimensionsLoaded(event.width, event.height) - } - is PlayerAttachedEvent -> playerUpdated(event.player) - is SubtitlesUpdatedEvent -> callbacks?.subtitlesChanged() - is TimestampSkippedEvent -> callbacks?.onTimestampSkipped(event.timestamp) - is TimestampInvokedEvent -> callbacks?.onTimestamp(event.timestamp) - is TracksChangedEvent -> callbacks?.onTracksInfoChanged() - is EmbeddedSubtitlesFetchedEvent -> callbacks?.embeddedSubtitlesFetched(event.tracks) - is ErrorEvent -> callbacks?.playerError(event.error) ?: playerError(event.error) - is RequestAudioFocusEvent -> requestAudioFocus() - is EpisodeSeekEvent -> when (event.offset) { - -1 -> callbacks?.prevEpisode() - 1 -> callbacks?.nextEpisode() - } - is StatusEvent -> { - updateIsPlaying(wasPlaying = event.wasPlaying, isPlaying = event.isPlaying) - scheduleAutoHide() - callbacks?.playerStatusChanged() - } - is PositionEvent -> callbacks?.playerPositionChanged( - position = event.toMs, - duration = event.durationMs - ) - is VideoEndedEvent -> { - // Only play next episode if autoplay is on (default). - val ctx = context - if (PreferenceManager.getDefaultSharedPreferences(ctx) - ?.getBoolean(ctx.getString(R.string.autoplay_next_key), true) == true - ) { - player.handleEvent(CSPlayerEvent.NextEpisode, source = PlayerEventSource.Player) - } - } - is PauseEvent -> Unit - is PlayEvent -> Unit - } - } - - /** Duration display */ - - fun setRemainingTimeCounter(showRemaining: Boolean) { - durationMode = showRemaining - exoDuration?.isInvisible = showRemaining - timeLeft?.isVisible = showRemaining - if (showRemaining) updateRemainingTime() - } - - fun updateRemainingTime() { - val duration = player.getDuration() - val position = player.getPosition() - - if (exoProgress?.isAtLiveEdge() == true) { - timeLeft?.alpha = 0f - exoDuration?.alpha = 0f - timeLive?.isVisible = true - } else { - timeLeft?.alpha = 1f - exoDuration?.alpha = 1f - timeLive?.isVisible = false - } - - if (duration != null && duration > 1 && position != null) { - val remainingTimeSeconds = (duration - position + 500) / 1000 - @SuppressLint("SetTextI18n") - timeLeft?.text = "-${DateUtils.formatElapsedTime(remainingTimeSeconds)}" - } - } - - /** Auto-hide */ - - /** - * Schedules a delayed auto-hide of the player UI after [delayMs] ms. - * Any previously pending hide is canceled first. - * The hide fires only when no touch is active and [Callbacks.isUIShowing] is true; - * the actual hide action is delegated to [Callbacks.onAutoHideUI]. - */ - fun scheduleAutoHide(delayMs: Long = 3000L) { - val token = ++autoHideToken - autoHideHandler.removeCallbacksAndMessages(null) - autoHideHandler.postDelayed({ - if (token != autoHideToken) return@postDelayed - if (gestureHelper.isCurrentTouchValid) return@postDelayed - if (callbacks?.isUIShowing() != true) return@postDelayed - callbacks?.onAutoHideUI() - }, delayMs) - } - - /** Cancels any pending auto-hide scheduled by [scheduleAutoHide]. */ - fun cancelAutoHide() { - autoHideToken++ - autoHideHandler.removeCallbacksAndMessages(null) - } -} diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/PreviewGenerator.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/PreviewGenerator.kt index 2893bcc47..9f92d3953 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/PreviewGenerator.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/PreviewGenerator.kt @@ -9,7 +9,7 @@ import android.util.Log import androidx.annotation.WorkerThread import androidx.core.graphics.scale import androidx.preference.PreferenceManager -import com.lagradost.cloudstream3.CloudStreamApp +import com.lagradost.cloudstream3.AcraApplication import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.ui.settings.Globals.TV @@ -65,10 +65,9 @@ interface IPreviewGenerator { companion object { fun new(): IPreviewGenerator { - val userDisabled = CloudStreamApp.context?.let { ctx -> + val userDisabled = AcraApplication.context?.let { ctx -> PreferenceManager.getDefaultSharedPreferences(ctx)?.getBoolean( - ctx.getString(R.string.preview_seekbar_key), true - ) == false + ctx.getString(R.string.preview_seekbar_key), true) == false } ?: false /** because TV has low ram + not show we disable this for now */ return if (isLayout(TV) || userDisabled) { @@ -252,6 +251,7 @@ private class M3u8PreviewGenerator(override var params: ImageParams) : IPreviewG } + // prefixSum[i] = sum(hsl.ts[0..i].time) // where [0] = 0, [1] = hsl.ts[0].time aka time at start of segment, do [b] - [a] for range a,b private var prefixSum: Array = arrayOf() @@ -327,12 +327,13 @@ private class M3u8PreviewGenerator(override var params: ImageParams) : IPreviewG // } val retriever = MediaMetadataRetriever() val hsl = M3u8Helper2.hslLazy( - M3u8Helper.M3u8Stream( - streamUrl = url, - headers = headers + listOf( + M3u8Helper.M3u8Stream( + streamUrl = url, + headers = headers + ) ), - selectBest = false, - requireAudio = false, + selectBest = false ) // no support for encryption atm diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/RepoLinkGenerator.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/RepoLinkGenerator.kt index 0668a194b..b97ca155b 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 @@ -6,25 +6,22 @@ import com.lagradost.cloudstream3.APIHolder.unixTime import com.lagradost.cloudstream3.LoadResponse import com.lagradost.cloudstream3.ui.APIRepository import com.lagradost.cloudstream3.ui.result.ResultEpisode -import com.lagradost.cloudstream3.utils.AppContextUtils.html import com.lagradost.cloudstream3.utils.ExtractorLink import com.lagradost.cloudstream3.utils.ExtractorLinkType -import java.util.concurrent.ConcurrentHashMap -import java.util.concurrent.atomic.AtomicInteger +import kotlin.math.max +import kotlin.math.min data class Cache( val linkCache: MutableSet, val subtitleCache: MutableSet, - /** When it was last updated */ - var lastCachedTimestamp: Long = unixTime, - /** If it has fully loaded */ - var saturated: Boolean, + var lastCachedTimestamp: Long = unixTime ) class RepoLinkGenerator( - episodes: List, + private val episodes: List, + private var currentIndex: Int = 0, val page: LoadResponse? = null, -) : VideoGenerator(episodes) { +) : IGenerator { companion object { const val TAG = "RepoLink" val cache: HashMap, Cache> = @@ -33,128 +30,138 @@ class RepoLinkGenerator( override val hasCache = true override val canSkipLoading = true - override fun getId(index: Int): Int? = videos.getOrNull(index)?.id + + override fun hasNext(): Boolean { + return currentIndex < episodes.size - 1 + } + + override fun hasPrev(): Boolean { + return currentIndex > 0 + } + + override fun next() { + Log.i(TAG, "next") + if (hasNext()) + currentIndex++ + } + + override fun prev() { + Log.i(TAG, "prev") + if (hasPrev()) + currentIndex-- + } + + override fun goto(index: Int) { + Log.i(TAG, "goto $index") + // clamps value + currentIndex = min(episodes.size - 1, max(0, index)) + } + + override fun getCurrentId(): Int { + return episodes[currentIndex].id + } + + override fun getCurrent(offset: Int): Any? { + return episodes.getOrNull(currentIndex + offset) + } + + override fun getAll(): List { + return episodes + } // this is a simple array that is used to instantly load links if they are already loaded //var linkCache = Array>(size = episodes.size, init = { setOf() }) //var subsCache = Array>(size = episodes.size, init = { setOf() }) - @Throws override suspend fun generateLinks( clearCache: Boolean, - sourceTypes: Set, + allowedTypes: Set, callback: (Pair) -> Unit, subtitleCallback: (SubtitleData) -> Unit, offset: Int, isCasting: Boolean, ): Boolean { - val current = videos.getOrNull(offset) ?: return false + val index = currentIndex + val current = episodes.getOrNull(index + offset) ?: return false - val currentCache = synchronized(cache) { - cache[current.apiName to current.id] ?: Cache( - mutableSetOf(), - mutableSetOf(), - unixTime, - false - ).also { - cache[current.apiName to current.id] = it - } + val (currentLinkCache, currentSubsCache, lastCachedTimestamp) = if (clearCache) { + Cache(mutableSetOf(), mutableSetOf(), unixTime) + } else { + cache[current.apiName to current.id] ?: Cache(mutableSetOf(), mutableSetOf(), unixTime) } - // 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() + //val currentLinkCache = if (clearCache) mutableSetOf() else linkCache[index].toMutableSet() + //val currentSubsCache = if (clearCache) mutableSetOf() else subsCache[index].toMutableSet() - synchronized(currentCache) { - val outdatedCache = - unixTime - currentCache.lastCachedTimestamp > 60 * 20 // 20 minutes + val currentLinks = mutableSetOf() // makes all urls unique + val currentSubsUrls = mutableSetOf() // makes all subs urls unique + val currentSubsNames = mutableSetOf() // makes all subs names unique - if (outdatedCache || clearCache) { - currentCache.linkCache.clear() - currentCache.subtitleCache.clear() - currentCache.saturated = false - } else if (currentCache.linkCache.isNotEmpty()) { - Log.d( - TAG, - "Resumed previous loading from ${unixTime - currentCache.lastCachedTimestamp}s ago" - ) - } + val invalidateCache = unixTime - lastCachedTimestamp > 60 * 20 // 20 minutes + if(invalidateCache){ + currentLinkCache.clear() + currentSubsCache.clear() + } - // call all callbacks - currentCache.linkCache.forEach { link -> - currentLinksUrls.add(link.url) - if (sourceTypes.contains(link.type)) { - callback(link to null) - } - } + currentLinkCache.filter { allowedTypes.contains(it.type) }.forEach { link -> + currentLinks.add(link.url) + callback(link to null) + } - currentCache.subtitleCache.forEach { sub -> - currentSubsUrls.add(sub.url) - lastCountedSuffix.getOrPut(sub.originalName) { AtomicInteger(0) }.incrementAndGet() - subtitleCallback(sub) - } + currentSubsCache.forEach { sub -> + currentSubsUrls.add(sub.url) + currentSubsNames.add(sub.name) + subtitleCallback(sub) + } - // this stops all execution if links are cached - // no extra get requests - if (currentCache.saturated) { - return true - } + // this stops all execution if links are cached + // no extra get requests + if (currentLinkCache.size > 0) { + return true } val result = APIRepository( getApiFromNameNull(current.apiName) ?: throw Exception("This provider does not exist") - ).loadLinks( - current.data, + ).loadLinks(current.data, isCasting = isCasting, subtitleCallback = { file -> - Log.d(TAG, "Loaded SubtitleFile: $file") val correctFile = PlayerSubtitleHelper.getSubtitleData(file) - if (correctFile.url.isBlank() || !currentSubsUrls.add(correctFile.url)) { - return@loadLinks - } + if (correctFile.url.isNotEmpty() && !currentSubsUrls.contains(correctFile.url)) { + currentSubsUrls.add(correctFile.url) - // this part makes sure that all names are unique for UX - val nameDecoded = correctFile.originalName.html().toString() - .trim() // `%3Ch1%3Esub%20name…` → `

sub name…` → `sub name…` - val suffixCount = - lastCountedSuffix.getOrPut(nameDecoded) { AtomicInteger(0) }.incrementAndGet() + // this part makes sure that all names are unique for UX + var name = correctFile.name + var count = 0 + while (currentSubsNames.contains(name)) { + count++ + name = "${correctFile.name} $count" + } - val updatedFile = - correctFile.copy(originalName = nameDecoded, nameSuffix = "$suffixCount") + currentSubsNames.add(name) + val updatedFile = correctFile.copy(name = name) - synchronized(currentCache) { - if (currentCache.subtitleCache.add(updatedFile)) { + if (!currentSubsCache.contains(updatedFile)) { subtitleCallback(updatedFile) - currentCache.lastCachedTimestamp = unixTime + currentSubsCache.add(updatedFile) + //subsCache[index] = currentSubsCache } } }, callback = { link -> Log.d(TAG, "Loaded ExtractorLink: $link") - if (link.url.isBlank() || !currentLinksUrls.add(link.url)) { - return@loadLinks - } + if (link.url.isNotEmpty() && !currentLinks.contains(link.url) && !currentLinkCache.contains(link)) { + currentLinks.add(link.url) - synchronized(currentCache) { - if (currentCache.linkCache.add(link)) { - if (sourceTypes.contains(link.type)) { - callback(Pair(link, null)) - } - - currentCache.linkCache.add(link) - currentCache.lastCachedTimestamp = unixTime + if (allowedTypes.contains(link.type)) { + callback(Pair(link, null)) } + + currentLinkCache.add(link) + // linkCache[index] = currentLinkCache } } ) - - synchronized(currentCache) { - currentCache.saturated = currentCache.linkCache.isNotEmpty() - currentCache.lastCachedTimestamp = unixTime - } + cache[Pair(current.apiName, current.id)] = Cache(currentLinkCache, currentSubsCache, unixTime) return result } 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 deleted file mode 100644 index 824b5d1a2..000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/RoundedBackgroundColorSpan.kt +++ /dev/null @@ -1,114 +0,0 @@ -package com.lagradost.cloudstream3.ui.player - -/** - * Inspired by https://medium.com/@Semper_Viventem/simple-implementation-of-rounded-background-for-text-in-android-60a7706c0419 - * however the connecting triangles cant be rendered on a transparent bg, also does not support alignment. - * - * This current implementation may be expanded to only draw the drawRoundRect with rounded corners iff - * it is on an edge for a nice look: - * - * /----------\ - * | large | - * \----------/ - * | | <- this instead of / and \ - * | small | - * \-------/ - * - * Also note that the background may be drawn wildly different from where exoplayer places it - * because exoplayer has their own custom drawing. This is only an attempt to correlate it. - * - * Additionally, not tested on RTL -*/ - -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 -import android.text.style.LineBackgroundSpan - -class RoundedBackgroundColorSpan( - private val backgroundColor: Int, - private val alignment: Alignment, - private val padding: Float, - private val radius: Float -) : LineBackgroundSpan { - private val paint = Paint().apply { - color = backgroundColor - isAntiAlias = true - } - - override fun drawBackground( - c: Canvas, - p: Paint, - left: Int, - right: Int, - top: Int, - baseline: Int, - bottom: Int, - text: CharSequence, - start: Int, - end: Int, - lineNumber: Int - ) { - - // https://github.com/androidx/media/blob/main/libraries/ui/src/main/java/androidx/media3/ui/SubtitlePainter.java - if (Color.alpha(backgroundColor) <= 0) { - return - } - - val width = p.measureText(text, start, end) - val textLayout: StaticLayout = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - StaticLayout.Builder - .obtain(text, 0, text.length, TextPaint(p), width.toInt()) - .setAlignment(alignment) - .setLineSpacing(0.0f, 1.0f) - .setIncludePad(true) - .build() - } else { - @Suppress("DEPRECATION") - StaticLayout( - text, - TextPaint(p), - width.toInt(), - alignment, - 1.0f, - 0.0f, - true - ) - } - - val center = (left + right).toFloat() * 0.5f - - // I know this is not how you actually do it, but fuck it. - // You have to override the subtitle painter to get all the correct value - val textLeft = when (alignment) { - Alignment.ALIGN_NORMAL -> { - 0.0f - } - - Alignment.ALIGN_OPPOSITE -> { - right - width - } - - Alignment.ALIGN_CENTER -> { - center - width * 0.5f - } - } - - val textTop = textLayout.getLineTop(lineNumber).toFloat() - val textBottom = textLayout.getLineBottom(lineNumber).toFloat() - - c.drawRoundRect( - textLeft - padding, - textTop, - textLeft + width + padding, - textBottom, - radius, - radius, - paint - ) - } -} diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/SubtitleOffsetItemAdapter.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/SubtitleOffsetItemAdapter.kt index fa65c322e..9e3e778be 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/SubtitleOffsetItemAdapter.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/SubtitleOffsetItemAdapter.kt @@ -5,10 +5,9 @@ import android.view.LayoutInflater import android.view.ViewGroup import android.view.animation.DecelerateInterpolator import androidx.core.view.isInvisible +import androidx.recyclerview.widget.RecyclerView import com.lagradost.cloudstream3.databinding.SubtitleOffsetItemBinding -import com.lagradost.cloudstream3.ui.BaseDiffCallback -import com.lagradost.cloudstream3.ui.NoStateAdapter -import com.lagradost.cloudstream3.ui.ViewHolderState +import com.lagradost.cloudstream3.utils.AppContextUtils import kotlin.math.roundToInt data class SubtitleCue(val startTimeMs: Long, val durationMs: Long, val text: List) { @@ -17,67 +16,25 @@ data class SubtitleCue(val startTimeMs: Long, val durationMs: Long, val text: Li class SubtitleOffsetItemAdapter( private var currentTimeMs: Long, + override val items: MutableList, val clickCallback: (SubtitleCue) -> Unit ) : - NoStateAdapter(diffCallback = BaseDiffCallback(itemSame = { a, b -> - a.startTimeMs == b.startTimeMs - })) { + AppContextUtils.DiffAdapter(items) { - override fun onCreateContent(parent: ViewGroup): ViewHolderState { + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { val inflater = LayoutInflater.from(parent.context) val binding = SubtitleOffsetItemBinding.inflate(inflater, parent, false) - return ViewHolderState(binding) + return SubtitleViewHolder(binding) } - override fun onBindContent(holder: ViewHolderState, item: SubtitleCue, position: Int) { - val binding = holder.view as? SubtitleOffsetItemBinding ?: return - - binding.root.setOnClickListener { - clickCallback.invoke(item) - } - - binding.subtitleText.text = item.text.joinToString("\n") - - val timeMs = currentTimeMs - val startTime = item.startTimeMs - val endTime = item.endTimeMs - - val newAlpha = if (timeMs >= startTime) 1f else 0.5f - ObjectAnimator.ofFloat( - binding.subtitleText, - "alpha", - binding.subtitleText.alpha, - newAlpha - ).apply { - interpolator = DecelerateInterpolator() - }.start() - - val showProgress = timeMs in startTime.. holder.bind(items[position]) } } fun getLatestActiveItem(position: Long): Int { - return immutableCurrentList.withIndex().lastOrNull { + return items.withIndex().lastOrNull { position >= it.value.startTimeMs }?.index ?: 0 } @@ -88,9 +45,7 @@ class SubtitleOffsetItemAdapter( val earlyTime = minOf(previousTime, timeMs) val lateTime = maxOf(previousTime, timeMs) - - // TODO Add binary search and notifyItemRangeChanged - val affectedItems = immutableCurrentList.withIndex().filter { cue -> + val affectedItems = items.withIndex().filter { cue -> // Padding is required in the range because changes can be done within one single subtitle range, // and that subtitle needs to be updated cue.value.startTimeMs in (earlyTime - cue.value.durationMs)..(lateTime + cue.value.durationMs) @@ -101,4 +56,57 @@ class SubtitleOffsetItemAdapter( this.notifyItemChanged(item.index) } } + + private inner class SubtitleViewHolder( + val binding: SubtitleOffsetItemBinding, + ) : + RecyclerView.ViewHolder(binding.root) { + + fun bind( + data: SubtitleCue + ) { + binding.root.setOnClickListener { + clickCallback.invoke(data) + } + + binding.subtitleText.text = data.text.joinToString("\n") + + val timeMs = currentTimeMs + val startTime = data.startTimeMs + val endTime = data.endTimeMs + + val newAlpha = if (timeMs >= startTime) 1f else 0.5f + ObjectAnimator.ofFloat( + binding.subtitleText, + "alpha", + binding.subtitleText.alpha, + newAlpha + ).apply { + interpolator = DecelerateInterpolator() + }.start() + + val showProgress = timeMs in startTime.. { - if(TORRENT_SERVER_URL.isEmpty()) { - throw ErrorLoadingException("Not initialized") - } - return app.post( - "$TORRENT_SERVER_URL/torrents", - json = TorrentRequest( - action = "list", - ), - timeout = TIMEOUT, - headers = emptyMap() - ).parsed>() - } - - /** Drops a single torrent, (I think) this means closing the stream. Returns returns if it is successful */ - private suspend fun drop(hash: String): Boolean { - if(TORRENT_SERVER_URL.isEmpty()) { - return false - } - return try { - return app.post( - "$TORRENT_SERVER_URL/torrents", - json = TorrentRequest( - action = "drop", - hash = hash - ), - timeout = TIMEOUT, - headers = emptyMap() - ).isSuccessful - } catch (t: Throwable) { - logError(t) - false - } - } - - /** Removes a single torrent from the server registry */ - private suspend fun rem(hash: String): Boolean { - if(TORRENT_SERVER_URL.isEmpty()) { - return false - } - return try { - return app.post( - "$TORRENT_SERVER_URL/torrents", - json = TorrentRequest( - action = "rem", - hash = hash - ), - timeout = TIMEOUT, - headers = emptyMap() - ).isSuccessful - } catch (t: Throwable) { - logError(t) - false - } - } - - - /** Removes all torrents from the server, and returns if it is successful */ - suspend fun clearAll(): Boolean { - if(TORRENT_SERVER_URL.isEmpty()) { - return true - } - return try { - val items = list() - var allSuccess = true - for (item in items) { - val hash = item.hash - if (hash == null) { - Log.i(TAG, "No hash on ${item.name}") - allSuccess = false - continue - } - if (drop(hash)) { - Log.i(TAG, "Successfully dropped ${item.name}") - } else { - Log.i(TAG, "Failed to drop ${item.name}") - allSuccess = false - continue - } - if (rem(hash)) { - Log.i(TAG, "Successfully removed ${item.name}") - } else { - Log.i(TAG, "Failed to remove ${item.name}") - allSuccess = false - continue - } - } - allSuccess - } catch (t: Throwable) { - logError(t) - false - } - } - - /** Gets all the metadata of a torrent, will throw if that hash does not exists - * https://github.com/Diegopyl1209/torrentserver-aniyomi/blob/c18f58e51b6738f053261bc863177078aa9c1c98/web/api/torrents.go#L126 */ - @Throws - suspend fun get( - hash: String, - ): TorrentStatus { - if(TORRENT_SERVER_URL.isEmpty()) { - throw ErrorLoadingException("Not initialized") - } - return app.post( - "$TORRENT_SERVER_URL/torrents", - json = TorrentRequest( - action = "get", - hash = hash, - ), - timeout = TIMEOUT, - headers = emptyMap() - ).parsed() - } - - /** Adds a torrent to the server, this is needed for us to get the hash for further modification, as well as start streaming it*/ - @Throws - private suspend fun add(url: String): TorrentStatus { - if(TORRENT_SERVER_URL.isEmpty()) { - throw ErrorLoadingException("Not initialized") - } - return app.post( - "$TORRENT_SERVER_URL/torrents", - json = TorrentRequest( - action = "add", - link = url, - ), - headers = emptyMap() - ).parsed() - } - - /** Spins up the torrent server. */ - private suspend fun setup(dir: String): Boolean { - go.Seq.load() - if (echo()) { - return true - } - val port = TorrServer.startTorrentServer(dir, 0) - if(port < 0) { - return false - } - TORRENT_SERVER_URL = "http://127.0.0.1:$port" - TorrServer.addTrackers(trackers.joinToString(separator = ",\n")) - return echo() - } - - /** Transforms a torrent link into a streamable link via the server */ - @Throws - suspend fun transformLink(link: ExtractorLink): Pair { - val act = CommonActivity.activity ?: throw IllegalArgumentException("No activity") - val defaultDirectory = "${act.cacheDir.path}/$TORRENT_SERVER_PATH" - File(defaultDirectory).mkdir() - if (!setup(defaultDirectory)) { - throw ErrorLoadingException("Unable to setup the torrent server") - } - val status = add(link.url) - - return newExtractorLink( - source = link.source, - name = link.name, - url = status.streamUrl(link.url), - type = ExtractorLinkType.VIDEO - ) { - this.referer = "" - this.quality = link.quality - } to status - } - - private val trackers = listOf( - "udp://tracker.opentrackr.org:1337/announce", - "https://tracker2.ctix.cn/announce", - "https://tracker1.520.jp:443/announce", - "udp://opentracker.i2p.rocks:6969/announce", - "udp://open.tracker.cl:1337/announce", - "udp://open.demonii.com:1337/announce", - "http://tracker.openbittorrent.com:80/announce", - "udp://tracker.openbittorrent.com:6969/announce", - "udp://open.stealth.si:80/announce", - "udp://exodus.desync.com:6969/announce", - "udp://tracker-udp.gbitt.info:80/announce", - "udp://explodie.org:6969/announce", - "https://tracker.gbitt.info:443/announce", - "http://tracker.gbitt.info:80/announce", - "udp://uploads.gamecoast.net:6969/announce", - "udp://tracker1.bt.moack.co.kr:80/announce", - "udp://tracker.tiny-vps.com:6969/announce", - "udp://tracker.theoks.net:6969/announce", - "udp://tracker.dump.cl:6969/announce", - "udp://tracker.bittor.pw:1337/announce", - "https://tracker1.520.jp:443/announce", - "udp://opentracker.i2p.rocks:6969/announce", - "udp://open.tracker.cl:1337/announce", - "udp://open.demonii.com:1337/announce", - "http://tracker.openbittorrent.com:80/announce", - "udp://tracker.openbittorrent.com:6969/announce", - "udp://open.stealth.si:80/announce", - "udp://exodus.desync.com:6969/announce", - "udp://tracker-udp.gbitt.info:80/announce", - "udp://explodie.org:6969/announce", - "https://tracker.gbitt.info:443/announce", - "http://tracker.gbitt.info:80/announce", - "udp://uploads.gamecoast.net:6969/announce", - "udp://tracker1.bt.moack.co.kr:80/announce", - "udp://tracker.tiny-vps.com:6969/announce", - "udp://tracker.theoks.net:6969/announce", - "udp://tracker.dump.cl:6969/announce", - "udp://tracker.bittor.pw:1337/announce" - ) - - - // https://github.com/Diegopyl1209/torrentserver-aniyomi/blob/c18f58e51b6738f053261bc863177078aa9c1c98/web/api/torrents.go#L18 - // https://github.com/Diegopyl1209/torrentserver-aniyomi/blob/main/web/api/route.go#L7 - data class TorrentRequest( - @JsonProperty("action") - val action: String, - @JsonProperty("hash") - val hash: String = "", - @JsonProperty("link") - val link: String = "", - @JsonProperty("title") - val title: String = "", - @JsonProperty("poster") - val poster: String = "", - @JsonProperty("data") - val data: String = "", - @JsonProperty("save_to_db") - val saveToDB: Boolean = false, - ) - - // https://github.com/Diegopyl1209/torrentserver-aniyomi/blob/c18f58e51b6738f053261bc863177078aa9c1c98/torr/state/state.go#L33 - // omitempty = nullable - data class TorrentStatus( - @JsonProperty("title") - var title: String, - @JsonProperty("poster") - var poster: String, - @JsonProperty("data") - var data: String?, - @JsonProperty("timestamp") - var timestamp: Long, - @JsonProperty("name") - var name: String?, - @JsonProperty("hash") - var hash: String?, - @JsonProperty("stat") - var stat: Int, - @JsonProperty("stat_string") - var statString: String, - @JsonProperty("loaded_size") - var loadedSize: Long?, - @JsonProperty("torrent_size") - var torrentSize: Long?, - @JsonProperty("preloaded_bytes") - var preloadedBytes: Long?, - @JsonProperty("preload_size") - var preloadSize: Long?, - @JsonProperty("download_speed") - var downloadSpeed: Double?, - @JsonProperty("upload_speed") - var uploadSpeed: Double?, - @JsonProperty("total_peers") - var totalPeers: Int?, - @JsonProperty("pending_peers") - var pendingPeers: Int?, - @JsonProperty("active_peers") - var activePeers: Int?, - @JsonProperty("connected_seeders") - var connectedSeeders: Int?, - @JsonProperty("half_open_peers") - var halfOpenPeers: Int?, - @JsonProperty("bytes_written") - var bytesWritten: Long?, - @JsonProperty("bytes_written_data") - var bytesWrittenData: Long?, - @JsonProperty("bytes_read") - var bytesRead: Long?, - @JsonProperty("bytes_read_data") - var bytesReadData: Long?, - @JsonProperty("bytes_read_useful_data") - var bytesReadUsefulData: Long?, - @JsonProperty("chunks_written") - var chunksWritten: Long?, - @JsonProperty("chunks_read") - var chunksRead: Long?, - @JsonProperty("chunks_read_useful") - var chunksReadUseful: Long?, - @JsonProperty("chunks_read_wasted") - var chunksReadWasted: Long?, - @JsonProperty("pieces_dirtied_good") - var piecesDirtiedGood: Long?, - @JsonProperty("pieces_dirtied_bad") - var piecesDirtiedBad: Long?, - @JsonProperty("duration_seconds") - var durationSeconds: Double?, - @JsonProperty("bit_rate") - var bitRate: String?, - @JsonProperty("file_stats") - var fileStats: List?, - @JsonProperty("trackers") - var trackers: List?, - ) { - fun streamUrl(url: String): String { - val fileName = - this.fileStats?.first { !it.path.isNullOrBlank() }?.path - ?: throw ErrorLoadingException("Null path") - - val index = url.substringAfter("index=").substringBefore("&").toIntOrNull() ?: 0 - - // https://github.com/Diegopyl1209/torrentserver-aniyomi/blob/c18f58e51b6738f053261bc863177078aa9c1c98/web/api/stream.go#L18 - return "$TORRENT_SERVER_URL/stream/${ - URLEncoder.encode(fileName, "utf-8") - }?link=${this.hash}&index=$index&play" - } - } - - data class TorrentFileStat( - @JsonProperty("id") - val id: Int?, - @JsonProperty("path") - val path: String?, - @JsonProperty("length") - val length: Long?, - ) -} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/UpdatedDefaultExtractorsFactory.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/UpdatedDefaultExtractorsFactory.kt deleted file mode 100644 index b3873bd32..000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/UpdatedDefaultExtractorsFactory.kt +++ /dev/null @@ -1,678 +0,0 @@ -@file:Suppress( - "ALL", - "DEPRECATION", - "RedundantVisibilityModifier", - "RemoveRedundantQualifierName", - "UNCHECKED_CAST", - "UNUSED", - "UNUSED_PARAMETER", - "UNUSED_VARIABLE" -) - -package com.lagradost.cloudstream3.ui.player - -import android.net.Uri -import androidx.annotation.GuardedBy -import androidx.media3.common.C -import androidx.media3.common.FileTypes -import androidx.media3.common.Format -import androidx.media3.common.util.TimestampAdjuster -import androidx.media3.common.util.UnstableApi -import androidx.media3.extractor.Extractor -import androidx.media3.extractor.ExtractorsFactory -import androidx.media3.extractor.amr.AmrExtractor -import androidx.media3.extractor.avi.AviExtractor -import androidx.media3.extractor.avif.AvifExtractor -import androidx.media3.extractor.bmp.BmpExtractor -import androidx.media3.extractor.flac.FlacExtractor -import androidx.media3.extractor.flv.FlvExtractor -import androidx.media3.extractor.heif.HeifExtractor -import androidx.media3.extractor.jpeg.JpegExtractor -import androidx.media3.extractor.mkv.UpdatedMatroskaExtractor -import androidx.media3.extractor.mp3.Mp3Extractor -import androidx.media3.extractor.mp4.FragmentedMp4Extractor -import androidx.media3.extractor.mp4.Mp4Extractor -import androidx.media3.extractor.ogg.OggExtractor -import androidx.media3.extractor.png.PngExtractor -import androidx.media3.extractor.text.DefaultSubtitleParserFactory -import androidx.media3.extractor.text.SubtitleParser -import androidx.media3.extractor.ts.Ac3Extractor -import androidx.media3.extractor.ts.Ac4Extractor -import androidx.media3.extractor.ts.AdtsExtractor -import androidx.media3.extractor.ts.DefaultTsPayloadReaderFactory -import androidx.media3.extractor.ts.PsExtractor -import androidx.media3.extractor.ts.TsExtractor -import androidx.media3.extractor.wav.WavExtractor -import androidx.media3.extractor.webp.WebpExtractor -import com.google.common.collect.ImmutableList -import java.lang.reflect.Constructor -import java.lang.reflect.InvocationTargetException -import java.util.concurrent.atomic.AtomicBoolean - -/** - * An [ExtractorsFactory] that provides an array of extractors for the following formats: - * - * - * * MP4, including M4A ([Mp4Extractor]) - * * fMP4 ([FragmentedMp4Extractor]) - * * Matroska and WebM ([UpdatedMatroskaExtractor]) - * * Ogg Vorbis/FLAC ([OggExtractor] - * * MP3 ([Mp3Extractor]) - * * AAC ([AdtsExtractor]) - * * MPEG TS ([TsExtractor]) - * * MPEG PS ([PsExtractor]) - * * FLV ([FlvExtractor]) - * * WAV ([WavExtractor]) - * * AC3 ([Ac3Extractor]) - * * AC4 ([Ac4Extractor]) - * * AMR ([AmrExtractor]) - * * FLAC - * - * * If available, the FLAC extension's `androidx.media3.decoder.flac.FlacExtractor` - * is used. - * * Otherwise, the core [FlacExtractor] is used. Note that Android devices do not - * generally include a FLAC decoder before API 27. This can be worked around by using - * the FLAC extension or the FFmpeg extension. - * - * * JPEG ([JpegExtractor]) - * * PNG ([PngExtractor]) - * * WEBP ([WebpExtractor]) - * * BMP ([BmpExtractor]) - * * HEIF ([HeifExtractor]) - * * AVIF ([AvifExtractor]) - * * MIDI, if available, the MIDI extension's `androidx.media3.decoder.midi.MidiExtractor` - * is used. - * - */ -@UnstableApi -class UpdatedDefaultExtractorsFactory : ExtractorsFactory { - private var constantBitrateSeekingEnabled = false - private var constantBitrateSeekingAlwaysEnabled = false - private var adtsFlags: @AdtsExtractor.Flags Int = 0 - private var amrFlags: @AmrExtractor.Flags Int = 0 - private var flacFlags: @FlacExtractor.Flags Int = 0 - private var matroskaFlags: @UpdatedMatroskaExtractor.Flags Int = 0 - private var mp4Flags: @Mp4Extractor.Flags Int = 0 - private var fragmentedMp4Flags: @FragmentedMp4Extractor.Flags Int = 0 - private var mp3Flags: @Mp3Extractor.Flags Int = 0 - private var tsMode: @TsExtractor.Mode Int - private var tsFlags: @DefaultTsPayloadReaderFactory.Flags Int = 0 - - // TODO (b/261183220): Initialize tsSubtitleFormats in constructor once shrinking bug is fixed. - private var tsSubtitleFormats: ImmutableList? = null - private var tsTimestampSearchBytes: Int - private var textTrackTranscodingEnabled: Boolean - private var subtitleParserFactory: SubtitleParser.Factory - private var codecsToParseWithinGopSampleDependencies: @C.VideoCodecFlags Int - private var jpegFlags: @JpegExtractor.Flags Int = 0 - private var heifFlags: @HeifExtractor.Flags Int = 0 - - init { - tsMode = TsExtractor.MODE_SINGLE_PMT - tsTimestampSearchBytes = TsExtractor.DEFAULT_TIMESTAMP_SEARCH_BYTES - subtitleParserFactory = DefaultSubtitleParserFactory() - textTrackTranscodingEnabled = true - codecsToParseWithinGopSampleDependencies = C.VIDEO_CODEC_FLAG_H264 or C.VIDEO_CODEC_FLAG_H265 - } - - /** - * Convenience method to set whether approximate seeking using constant bitrate assumptions should - * be enabled for all extractors that support it. If set to true, the flags required to enable - * this functionality will be OR'd with those passed to the setters when creating extractor - * instances. If set to false then the flags passed to the setters will be used without - * modification. - * - * @param constantBitrateSeekingEnabled Whether approximate seeking using a constant bitrate - * assumption should be enabled for all extractors that support it. - * @return The factory, for convenience. - */ - @Synchronized - fun setConstantBitrateSeekingEnabled( - constantBitrateSeekingEnabled: Boolean - ): UpdatedDefaultExtractorsFactory { - this.constantBitrateSeekingEnabled = constantBitrateSeekingEnabled - return this - } - - /** - * Convenience method to set whether approximate seeking using constant bitrate assumptions should - * be enabled for all extractors that support it, and if it should be enabled even if the content - * length (and hence the duration of the media) is unknown. If set to true, the flags required to - * enable this functionality will be OR'd with those passed to the setters when creating extractor - * instances. If set to false then the flags passed to the setters will be used without - * modification. - * - * - * When seeking into content where the length is unknown, application code should ensure that - * requested seek positions are valid, or should be ready to handle playback failures reported - * through [Player.Listener.onPlayerError] with [PlaybackException.errorCode] set to - * [PlaybackException.ERROR_CODE_IO_READ_POSITION_OUT_OF_RANGE]. - * - * @param constantBitrateSeekingAlwaysEnabled Whether approximate seeking using a constant bitrate - * assumption should be enabled for all extractors that support it, including when the content - * duration is unknown. - * @return The factory, for convenience. - */ - @Synchronized - fun setConstantBitrateSeekingAlwaysEnabled( - constantBitrateSeekingAlwaysEnabled: Boolean - ): UpdatedDefaultExtractorsFactory { - this.constantBitrateSeekingAlwaysEnabled = constantBitrateSeekingAlwaysEnabled - return this - } - - /** - * Sets flags for [AdtsExtractor] instances created by the factory. - * - * @see AdtsExtractor.AdtsExtractor - * @param flags The flags to use. - * @return The factory, for convenience. - */ - @Synchronized - fun setAdtsExtractorFlags( - flags: @AdtsExtractor.Flags Int - ): UpdatedDefaultExtractorsFactory { - this.adtsFlags = flags - return this - } - - /** - * Sets flags for [AmrExtractor] instances created by the factory. - * - * @see AmrExtractor.AmrExtractor - * @param flags The flags to use. - * @return The factory, for convenience. - */ - @Synchronized - fun setAmrExtractorFlags(flags: @AmrExtractor.Flags Int): UpdatedDefaultExtractorsFactory { - this.amrFlags = flags - return this - } - - /** - * Sets flags for [FlacExtractor] instances created by the factory. The flags are also used - * by `androidx.media3.decoder.flac.FlacExtractor` instances if the FLAC extension is being - * used. - * - * @see FlacExtractor.FlacExtractor - * @param flags The flags to use. - * @return The factory, for convenience. - */ - @Synchronized - fun setFlacExtractorFlags( - flags: @FlacExtractor.Flags Int - ): UpdatedDefaultExtractorsFactory { - this.flacFlags = flags - return this - } - - /** - * Sets flags for [UpdatedMatroskaExtractor] instances created by the factory. - * - * @see UpdatedMatroskaExtractor.MatroskaExtractor - * @param flags The flags to use. - * @return The factory, for convenience. - */ - @Synchronized - fun setMatroskaExtractorFlags( - flags: @UpdatedMatroskaExtractor.Flags Int - ): UpdatedDefaultExtractorsFactory { - this.matroskaFlags = flags - return this - } - - /** - * Sets flags for [Mp4Extractor] instances created by the factory. - * - * @see Mp4Extractor.Mp4Extractor - * @param flags The flags to use. - * @return The factory, for convenience. - */ - @Synchronized - fun setMp4ExtractorFlags(flags: @Mp4Extractor.Flags Int): UpdatedDefaultExtractorsFactory { - this.mp4Flags = flags - return this - } - - /** - * Sets flags for [FragmentedMp4Extractor] instances created by the factory. - * - * @see FragmentedMp4Extractor.FragmentedMp4Extractor - * @param flags The flags to use. - * @return The factory, for convenience. - */ - @Synchronized - fun setFragmentedMp4ExtractorFlags( - flags: @FragmentedMp4Extractor.Flags Int - ): UpdatedDefaultExtractorsFactory { - this.fragmentedMp4Flags = flags - return this - } - - /** - * Sets flags for [Mp3Extractor] instances created by the factory. - * - * @see Mp3Extractor.Mp3Extractor - * @param flags The flags to use. - * @return The factory, for convenience. - */ - @Synchronized - fun setMp3ExtractorFlags(flags: @Mp3Extractor.Flags Int): UpdatedDefaultExtractorsFactory { - mp3Flags = flags - return this - } - - /** - * Sets the mode for [TsExtractor] instances created by the factory. - * - * @see TsExtractor.TsExtractor - * @param mode The mode to use. - * @return The factory, for convenience. - */ - @Synchronized - fun setTsExtractorMode(mode: @TsExtractor.Mode Int): UpdatedDefaultExtractorsFactory { - tsMode = mode - return this - } - - /** - * Sets flags for [DefaultTsPayloadReaderFactory]s used by [TsExtractor] instances - * created by the factory. - * - * @see TsExtractor.TsExtractor - * @param flags The flags to use. - * @return The factory, for convenience. - */ - @Synchronized - fun setTsExtractorFlags( - flags: @DefaultTsPayloadReaderFactory.Flags Int - ): UpdatedDefaultExtractorsFactory { - tsFlags = flags - return this - } - - /** - * Sets a list of subtitle formats to pass to the [DefaultTsPayloadReaderFactory] used by - * [TsExtractor] instances created by the factory. - * - * @see DefaultTsPayloadReaderFactory.DefaultTsPayloadReaderFactory - * @param subtitleFormats The subtitle formats. - * @return The factory, for convenience. - */ - @Synchronized - fun setTsSubtitleFormats(subtitleFormats: List?): UpdatedDefaultExtractorsFactory { - tsSubtitleFormats = subtitleFormats?.let { ImmutableList.copyOf(it) } - return this - } - - /** - * Sets the number of bytes searched to find a timestamp for [TsExtractor] instances created - * by the factory. - * - * @see TsExtractor.TsExtractor - * @param timestampSearchBytes The number of search bytes to use. - * @return The factory, for convenience. - */ - @Synchronized - fun setTsExtractorTimestampSearchBytes( - timestampSearchBytes: Int - ): UpdatedDefaultExtractorsFactory { - tsTimestampSearchBytes = timestampSearchBytes - return this - } - - @Deprecated( - """This method (and all support for 'legacy' subtitle decoding during rendering) will - be removed in a future release.""" - ) - @Synchronized - fun setTextTrackTranscodingEnabled( - textTrackTranscodingEnabled: Boolean - ): UpdatedDefaultExtractorsFactory { - return experimentalSetTextTrackTranscodingEnabled(textTrackTranscodingEnabled) - } - - @Deprecated("") - @Synchronized - override fun experimentalSetTextTrackTranscodingEnabled( - textTrackTranscodingEnabled: Boolean - ): UpdatedDefaultExtractorsFactory { - this.textTrackTranscodingEnabled = textTrackTranscodingEnabled - return this - } - - @Synchronized - override fun setSubtitleParserFactory( - subtitleParserFactory: SubtitleParser.Factory - ): UpdatedDefaultExtractorsFactory { - this.subtitleParserFactory = subtitleParserFactory - 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. - * - * @see JpegExtractor.JpegExtractor - * @param flags The flags to use. - * @return The factory, for convenience. - */ - @Synchronized - fun setJpegExtractorFlags( - flags: @JpegExtractor.Flags Int - ): UpdatedDefaultExtractorsFactory { - this.jpegFlags = flags - 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()) - } - - @Synchronized - override fun createExtractors( - uri: Uri, responseHeaders: Map> - ): Array { - val extractors: MutableList = - ArrayList( /* initialCapacity= */DEFAULT_EXTRACTOR_ORDER.size) - - val responseHeadersInferredFileType: @FileTypes.Type Int = - FileTypes.inferFileTypeFromResponseHeaders(responseHeaders) - if (responseHeadersInferredFileType != FileTypes.UNKNOWN) { - addExtractorsForFileType(responseHeadersInferredFileType, extractors) - } - - val uriInferredFileType: @FileTypes.Type Int = FileTypes.inferFileTypeFromUri(uri) - if (uriInferredFileType != FileTypes.UNKNOWN - && uriInferredFileType != responseHeadersInferredFileType - ) { - addExtractorsForFileType(uriInferredFileType, extractors) - } - - for (fileType in DEFAULT_EXTRACTOR_ORDER) { - if (fileType != responseHeadersInferredFileType && fileType != uriInferredFileType) { - addExtractorsForFileType(fileType, extractors) - } - } - return extractors.toTypedArray() - } - - private fun addExtractorsForFileType( - fileType: @FileTypes.Type Int, - extractors: MutableList - ) { - when (fileType) { - FileTypes.AC3 -> extractors.add(Ac3Extractor()) - FileTypes.AC4 -> extractors.add(Ac4Extractor()) - FileTypes.ADTS -> extractors.add( - AdtsExtractor( - (adtsFlags - or (if (constantBitrateSeekingEnabled) - AdtsExtractor.FLAG_ENABLE_CONSTANT_BITRATE_SEEKING - else - 0) - or (if (constantBitrateSeekingAlwaysEnabled) - AdtsExtractor.FLAG_ENABLE_CONSTANT_BITRATE_SEEKING_ALWAYS - else - 0)) - ) - ) - - FileTypes.AMR -> extractors.add( - AmrExtractor( - (amrFlags - or (if (constantBitrateSeekingEnabled) - AmrExtractor.FLAG_ENABLE_CONSTANT_BITRATE_SEEKING - else - 0) - or (if (constantBitrateSeekingAlwaysEnabled) - AmrExtractor.FLAG_ENABLE_CONSTANT_BITRATE_SEEKING_ALWAYS - else - 0)) - ) - ) - - FileTypes.FLAC -> { - val flacExtractor: Extractor? = FLAC_EXTENSION_LOADER.getExtractor(flacFlags) - if (flacExtractor != null) { - extractors.add(flacExtractor) - } else { - extractors.add(FlacExtractor(flacFlags)) - } - } - - FileTypes.FLV -> extractors.add(FlvExtractor()) - FileTypes.MATROSKA -> extractors.add( - UpdatedMatroskaExtractor( - subtitleParserFactory, - matroskaFlags - or (if (textTrackTranscodingEnabled) - 0 - else - UpdatedMatroskaExtractor.FLAG_EMIT_RAW_SUBTITLE_DATA) - ) - ) - - FileTypes.MP3 -> extractors.add( - Mp3Extractor( - (mp3Flags - or (if (constantBitrateSeekingEnabled) - Mp3Extractor.FLAG_ENABLE_CONSTANT_BITRATE_SEEKING - else - 0) - or (if (constantBitrateSeekingAlwaysEnabled) - Mp3Extractor.FLAG_ENABLE_CONSTANT_BITRATE_SEEKING_ALWAYS - else - 0)) - ) - ) - - FileTypes.MP4 -> { - extractors.add( - FragmentedMp4Extractor( - subtitleParserFactory, - fragmentedMp4Flags or - FragmentedMp4Extractor - .codecsToParseWithinGopSampleDependenciesAsFlags( - codecsToParseWithinGopSampleDependencies - ) or - if (textTrackTranscodingEnabled) 0 - else FragmentedMp4Extractor.FLAG_EMIT_RAW_SUBTITLE_DATA - ) - ) - - extractors.add( - Mp4Extractor( - subtitleParserFactory, - mp4Flags or - Mp4Extractor - .codecsToParseWithinGopSampleDependenciesAsFlags( - codecsToParseWithinGopSampleDependencies - ) or - if (textTrackTranscodingEnabled) 0 - else Mp4Extractor.FLAG_EMIT_RAW_SUBTITLE_DATA - ) - ) - } - - FileTypes.OGG -> extractors.add(OggExtractor()) - FileTypes.PS -> extractors.add(PsExtractor()) - FileTypes.TS -> { - if (tsSubtitleFormats == null) { - tsSubtitleFormats = ImmutableList.of() - } - extractors.add( - TsExtractor( - tsMode, - (if (textTrackTranscodingEnabled) 0 else TsExtractor.FLAG_EMIT_RAW_SUBTITLE_DATA), - subtitleParserFactory, - TimestampAdjuster(0), - DefaultTsPayloadReaderFactory(tsFlags, tsSubtitleFormats!!), - tsTimestampSearchBytes - ) - ) - } - - FileTypes.WAV -> extractors.add(WavExtractor()) - FileTypes.JPEG -> extractors.add(JpegExtractor(jpegFlags)) - FileTypes.MIDI -> { - val midiExtractor: Extractor? = MIDI_EXTENSION_LOADER.getExtractor() - if (midiExtractor != null) { - extractors.add(midiExtractor) - } - } - - FileTypes.AVI -> extractors.add( - AviExtractor( - (if (textTrackTranscodingEnabled) 0 else AviExtractor.FLAG_EMIT_RAW_SUBTITLE_DATA), - subtitleParserFactory - ) - ) - - FileTypes.PNG -> extractors.add(PngExtractor()) - FileTypes.WEBP -> extractors.add(WebpExtractor()) - FileTypes.BMP -> extractors.add(BmpExtractor()) - FileTypes.HEIF -> extractors.add(HeifExtractor(heifFlags)) - FileTypes.AVIF -> extractors.add(AvifExtractor()) - FileTypes.WEBVTT, FileTypes.UNKNOWN -> {} - else -> {} - } - } - - private class ExtensionLoader(private val constructorSupplier: ConstructorSupplier) { - interface ConstructorSupplier { - @get:Throws( - InvocationTargetException::class, - IllegalAccessException::class, - NoSuchMethodException::class, - ClassNotFoundException::class - ) - val constructor: Constructor? - } - - private val extensionLoaded = AtomicBoolean(false) - - @GuardedBy("extensionLoaded") - private val extractorConstructor: Constructor? = null - - fun getExtractor(vararg constructorParams: Any?): Extractor? { - val extractorConstructor: Constructor = maybeLoadExtractorConstructor() - ?: return null - try { - return extractorConstructor.newInstance(*constructorParams) - } catch (e: Exception) { - throw IllegalStateException("Unexpected error creating extractor", e) - } - } - - fun maybeLoadExtractorConstructor(): Constructor? { - synchronized(extensionLoaded) { - if (extensionLoaded.get()) { - return extractorConstructor - } - try { - return constructorSupplier.constructor - } catch (e: ClassNotFoundException) { - // Expected if the app was built without the extension. - } catch (e: Exception) { - // The extension is present, but instantiation failed. - throw RuntimeException("Error instantiating extension", e) - } - extensionLoaded.set(true) - return extractorConstructor - } - } - } - - companion object { - // Extractors order is optimized according to - // https://docs.google.com/document/d/1w2mKaWMxfz2Ei8-LdxqbPs1VLe_oudB-eryXXw9OvQQ. - // The JPEG extractor appears after audio/video extractors because we expect audio/video input to - // be more common. - private val DEFAULT_EXTRACTOR_ORDER = intArrayOf( - FileTypes.FLV, - FileTypes.FLAC, - FileTypes.WAV, - FileTypes.MP4, - FileTypes.AMR, - FileTypes.PS, - FileTypes.OGG, - FileTypes.TS, - FileTypes.MATROSKA, - FileTypes.ADTS, - FileTypes.AC3, - FileTypes.AC4, - FileTypes.MP3, // The following extractors are not part of the optimized ordering, and were appended - // without further analysis. - FileTypes.AVI, - FileTypes.MIDI, - FileTypes.JPEG, - FileTypes.PNG, - FileTypes.WEBP, - FileTypes.BMP, - FileTypes.HEIF, - FileTypes.AVIF - ) - - private val FLAC_EXTENSION_LOADER = - ExtensionLoader(object : ExtensionLoader.ConstructorSupplier { - override val constructor get() = flacExtractorConstructor - }) - private val MIDI_EXTENSION_LOADER = - ExtensionLoader(object : ExtensionLoader.ConstructorSupplier { - override val constructor get() = midiExtractorConstructor - }) - - @get:Throws( - ClassNotFoundException::class, - NoSuchMethodException::class - ) - private val midiExtractorConstructor: Constructor - get() = Class.forName("androidx.media3.decoder.midi.MidiExtractor") - .asSubclass(Extractor::class.java) - .getConstructor() - - @get:Throws( - ClassNotFoundException::class, - NoSuchMethodException::class, - InvocationTargetException::class, - IllegalAccessException::class - ) - private val flacExtractorConstructor: Constructor? - get() { - val isFlacNativeLibraryAvailable = - java.lang.Boolean.TRUE == Class.forName("androidx.media3.decoder.flac.FlacLibrary") - .getMethod("isAvailable") - .invoke( /* obj= */null) - if (isFlacNativeLibraryAvailable) { - return Class.forName("androidx.media3.decoder.flac.FlacExtractor") - .asSubclass(Extractor::class.java) - .getConstructor(Int::class.javaPrimitiveType) - } - return null - } - } -} 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 deleted file mode 100644 index 5937b1973..000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/UpdatedMatroskaExtractor.kt +++ /dev/null @@ -1,3242 +0,0 @@ -@file:Suppress( - "ALL", - "DEPRECATION", - "RedundantVisibilityModifier", - "RemoveRedundantQualifierName", - "UNCHECKED_CAST", - "UNUSED", - "UNUSED_PARAMETER", - "UNUSED_VARIABLE" -) - -/* - * 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. - */ -package androidx.media3.extractor.mkv // we cant change the pkg as EbmlReader is private - -import android.util.Pair -import android.util.SparseArray -import androidx.annotation.CallSuper -import androidx.annotation.IntDef -import androidx.media3.common.C -import androidx.media3.common.C.BufferFlags -import androidx.media3.common.C.ColorRange -import androidx.media3.common.C.ColorTransfer -import androidx.media3.common.C.PcmEncoding -import androidx.media3.common.C.SelectionFlags -import androidx.media3.common.C.StereoMode -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.Log -import androidx.media3.common.util.ParsableByteArray -import androidx.media3.common.util.UnstableApi -import androidx.media3.common.util.Util -import androidx.media3.container.DolbyVisionConfig -import androidx.media3.container.NalUnitUtil -import androidx.media3.extractor.AacUtil -import androidx.media3.extractor.AvcConfig -import androidx.media3.extractor.ChunkIndex -import androidx.media3.extractor.ChunkIndexProvider -import androidx.media3.extractor.DtsUtil -import androidx.media3.extractor.Extractor -import androidx.media3.extractor.ExtractorInput -import androidx.media3.extractor.ExtractorOutput -import androidx.media3.extractor.ExtractorsFactory -import androidx.media3.extractor.HevcConfig -import androidx.media3.extractor.MpegAudioUtil -import androidx.media3.extractor.PositionHolder -import androidx.media3.extractor.SeekMap -import androidx.media3.extractor.SeekMap.SeekPoints -import androidx.media3.extractor.SeekPoint -import androidx.media3.extractor.TrackAwareSeekMap -import androidx.media3.extractor.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 -import java.nio.ByteOrder -import java.util.Arrays -import java.util.Collections -import java.util.Locale -import java.util.Objects -import java.util.UUID -import kotlin.math.max -import kotlin.math.min - -/** Extracts data from the Matroska and WebM container formats. */ -@UnstableApi -class UpdatedMatroskaExtractor private constructor( - private val reader: EbmlReader, - flags: @Flags Int, - subtitleParserFactory: SubtitleParser.Factory -) : - Extractor { - /** - * Flags controlling the behavior of the extractor. Possible flag values are [ ][.FLAG_DISABLE_SEEK_FOR_CUES] and {#FLAG_EMIT_RAW_SUBTITLE_DATA}. - */ - @MustBeDocumented - @Retention(AnnotationRetention.SOURCE) - @Target(AnnotationTarget.CLASS, AnnotationTarget.TYPE, AnnotationTarget.TYPE_PARAMETER) - @IntDef(flag = true, value = [FLAG_DISABLE_SEEK_FOR_CUES, FLAG_EMIT_RAW_SUBTITLE_DATA]) - annotation class Flags - - private val varintReader: VarintReader - private val tracks: SparseArray - private val seekForCuesEnabled: Boolean - private val parseSubtitlesDuringExtraction: Boolean - private val subtitleParserFactory: SubtitleParser.Factory - - // Temporary arrays. - private val nalStartCode: ParsableByteArray - private val nalLength: ParsableByteArray - private val scratch: ParsableByteArray - private val vorbisNumPageSamples: ParsableByteArray - private val seekEntryIdBytes: ParsableByteArray - private val sampleStrippedBytes: ParsableByteArray - private val subtitleSample: ParsableByteArray - private val encryptionInitializationVector: ParsableByteArray - private val encryptionSubsampleData: ParsableByteArray - private val supplementalData: ParsableByteArray - private var encryptionSubsampleDataBuffer: ByteBuffer? = null - - private var segmentContentSize: Long = 0 - private var segmentContentPosition = C.INDEX_UNSET.toLong() - 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 - - // Whether a seek map has been sent to the output. - private var sentSeekMap = false - - // Master seek entry related elements. - private var seekEntryId = 0 - 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() - private var pendingSeekHeads: ArrayList = ArrayList() - private var seekPositionAfterSeekingForHead = C.INDEX_UNSET.toLong() - private var cuesContentPosition = C.INDEX_UNSET.toLong() - private var seekPositionAfterBuildingCues = C.INDEX_UNSET.toLong() - private var clusterTimecodeUs = C.TIME_UNSET - - // Reading state. - private var haveOutputSample = false - - // Block reading state. - private var blockState = 0 - private var blockTimeUs: Long = 0 - private var blockDurationUs: Long = 0 - private var blockSampleIndex = 0 - private var blockSampleCount = 0 - private var blockSampleSizes: IntArray - private var blockTrackNumber = 0 - private var blockTrackNumberLength = 0 - private var blockFlags: @BufferFlags Int = 0 - private var blockAdditionalId = 0 - private var blockHasReferenceBlock = false - private var blockGroupDiscardPaddingNs: Long = 0 - - // Sample writing state. - private var sampleBytesRead = 0 - private var sampleBytesWritten = 0 - private var sampleCurrentNalBytesRemaining = 0 - private var sampleEncodingHandled = false - private var sampleSignalByteRead = false - private var samplePartitionCountRead = false - private var samplePartitionCount = 0 - private var sampleSignalByte: Byte = 0 - private var sampleInitializationVectorRead = false - - // Extractor outputs. - private var extractorOutput: ExtractorOutput? = - null - - @Deprecated("Use {@link #MatroskaExtractor(SubtitleParser.Factory)} instead.") - constructor() : this( - DefaultEbmlReader(), - FLAG_EMIT_RAW_SUBTITLE_DATA, - SubtitleParser.Factory.UNSUPPORTED - ) - - @Deprecated("Use {@link #MatroskaExtractor(SubtitleParser.Factory, int)} instead.") - constructor(flags: @Flags Int) : this( - DefaultEbmlReader(), - flags or FLAG_EMIT_RAW_SUBTITLE_DATA, - SubtitleParser.Factory.UNSUPPORTED - ) - - /** - * Constructs an instance. - * - * @param subtitleParserFactory The [SubtitleParser.Factory] for parsing subtitles during - * extraction. - */ - constructor(subtitleParserFactory: SubtitleParser.Factory) : this( - DefaultEbmlReader(), /* flags= */ - 0, - subtitleParserFactory - ) - - /** - * Constructs an instance. - * - * @param subtitleParserFactory The [SubtitleParser.Factory] for parsing subtitles during - * extraction. - * @param flags Flags that control the extractor's behavior. - */ - constructor(subtitleParserFactory: SubtitleParser.Factory, flags: @Flags Int) : this( - DefaultEbmlReader(), - flags, - subtitleParserFactory - ) - - /* package */ - 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() - tracks = SparseArray() - scratch = ParsableByteArray(4) - vorbisNumPageSamples = ParsableByteArray(ByteBuffer.allocate(4).putInt(-1).array()) - seekEntryIdBytes = ParsableByteArray(4) - nalStartCode = ParsableByteArray(NalUnitUtil.NAL_START_CODE) - nalLength = ParsableByteArray(4) - sampleStrippedBytes = ParsableByteArray() - subtitleSample = ParsableByteArray() - encryptionInitializationVector = ParsableByteArray(ENCRYPTION_IV_SIZE) - encryptionSubsampleData = ParsableByteArray() - supplementalData = ParsableByteArray() - blockSampleSizes = IntArray(1) - pendingEndTracks = true - } - - @Throws(IOException::class) - override fun sniff(input: ExtractorInput): Boolean { - return Sniffer().sniff(input) - } - - override fun init(output: ExtractorOutput) { - extractorOutput = - if (parseSubtitlesDuringExtraction) - SubtitleTranscodingExtractorOutput(output, subtitleParserFactory) - else - output - } - - @CallSuper - override fun seek(position: Long, timeUs: Long) { - clusterTimecodeUs = C.TIME_UNSET - blockState = BLOCK_STATE_START - reader.reset() - varintReader.reset() - resetWriteSampleData() - inCuesElement = false - currentCueTimeUs = C.TIME_UNSET - currentCueTrackNumber = C.INDEX_UNSET - currentCueClusterPosition = C.INDEX_UNSET.toLong() - currentCueRelativePosition = C.INDEX_UNSET.toLong() - // To prevent creating duplicate cue points on a re-parse, clear any existing cue data if the - // seek map has not yet been sent. Once sent, the cue data is considered final, and subsequent - // Cues elements will be ignored by the parsing logic. - if (!sentSeekMap) { - perTrackCues.clear() - } - for (i in 0.. EbmlProcessor.ELEMENT_TYPE_MASTER - - ID_EBML_READ_VERSION, ID_DOC_TYPE_READ_VERSION, ID_SEEK_POSITION, ID_TIMECODE_SCALE, ID_TIME_CODE, ID_BLOCK_DURATION, ID_PIXEL_WIDTH, ID_PIXEL_HEIGHT, ID_DISPLAY_WIDTH, ID_DISPLAY_HEIGHT, ID_DISPLAY_UNIT, ID_TRACK_NUMBER, ID_TRACK_TYPE, ID_FLAG_DEFAULT, ID_FLAG_FORCED, ID_DEFAULT_DURATION, ID_MAX_BLOCK_ADDITION_ID, ID_BLOCK_ADD_ID_TYPE, ID_CODEC_DELAY, ID_SEEK_PRE_ROLL, ID_DISCARD_PADDING, ID_CHANNELS, ID_AUDIO_BIT_DEPTH, ID_CONTENT_ENCODING_ORDER, ID_CONTENT_ENCODING_SCOPE, ID_CONTENT_COMPRESSION_ALGORITHM, ID_CONTENT_ENCRYPTION_ALGORITHM, ID_CONTENT_ENCRYPTION_AES_SETTINGS_CIPHER_MODE, ID_CUE_TIME, ID_CUE_CLUSTER_POSITION, ID_CUE_RELATIVE_POSITION, ID_CUE_TRACK, ID_REFERENCE_BLOCK, ID_STEREO_MODE, ID_COLOUR_BITS_PER_CHANNEL, ID_COLOUR_RANGE, ID_COLOUR_TRANSFER, ID_COLOUR_PRIMARIES, ID_MAX_CLL, ID_MAX_FALL, ID_PROJECTION_TYPE, ID_BLOCK_ADD_ID -> EbmlProcessor.ELEMENT_TYPE_UNSIGNED_INT - - ID_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 - ID_DURATION, ID_SAMPLING_FREQUENCY, ID_PRIMARY_R_CHROMATICITY_X, ID_PRIMARY_R_CHROMATICITY_Y, ID_PRIMARY_G_CHROMATICITY_X, ID_PRIMARY_G_CHROMATICITY_Y, ID_PRIMARY_B_CHROMATICITY_X, ID_PRIMARY_B_CHROMATICITY_Y, ID_WHITE_POINT_CHROMATICITY_X, ID_WHITE_POINT_CHROMATICITY_Y, ID_LUMNINANCE_MAX, ID_LUMNINANCE_MIN, ID_PROJECTION_POSE_YAW, ID_PROJECTION_POSE_PITCH, ID_PROJECTION_POSE_ROLL -> EbmlProcessor.ELEMENT_TYPE_FLOAT - - else -> EbmlProcessor.ELEMENT_TYPE_UNKNOWN - } - } - - /** - * Checks if the given id is that of a level 1 element. - * - * @see EbmlProcessor.isLevel1Element - */ - @CallSuper - protected fun isLevel1Element(id: Int): Boolean { - return id == ID_SEGMENT_INFO || id == ID_CLUSTER || id == ID_CUES || id == ID_TRACKS - } - - /** - * Called when the start of a master element is encountered. - * - * @see EbmlProcessor.startMasterElement - */ - @CallSuper - @Throws(ParserException::class) - protected fun startMasterElement(id: Int, contentPosition: Long, contentSize: Long) { - assertInitialized() - when (id) { - ID_SEGMENT -> { - if (segmentContentPosition != C.INDEX_UNSET.toLong() && segmentContentPosition != contentPosition) { - throw ParserException.createForMalformedContainer( - "Multiple Segment elements not supported", /* cause= */null - ) - } - segmentContentPosition = contentPosition - segmentContentSize = contentSize - } - - ID_SEEK -> { - seekEntryId = UNSET_ENTRY_ID - seekEntryPosition = C.INDEX_UNSET.toLong() - } - - ID_CUES -> { - if (!sentSeekMap) { - inCuesElement = true - } - } - - ID_CUE_POINT -> { - if (!sentSeekMap) { - assertInCues(id) - currentCueTimeUs = C.TIME_UNSET - } - } - - ID_CUE_TRACK_POSITIONS -> { - if (!sentSeekMap) { - assertInCues(id) - currentCueTrackNumber = C.INDEX_UNSET - currentCueClusterPosition = C.INDEX_UNSET.toLong() - currentCueRelativePosition = C.INDEX_UNSET.toLong() - } - } - - ID_CLUSTER -> if (!sentSeekMap) { - // We need to build cues before parsing the cluster. - if (seekForCuesEnabled && cuesContentPosition != C.INDEX_UNSET.toLong()) { - // We know where the Cues element is located. Seek to request it. - seekForCues = true - } else if (seekForCuesEnabled && pendingSeekHeads.isNotEmpty()) { - // We do not know where the cues are located, however we have seek-heads - // we have not yet visited - seekForSeekContent = true - } else { - // We don't know where the Cues element is located. It's most likely omitted. Allow - // playback, but disable seeking. - extractorOutput!!.seekMap(SeekMap.Unseekable(durationUs)) - sentSeekMap = true - } - } - - ID_BLOCK_GROUP -> { - blockHasReferenceBlock = false - blockGroupDiscardPaddingNs = 0L - } - - ID_CONTENT_ENCODING -> {} - ID_CONTENT_ENCRYPTION -> getCurrentTrack(id).hasContentEncryption = true - ID_TRACK_ENTRY -> { - currentTrack = Track() - currentTrack!!.isWebm = isWebm - } - ID_MASTERING_METADATA -> getCurrentTrack(id).hasColorInfo = true - else -> {} - } - } - - /** - * Called when the end of a master element is encountered. - * - * @see EbmlProcessor.endMasterElement - */ - @CallSuper - @Throws(ParserException::class) - protected fun endMasterElement(id: Int) { - assertInitialized() - when (id) { - ID_SEGMENT_INFO -> { - if (timecodeScale == C.TIME_UNSET) { - // timecodeScale was omitted. Use the default value. - timecodeScale = 1000000 - } - if (durationTimecode != C.TIME_UNSET) { - durationUs = scaleTimecodeToUs(durationTimecode) - } - } - - ID_SEGMENT -> { - // We only care if we have not already sent the seek map - if (!sentSeekMap) { - // We have reached the end of the segment, however we can still decide how to handle - // pending seek heads. - // - // This is treated as the end as "Multiple Segment elements not supported" - if (pendingSeekHeads.isNotEmpty() && seekForCuesEnabled) { - // We seek to the next seek point if we can seek and there is seek heads - seekForSeekContent = true - } else { - // Otherwise, if we not found any cues nor any more seek heads then we mark - // this as unseekable. - extractorOutput!!.seekMap(SeekMap.Unseekable(durationUs)) - sentSeekMap = true - } - } - } - - ID_SEEK -> { - if (seekEntryId == UNSET_ENTRY_ID || seekEntryPosition == C.INDEX_UNSET.toLong()) { - throw ParserException.createForMalformedContainer( - "Mandatory element SeekID or SeekPosition not found", /* cause= */null - ) - } else if (seekEntryId == ID_SEEK_HEAD) { - // We have a set here to prevent inf recursion, only if this seek head is non - // visited we add it. VLC limits this to 10, but this should work equally as well. - if (visitedSeekHeads.add(seekEntryPosition)) { - pendingSeekHeads.add(seekEntryPosition) - } - } else if (seekEntryId == ID_CUES) { - cuesContentPosition = seekEntryPosition - // We are currently seeking from the seek-head, so we seek again to get to the cues - // instead of waiting for the cluster - if (seekForCuesEnabled && seekPositionAfterSeekingForHead != C.INDEX_UNSET.toLong()) { - seekForCues = true - } - } - } - - ID_CUES -> { - if (!sentSeekMap) { - var hasAnyCues = false - for (i in 0 until perTrackCues.size()) { - if (perTrackCues.valueAt(i).isNotEmpty()) { - hasAnyCues = true - break - } - } - - if (!hasAnyCues || durationUs == C.TIME_UNSET) { - // Cues are missing, empty, or duration is unknown. - extractorOutput!!.seekMap(SeekMap.Unseekable(durationUs)) - } else { - for (i in 0 until perTrackCues.size()) { - perTrackCues.valueAt(i).sort() - } - - val seekMap = MatroskaSeekMap( - perTrackCues, - durationUs, - primarySeekTrackNumber, - segmentContentPosition, - segmentContentSize - ) - extractorOutput!!.seekMap(seekMap) - } - sentSeekMap = true - inCuesElement = false - for (i in 0 until tracks.size()) { - val track: Track = tracks.valueAt(i) - track.maybeAddThumbnailMetadata(perTrackCues, durationUs, segmentContentPosition, segmentContentSize) - if (!track.waitingForDtsAnalysis) { - track.assertOutputInitialized() - track.output!!.format(requireNotNull(track.format)) - } - } - maybeEndTracks() - } - } - - ID_CUE_TRACK_POSITIONS -> { - if (!sentSeekMap) { - assertInCues(id) - if (currentCueTimeUs != C.TIME_UNSET - && currentCueTrackNumber != C.INDEX_UNSET - && currentCueClusterPosition != C.INDEX_UNSET.toLong() - ) { - var trackCues = perTrackCues[currentCueTrackNumber] - if (trackCues == null) { - trackCues = ArrayList() - perTrackCues.put(currentCueTrackNumber, trackCues) - } - - trackCues.add( - MatroskaSeekMap.CuePointData( - currentCueTimeUs, - /* clusterPosition= */ segmentContentPosition + currentCueClusterPosition, - /* relativePosition= */ currentCueRelativePosition - ) - ) - } - } - } - - ID_BLOCK_GROUP -> { - if (blockState != BLOCK_STATE_DATA) { - // We've skipped this block (due to incompatible track number). - return - } - val track = tracks[blockTrackNumber] - track.assertOutputInitialized() - if (blockGroupDiscardPaddingNs > 0L && CODEC_ID_OPUS == track.codecId) { - // For Opus, attach DiscardPadding to the block group samples as supplemental data. - supplementalData.reset( - ByteBuffer.allocate(8) - .order(ByteOrder.LITTLE_ENDIAN) - .putLong(blockGroupDiscardPaddingNs) - .array() - ) - } - - // Commit sample metadata. - var sampleOffset = 0 - run { - var i = 0 - while (i < blockSampleCount) { - sampleOffset += blockSampleSizes[i] - i++ - } - } - var i = 0 - while (i < blockSampleCount) { - val sampleTimeUs = blockTimeUs + (i * track.defaultSampleDurationNs) / 1000 - var sampleFlags = blockFlags - if (i == 0 && !blockHasReferenceBlock) { - // If the ReferenceBlock element was not found in this block, then the first frame is a - // keyframe. - sampleFlags = sampleFlags or C.BUFFER_FLAG_KEY_FRAME - } - val sampleSize = blockSampleSizes[i] - sampleOffset -= sampleSize // The offset is to the end of the sample. - commitSampleToOutput(track, sampleTimeUs, sampleFlags, sampleSize, sampleOffset) - i++ - } - blockState = BLOCK_STATE_START - } - - ID_CONTENT_ENCODING -> { - assertInTrackEntry(id) - if (currentTrack!!.hasContentEncryption) { - if (currentTrack!!.cryptoData == null) { - throw ParserException.createForMalformedContainer( - "Encrypted Track found but ContentEncKeyID was not found", /* cause= */ - null - ) - } - currentTrack!!.drmInitData = - DrmInitData( - SchemeData( - C.UUID_NIL, - MimeTypes.VIDEO_WEBM, - currentTrack!!.cryptoData!!.encryptionKey - ) - ) - } - } - - ID_CONTENT_ENCODINGS -> { - assertInTrackEntry(id) - if (currentTrack!!.hasContentEncryption && currentTrack!!.sampleStrippedBytes != null) { - throw ParserException.createForMalformedContainer( - "Combining encryption and compression is not supported", /* cause= */null - ) - } - } - - ID_TRACK_ENTRY -> { - 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.initializeFormat(currentTrack.number); - currentTrack.output = extractorOutput!!.track(currentTrack.number, currentTrack.type); - tracks.put(currentTrack.number, currentTrack) - } - } - this.currentTrack = null - } - - ID_TRACKS -> { - if (tracks.size() == 0) { - throw ParserException.createForMalformedContainer( - "No valid tracks were found", /* cause= */ null - ) - } - - // Determine the track to use for default seeking. - var defaultVideoTrackNumber: Int = C.INDEX_UNSET - var firstVideoTrackNumber: Int = C.INDEX_UNSET - var defaultAudioTrackNumber: Int = C.INDEX_UNSET - var firstAudioTrackNumber: Int = C.INDEX_UNSET - - // If we're not going to seek for cues, output the formats immediately. - val mayBeSendFormatsEarly = !seekForCuesEnabled || cuesContentPosition == C.INDEX_UNSET.toLong(); - - for (i in 0 until tracks.size()) { - val trackItem: Track = tracks.valueAt(i) - - val trackType: @C.TrackType Int = trackItem.type - when (trackType) { - C.TRACK_TYPE_VIDEO -> { - if (trackItem.flagDefault) { - defaultVideoTrackNumber = trackItem.number - } - if (firstVideoTrackNumber == C.INDEX_UNSET) { - firstVideoTrackNumber = trackItem.number - } - } - - C.TRACK_TYPE_AUDIO -> { - if (trackItem.flagDefault) { - defaultAudioTrackNumber = trackItem.number - } - if (firstAudioTrackNumber == C.INDEX_UNSET) { - firstAudioTrackNumber = trackItem.number - } - } - } - - if (mayBeSendFormatsEarly) { - trackItem.assertOutputInitialized() - if (!trackItem.waitingForDtsAnalysis) { - trackItem.output!!.format(checkNotNull(trackItem.format)) - } - } - } - - primarySeekTrackNumber = when { - defaultVideoTrackNumber != C.INDEX_UNSET -> defaultVideoTrackNumber - firstVideoTrackNumber != C.INDEX_UNSET -> firstVideoTrackNumber - defaultAudioTrackNumber != C.INDEX_UNSET -> defaultAudioTrackNumber - firstAudioTrackNumber != C.INDEX_UNSET -> firstAudioTrackNumber - tracks.size() > 0 -> tracks.valueAt(0).number - else -> C.INDEX_UNSET - } - - if (mayBeSendFormatsEarly) { - maybeEndTracks() - } - } - - else -> {} - } - } - - /** - * Called when an integer element is encountered. - * - * @see EbmlProcessor.integerElement - */ - @CallSuper - @Throws(ParserException::class) - protected fun integerElement(id: Int, value: Long) { - when (id) { - ID_EBML_READ_VERSION -> // Validate that EBMLReadVersion is supported. This extractor only supports v1. - if (value != 1L) { - throw ParserException.createForMalformedContainer( - "EBMLReadVersion $value not supported", /* cause= */null - ) - } - - ID_DOC_TYPE_READ_VERSION -> // Validate that DocTypeReadVersion is supported. This extractor only supports up to v2. - if (value < 1 || value > 2) { - throw ParserException.createForMalformedContainer( - "DocTypeReadVersion $value not supported", /* cause= */null - ) - } - - ID_SEEK_POSITION -> // Seek Position is the relative offset beginning from the Segment. So to get absolute - // offset from the beginning of the file, we need to add segmentContentPosition to it. - seekEntryPosition = value + segmentContentPosition - - ID_TIMECODE_SCALE -> timecodeScale = value - ID_PIXEL_WIDTH -> getCurrentTrack(id).width = value.toInt() - ID_PIXEL_HEIGHT -> getCurrentTrack(id).height = value.toInt() - ID_DISPLAY_WIDTH -> getCurrentTrack(id).displayWidth = value.toInt() - ID_DISPLAY_HEIGHT -> getCurrentTrack(id).displayHeight = value.toInt() - ID_DISPLAY_UNIT -> getCurrentTrack(id).displayUnit = value.toInt() - ID_TRACK_NUMBER -> getCurrentTrack(id).number = value.toInt() - ID_FLAG_DEFAULT -> getCurrentTrack(id).flagDefault = value == 1L - ID_FLAG_FORCED -> getCurrentTrack(id).flagForced = value == 1L - ID_TRACK_TYPE -> { - val matroskaTrackType = value.toInt() - getCurrentTrack(id).type = when (matroskaTrackType) { - 1 -> C.TRACK_TYPE_VIDEO // Matroska video - 2 -> C.TRACK_TYPE_AUDIO // Matroska audio - 17 -> C.TRACK_TYPE_TEXT // Matroska subtitle - 33 -> C.TRACK_TYPE_METADATA // Matroska metadata - else -> C.TRACK_TYPE_UNKNOWN - } - } - ID_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() - ID_CODEC_DELAY -> getCurrentTrack(id).codecDelayNs = value - ID_SEEK_PRE_ROLL -> getCurrentTrack(id).seekPreRollNs = value - ID_DISCARD_PADDING -> blockGroupDiscardPaddingNs = value - ID_CHANNELS -> getCurrentTrack(id).channelCount = value.toInt() - ID_AUDIO_BIT_DEPTH -> getCurrentTrack(id).audioBitDepth = value.toInt() - ID_REFERENCE_BLOCK -> blockHasReferenceBlock = true - ID_CONTENT_ENCODING_ORDER -> // This extractor only supports one ContentEncoding element and hence the order has to be 0. - if (value != 0L) { - throw ParserException.createForMalformedContainer( - "ContentEncodingOrder $value not supported", /* cause= */null - ) - } - - ID_CONTENT_ENCODING_SCOPE -> // This extractor only supports the scope of all frames. - if (value != 1L) { - throw ParserException.createForMalformedContainer( - "ContentEncodingScope $value not supported", /* cause= */null - ) - } - - ID_CONTENT_COMPRESSION_ALGORITHM -> // This extractor only supports header stripping. - if (value != 3L) { - throw ParserException.createForMalformedContainer( - "ContentCompAlgo $value not supported", /* cause= */null - ) - } - - ID_CONTENT_ENCRYPTION_ALGORITHM -> // Only the value 5 (AES) is allowed according to the WebM specification. - if (value != 5L) { - throw ParserException.createForMalformedContainer( - "ContentEncAlgo $value not supported", /* cause= */null - ) - } - - ID_CONTENT_ENCRYPTION_AES_SETTINGS_CIPHER_MODE -> // Only the value 1 is allowed according to the WebM specification. - if (value != 1L) { - throw ParserException.createForMalformedContainer( - "AESSettingsCipherMode $value not supported", /* cause= */null - ) - } - - ID_CUE_TIME -> { - 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_RELATIVE_POSITION -> { - if (!sentSeekMap) { - assertInCues(id) - if (currentCueRelativePosition == C.INDEX_UNSET.toLong()) { - currentCueRelativePosition = value - } - } - } - - ID_TIME_CODE -> clusterTimecodeUs = scaleTimecodeToUs(value) - ID_BLOCK_DURATION -> blockDurationUs = scaleTimecodeToUs(value) - ID_STEREO_MODE -> { - val layout = value.toInt() - assertInTrackEntry(id) - when (layout) { - 0 -> currentTrack!!.stereoMode = C.STEREO_MODE_MONO - 1 -> currentTrack!!.stereoMode = C.STEREO_MODE_LEFT_RIGHT - 3 -> currentTrack!!.stereoMode = C.STEREO_MODE_TOP_BOTTOM - 15 -> currentTrack!!.stereoMode = C.STEREO_MODE_STEREO_MESH - else -> {} - } - } - - ID_COLOUR_PRIMARIES -> { - assertInTrackEntry(id) - currentTrack!!.hasColorInfo = true - val colorSpace = ColorInfo.isoColorPrimariesToColorSpace(value.toInt()) - if (colorSpace != Format.NO_VALUE) { - currentTrack!!.colorSpace = colorSpace - } - } - - ID_COLOUR_TRANSFER -> { - assertInTrackEntry(id) - val colorTransfer = - ColorInfo.isoTransferCharacteristicsToColorTransfer(value.toInt()) - if (colorTransfer != Format.NO_VALUE) { - currentTrack!!.colorTransfer = colorTransfer - } - } - - ID_COLOUR_BITS_PER_CHANNEL -> { - assertInTrackEntry(id) - currentTrack!!.hasColorInfo = true - currentTrack!!.bitsPerChannel = value.toInt() - } - - ID_COLOUR_RANGE -> { - assertInTrackEntry(id) - when (value.toInt()) { - 1 -> currentTrack!!.colorRange = C.COLOR_RANGE_LIMITED - 2 -> currentTrack!!.colorRange = C.COLOR_RANGE_FULL - else -> {} - } - } - - ID_MAX_CLL -> getCurrentTrack(id).maxContentLuminance = value.toInt() - ID_MAX_FALL -> getCurrentTrack(id).maxFrameAverageLuminance = value.toInt() - ID_PROJECTION_TYPE -> { - assertInTrackEntry(id) - when (value.toInt()) { - 0 -> currentTrack!!.projectionType = C.PROJECTION_RECTANGULAR - 1 -> currentTrack!!.projectionType = C.PROJECTION_EQUIRECTANGULAR - 2 -> currentTrack!!.projectionType = C.PROJECTION_CUBEMAP - 3 -> currentTrack!!.projectionType = C.PROJECTION_MESH - else -> {} - } - } - - ID_BLOCK_ADD_ID -> blockAdditionalId = value.toInt() - else -> {} - } - } - - /** - * Called when a float element is encountered. - * - * @see EbmlProcessor.floatElement - */ - @CallSuper - @Throws(ParserException::class) - protected fun floatElement(id: Int, value: Double) { - when (id) { - ID_DURATION -> durationTimecode = value.toLong() - ID_SAMPLING_FREQUENCY -> getCurrentTrack(id).sampleRate = value.toInt() - ID_PRIMARY_R_CHROMATICITY_X -> getCurrentTrack(id).primaryRChromaticityX = - value.toFloat() - - ID_PRIMARY_R_CHROMATICITY_Y -> getCurrentTrack(id).primaryRChromaticityY = - value.toFloat() - - ID_PRIMARY_G_CHROMATICITY_X -> getCurrentTrack(id).primaryGChromaticityX = - value.toFloat() - - ID_PRIMARY_G_CHROMATICITY_Y -> getCurrentTrack(id).primaryGChromaticityY = - value.toFloat() - - ID_PRIMARY_B_CHROMATICITY_X -> getCurrentTrack(id).primaryBChromaticityX = - value.toFloat() - - ID_PRIMARY_B_CHROMATICITY_Y -> getCurrentTrack(id).primaryBChromaticityY = - value.toFloat() - - ID_WHITE_POINT_CHROMATICITY_X -> getCurrentTrack(id).whitePointChromaticityX = - value.toFloat() - - ID_WHITE_POINT_CHROMATICITY_Y -> getCurrentTrack(id).whitePointChromaticityY = - value.toFloat() - - ID_LUMNINANCE_MAX -> getCurrentTrack(id).maxMasteringLuminance = value.toFloat() - ID_LUMNINANCE_MIN -> getCurrentTrack(id).minMasteringLuminance = value.toFloat() - ID_PROJECTION_POSE_YAW -> getCurrentTrack(id).projectionPoseYaw = value.toFloat() - ID_PROJECTION_POSE_PITCH -> getCurrentTrack(id).projectionPosePitch = value.toFloat() - ID_PROJECTION_POSE_ROLL -> getCurrentTrack(id).projectionPoseRoll = value.toFloat() - else -> {} - } - } - - /** - * Called when a string element is encountered. - * - * @see EbmlProcessor.stringElement - */ - @CallSuper - @Throws(ParserException::class) - protected fun stringElement(id: Int, value: String) { - when (id) { - ID_DOC_TYPE -> // Validate that DocType is supported. - if (DOC_TYPE_WEBM != value && DOC_TYPE_MATROSKA != value) { - throw ParserException.createForMalformedContainer( - "DocType $value not supported", /* cause= */null - ) - } - - ID_NAME -> getCurrentTrack(id).name = value - ID_CODEC_ID -> getCurrentTrack(id).codecId = value - ID_LANGUAGE -> getCurrentTrack(id).language = value - else -> {} - } - } - - /** - * Called when a binary element is encountered. - * - * @see EbmlProcessor.binaryElement - */ - @CallSuper - @Throws(IOException::class) - protected fun binaryElement(id: Int, contentSize: Int, input: ExtractorInput) { - when (id) { - ID_SEEK_ID -> { - Arrays.fill(seekEntryIdBytes.data, 0.toByte()) - input.readFully(seekEntryIdBytes.data, 4 - contentSize, contentSize) - seekEntryIdBytes.position = 0 - seekEntryId = seekEntryIdBytes.readUnsignedInt().toInt() - } - - ID_BLOCK_ADD_ID_EXTRA_DATA -> handleBlockAddIDExtraData( - getCurrentTrack(id), - input, - contentSize - ) - - ID_CODEC_PRIVATE -> { - assertInTrackEntry(id) - currentTrack!!.codecPrivate = ByteArray(contentSize) - input.readFully(currentTrack!!.codecPrivate!!, 0, contentSize) - } - - ID_PROJECTION_PRIVATE -> { - assertInTrackEntry(id) - currentTrack!!.projectionData = ByteArray(contentSize) - input.readFully(currentTrack!!.projectionData!!, 0, contentSize) - } - - ID_CONTENT_COMPRESSION_SETTINGS -> { - assertInTrackEntry(id) - // This extractor only supports header stripping, so the payload is the stripped bytes. - currentTrack!!.sampleStrippedBytes = ByteArray(contentSize) - input.readFully(currentTrack!!.sampleStrippedBytes!!, 0, contentSize) - } - - ID_CONTENT_ENCRYPTION_KEY_ID -> { - val encryptionKey = ByteArray(contentSize) - input.readFully(encryptionKey, 0, contentSize) - getCurrentTrack(id).cryptoData = - CryptoData( - C.CRYPTO_MODE_AES_CTR, encryptionKey, 0, 0 - ) // We assume patternless AES-CTR. - } - - ID_SIMPLE_BLOCK, ID_BLOCK -> { - // Please refer to http://www.matroska.org/technical/specs/index.html#simpleblock_structure - // and http://matroska.org/technical/specs/index.html#block_structure - // for info about how data is organized in SimpleBlock and Block elements respectively. They - // differ only in the way flags are specified. - if (blockState == BLOCK_STATE_START) { - blockTrackNumber = - varintReader.readUnsignedVarint(input, false, true, 8).toInt() - blockTrackNumberLength = varintReader.lastLength - blockDurationUs = C.TIME_UNSET - blockState = BLOCK_STATE_HEADER - scratch.reset( /* limit= */0) - } - - val track = tracks[blockTrackNumber] - - // Ignore the block if we don't know about the track to which it belongs. - if (track == null) { - input.skipFully(contentSize - blockTrackNumberLength) - blockState = BLOCK_STATE_START - return - } - - track.assertOutputInitialized() - - if (blockState == BLOCK_STATE_HEADER) { - // Read the relative timecode (2 bytes) and flags (1 byte). - readScratch(input, 3) - val lacing = (scratch.data[2].toInt() and 0x06) shr 1 - if (lacing == LACING_NONE) { - blockSampleCount = 1 - blockSampleSizes = ensureArrayCapacity(blockSampleSizes, 1) - blockSampleSizes[0] = contentSize - blockTrackNumberLength - 3 - } else { - // Read the sample count (1 byte). - readScratch(input, 4) - blockSampleCount = (scratch.data[3].toInt() and 0xFF) + 1 - blockSampleSizes = ensureArrayCapacity(blockSampleSizes, blockSampleCount) - if (lacing == LACING_FIXED_SIZE) { - val blockLacingSampleSize = - (contentSize - blockTrackNumberLength - 4) / blockSampleCount - Arrays.fill( - blockSampleSizes, - 0, - blockSampleCount, - blockLacingSampleSize - ) - } else if (lacing == LACING_XIPH) { - var totalSamplesSize = 0 - var headerSize = 4 - var sampleIndex = 0 - while (sampleIndex < blockSampleCount - 1) { - blockSampleSizes[sampleIndex] = 0 - var byteValue: Int - do { - readScratch(input, ++headerSize) - byteValue = scratch.data[headerSize - 1].toInt() and 0xFF - blockSampleSizes[sampleIndex] += byteValue - } while (byteValue == 0xFF) - totalSamplesSize += blockSampleSizes[sampleIndex] - sampleIndex++ - } - blockSampleSizes[blockSampleCount - 1] = - contentSize - blockTrackNumberLength - headerSize - totalSamplesSize - } else if (lacing == LACING_EBML) { - var totalSamplesSize = 0 - var headerSize = 4 - var sampleIndex = 0 - while (sampleIndex < blockSampleCount - 1) { - blockSampleSizes[sampleIndex] = 0 - readScratch(input, ++headerSize) - if (scratch.data[headerSize - 1].toInt() == 0) { - throw ParserException.createForMalformedContainer( - "No valid varint length mask found", /* cause= */null - ) - } - var readValue: Long = 0 - var i = 0 - while (i < 8) { - val lengthMask = 1 shl (7 - i) - if ((scratch.data[headerSize - 1].toInt() and lengthMask) != 0) { - var readPosition = headerSize - 1 - headerSize += i - readScratch(input, headerSize) - readValue = - ((scratch.data[readPosition++].toInt() and 0xFF) and lengthMask.inv()).toLong() - while (readPosition < headerSize) { - readValue = readValue shl 8 - readValue = - readValue or (scratch.data[readPosition++].toInt() and 0xFF).toLong() - } - // The first read value is the first size. Later values are signed offsets. - if (sampleIndex > 0) { - readValue -= (1L shl (6 + i * 7)) - 1 - } - break - } - i++ - } - if (readValue < Int.MIN_VALUE || readValue > Int.MAX_VALUE) { - throw ParserException.createForMalformedContainer( - "EBML lacing sample size out of range.", /* cause= */null - ) - } - val intReadValue = readValue.toInt() - blockSampleSizes[sampleIndex] = - if (sampleIndex == 0) - intReadValue - else - blockSampleSizes[sampleIndex - 1] + intReadValue - totalSamplesSize += blockSampleSizes[sampleIndex] - sampleIndex++ - } - blockSampleSizes[blockSampleCount - 1] = - contentSize - blockTrackNumberLength - headerSize - totalSamplesSize - } else { - // Lacing is always in the range 0--3. - throw ParserException.createForMalformedContainer( - "Unexpected lacing value: $lacing", /* cause= */null - ) - } - } - - val timecode = - (scratch.data[0].toInt() shl 8) or (scratch.data[1].toInt() and 0xFF) - blockTimeUs = clusterTimecodeUs + scaleTimecodeToUs(timecode.toLong()) - val isKeyframe = - track.type == C.TRACK_TYPE_AUDIO - || (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 - blockSampleIndex = 0 - } - - if (id == ID_SIMPLE_BLOCK) { - // For SimpleBlock, we can write sample data and immediately commit the corresponding - // sample metadata. - while (blockSampleIndex < blockSampleCount) { - val sampleSize = - writeSampleData( - input, - track, - blockSampleSizes[blockSampleIndex], /* isBlockGroup= */ - false - ) - val sampleTimeUs = - blockTimeUs + (blockSampleIndex * track.defaultSampleDurationNs) / 1000 - commitSampleToOutput( - track, - sampleTimeUs, - blockFlags, - sampleSize, /* offset= */ - 0 - ) - blockSampleIndex++ - } - blockState = BLOCK_STATE_START - } else { - // For Block, we need to wait until the end of the BlockGroup element before committing - // sample metadata. This is so that we can handle ReferenceBlock (which can be used to - // infer whether the first sample in the block is a keyframe), and BlockAdditions (which - // can contain additional sample data to append) contained in the block group. Just output - // the sample data, storing the final sample sizes for when we commit the metadata. - while (blockSampleIndex < blockSampleCount) { - blockSampleSizes[blockSampleIndex] = - writeSampleData( - input, - track, - blockSampleSizes[blockSampleIndex], /* isBlockGroup= */ - true - ) - blockSampleIndex++ - } - } - } - - ID_BLOCK_ADDITIONAL -> { - if (blockState != BLOCK_STATE_DATA) { - return - } - handleBlockAdditionalData( - tracks[blockTrackNumber], blockAdditionalId, input, contentSize - ) - } - - else -> throw ParserException.createForMalformedContainer( - "Unexpected id: $id", /* cause= */null - ) - } - } - - @Throws(IOException::class) - protected fun handleBlockAddIDExtraData(track: Track, input: ExtractorInput, contentSize: Int) { - if (track.blockAddIdType == BLOCK_ADD_ID_TYPE_DVVC - || track.blockAddIdType == BLOCK_ADD_ID_TYPE_DVCC - ) { - track.dolbyVisionConfigBytes = ByteArray(contentSize) - input.readFully(track.dolbyVisionConfigBytes!!, 0, contentSize) - } else { - // Unhandled BlockAddIDExtraData. - input.skipFully(contentSize) - } - } - - @Throws(IOException::class) - protected fun handleBlockAdditionalData( - track: Track, blockAdditionalId: Int, input: ExtractorInput, contentSize: Int - ) { - if (blockAdditionalId == BLOCK_ADDITIONAL_ID_VP9_ITU_T_35 - && CODEC_ID_VP9 == track.codecId - ) { - supplementalData.reset(contentSize) - input.readFully(supplementalData.data, 0, contentSize) - } else { - // Unhandled block additional data. - input.skipFully(contentSize) - } - } - - @Throws(ParserException::class) - private fun assertInTrackEntry(id: Int) { - if (currentTrack == null) { - throw ParserException.createForMalformedContainer( - "Element $id must be in a TrackEntry", /* cause= */null - ) - } - } - - @Throws(ParserException::class) - private fun assertInCues(id: Int) { - if (!inCuesElement) { - throw ParserException.createForMalformedContainer( - "Element $id must be in a Cues", /* cause= */null - ) - } - } - - /** - * Returns the track corresponding to the current TrackEntry element. - * - * @throws ParserException if the element id is not in a TrackEntry. - */ - @Throws(ParserException::class) - protected fun getCurrentTrack(currentElementId: Int): Track { - assertInTrackEntry(currentElementId) - return currentTrack!! - } - - private fun commitSampleToOutput( - track: Track, timeUs: Long, flags: @BufferFlags Int, size: Int, offset: Int - ) { - var size = size - if (track.trueHdSampleRechunker != null) { - track.trueHdSampleRechunker!!.sampleMetadata( - track.output!!, timeUs, flags, size, offset, track.cryptoData - ) - } 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) { - Log.w(TAG, "Skipping subtitle sample in laced block.") - } else if (blockDurationUs == C.TIME_UNSET) { - Log.w(TAG, "Skipping subtitle sample with no duration.") - } else { - setSubtitleEndTime( - track.codecId!!, blockDurationUs, subtitleSample.data - ) - // The Matroska spec doesn't clearly define whether subtitle samples are null-terminated - // or the sample should instead be sized precisely. We truncate the sample at a null-byte - // to gracefully handle null-terminated strings followed by garbage bytes. - for (i in subtitleSample.position.. 1) { - // There were multiple samples in the block. Appending the additional data to the last - // sample doesn't make sense. Skip instead. - supplementalData.reset( /* limit= */0) - } else { - // Append supplemental data. - val supplementalDataSize = supplementalData.limit() - track.output!!.sampleData( - supplementalData, - supplementalDataSize, - TrackOutput.SAMPLE_DATA_PART_SUPPLEMENTAL - ) - size += supplementalDataSize - } - } - track.output!!.sampleMetadata(timeUs, flags, size, offset, track.cryptoData) - } - haveOutputSample = true - } - - /** - * Ensures [.scratch] contains at least `requiredLength` bytes of data, reading from - * the extractor input if necessary. - */ - @Throws(IOException::class) - private fun readScratch(input: ExtractorInput, requiredLength: Int) { - if (scratch.limit() >= requiredLength) { - return - } - if (scratch.capacity() < requiredLength) { - scratch.ensureCapacity( - max( - (scratch.capacity() * 2).toDouble(), - requiredLength.toDouble() - ).toInt() - ) - } - input.readFully(scratch.data, scratch.limit(), requiredLength - scratch.limit()) - scratch.setLimit(requiredLength) - } - - /** - * Writes data for a single sample to the track output. - * - * @param input The input from which to read sample data. - * @param track The track to output the sample to. - * @param size The size of the sample data on the input side. - * @param isBlockGroup Whether the samples are from a BlockGroup. - * @return The final size of the written sample. - * @throws IOException If an error occurs reading from the input. - */ - @Throws(IOException::class) - private fun writeSampleData( - input: ExtractorInput, - track: Track, - size: Int, - isBlockGroup: Boolean - ): Int { - var size = size - if (CODEC_ID_SUBRIP == track.codecId) { - writeSubtitleSampleData(input, SUBRIP_PREFIX, size) - return finishWriteSampleData() - } else if (CODEC_ID_ASS == track.codecId || CODEC_ID_SSA == track.codecId) { - writeSubtitleSampleData(input, SSA_PREFIX, size) - return finishWriteSampleData() - } else if (CODEC_ID_VTT == track.codecId) { - writeSubtitleSampleData(input, VTT_PREFIX, size) - 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) { - // If the sample is encrypted, read its encryption signal byte and set the IV size. - // Clear the encrypted flag. - blockFlags = blockFlags and C.BUFFER_FLAG_ENCRYPTED.inv() - if (!sampleSignalByteRead) { - input.readFully(scratch.data, 0, 1) - sampleBytesRead++ - if ((scratch.data[0].toInt() and 0x80) == 0x80) { - throw ParserException.createForMalformedContainer( - "Extension bit is set in signal byte", /* cause= */null - ) - } - sampleSignalByte = scratch.data[0] - sampleSignalByteRead = true - } - val isEncrypted = (sampleSignalByte.toInt() and 0x01) == 0x01 - if (isEncrypted) { - val hasSubsampleEncryption = (sampleSignalByte.toInt() and 0x02) == 0x02 - blockFlags = blockFlags or C.BUFFER_FLAG_ENCRYPTED - if (!sampleInitializationVectorRead) { - input.readFully(encryptionInitializationVector.data, 0, ENCRYPTION_IV_SIZE) - sampleBytesRead += ENCRYPTION_IV_SIZE - sampleInitializationVectorRead = true - // Write the signal byte, containing the IV size and the subsample encryption flag. - scratch.data[0] = - (ENCRYPTION_IV_SIZE or (if (hasSubsampleEncryption) 0x80 else 0x00)).toByte() - scratch.position = 0 - output!!.sampleData(scratch, 1, TrackOutput.SAMPLE_DATA_PART_ENCRYPTION) - sampleBytesWritten++ - // Write the IV. - encryptionInitializationVector.position = 0 - output.sampleData( - encryptionInitializationVector, - ENCRYPTION_IV_SIZE, - TrackOutput.SAMPLE_DATA_PART_ENCRYPTION - ) - sampleBytesWritten += ENCRYPTION_IV_SIZE - } - if (hasSubsampleEncryption) { - if (!samplePartitionCountRead) { - input.readFully(scratch.data, 0, 1) - sampleBytesRead++ - scratch.position = 0 - samplePartitionCount = scratch.readUnsignedByte() - samplePartitionCountRead = true - } - val samplePartitionDataSize = samplePartitionCount * 4 - scratch.reset(samplePartitionDataSize) - input.readFully(scratch.data, 0, samplePartitionDataSize) - sampleBytesRead += samplePartitionDataSize - val subsampleCount = (1 + (samplePartitionCount / 2)).toShort() - val subsampleDataSize = 2 + 6 * subsampleCount - if (encryptionSubsampleDataBuffer == null - || encryptionSubsampleDataBuffer!!.capacity() < subsampleDataSize - ) { - encryptionSubsampleDataBuffer = ByteBuffer.allocate(subsampleDataSize) - } - encryptionSubsampleDataBuffer!!.position(0) - encryptionSubsampleDataBuffer!!.putShort(subsampleCount) - // Loop through the partition offsets and write out the data in the way ExoPlayer - // wants it (ISO 23001-7 Part 7): - // 2 bytes - sub sample count. - // for each sub sample: - // 2 bytes - clear data size. - // 4 bytes - encrypted data size. - var partitionOffset = 0 - for (i in 0.. 0) { - sampleStrippedBytes.readBytes(target, offset, pendingStrippedBytes) - } - } - - /** - * Outputs up to `length` bytes of sample data to `output`, consisting of either - * [.sampleStrippedBytes] or data read from `input`. - */ - @Throws(IOException::class) - private fun writeToOutput(input: ExtractorInput, output: TrackOutput, length: Int): Int { - val bytesWritten: Int - val strippedBytesLeft = sampleStrippedBytes.bytesLeft() - if (strippedBytesLeft > 0) { - bytesWritten = min(length.toDouble(), strippedBytesLeft.toDouble()).toInt() - output.sampleData(sampleStrippedBytes, bytesWritten) - } else { - bytesWritten = output.sampleData(input, length, false) - } - return bytesWritten - } - - /** - * 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 - * it was before. - * - * @param seekPosition The holder whose position will be updated. - * @param currentPosition Current position of the input. - * @return Whether the seek position was updated. - */ - private fun maybeSeekForCues(seekPosition: PositionHolder, currentPosition: Long): Boolean { - // This seeks in a lazy manner, unlike VLC that seeks immediately when encountering a seek head - // This minimizes the amount of seeking done, but also does not seek if the cues element is - // already found, even if seek heads exits. This might be nice to change if we need other - // critical information from seek heads. - // - // The nature of each recursive query becomes to consume as much content as possible - // (until cues or end of segment). However this also means that we only need to seek - // back to the top once, instead seeking back in a stack like manner. - if (seekForSeekContent) { - checkArgument(pendingSeekHeads.isNotEmpty(), "Illegal value of seekForSeekContent") - // 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 - seekForSeekContent = false - if (seekPositionAfterSeekingForHead == C.INDEX_UNSET.toLong()) { - seekPositionAfterSeekingForHead = currentPosition - } - return true - } - - if (seekForCues) { - seekPositionAfterBuildingCues = currentPosition - seekPosition.position = cuesContentPosition - seekForCues = false - return true - } - - // After parsing Cues, seek back to original position if available. We will not do this unless - // we seeked to get to the Cues in the first place. - if (sentSeekMap && seekPositionAfterBuildingCues != C.INDEX_UNSET.toLong()) { - seekPosition.position = seekPositionAfterBuildingCues - seekPositionAfterBuildingCues = C.INDEX_UNSET.toLong() - return true - } - - // After we have seeked back from seekPositionAfterBuildingCues seek back again to the seek head - if (sentSeekMap && seekPositionAfterSeekingForHead != C.INDEX_UNSET.toLong()) { - seekPosition.position = seekPositionAfterSeekingForHead - seekPositionAfterSeekingForHead = C.INDEX_UNSET.toLong() - return true - } - - return false - } - - @Throws(ParserException::class) - private fun scaleTimecodeToUs(unscaledTimecode: Long): Long { - if (timecodeScale == C.TIME_UNSET) { - throw ParserException.createForMalformedContainer( - "Can't scale timecode prior to timecodeScale being set.", /* cause= */null - ) - } - return Util.scaleLargeTimestamp(unscaledTimecode, timecodeScale, 1000) - } - - private fun assertInitialized() { - 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 { - return this@UpdatedMatroskaExtractor.getElementType(id) - } - - override fun isLevel1Element(id: Int): Boolean { - return this@UpdatedMatroskaExtractor.isLevel1Element(id) - } - - @Throws(ParserException::class) - override fun startMasterElement(id: Int, contentPosition: Long, contentSize: Long) { - this@UpdatedMatroskaExtractor.startMasterElement(id, contentPosition, contentSize) - } - - @Throws(ParserException::class) - override fun endMasterElement(id: Int) { - this@UpdatedMatroskaExtractor.endMasterElement(id) - } - - @Throws(ParserException::class) - override fun integerElement(id: Int, value: Long) { - this@UpdatedMatroskaExtractor.integerElement(id, value) - } - - @Throws(ParserException::class) - override fun floatElement(id: Int, value: Double) { - this@UpdatedMatroskaExtractor.floatElement(id, value) - } - - @Throws(ParserException::class) - override fun stringElement(id: Int, value: String) { - this@UpdatedMatroskaExtractor.stringElement(id, value) - } - - @Throws(IOException::class) - override fun binaryElement(id: Int, contentsSize: Int, input: ExtractorInput) { - this@UpdatedMatroskaExtractor.binaryElement(id, contentsSize, input) - } - } - - /** Holds data corresponding to a single track. */ - protected class Track { - // Common elements. - var isWebm: Boolean = false - var name: String? = null - var codecId: String? = null - var number: Int = 0 - var type: @C.TrackType Int = 0 - var defaultSampleDurationNs: Int = 0 - var maxBlockAdditionId: Int = 0 - var blockAddIdType: Int = 0 - var hasContentEncryption: Boolean = false - var sampleStrippedBytes: ByteArray? = null - var cryptoData: CryptoData? = - null - var codecPrivate: ByteArray? = null - var drmInitData: DrmInitData? = - null - - // Video elements. - var width: Int = Format.NO_VALUE - var height: Int = Format.NO_VALUE - var bitsPerChannel: Int = Format.NO_VALUE - var displayWidth: Int = Format.NO_VALUE - var displayHeight: Int = Format.NO_VALUE - var displayUnit: Int = DISPLAY_UNIT_PIXELS - var projectionType: @C.Projection Int = Format.NO_VALUE - var projectionPoseYaw: Float = 0f - var projectionPosePitch: Float = 0f - var projectionPoseRoll: Float = 0f - var projectionData: ByteArray? = - null - var stereoMode: @StereoMode Int = Format.NO_VALUE - var hasColorInfo: Boolean = false - var colorSpace: @C.ColorSpace Int = Format.NO_VALUE - var colorTransfer: @ColorTransfer Int = Format.NO_VALUE - var colorRange: @ColorRange Int = Format.NO_VALUE - var maxContentLuminance: Int = DEFAULT_MAX_CLL - var maxFrameAverageLuminance: Int = DEFAULT_MAX_FALL - var primaryRChromaticityX: Float = Format.NO_VALUE.toFloat() - var primaryRChromaticityY: Float = Format.NO_VALUE.toFloat() - var primaryGChromaticityX: Float = Format.NO_VALUE.toFloat() - var primaryGChromaticityY: Float = Format.NO_VALUE.toFloat() - var primaryBChromaticityX: Float = Format.NO_VALUE.toFloat() - var primaryBChromaticityY: Float = Format.NO_VALUE.toFloat() - var whitePointChromaticityX: Float = Format.NO_VALUE.toFloat() - var whitePointChromaticityY: Float = Format.NO_VALUE.toFloat() - var maxMasteringLuminance: Float = Format.NO_VALUE.toFloat() - var minMasteringLuminance: Float = Format.NO_VALUE.toFloat() - var dolbyVisionConfigBytes: ByteArray? = null - - // Audio elements. Initially set to their default values. - var channelCount: Int = 1 - var audioBitDepth: Int = Format.NO_VALUE - var sampleRate: Int = 8000 - var codecDelayNs: Long = 0 - var seekPreRollNs: Long = 0 - 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 - - /** 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 - var initializationData: List? = null - var codecs: String? = null - when (codecId) { - CODEC_ID_VP8 -> mimeType = MimeTypes.VIDEO_VP8 - CODEC_ID_VP9 -> { - mimeType = MimeTypes.VIDEO_VP9 - initializationData = - if (codecPrivate == null) null else ImmutableList.of( - codecPrivate!! - ) - } - CODEC_ID_AV1 -> { - mimeType = MimeTypes.VIDEO_AV1 - initializationData = - if (codecPrivate == null) null else ImmutableList.of( - codecPrivate!! - ) - } - CODEC_ID_MPEG2 -> mimeType = MimeTypes.VIDEO_MPEG2 - CODEC_ID_MPEG4_SP, CODEC_ID_MPEG4_ASP, CODEC_ID_MPEG4_AP -> { - mimeType = MimeTypes.VIDEO_MP4V - initializationData = - if (codecPrivate == null) null else listOf( - codecPrivate!! - ) - } - - CODEC_ID_H264 -> { - mimeType = MimeTypes.VIDEO_H264 - val avcConfig = AvcConfig.parse( - ParsableByteArray( - getCodecPrivate( - codecId!! - ) - ) - ) - initializationData = avcConfig.initializationData - nalUnitLengthFieldLength = avcConfig.nalUnitLengthFieldLength - codecs = avcConfig.codecs - } - - CODEC_ID_H265 -> { - mimeType = MimeTypes.VIDEO_H265 - val hevcConfig = HevcConfig.parse( - ParsableByteArray( - getCodecPrivate( - codecId!! - ) - ) - ) - initializationData = hevcConfig.initializationData - nalUnitLengthFieldLength = hevcConfig.nalUnitLengthFieldLength - codecs = hevcConfig.codecs - } - - CODEC_ID_FOURCC -> { - val pair = - parseFourCcPrivate( - ParsableByteArray( - getCodecPrivate( - codecId!! - ) - ) - ) - mimeType = pair.first - initializationData = pair.second - } - - CODEC_ID_THEORA -> // TODO: This can be set to the real mimeType if/when we work out what initializationData - // should be set to for this case. - mimeType = MimeTypes.VIDEO_UNKNOWN - - CODEC_ID_VORBIS -> { - mimeType = MimeTypes.AUDIO_VORBIS - maxInputSize = VORBIS_MAX_INPUT_SIZE - initializationData = parseVorbisCodecPrivate( - getCodecPrivate( - codecId!! - ) - ) - } - - CODEC_ID_OPUS -> { - mimeType = MimeTypes.AUDIO_OPUS - maxInputSize = OPUS_MAX_INPUT_SIZE - initializationData = ArrayList(3) - initializationData.add(getCodecPrivate(codecId!!)) - initializationData.add( - ByteBuffer.allocate(8).order(ByteOrder.LITTLE_ENDIAN).putLong(codecDelayNs) - .array() - ) - initializationData.add( - ByteBuffer.allocate(8).order(ByteOrder.LITTLE_ENDIAN).putLong(seekPreRollNs) - .array() - ) - } - - CODEC_ID_AAC -> { - mimeType = MimeTypes.AUDIO_AAC - initializationData = listOf( - getCodecPrivate( - codecId!! - ) - ) - val aacConfig = AacUtil.parseAudioSpecificConfig(codecPrivate!!) - // Update sampleRate and channelCount from the AudioSpecificConfig initialization data, - // which is more reliable. See [Internal: b/10903778]. - sampleRate = aacConfig.sampleRateHz - channelCount = aacConfig.channelCount - codecs = aacConfig.codecs - } - - CODEC_ID_MP2 -> { - mimeType = MimeTypes.AUDIO_MPEG_L2 - maxInputSize = MpegAudioUtil.MAX_FRAME_SIZE_BYTES - } - - CODEC_ID_MP3 -> { - mimeType = MimeTypes.AUDIO_MPEG - maxInputSize = MpegAudioUtil.MAX_FRAME_SIZE_BYTES - } - - CODEC_ID_AC3 -> mimeType = MimeTypes.AUDIO_AC3 - CODEC_ID_E_AC3 -> mimeType = MimeTypes.AUDIO_E_AC3 - CODEC_ID_TRUEHD -> { - mimeType = MimeTypes.AUDIO_TRUEHD - trueHdSampleRechunker = TrueHdSampleRechunker() - } - - 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 - initializationData = listOf( - getCodecPrivate( - codecId!! - ) - ) - } - - CODEC_ID_ACM -> { - mimeType = MimeTypes.AUDIO_RAW - if (parseMsAcmCodecPrivate( - ParsableByteArray( - getCodecPrivate( - codecId!! - ) - ) - ) - ) { - pcmEncoding = Util.getPcmEncoding(audioBitDepth) - if (pcmEncoding == C.ENCODING_INVALID) { - pcmEncoding = Format.NO_VALUE - mimeType = MimeTypes.AUDIO_UNKNOWN - Log.w( - TAG, - ("Unsupported PCM bit depth: " - + audioBitDepth - + ". Setting mimeType to " - + mimeType) - ) - } - } else { - mimeType = MimeTypes.AUDIO_UNKNOWN - Log.w( - TAG, - "Non-PCM MS/ACM is unsupported. Setting mimeType to $mimeType" - ) - } - } - - CODEC_ID_PCM_INT_LIT -> { - mimeType = MimeTypes.AUDIO_RAW - pcmEncoding = Util.getPcmEncoding(audioBitDepth) - if (pcmEncoding == C.ENCODING_INVALID) { - pcmEncoding = Format.NO_VALUE - mimeType = MimeTypes.AUDIO_UNKNOWN - Log.w( - TAG, - ("Unsupported little endian PCM bit depth: " - + audioBitDepth - + ". Setting mimeType to " - + mimeType) - ) - } - } - - CODEC_ID_PCM_INT_BIG -> { - mimeType = MimeTypes.AUDIO_RAW - if (audioBitDepth == 8) { - pcmEncoding = C.ENCODING_PCM_8BIT - } else if (audioBitDepth == 16) { - pcmEncoding = C.ENCODING_PCM_16BIT_BIG_ENDIAN - } else if (audioBitDepth == 24) { - pcmEncoding = C.ENCODING_PCM_24BIT_BIG_ENDIAN - } else if (audioBitDepth == 32) { - pcmEncoding = C.ENCODING_PCM_32BIT_BIG_ENDIAN - } else { - pcmEncoding = Format.NO_VALUE - mimeType = MimeTypes.AUDIO_UNKNOWN - Log.w( - TAG, - ("Unsupported big endian PCM bit depth: " - + audioBitDepth - + ". Setting mimeType to " - + mimeType) - ) - } - } - - CODEC_ID_PCM_FLOAT -> { - mimeType = MimeTypes.AUDIO_RAW - if (audioBitDepth == 32) { - pcmEncoding = C.ENCODING_PCM_FLOAT - } else { - pcmEncoding = Format.NO_VALUE - mimeType = MimeTypes.AUDIO_UNKNOWN - Log.w( - TAG, - ("Unsupported floating point PCM bit depth: " - + audioBitDepth - + ". Setting mimeType to " - + mimeType) - ) - } - } - - CODEC_ID_SUBRIP -> mimeType = MimeTypes.APPLICATION_SUBRIP - CODEC_ID_ASS, CODEC_ID_SSA -> { - mimeType = MimeTypes.TEXT_SSA - initializationData = ImmutableList.of( - SSA_DIALOGUE_FORMAT, getCodecPrivate( - codecId!! - ) - ) - } - - CODEC_ID_VTT -> mimeType = MimeTypes.TEXT_VTT - CODEC_ID_VOBSUB -> { - mimeType = MimeTypes.APPLICATION_VOBSUB - initializationData = ImmutableList.of( - getCodecPrivate( - codecId!! - ) - ) - } - - CODEC_ID_PGS -> mimeType = MimeTypes.APPLICATION_PGS - CODEC_ID_DVBSUB -> { - mimeType = MimeTypes.APPLICATION_DVBSUBS - // Init data: composition_page (2), ancillary_page (2) - val initializationDataBytes = ByteArray(4) - System.arraycopy(getCodecPrivate(codecId!!), 0, initializationDataBytes, 0, 4) - initializationData = ImmutableList.of(initializationDataBytes) - } - - else -> throw ParserException.createForMalformedContainer( - "Unrecognized codec identifier.", /* cause= */null - ) - } - - if (dolbyVisionConfigBytes != null) { - val dolbyVisionConfig = - DolbyVisionConfig.parse(ParsableByteArray(dolbyVisionConfigBytes!!)) - if (dolbyVisionConfig != null) { - codecs = dolbyVisionConfig.codecs - mimeType = MimeTypes.VIDEO_DOLBY_VISION - } - } - - var selectionFlags: @SelectionFlags Int = 0 - selectionFlags = selectionFlags or if (flagDefault) C.SELECTION_FLAG_DEFAULT else 0 - selectionFlags = selectionFlags or if (flagForced) C.SELECTION_FLAG_FORCED else 0 - - 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)) { - formatBuilder - .setChannelCount(channelCount) - .setSampleRate(sampleRate) - .setPcmEncoding(pcmEncoding) - } else if (MimeTypes.isVideo(mimeType)) { - if (displayUnit == DISPLAY_UNIT_PIXELS) { - displayWidth = if (displayWidth == Format.NO_VALUE) width else displayWidth - displayHeight = if (displayHeight == Format.NO_VALUE) height else displayHeight - } - var pixelWidthHeightRatio = Format.NO_VALUE.toFloat() - if (displayWidth != Format.NO_VALUE && displayHeight != Format.NO_VALUE) { - pixelWidthHeightRatio = - ((height * displayWidth).toFloat()) / (width * displayHeight) - } - var colorInfo: ColorInfo? = null - if (hasColorInfo) { - val hdrStaticInfo = hdrStaticInfo - colorInfo = - ColorInfo.Builder() - .setColorSpace(colorSpace) - .setColorRange(colorRange) - .setColorTransfer(colorTransfer) - .setHdrStaticInfo(hdrStaticInfo) - .setLumaBitdepth(bitsPerChannel) - .setChromaBitdepth(bitsPerChannel) - .build() - } - var rotationDegrees = Format.NO_VALUE - - if (name != null && TRACK_NAME_TO_ROTATION_DEGREES.containsKey(name)) { - rotationDegrees = TRACK_NAME_TO_ROTATION_DEGREES[name]!! - } - if (projectionType == C.PROJECTION_RECTANGULAR && java.lang.Float.compare( - projectionPoseYaw, - 0f - ) == 0 && java.lang.Float.compare(projectionPosePitch, 0f) == 0 - ) { - // The range of projectionPoseRoll is [-180, 180]. - if (java.lang.Float.compare(projectionPoseRoll, 0f) == 0) { - rotationDegrees = 0 - } else if (java.lang.Float.compare(projectionPoseRoll, 90f) == 0) { - rotationDegrees = 90 - } else if (java.lang.Float.compare(projectionPoseRoll, -180f) == 0 - || java.lang.Float.compare(projectionPoseRoll, 180f) == 0 - ) { - rotationDegrees = 180 - } else if (java.lang.Float.compare(projectionPoseRoll, -90f) == 0) { - rotationDegrees = 270 - } - } - formatBuilder - .setWidth(width) - .setHeight(height) - .setPixelWidthHeightRatio(pixelWidthHeightRatio) - .setRotationDegrees(rotationDegrees) - .setProjectionData(projectionData) - .setStereoMode(stereoMode) - .setColorInfo(colorInfo) - } else if (MimeTypes.APPLICATION_SUBRIP == mimeType - || MimeTypes.TEXT_SSA == mimeType - || MimeTypes.TEXT_VTT == mimeType - || MimeTypes.APPLICATION_VOBSUB == mimeType - || MimeTypes.APPLICATION_PGS == mimeType - || MimeTypes.APPLICATION_DVBSUBS == mimeType - ) { - } else { - throw ParserException.createForMalformedContainer( - "Unexpected MIME type.", /* cause= */null - ) - } - - if (name != null && !TRACK_NAME_TO_ROTATION_DEGREES.containsKey(name)) { - formatBuilder.setLabel(name) - } - - format = - formatBuilder - .setId(trackId) - .setContainerMimeType(if (isWebm) MimeTypes.VIDEO_WEBM else MimeTypes.VIDEO_MATROSKA) - .setSampleMimeType(mimeType) - .setMaxInputSize(maxInputSize) - .setLanguage(language) - .setSelectionFlags(selectionFlags) - .setInitializationData(initializationData) - .setCodecs(codecs) - .setDrmInitData(drmInitData) - .build() - } - - /** Forces any pending sample metadata to be flushed to the output. */ - fun outputPendingSampleMetadata() { - if (trueHdSampleRechunker != null) { - trueHdSampleRechunker!!.outputPendingSampleMetadata(output!!, cryptoData) - } - } - - /** Resets any state stored in the track in response to a seek. */ - fun reset() { - if (trueHdSampleRechunker != null) { - trueHdSampleRechunker!!.reset() - } - } - - /** - * Returns true if supplemental data will be attached to the samples. - * - * @param isBlockGroup Whether the samples are from a BlockGroup. - */ - fun samplesHaveSupplementalData(isBlockGroup: Boolean): Boolean { - if (CODEC_ID_OPUS == codecId) { - // At the end of a BlockGroup, a positive DiscardPadding value will be written out as - // supplemental data for Opus codec. Otherwise (i.e. DiscardPadding <= 0) supplemental data - // size will be 0. - return isBlockGroup - } - return maxBlockAdditionId > 0 - } - - private val hdrStaticInfo: ByteArray? - /** Returns the HDR Static Info as defined in CTA-861.3. */ - get() { - // Are all fields present. - if (primaryRChromaticityX == Format.NO_VALUE.toFloat() || primaryRChromaticityY == Format.NO_VALUE.toFloat() || primaryGChromaticityX == Format.NO_VALUE.toFloat() || primaryGChromaticityY == Format.NO_VALUE.toFloat() || primaryBChromaticityX == Format.NO_VALUE.toFloat() || primaryBChromaticityY == Format.NO_VALUE.toFloat() || whitePointChromaticityX == Format.NO_VALUE.toFloat() || whitePointChromaticityY == Format.NO_VALUE.toFloat() || maxMasteringLuminance == Format.NO_VALUE.toFloat() || minMasteringLuminance == Format.NO_VALUE.toFloat()) { - return null - } - - val hdrStaticInfoData = ByteArray(25) - val hdrStaticInfo = - ByteBuffer.wrap(hdrStaticInfoData).order(ByteOrder.LITTLE_ENDIAN) - hdrStaticInfo.put(0.toByte()) // Type. - hdrStaticInfo.putShort( - ((primaryRChromaticityX * MAX_CHROMATICITY) + 0.5f).toInt().toShort() - ) - hdrStaticInfo.putShort( - ((primaryRChromaticityY * MAX_CHROMATICITY) + 0.5f).toInt().toShort() - ) - hdrStaticInfo.putShort( - ((primaryGChromaticityX * MAX_CHROMATICITY) + 0.5f).toInt().toShort() - ) - hdrStaticInfo.putShort( - ((primaryGChromaticityY * MAX_CHROMATICITY) + 0.5f).toInt().toShort() - ) - hdrStaticInfo.putShort( - ((primaryBChromaticityX * MAX_CHROMATICITY) + 0.5f).toInt().toShort() - ) - hdrStaticInfo.putShort( - ((primaryBChromaticityY * MAX_CHROMATICITY) + 0.5f).toInt().toShort() - ) - hdrStaticInfo.putShort( - ((whitePointChromaticityX * MAX_CHROMATICITY) + 0.5f).toInt().toShort() - ) - hdrStaticInfo.putShort( - ((whitePointChromaticityY * MAX_CHROMATICITY) + 0.5f).toInt().toShort() - ) - hdrStaticInfo.putShort((maxMasteringLuminance + 0.5f).toInt().toShort()) - hdrStaticInfo.putShort((minMasteringLuminance + 0.5f).toInt().toShort()) - hdrStaticInfo.putShort(maxContentLuminance.toShort()) - hdrStaticInfo.putShort(maxFrameAverageLuminance.toShort()) - 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. - * - * - * It is unfortunately not possible to mark [UpdatedMatroskaExtractor.tracks] as only - * containing tracks with output with the nullness checker. This method is used to check that - * fact at runtime. - */ - fun assertOutputInitialized() { - checkNotNull( - output - ) - } - - @Throws(ParserException::class) - private fun getCodecPrivate(codecId: String): ByteArray { - if (codecPrivate == null) { - throw ParserException.createForMalformedContainer( - "Missing CodecPrivate for codec $codecId", /* cause= */null - ) - } - return codecPrivate!! - } - - companion object { - private const val DISPLAY_UNIT_PIXELS = 0 - private const val MAX_CHROMATICITY = 50000 // Defined in CTA-861.3. - - /** Default max content light level (CLL) that should be encoded into hdrStaticInfo. */ - private const val DEFAULT_MAX_CLL = 1000 // nits. - - /** Default frame-average light level (FALL) that should be encoded into hdrStaticInfo. */ - private const val DEFAULT_MAX_FALL = 200 // nits. - - /** - * Builds initialization data for a [Format] from FourCC codec private data. - * - * @return The codec MIME type and initialization data. If the compression type is not supported - * then the MIME type is set to [MimeTypes.VIDEO_UNKNOWN] and the initialization data - * is `null`. - * @throws ParserException If the initialization data could not be built. - */ - @Throws(ParserException::class) - private fun parseFourCcPrivate( - buffer: ParsableByteArray - ): Pair> { - try { - buffer.skipBytes(16) // size(4), width(4), height(4), planes(2), bitcount(2). - val compression = buffer.readLittleEndianUnsignedInt() - if (compression == FOURCC_COMPRESSION_DIVX.toLong()) { - return Pair(MimeTypes.VIDEO_DIVX, null) - } else if (compression == FOURCC_COMPRESSION_H263.toLong()) { - return Pair(MimeTypes.VIDEO_H263, null) - } else if (compression == FOURCC_COMPRESSION_VC1.toLong()) { - // Search for the initialization data from the end of the BITMAPINFOHEADER. The last 20 - // bytes of which are: sizeImage(4), xPel/m (4), yPel/m (4), clrUsed(4), clrImportant(4). - val startOffset = buffer.position + 20 - val bufferData = buffer.data - for (offset in startOffset.. { - try { - if (codecPrivate[0].toInt() != 0x02) { - throw ParserException.createForMalformedContainer( - "Error parsing vorbis codec private", /* cause= */null - ) - } - var offset = 1 - var vorbisInfoLength = 0 - while ((codecPrivate[offset].toInt() and 0xFF) == 0xFF) { - vorbisInfoLength += 0xFF - offset++ - } - vorbisInfoLength += codecPrivate[offset++].toInt() and 0xFF - - var vorbisSkipLength = 0 - while ((codecPrivate[offset].toInt() and 0xFF) == 0xFF) { - vorbisSkipLength += 0xFF - offset++ - } - vorbisSkipLength += codecPrivate[offset++].toInt() and 0xFF - - if (codecPrivate[offset].toInt() != 0x01) { - throw ParserException.createForMalformedContainer( - "Error parsing vorbis codec private", /* cause= */null - ) - } - val vorbisInfo = ByteArray(vorbisInfoLength) - System.arraycopy(codecPrivate, offset, vorbisInfo, 0, vorbisInfoLength) - offset += vorbisInfoLength - if (codecPrivate[offset].toInt() != 0x03) { - throw ParserException.createForMalformedContainer( - "Error parsing vorbis codec private", /* cause= */null - ) - } - offset += vorbisSkipLength - if (codecPrivate[offset].toInt() != 0x05) { - throw ParserException.createForMalformedContainer( - "Error parsing vorbis codec private", /* cause= */null - ) - } - val vorbisBooks = ByteArray(codecPrivate.size - offset) - System.arraycopy( - codecPrivate, - offset, - vorbisBooks, - 0, - codecPrivate.size - offset - ) - val initializationData: MutableList = ArrayList(2) - initializationData.add(vorbisInfo) - initializationData.add(vorbisBooks) - return initializationData - } catch (e: ArrayIndexOutOfBoundsException) { - throw ParserException.createForMalformedContainer( - "Error parsing vorbis codec private", /* cause= */null - ) - } - } - - /** - * Parses an MS/ACM codec private, returning whether it indicates PCM audio. - * - * @return Whether the codec private indicates PCM audio. - * @throws ParserException If a parsing error occurs. - */ - @Throws(ParserException::class) - private fun parseMsAcmCodecPrivate(buffer: ParsableByteArray): Boolean { - try { - val formatTag = buffer.readLittleEndianUnsignedShort() - if (formatTag == WAVE_FORMAT_PCM) { - return true - } else if (formatTag == WAVE_FORMAT_EXTENSIBLE) { - buffer.position = WAVE_FORMAT_SIZE + 6 // unionSamples(2), channelMask(4) - return buffer.readLong() == WAVE_SUBFORMAT_PCM.mostSignificantBits - && buffer.readLong() == WAVE_SUBFORMAT_PCM.leastSignificantBits - } else { - return false - } - } catch (e: ArrayIndexOutOfBoundsException) { - throw ParserException.createForMalformedContainer( - "Error parsing MS/ACM codec private", /* cause= */null - ) - } - } - } - } - - companion object { - /** - * Creates a factory for [UpdatedMatroskaExtractor] instances with the provided [ ]. - */ - fun newFactory(subtitleParserFactory: SubtitleParser.Factory): ExtractorsFactory { - return ExtractorsFactory { - arrayOf( - UpdatedMatroskaExtractor(subtitleParserFactory) - ) - } - } - - /** - * Flag to disable seeking for cues. - * - * - * Normally (i.e. when this flag is not set) the extractor will seek to the cues element if its - * position is specified in the seek head and if it's after the first cluster. Setting this flag - * disables seeking to the cues element. If the cues element is after the first cluster then the - * media is treated as being unseekable. - */ - const val FLAG_DISABLE_SEEK_FOR_CUES: Int = 1 - - /** - * Flag to use the source subtitle formats without modification. If unset, subtitles will be - * transcoded to [MimeTypes.APPLICATION_MEDIA3_CUES] during extraction. - */ - const val FLAG_EMIT_RAW_SUBTITLE_DATA: Int = 1 shl 1 // 2 - - @Deprecated("Use {@link #newFactory(SubtitleParser.Factory)} instead.") - val FACTORY: ExtractorsFactory = ExtractorsFactory { - arrayOf( - UpdatedMatroskaExtractor( - SubtitleParser.Factory.UNSUPPORTED, - FLAG_EMIT_RAW_SUBTITLE_DATA - ) - ) - } - - private const val TAG = "MatroskaExtractor" - - private const val UNSET_ENTRY_ID = -1 - - private const val BLOCK_STATE_START = 0 - private const val BLOCK_STATE_HEADER = 1 - private const val BLOCK_STATE_DATA = 2 - - private const val DOC_TYPE_MATROSKA = "matroska" - private const val DOC_TYPE_WEBM = "webm" - private const val CODEC_ID_VP8 = "V_VP8" - private const val CODEC_ID_VP9 = "V_VP9" - private const val CODEC_ID_AV1 = "V_AV1" - private const val CODEC_ID_MPEG2 = "V_MPEG2" - private const val CODEC_ID_MPEG4_SP = "V_MPEG4/ISO/SP" - private const val CODEC_ID_MPEG4_ASP = "V_MPEG4/ISO/ASP" - private const val CODEC_ID_MPEG4_AP = "V_MPEG4/ISO/AP" - private const val CODEC_ID_H264 = "V_MPEG4/ISO/AVC" - private const val CODEC_ID_H265 = "V_MPEGH/ISO/HEVC" - private const val CODEC_ID_FOURCC = "V_MS/VFW/FOURCC" - private const val CODEC_ID_THEORA = "V_THEORA" - private const val CODEC_ID_VORBIS = "A_VORBIS" - private const val CODEC_ID_OPUS = "A_OPUS" - private const val CODEC_ID_AAC = "A_AAC" - private const val CODEC_ID_MP2 = "A_MPEG/L2" - private const val CODEC_ID_MP3 = "A_MPEG/L3" - private const val CODEC_ID_AC3 = "A_AC3" - private const val CODEC_ID_E_AC3 = "A_EAC3" - private const val CODEC_ID_TRUEHD = "A_TRUEHD" - private const val CODEC_ID_DTS = "A_DTS" - private const val CODEC_ID_DTS_EXPRESS = "A_DTS/EXPRESS" - private const val CODEC_ID_DTS_LOSSLESS = "A_DTS/LOSSLESS" - private const val CODEC_ID_FLAC = "A_FLAC" - private const val CODEC_ID_ACM = "A_MS/ACM" - private const val CODEC_ID_PCM_INT_LIT = "A_PCM/INT/LIT" - private const val CODEC_ID_PCM_INT_BIG = "A_PCM/INT/BIG" - 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" - private const val CODEC_ID_DVBSUB = "S_DVBSUB" - - private const val VORBIS_MAX_INPUT_SIZE = 8192 - private const val OPUS_MAX_INPUT_SIZE = 5760 - private const val ENCRYPTION_IV_SIZE = 8 - private const val TRACK_TYPE_AUDIO = 2 - - private const val ID_EBML = 0x1A45DFA3 - private const val ID_EBML_READ_VERSION = 0x42F7 - private const val ID_DOC_TYPE = 0x4282 - private const val ID_DOC_TYPE_READ_VERSION = 0x4285 - private const val ID_SEGMENT = 0x18538067 - private const val ID_SEGMENT_INFO = 0x1549A966 - private const val ID_SEEK_HEAD = 0x114D9B74 - private const val ID_SEEK = 0x4DBB - private const val ID_SEEK_ID = 0x53AB - private const val ID_SEEK_POSITION = 0x53AC - private const val ID_INFO = 0x1549A966 - private const val ID_TIMECODE_SCALE = 0x2AD7B1 - private const val ID_DURATION = 0x4489 - private const val ID_CLUSTER = 0x1F43B675 - private const val ID_TIME_CODE = 0xE7 - private const val ID_SIMPLE_BLOCK = 0xA3 - private const val ID_BLOCK_GROUP = 0xA0 - private const val ID_BLOCK = 0xA1 - private const val ID_BLOCK_DURATION = 0x9B - private const val ID_BLOCK_ADDITIONS = 0x75A1 - private const val ID_BLOCK_MORE = 0xA6 - private const val ID_BLOCK_ADD_ID = 0xEE - private const val ID_BLOCK_ADDITIONAL = 0xA5 - private const val ID_REFERENCE_BLOCK = 0xFB - private const val ID_TRACKS = 0x1654AE6B - private const val ID_TRACK_ENTRY = 0xAE - private const val ID_TRACK_NUMBER = 0xD7 - private const val ID_TRACK_TYPE = 0x83 - private const val ID_FLAG_DEFAULT = 0x88 - private const val ID_FLAG_FORCED = 0x55AA - private const val ID_DEFAULT_DURATION = 0x23E383 - private const val ID_MAX_BLOCK_ADDITION_ID = 0x55EE - private const val ID_BLOCK_ADDITION_MAPPING = 0x41E4 - private const val ID_BLOCK_ADD_ID_TYPE = 0x41E7 - private const val ID_BLOCK_ADD_ID_EXTRA_DATA = 0x41ED - private const val ID_NAME = 0x536E - private const val ID_CODEC_ID = 0x86 - private const val ID_CODEC_PRIVATE = 0x63A2 - private const val ID_CODEC_DELAY = 0x56AA - private const val ID_SEEK_PRE_ROLL = 0x56BB - private const val ID_DISCARD_PADDING = 0x75A2 - private const val ID_VIDEO = 0xE0 - private const val ID_PIXEL_WIDTH = 0xB0 - private const val ID_PIXEL_HEIGHT = 0xBA - private const val ID_DISPLAY_WIDTH = 0x54B0 - private const val ID_DISPLAY_HEIGHT = 0x54BA - private const val ID_DISPLAY_UNIT = 0x54B2 - private const val ID_AUDIO = 0xE1 - private const val ID_CHANNELS = 0x9F - private const val ID_AUDIO_BIT_DEPTH = 0x6264 - private const val ID_SAMPLING_FREQUENCY = 0xB5 - private const val ID_CONTENT_ENCODINGS = 0x6D80 - private const val ID_CONTENT_ENCODING = 0x6240 - private const val ID_CONTENT_ENCODING_ORDER = 0x5031 - private const val ID_CONTENT_ENCODING_SCOPE = 0x5032 - private const val ID_CONTENT_COMPRESSION = 0x5034 - private const val ID_CONTENT_COMPRESSION_ALGORITHM = 0x4254 - private const val ID_CONTENT_COMPRESSION_SETTINGS = 0x4255 - private const val ID_CONTENT_ENCRYPTION = 0x5035 - private const val ID_CONTENT_ENCRYPTION_ALGORITHM = 0x47E1 - private const val ID_CONTENT_ENCRYPTION_KEY_ID = 0x47E2 - private const val ID_CONTENT_ENCRYPTION_AES_SETTINGS = 0x47E7 - private const val ID_CONTENT_ENCRYPTION_AES_SETTINGS_CIPHER_MODE = 0x47E8 - 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 - private const val ID_PROJECTION_PRIVATE = 0x7672 - private const val ID_PROJECTION_POSE_YAW = 0x7673 - private const val ID_PROJECTION_POSE_PITCH = 0x7674 - private const val ID_PROJECTION_POSE_ROLL = 0x7675 - private const val ID_STEREO_MODE = 0x53B8 - private const val ID_COLOUR = 0x55B0 - private const val ID_COLOUR_RANGE = 0x55B9 - private const val ID_COLOUR_BITS_PER_CHANNEL = 0x55B2 - private const val ID_COLOUR_TRANSFER = 0x55BA - private const val ID_COLOUR_PRIMARIES = 0x55BB - private const val ID_MAX_CLL = 0x55BC - private const val ID_MAX_FALL = 0x55BD - private const val ID_MASTERING_METADATA = 0x55D0 - private const val ID_PRIMARY_R_CHROMATICITY_X = 0x55D1 - private const val ID_PRIMARY_R_CHROMATICITY_Y = 0x55D2 - private const val ID_PRIMARY_G_CHROMATICITY_X = 0x55D3 - private const val ID_PRIMARY_G_CHROMATICITY_Y = 0x55D4 - private const val ID_PRIMARY_B_CHROMATICITY_X = 0x55D5 - private const val ID_PRIMARY_B_CHROMATICITY_Y = 0x55D6 - private const val ID_WHITE_POINT_CHROMATICITY_X = 0x55D7 - private const val ID_WHITE_POINT_CHROMATICITY_Y = 0x55D8 - private const val ID_LUMNINANCE_MAX = 0x55D9 - private const val ID_LUMNINANCE_MIN = 0x55DA - - /** - * BlockAddID value for ITU T.35 metadata in a VP9 track. See also - * https://www.webmproject.org/docs/container/. - */ - private const val BLOCK_ADDITIONAL_ID_VP9_ITU_T_35 = 4 - - /** - * BlockAddIdType value for Dolby Vision configuration with profile <= 7. See also - * https://www.matroska.org/technical/codec_specs.html. - */ - private const val BLOCK_ADD_ID_TYPE_DVCC = 0x64766343 - - /** - * BlockAddIdType value for Dolby Vision configuration with profile > 7. See also - * https://www.matroska.org/technical/codec_specs.html. - */ - private const val BLOCK_ADD_ID_TYPE_DVVC = 0x64767643 - - private const val LACING_NONE = 0 - private const val LACING_XIPH = 1 - private const val LACING_FIXED_SIZE = 2 - private const val LACING_EBML = 3 - - private const val FOURCC_COMPRESSION_DIVX = 0x58564944 - 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. - * - * - * The display time of each subtitle is passed as `timeUs` to [ ][TrackOutput.sampleMetadata]. The start and end timecodes in this template are relative to - * `timeUs`. Hence the start timecode is always zero. The 12 byte end timecode starting at - * [.SUBRIP_PREFIX_END_TIMECODE_OFFSET] is set to a placeholder value, and must be replaced - * with the duration of the subtitle. - * - * - * Equivalent to the UTF-8 string: "1\n00:00:00,000 --> 00:00:00,000\n". - */ - private val SUBRIP_PREFIX = byteArrayOf( - 49, - 10, - 48, - 48, - 58, - 48, - 48, - 58, - 48, - 48, - 44, - 48, - 48, - 48, - 32, - 45, - 45, - 62, - 32, - 48, - 48, - 58, - 48, - 48, - 58, - 48, - 48, - 44, - 48, - 48, - 48, - 10 - ) - - /** The byte offset of the end timecode in [.SUBRIP_PREFIX]. */ - private const val SUBRIP_PREFIX_END_TIMECODE_OFFSET = 19 - - /** - * The value by which to divide a time in microseconds to convert it to the unit of the last value - * in a subrip timecode (milliseconds). - */ - private const val SUBRIP_TIMECODE_LAST_VALUE_SCALING_FACTOR: Long = 1000 - - /** The format of a subrip timecode. */ - private const val SUBRIP_TIMECODE_FORMAT = "%02d:%02d:%02d,%03d" - - /** Matroska specific format line for SSA subtitles. */ - private val SSA_DIALOGUE_FORMAT = Util.getUtf8Bytes( - "Format: Start, End, " - + "ReadOrder, Layer, Style, Name, MarginL, MarginR, MarginV, Effect, Text" - ) - - /** - * A template for the prefix that must be added to each SSA sample. - * - * - * The display time of each subtitle is passed as `timeUs` to [ ][TrackOutput.sampleMetadata]. The start and end timecodes in this template are relative to - * `timeUs`. Hence the start timecode is always zero. The 12 byte end timecode starting at - * [.SUBRIP_PREFIX_END_TIMECODE_OFFSET] is set to a placeholder value, and must be replaced - * with the duration of the subtitle. - * - * - * Equivalent to the UTF-8 string: "Dialogue: 0:00:00:00,0:00:00:00,". - */ - private val SSA_PREFIX = byteArrayOf( - 68, - 105, - 97, - 108, - 111, - 103, - 117, - 101, - 58, - 32, - 48, - 58, - 48, - 48, - 58, - 48, - 48, - 58, - 48, - 48, - 44, - 48, - 58, - 48, - 48, - 58, - 48, - 48, - 58, - 48, - 48, - 44 - ) - - /** The byte offset of the end timecode in [.SSA_PREFIX]. */ - private const val SSA_PREFIX_END_TIMECODE_OFFSET = 21 - - /** - * The value by which to divide a time in microseconds to convert it to the unit of the last value - * in an SSA timecode (1/100ths of a second). - */ - private const val SSA_TIMECODE_LAST_VALUE_SCALING_FACTOR: Long = 10000 - - /** The format of an SSA timecode. */ - private const val SSA_TIMECODE_FORMAT = "%01d:%02d:%02d:%02d" - - /** - * A template for the prefix that must be added to each VTT sample. - * - * - * The display time of each subtitle is passed as `timeUs` to [ ][TrackOutput.sampleMetadata]. The start and end timecodes in this template are relative to - * `timeUs`. Hence the start timecode is always zero. The 12 byte end timecode starting at - * [.VTT_PREFIX_END_TIMECODE_OFFSET] is set to a placeholder value, and must be replaced - * with the duration of the subtitle. - * - * - * Equivalent to the UTF-8 string: "WEBVTT\n\n00:00:00.000 --> 00:00:00.000\n". - */ - private val VTT_PREFIX = byteArrayOf( - 87, - 69, - 66, - 86, - 84, - 84, - 10, - 10, - 48, - 48, - 58, - 48, - 48, - 58, - 48, - 48, - 46, - 48, - 48, - 48, - 32, - 45, - 45, - 62, - 32, - 48, - 48, - 58, - 48, - 48, - 58, - 48, - 48, - 46, - 48, - 48, - 48, - 10 - ) - - /** The byte offset of the end timecode in [.VTT_PREFIX]. */ - private const val VTT_PREFIX_END_TIMECODE_OFFSET = 25 - - /** - * The value by which to divide a time in microseconds to convert it to the unit of the last value - * in a VTT timecode (milliseconds). - */ - private const val VTT_TIMECODE_LAST_VALUE_SCALING_FACTOR: Long = 1000 - - /** The format of a VTT timecode. */ - private const val VTT_TIMECODE_FORMAT = "%02d:%02d:%02d.%03d" - - /** The length in bytes of a WAVEFORMATEX structure. */ - private const val WAVE_FORMAT_SIZE = 18 - - /** Format tag indicating a WAVEFORMATEXTENSIBLE structure. */ - private const val WAVE_FORMAT_EXTENSIBLE = 0xFFFE - - /** Format tag for PCM. */ - private const val WAVE_FORMAT_PCM = 1 - - /** Sub format for PCM. */ - private val WAVE_SUBFORMAT_PCM = UUID(0x0100000000001000L, -0x7fffff55ffc7648fL) - - /** Some HTC devices signal rotation in track names. */ - private val TRACK_NAME_TO_ROTATION_DEGREES: Map - - init { - val trackNameToRotationDegrees: MutableMap = HashMap() - trackNameToRotationDegrees["htc_video_rotA-000"] = 0 - trackNameToRotationDegrees["htc_video_rotA-090"] = 90 - trackNameToRotationDegrees["htc_video_rotA-180"] = 180 - trackNameToRotationDegrees["htc_video_rotA-270"] = 270 - TRACK_NAME_TO_ROTATION_DEGREES = Collections.unmodifiableMap(trackNameToRotationDegrees) - } - - /** - * Overwrites the end timecode in `subtitleData` with the correctly formatted time derived - * from `durationUs`. - * - * - * See documentation on [.SSA_DIALOGUE_FORMAT] and [.SUBRIP_PREFIX] for why we use - * the duration as the end timecode. - * - * @param codecId The subtitle codec; must be [.CODEC_ID_SUBRIP], [.CODEC_ID_ASS], - * [.CODEC_ID_SSA] or [.CODEC_ID_VTT]. - * @param durationUs The duration of the sample, in microseconds. - * @param subtitleData The subtitle sample in which to overwrite the end timecode (output - * parameter). - */ - private fun setSubtitleEndTime(codecId: String, durationUs: Long, subtitleData: ByteArray) { - val endTimecode: ByteArray - val endTimecodeOffset: Int - when (codecId) { - CODEC_ID_SUBRIP -> { - endTimecode = - formatSubtitleTimecode( - durationUs, - SUBRIP_TIMECODE_FORMAT, - SUBRIP_TIMECODE_LAST_VALUE_SCALING_FACTOR - ) - endTimecodeOffset = SUBRIP_PREFIX_END_TIMECODE_OFFSET - } - - CODEC_ID_ASS, CODEC_ID_SSA -> { - endTimecode = - formatSubtitleTimecode( - durationUs, SSA_TIMECODE_FORMAT, SSA_TIMECODE_LAST_VALUE_SCALING_FACTOR - ) - endTimecodeOffset = SSA_PREFIX_END_TIMECODE_OFFSET - } - - CODEC_ID_VTT -> { - endTimecode = - formatSubtitleTimecode( - durationUs, VTT_TIMECODE_FORMAT, VTT_TIMECODE_LAST_VALUE_SCALING_FACTOR - ) - endTimecodeOffset = VTT_PREFIX_END_TIMECODE_OFFSET - } - - else -> throw IllegalArgumentException() - } - System.arraycopy(endTimecode, 0, subtitleData, endTimecodeOffset, endTimecode.size) - } - - /** - * Formats `timeUs` using `timecodeFormat`, and sets it as the end timecode in `subtitleSampleData`. - */ - private fun formatSubtitleTimecode( - timeUs: Long, timecodeFormat: String, lastTimecodeValueScalingFactor: Long - ): ByteArray { - var timeUs = timeUs - checkArgument(timeUs != C.TIME_UNSET) - val timeCodeData: ByteArray - val hours = (timeUs / (3600 * C.MICROS_PER_SECOND)).toInt() - timeUs -= (hours * 3600L * C.MICROS_PER_SECOND) - val minutes = (timeUs / (60 * C.MICROS_PER_SECOND)).toInt() - timeUs -= (minutes * 60L * C.MICROS_PER_SECOND) - val seconds = (timeUs / C.MICROS_PER_SECOND).toInt() - timeUs -= (seconds * C.MICROS_PER_SECOND) - val lastValue = (timeUs / lastTimecodeValueScalingFactor).toInt() - timeCodeData = - Util.getUtf8Bytes( - String.format(Locale.US, timecodeFormat, hours, minutes, seconds, lastValue) - ) - return timeCodeData - } - - private fun isCodecSupported(codecId: String): Boolean { - return when (codecId) { - CODEC_ID_VP8, CODEC_ID_VP9, CODEC_ID_AV1, CODEC_ID_MPEG2, CODEC_ID_MPEG4_SP, CODEC_ID_MPEG4_ASP, CODEC_ID_MPEG4_AP, CODEC_ID_H264, CODEC_ID_H265, CODEC_ID_FOURCC, CODEC_ID_THEORA, CODEC_ID_OPUS, CODEC_ID_VORBIS, CODEC_ID_AAC, CODEC_ID_MP2, CODEC_ID_MP3, CODEC_ID_AC3, CODEC_ID_E_AC3, CODEC_ID_TRUEHD, CODEC_ID_DTS, CODEC_ID_DTS_EXPRESS, CODEC_ID_DTS_LOSSLESS, CODEC_ID_FLAC, CODEC_ID_ACM, CODEC_ID_PCM_INT_LIT, CODEC_ID_PCM_INT_BIG, CODEC_ID_PCM_FLOAT, CODEC_ID_SUBRIP, CODEC_ID_ASS, CODEC_ID_SSA, CODEC_ID_VTT, CODEC_ID_VOBSUB, CODEC_ID_PGS, CODEC_ID_DVBSUB -> true - - else -> false - } - } - - /** - * Returns an array that can store (at least) `length` elements, which will be either a new - * array or `array` if it's not null and large enough. - */ - private fun ensureArrayCapacity(array: IntArray?, length: Int): IntArray { - return if (array == null) { - IntArray(length) - } else if (array.size >= length) { - array - } else { - // Double the size to avoid allocating constantly if the required length increases gradually. - IntArray( - max((array.size * 2).toDouble(), length.toDouble()) - .toInt() - ) - } - } - } - - 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 deleted file mode 100644 index 52cd4361b..000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/live/LiveHelper.kt +++ /dev/null @@ -1,77 +0,0 @@ -package com.lagradost.cloudstream3.ui.player.live - -import androidx.annotation.OptIn -import androidx.media3.common.Player -import androidx.media3.common.Timeline -import androidx.media3.common.util.UnstableApi -import com.lagradost.cloudstream3.mvvm.debugWarning -import java.util.WeakHashMap - -object LiveHelper { - private val liveManagers = WeakHashMap>() - - @OptIn(UnstableApi::class) - fun registerPlayer(player: Player?) { - if (player == null) { - debugWarning { "LiveHelper registerPlayer called with null player!" } - return - } - - // Prevent duplicates - if (liveManagers.contains(player)) { - return - } - - val liveManager = LiveManager(player) - val listener = object : Player.Listener { - override fun onTimelineChanged(timeline: Timeline, reason: Int) { - val window = Timeline.Window() - timeline.getWindow(player.currentMediaItemIndex, window) - if (window.isDynamic) { - liveManager.submitLivestreamChunk(LivestreamChunk(window.durationMs)) - } - super.onTimelineChanged(timeline, reason) - } - - override fun onPositionDiscontinuity( - oldPosition: Player.PositionInfo, - newPosition: Player.PositionInfo, - reason: Int - ) { - super.onPositionDiscontinuity(oldPosition, newPosition, reason) - val timeAheadOfLive = liveManager.getTimeAheadOfLive(newPosition.positionMs) - - // Seek back to the optimal live spot - if (timeAheadOfLive > 100) { - player.seekTo(newPosition.positionMs - timeAheadOfLive) - } - } - } - - synchronized(liveManagers) { - player.addListener(listener) - liveManagers[player] = liveManager to listener - } - } - - fun unregisterPlayer(player: Player?) { - if (player == null) { - debugWarning { "LiveHelper unregisterPlayer called with null player!" } - return - } - - // Prevent duplicates - if (!liveManagers.contains(player)) { - return - } - - synchronized(liveManagers) { - liveManagers[player]?.let { (_, listener) -> - player.removeListener(listener) - } - liveManagers.remove(player) - } - } - - fun getLiveManager(player: Player?) = liveManagers[player]?.first -} diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/live/LiveManager.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/live/LiveManager.kt deleted file mode 100644 index 8d848d46a..000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/live/LiveManager.kt +++ /dev/null @@ -1,97 +0,0 @@ -package com.lagradost.cloudstream3.ui.player.live - -import androidx.media3.common.C -import androidx.media3.common.Player -import java.lang.ref.WeakReference - -// How much margin from the live point is still considered "live" -const val LIVE_MARGIN = 6_000L - -// How many ms should we be behind the real live point? -// Too low, and we cannot pre-buffer -// Too high, and we are no longer live -const val PREFERRED_LIVE_OFFSET = 5_000L - -// An extra offset from the optimal calculated timestamp -// This is to account for chunk updates not always being the same size -const val CHUNK_VARIANCE = 3000L - -// A livestream chunk from the player, the time we get it and the duration can be used to calculate -// the expected live timestamp. -class LivestreamChunk( - durationMs: Long, val receiveTimeMs: Long = System.currentTimeMillis() -) { - // We want to be PREFERRED_LIVE_OFFSET ms after the latest update, but we cannot be ahead of the middle point. - // If we are ahead of the middle point we will reach the end before the new chunk is expected to be released. - val targetPosition = maxOf(0,minOf( - durationMs - PREFERRED_LIVE_OFFSET, - durationMs / 2 - CHUNK_VARIANCE - )) - - fun isPositionLive(position: Long): Boolean { - val currentTime = System.currentTimeMillis() - val livePosition = targetPosition + (currentTime - receiveTimeMs) - val withinLive = position + LIVE_MARGIN > livePosition - PREFERRED_LIVE_OFFSET - // println("Position: $position, livePosition: ${livePosition}, Behind live: ${livePosition-position}, within live: $withinLive") - return withinLive - } - - fun getTimeAheadOfLive(position: Long): Long { - val currentTime = System.currentTimeMillis() - val livePosition = targetPosition + (currentTime - receiveTimeMs) - // println("Ahead of live: ${position-livePosition}") - return position - livePosition - } -} - -// There are two types of livestreams we need to manage -// 1. A livestream with no history, a continually sliding window. -// This livestream has no currentLiveOffset, which means we need to calculate -// the real live point based on when we receive the latest update and the size of that update. -// 2. A livestream with history. -// This livestream has a currentLiveOffset and therefore requires no calculation to get the live point. -// currentLiveOffset can however be inaccurate, and we need to be able to fall back to manual calculations. -class LiveManager { - private var _currentPlayer: WeakReference? = null - val currentPlayer: Player? get() = _currentPlayer?.get() - - constructor(player: Player?) { - _currentPlayer = WeakReference(player) - } - - private var lastLivestreamChunk: LivestreamChunk? = null - - fun submitLivestreamChunk(chunk: LivestreamChunk) { - lastLivestreamChunk = chunk - } - - /** Returns how much a position is ahead of the calculated live window. Returns 0 if not ahead of live window */ - fun getTimeAheadOfLive(position: Long): Long { - val player = currentPlayer ?: return 0 - if (!player.isCurrentMediaItemDynamic || player.duration == C.TIME_UNSET) return 0 - - // If the currentLiveOffset is wrong we fall back to manual calculations - val ahead = if (player.currentLiveOffset != C.TIME_UNSET && player.currentLiveOffset < player.duration) { - val relativeOffset = player.currentLiveOffset - player.currentPosition + position - PREFERRED_LIVE_OFFSET - relativeOffset - } else { - lastLivestreamChunk?.getTimeAheadOfLive(position) ?: 0 - } - - // Ensure min of 0 - return maxOf(0, ahead) - } - - /** Check if the stream is currently at the expected live edge, with margins */ - fun isAtLiveEdge(): Boolean { - val player = currentPlayer ?: return false - if (!player.isCurrentMediaItemDynamic || player.duration == C.TIME_UNSET) return false - - // If the currentLiveOffset is wrong we fall back to manual calculations - return if (player.currentLiveOffset != C.TIME_UNSET && player.currentLiveOffset < player.duration) { - player.currentLiveOffset < LIVE_MARGIN + PREFERRED_LIVE_OFFSET - } else { - lastLivestreamChunk?.isPositionLive(player.currentPosition) == true - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/live/LivePreviewTimeBar.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/live/LivePreviewTimeBar.kt deleted file mode 100644 index 3001281fd..000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/live/LivePreviewTimeBar.kt +++ /dev/null @@ -1,38 +0,0 @@ -package com.lagradost.cloudstream3.ui.player.live - -import android.content.Context -import android.util.AttributeSet -import androidx.annotation.OptIn -import androidx.media3.common.Player -import androidx.media3.common.util.UnstableApi -import androidx.media3.ui.PlayerControlView -import androidx.media3.ui.PlayerView -import androidx.media3.ui.R -import com.github.rubensousa.previewseekbar.media3.PreviewTimeBar -import java.lang.ref.WeakReference - - -@OptIn(UnstableApi::class) -class LivePreviewTimeBar(val ctx: Context, attrs: AttributeSet) : PreviewTimeBar(ctx, attrs) { - - private var _currentPlayerView: WeakReference? = null - val currentPlayer: Player? get() = _currentPlayerView?.get()?.player - - fun registerPlayerView(player: PlayerView?) { - _currentPlayerView = WeakReference(player) - val controller = - _currentPlayerView?.get()?.findViewById(R.id.exo_controller) - - controller?.setProgressUpdateListener { position, bufferedPosition -> - currentPlayer?.let { player -> - if (isAtLiveEdge()) { - setPosition(player.duration) - } - } - } - } - - fun isAtLiveEdge(): Boolean { - return LiveHelper.getLiveManager(currentPlayer)?.isAtLiveEdge() == true - } -} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/source_priority/PriorityAdapter.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/source_priority/PriorityAdapter.kt index 11dd39105..ce457740d 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/source_priority/PriorityAdapter.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/source_priority/PriorityAdapter.kt @@ -2,9 +2,9 @@ package com.lagradost.cloudstream3.ui.player.source_priority import android.view.LayoutInflater import android.view.ViewGroup +import androidx.recyclerview.widget.RecyclerView import com.lagradost.cloudstream3.databinding.PlayerPrioritizeItemBinding -import com.lagradost.cloudstream3.ui.NoStateAdapter -import com.lagradost.cloudstream3.ui.ViewHolderState +import com.lagradost.cloudstream3.utils.AppContextUtils data class SourcePriority( val data: T, @@ -12,41 +12,41 @@ data class SourcePriority( var priority: Int ) -class PriorityAdapter() : - NoStateAdapter>() { - - override fun onCreateContent(parent: ViewGroup): ViewHolderState { - return ViewHolderState( - PlayerPrioritizeItemBinding.inflate( - LayoutInflater.from(parent.context), - parent, - false - ) +class PriorityAdapter(override val items: MutableList>) : + AppContextUtils.DiffAdapter>(items) { + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { + return PriorityViewHolder( + PlayerPrioritizeItemBinding.inflate(LayoutInflater.from(parent.context),parent,false), ) } - override fun onBindContent( - holder: ViewHolderState, - item: SourcePriority, - position: Int - ) { - val binding = holder.view as? PlayerPrioritizeItemBinding ?: return - binding.priorityText.text = item.name - - fun updatePriority() { - binding.priorityNumber.text = item.priority.toString() + override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { + when (holder) { + is PriorityViewHolder -> holder.bind(items[position]) } + } - updatePriority() - binding.addButton.setOnClickListener { - // If someone clicks til the integer limit then they deserve to crash. - item.priority++ - updatePriority() - } + class PriorityViewHolder( + val binding: PlayerPrioritizeItemBinding, + ) : RecyclerView.ViewHolder(binding.root) { + fun bind(item: SourcePriority) { + binding.priorityText.text = item.name + + fun updatePriority() { + binding.priorityNumber.text = item.priority.toString() + } - binding.subtractButton.setOnClickListener { - item.priority-- updatePriority() + binding.addButton.setOnClickListener { + // If someone clicks til the integer limit then they deserve to crash. + item.priority++ + updatePriority() + } + + binding.subtractButton.setOnClickListener { + item.priority-- + updatePriority() + } } } } \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/source_priority/ProfilesAdapter.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/source_priority/ProfilesAdapter.kt index 85c2a85df..45f6aa660 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 @@ -9,26 +9,45 @@ import android.widget.ImageView import android.widget.TextView import androidx.core.content.ContextCompat import androidx.core.view.isVisible -import androidx.palette.graphics.Palette +import androidx.recyclerview.widget.RecyclerView import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.databinding.PlayerQualityProfileItemBinding -import com.lagradost.cloudstream3.ui.BaseDiffCallback -import com.lagradost.cloudstream3.ui.NoStateAdapter -import com.lagradost.cloudstream3.ui.ViewHolderState -import com.lagradost.cloudstream3.utils.ImageLoader.loadImage -import com.lagradost.cloudstream3.utils.drawableToBitmap -import com.lagradost.cloudstream3.utils.setText +import com.lagradost.cloudstream3.ui.result.UiImage +import com.lagradost.cloudstream3.utils.AppContextUtils +import com.lagradost.cloudstream3.utils.UIHelper.setImage class ProfilesAdapter( - val usedProfile: Int?, + override val items: MutableList, + val usedProfile: Int, val clickCallback: (oldIndex: Int?, newIndex: Int) -> Unit, ) : - NoStateAdapter(diffCallback = BaseDiffCallback(itemSame = { a, b -> - a.id == b.id - })) { + AppContextUtils.DiffAdapter( + items, + comparison = { first: QualityDataHelper.QualityProfile, second: QualityDataHelper.QualityProfile -> + first.id == second.id + }) { + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { + return ProfilesViewHolder( + PlayerQualityProfileItemBinding.inflate(LayoutInflater.from(parent.context),parent,false) + ) + } - companion object { - private val art = arrayOf( + override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { + when (holder) { + is ProfilesViewHolder -> holder.bind(items[position], position) + } + } + + private var currentItem: Pair? = null + + fun getCurrentProfile(): QualityDataHelper.QualityProfile? { + return currentItem?.second + } + + inner class ProfilesViewHolder( + val binding: PlayerQualityProfileItemBinding, + ) : RecyclerView.ViewHolder(binding.root) { + private val art = listOf( R.drawable.profile_bg_teal, R.drawable.profile_bg_blue, R.drawable.profile_bg_dark_blue, @@ -37,101 +56,54 @@ class ProfilesAdapter( R.drawable.profile_bg_red, R.drawable.profile_bg_orange, ) - } - override fun onCreateContent(parent: ViewGroup): ViewHolderState { - return ViewHolderState( - PlayerQualityProfileItemBinding.inflate( - LayoutInflater.from(parent.context), - parent, - false - ) - ) - } + fun bind(item: QualityDataHelper.QualityProfile, index: Int) { + val priorityText: TextView = binding.profileText + val profileBg: ImageView = binding.profileImageBackground + val wifiText: TextView = binding.textIsWifi + val dataText: TextView = binding.textIsMobileData + val outline: View = binding.outline + val cardView: View = binding.cardView - override fun onClearView(holder: ViewHolderState) { - when (val binding = holder.view) { - is PlayerQualityProfileItemBinding -> { - clearImage(binding.profileImageBackground) - } - } - } + priorityText.text = item.name.asString(itemView.context) + dataText.isVisible = item.type == QualityDataHelper.QualityProfileType.Data + wifiText.isVisible = item.type == QualityDataHelper.QualityProfileType.WiFi - 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) - downloadText.backgroundTintList = ColorStateList.valueOf(color) - } + fun setCurrentItem() { + val prevIndex = currentItem?.first + // Prevent UI bug when re-selecting the item quickly + if (prevIndex == index) { + return } - } - } - - val textStyle = - if (item.id == usedProfile) { - Typeface.BOLD - } else { - Typeface.NORMAL + currentItem = index to item + clickCallback.invoke(prevIndex, index) } - priorityText.setTypeface(null, textStyle) + outline.isVisible = currentItem?.second?.id == item.id - cardView.setOnClickListener { - setCurrentItem() + profileBg.setImage(UiImage.Drawable(art[index % art.size]), null, false) { palette -> + val color = palette.getDarkVibrantColor( + ContextCompat.getColor( + itemView.context, + R.color.dubColorBg + ) + ) + wifiText.backgroundTintList = ColorStateList.valueOf(color) + dataText.backgroundTintList = ColorStateList.valueOf(color) + } + + val textStyle = + if (item.id == usedProfile) { + Typeface.BOLD + } else { + Typeface.NORMAL + } + + priorityText.setTypeface(null, textStyle) + + cardView.setOnClickListener { + setCurrentItem() + } } } - - private var currentItem: Int? = null - - fun getCurrentProfile(): QualityDataHelper.QualityProfile? { - return currentItem?.let { index -> immutableCurrentList.getOrNull(index) } - } } \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/source_priority/QualityDataHelper.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/source_priority/QualityDataHelper.kt index 02470484e..3267efd73 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/source_priority/QualityDataHelper.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/source_priority/QualityDataHelper.kt @@ -1,32 +1,22 @@ package com.lagradost.cloudstream3.ui.player.source_priority import androidx.annotation.StringRes -import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey -import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKeys -import com.lagradost.cloudstream3.CloudStreamApp.Companion.removeKey -import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey +import com.lagradost.cloudstream3.AcraApplication.Companion.getKey +import com.lagradost.cloudstream3.AcraApplication.Companion.removeKey +import com.lagradost.cloudstream3.AcraApplication.Companion.setKey import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.mvvm.debugAssert -import com.lagradost.cloudstream3.utils.UiText -import com.lagradost.cloudstream3.utils.txt +import com.lagradost.cloudstream3.ui.result.UiText +import com.lagradost.cloudstream3.ui.result.txt import com.lagradost.cloudstream3.utils.DataStoreHelper.currentAccount -import com.lagradost.cloudstream3.utils.ExtractorLink import com.lagradost.cloudstream3.utils.Qualities -import kotlin.math.abs object QualityDataHelper { private const val VIDEO_SOURCE_PRIORITY = "video_source_priority" private const val VIDEO_PROFILE_NAME = "video_profile_name" private const val VIDEO_QUALITY_PRIORITY = "video_quality_priority" - - // Old key only supporting one type per profile - @Deprecated("Changed to support multiple types per profile") private const val VIDEO_PROFILE_TYPE = "video_profile_type" - // New key supporting more than one type per profile - - private const val VIDEO_PROFILE_TYPES = "video_profile_types_2" private const val DEFAULT_SOURCE_PRIORITY = 1 - /** * Automatically skip loading links once this priority is reached **/ @@ -43,14 +33,13 @@ object QualityDataHelper { enum class QualityProfileType(@StringRes val stringRes: Int, val unique: Boolean) { None(R.string.none, false), WiFi(R.string.wifi, true), - Data(R.string.mobile_data, true), - Download(R.string.download, true) + Data(R.string.mobile_data, true) } data class QualityProfile( val name: UiText, val id: Int, - val types: Set + val type: QualityProfileType ) fun getSourcePriority(profile: Int, name: String?): Int { @@ -62,21 +51,8 @@ object QualityDataHelper { ) ?: DEFAULT_SOURCE_PRIORITY } - fun getAllSourcePriorityNames(profile: Int): List { - val folder = "$currentAccount/$VIDEO_SOURCE_PRIORITY/$profile" - return getKeys(folder)?.map { key -> - key.substringAfter("$folder/") - } ?: emptyList() - } - fun setSourcePriority(profile: Int, name: String, priority: Int) { - val folder = "$currentAccount/$VIDEO_SOURCE_PRIORITY/$profile" - // Prevent unnecessary keys - if (priority == DEFAULT_SOURCE_PRIORITY) { - removeKey(folder, name) - } else { - setKey(folder, name, priority) - } + setKey("$currentAccount/$VIDEO_SOURCE_PRIORITY/$profile", name, priority) } fun setProfileName(profile: Int, name: String?) { @@ -109,40 +85,16 @@ object QualityDataHelper { ) } + fun getQualityProfileType(profile: Int): QualityProfileType { + return getKey("$currentAccount/$VIDEO_PROFILE_TYPE/$profile") ?: QualityProfileType.None + } - @Suppress("DEPRECATION") - fun getQualityProfileTypes(profile: Int): Set { - val newKey = "$currentAccount/$VIDEO_PROFILE_TYPES/$profile" - // Use arrays for to make with work with setKey properly (weird crashes otherwise) - val newProfiles = getKey>(newKey)?.toSet() - - // Migrate to new profile key - if (newProfiles == null) { - val oldProfile = - getKey("$currentAccount/$VIDEO_PROFILE_TYPE/$profile") - val newSet = oldProfile?.let { arrayOf(it) } ?: arrayOf() - setKey(newKey, newSet) - return newSet.toSet() + fun setQualityProfileType(profile: Int, type: QualityProfileType?) { + val path = "$currentAccount/$VIDEO_PROFILE_TYPE/$profile" + if (type == QualityProfileType.None) { + removeKey(path) } else { - return newProfiles - } - } - - fun addQualityProfileType(profile: Int, type: QualityProfileType) { - val path = "$currentAccount/$VIDEO_PROFILE_TYPES/$profile" - val currentTypes = getQualityProfileTypes(profile) - - if (type != QualityProfileType.None) { - setKey(path, (currentTypes + type).toTypedArray()) - } - } - - fun removeQualityProfileType(profile: Int, type: QualityProfileType) { - val path = "$currentAccount/$VIDEO_PROFILE_TYPES/$profile" - val currentTypes = getQualityProfileTypes(profile) - - if (type != QualityProfileType.None) { - setKey(path, (currentTypes - type).toTypedArray()) + setKey(path, type) } } @@ -154,39 +106,37 @@ object QualityDataHelper { val availableTypes = QualityProfileType.entries.toMutableList() val profiles = (1..PROFILE_COUNT).map { profileNumber -> // Get the real type - val types = getQualityProfileTypes(profileNumber) + val type = getQualityProfileType(profileNumber) - val uniqueTypes = types.mapNotNull { type -> - // This makes it impossible to get more than one of each type - if (type.unique && !availableTypes.remove(type)) { - null - } else { - type - } - }.toSet() + // This makes it impossible to get more than one of each type + // Duplicates will be turned to None + val uniqueType = if (type.unique && !availableTypes.remove(type)) { + QualityProfileType.None + } else { + type + } QualityProfile( getProfileName(profileNumber), profileNumber, - uniqueTypes + uniqueType ) }.toMutableList() /** - * If no profile of this type exists: insert it on the earliest profile + * If no profile of this type exists: insert it on the earliest profile with None type **/ fun insertType( list: MutableList, type: QualityProfileType ) { - if (list.any { it.types.contains(type) }) return - - synchronized(list) { - val firstItem = list.firstOrNull() ?: return - val fixedTypes = firstItem.types + type - val fixedItem = firstItem.copy(types = fixedTypes) - list.set(0, fixedItem) - } + if (list.any { it.type == type }) return + val index = + list.indexOfFirst { it.type == QualityProfileType.None } + list.getOrNull(index)?.copy(type = type) + ?.let { fixed -> + list.set(index, fixed) + } } QualityProfileType.entries.forEach { @@ -195,7 +145,7 @@ object QualityDataHelper { debugAssert({ !QualityProfileType.entries.all { type -> - !type.unique || profiles.any { it.types.contains(type) } + !type.unique || profiles.any { it.type == type } } }, { "All unique quality types do not exist" }) @@ -205,22 +155,4 @@ object QualityDataHelper { return profiles } - - fun getLinkPriority( - qualityProfile: Int, - linkData: ExtractorLink? - ): Int { - val qualityPriority = getQualityPriority( - qualityProfile, - closestQuality(linkData?.quality) - ) - val sourcePriority = getSourcePriority(qualityProfile, linkData?.source) - - return qualityPriority + sourcePriority - } - - private fun closestQuality(target: Int?): Qualities { - if (target == null) return Qualities.Unknown - return Qualities.entries.minBy { abs(it.value - target) } - } } \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/source_priority/QualityProfileDialog.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/source_priority/QualityProfileDialog.kt index 6a0f12e9a..0537092c1 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/source_priority/QualityProfileDialog.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/source_priority/QualityProfileDialog.kt @@ -2,78 +2,47 @@ package com.lagradost.cloudstream3.ui.player.source_priority import android.app.Dialog import androidx.annotation.StyleRes -import androidx.core.view.isVisible import androidx.fragment.app.FragmentActivity import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.databinding.PlayerQualityProfileDialogBinding -import com.lagradost.cloudstream3.ui.player.source_priority.QualityDataHelper.getAllSourcePriorityNames import com.lagradost.cloudstream3.ui.player.source_priority.QualityDataHelper.getProfileName import com.lagradost.cloudstream3.ui.player.source_priority.QualityDataHelper.getProfiles -import com.lagradost.cloudstream3.utils.Coroutines.ioWork -import com.lagradost.cloudstream3.utils.txt +import com.lagradost.cloudstream3.ui.result.txt import com.lagradost.cloudstream3.utils.ExtractorLink -import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showMultiDialog +import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showBottomDialog import com.lagradost.cloudstream3.utils.UIHelper.dismissSafe -import com.lagradost.cloudstream3.utils.UIHelper.fixSystemBarsPadding -import com.lagradost.cloudstream3.utils.setText -/** Simplified ExtractorLink for the quality profile dialog */ -data class LinkSource( - val source: String -) { - constructor(extractorLink: ExtractorLink) : this(extractorLink.source) -} - - -class QualityProfileDialog private constructor( +class QualityProfileDialog( val activity: FragmentActivity, @StyleRes val themeRes: Int, - private val links: List, - private val usedProfile: Int?, - private val profileSelectionCallback: ((QualityDataHelper.QualityProfile) -> Unit)?, - private val useProfileSelection: Boolean + private val links: List, + private val usedProfile: Int, + private val profileSelectionCallback: (QualityDataHelper.QualityProfile) -> Unit ) : Dialog(activity, themeRes) { - constructor( - activity: FragmentActivity, - @StyleRes themeRes: Int, - links: List, - usedProfile: Int, - profileSelectionCallback: ((QualityDataHelper.QualityProfile) -> Unit), - ) : this(activity, themeRes, links, usedProfile, profileSelectionCallback, true) - - constructor( - activity: FragmentActivity, - @StyleRes themeRes: Int, - links: List - ) : this(activity, themeRes, links, null, null, false) - - companion object { - // Run on IO as this may be a heavy operation - suspend fun getAllDefaultSources(): List = ioWork { - getProfiles().flatMap { - getAllSourcePriorityNames(it.id) - }.distinct().map { LinkSource(it) } - } - } - override fun show() { + val binding = PlayerQualityProfileDialogBinding.inflate(this.layoutInflater, null, false) - setContentView(binding.root) - fixSystemBarsPadding(binding.root) + setContentView(binding.root)//R.layout.player_quality_profile_dialog) + /*val profilesRecyclerView: RecyclerView = profiles_recyclerview + val useBtt: View = use_btt + val editBtt: View = edit_btt + val cancelBtt: View = cancel_btt + val defaultBtt: View = set_default_btt + val currentProfileText: TextView = currently_selected_profile_text + val selectedItemActionsHolder: View = selected_item_holder*/ binding.apply { fun getCurrentProfile(): QualityDataHelper.QualityProfile? { return (profilesRecyclerview.adapter as? ProfilesAdapter)?.getCurrentProfile() } fun refreshProfiles() { - if (usedProfile != null) { - currentlySelectedProfileText.setText(getProfileName(usedProfile)) - } - (profilesRecyclerview.adapter as? ProfilesAdapter)?.submitList(getProfiles()) + currentlySelectedProfileText.text = getProfileName(usedProfile).asString(context) + (profilesRecyclerview.adapter as? ProfilesAdapter)?.updateList(getProfiles()) } profilesRecyclerview.adapter = ProfilesAdapter( + mutableListOf(), usedProfile, ) { oldIndex: Int?, newIndex: Int -> profilesRecyclerview.adapter?.notifyItemChanged(newIndex) @@ -96,52 +65,37 @@ class QualityProfileDialog private constructor( setDefaultBtt.setOnClickListener { val currentProfile = getCurrentProfile() ?: return@setOnClickListener - val choices = - QualityDataHelper.QualityProfileType.entries.filter { it != QualityDataHelper.QualityProfileType.None } + val choices = QualityDataHelper.QualityProfileType.entries + .filter { it != QualityDataHelper.QualityProfileType.None } val choiceNames = choices.map { txt(it.stringRes).asString(context) } - val selectedIndices = choices.mapIndexed { index, type -> index to type } - .filter { currentProfile.types.contains(it.second) }.map { it.first } - activity.showMultiDialog( + activity.showBottomDialog( choiceNames, - selectedIndices, + choices.indexOf(currentProfile.type), txt(R.string.set_default).asString(context), + false, {}, { index -> - val pickedChoices = index.mapNotNull { choices.getOrNull(it) } - - pickedChoices.forEach { pickedChoice -> - // Remove previous picks - if (pickedChoice.unique) { - getProfiles().filter { it.types.contains(pickedChoice) }.forEach { - QualityDataHelper.removeQualityProfileType(it.id, pickedChoice) - } + val pickedChoice = choices.getOrNull(index) ?: return@showBottomDialog + // Remove previous picks + if (pickedChoice.unique) { + getProfiles().filter { it.type == pickedChoice }.forEach { + QualityDataHelper.setQualityProfileType(it.id, null) } - - QualityDataHelper.addQualityProfileType(currentProfile.id, pickedChoice) } + QualityDataHelper.setQualityProfileType(currentProfile.id, pickedChoice) refreshProfiles() }) } - cancelBtt.isVisible = useProfileSelection - useBtt.isVisible = useProfileSelection - applyBtt.isVisible = !useProfileSelection + cancelBtt.setOnClickListener { + this@QualityProfileDialog.dismissSafe() + } - if (useProfileSelection) { - cancelBtt.setOnClickListener { - this@QualityProfileDialog.dismissSafe() - } - - useBtt.setOnClickListener { - getCurrentProfile()?.let { - profileSelectionCallback?.invoke(it) - this@QualityProfileDialog.dismissSafe() - } - } - } else { - applyBtt.setOnClickListener { + useBtt.setOnClickListener { + getCurrentProfile()?.let { + profileSelectionCallback.invoke(it) this@QualityProfileDialog.dismissSafe() } } diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/source_priority/SourcePriorityDialog.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/source_priority/SourcePriorityDialog.kt index c8ac96ebb..bc6282af2 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 @@ -7,15 +7,15 @@ import androidx.annotation.StyleRes 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.ui.result.txt +import com.lagradost.cloudstream3.utils.ExtractorLink import com.lagradost.cloudstream3.utils.Qualities import com.lagradost.cloudstream3.utils.UIHelper.dismissSafe -import com.lagradost.cloudstream3.utils.UIHelper.fixSystemBarsPadding class SourcePriorityDialog( val ctx: Context, @StyleRes themeRes: Int, - val links: List, + val links: List, private val profile: QualityDataHelper.QualityProfile, /** * Notify that the profile overview should be updated, for example if the name has been updated @@ -24,10 +24,8 @@ class SourcePriorityDialog( private val updatedCallback: () -> Unit ) : Dialog(ctx, themeRes) { override fun show() { - val binding = - PlayerSelectSourcePriorityBinding.inflate(LayoutInflater.from(ctx), null, false) + val binding = PlayerSelectSourcePriorityBinding.inflate(LayoutInflater.from(ctx), null, false) setContentView(binding.root) - fixSystemBarsPadding(binding.root) val sourcesRecyclerView = binding.sortSources val qualitiesRecyclerView = binding.sortQualities val profileText = binding.profileTextEditable @@ -38,46 +36,45 @@ class SourcePriorityDialog( profileText.setText(QualityDataHelper.getProfileName(profile.id).asString(context)) profileText.hint = txt(R.string.profile_number, profile.id).asString(context) - sourcesRecyclerView.adapter = PriorityAdapter( - ).apply { - submitList(links.map { link -> + sourcesRecyclerView.adapter = PriorityAdapter( + links.map { link -> SourcePriority( null, link.source, QualityDataHelper.getSourcePriority(profile.id, link.source) ) - }.distinctBy { it.name }.sortedBy { -it.priority }) - } + }.distinctBy { it.name }.sortedBy { -it.priority }.toMutableList() + ) - qualitiesRecyclerView.adapter = PriorityAdapter( - ).apply { - submitList(Qualities.entries.mapNotNull { + qualitiesRecyclerView.adapter = PriorityAdapter( + Qualities.entries.mapNotNull { SourcePriority( it, Qualities.getStringByIntFull(it.value).ifBlank { return@mapNotNull null }, QualityDataHelper.getQualityPriority(profile.id, it) ) - }.sortedBy { -it.priority }) - } + }.sortedBy { -it.priority }.toMutableList() + ) @Suppress("UNCHECKED_CAST") // We know the types saveBtt.setOnClickListener { val qualityAdapter = qualitiesRecyclerView.adapter as? PriorityAdapter - val sourcesAdapter = sourcesRecyclerView.adapter as? PriorityAdapter + val sourcesAdapter = sourcesRecyclerView.adapter as? PriorityAdapter - val qualities = qualityAdapter?.immutableCurrentList ?: emptyList() - val sources = sourcesAdapter?.immutableCurrentList ?: emptyList() + val qualities = qualityAdapter?.items ?: emptyList() + val sources = sourcesAdapter?.items ?: emptyList() qualities.forEach { - QualityDataHelper.setQualityPriority(profile.id, it.data, it.priority) + val data = it.data as? Qualities ?: return@forEach + QualityDataHelper.setQualityPriority(profile.id, data, it.priority) } sources.forEach { QualityDataHelper.setSourcePriority(profile.id, it.name, it.priority) } - qualityAdapter?.submitList(qualities.sortedBy { -it.priority }) - sourcesAdapter?.submitList(sources.sortedBy { -it.priority }) + qualityAdapter?.updateList(qualities.sortedBy { -it.priority }) + sourcesAdapter?.updateList(sources.sortedBy { -it.priority }) val savedProfileName = profileText.text.toString() if (savedProfileName.isBlank()) { diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/quicksearch/QuickSearchFragment.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/quicksearch/QuickSearchFragment.kt index cf9bc9975..12adc0400 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/quicksearch/QuickSearchFragment.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/quicksearch/QuickSearchFragment.kt @@ -2,7 +2,9 @@ package com.lagradost.cloudstream3.ui.quicksearch import android.app.Activity import android.content.Context +import android.content.res.Configuration import android.os.Bundle +import android.util.TypedValue import android.view.LayoutInflater import android.view.View import android.view.ViewGroup @@ -11,9 +13,9 @@ import android.widget.ImageView import androidx.appcompat.widget.SearchView import androidx.core.view.isGone import androidx.core.view.isVisible +import androidx.fragment.app.Fragment import androidx.lifecycle.ViewModelProvider import androidx.recyclerview.widget.GridLayoutManager -import androidx.recyclerview.widget.RecyclerView import com.google.android.material.bottomsheet.BottomSheetDialog import com.lagradost.cloudstream3.APIHolder.getApiFromNameNull import com.lagradost.cloudstream3.CommonActivity.activity @@ -23,35 +25,28 @@ import com.lagradost.cloudstream3.databinding.QuickSearchBinding import com.lagradost.cloudstream3.mvvm.Resource import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.mvvm.observe -import com.lagradost.cloudstream3.ui.BaseFragment import com.lagradost.cloudstream3.ui.home.HomeFragment import com.lagradost.cloudstream3.ui.home.HomeFragment.Companion.loadHomepageList -import com.lagradost.cloudstream3.ui.home.HomeViewModel import com.lagradost.cloudstream3.ui.home.ParentItemAdapter import com.lagradost.cloudstream3.ui.search.SearchAdapter import com.lagradost.cloudstream3.ui.search.SearchClickCallback import com.lagradost.cloudstream3.ui.search.SearchHelper import com.lagradost.cloudstream3.ui.search.SearchViewModel -import com.lagradost.cloudstream3.ui.setRecycledViewPool import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR import com.lagradost.cloudstream3.ui.settings.Globals.PHONE import com.lagradost.cloudstream3.ui.settings.Globals.TV import com.lagradost.cloudstream3.ui.settings.Globals.isLayout import com.lagradost.cloudstream3.utils.AppContextUtils.filterProviderByPreferredMedia import com.lagradost.cloudstream3.utils.AppContextUtils.filterSearchResultByFilmQuality -import com.lagradost.cloudstream3.utils.AppContextUtils.isRecyclerScrollable import com.lagradost.cloudstream3.utils.AppContextUtils.ownShow -import com.lagradost.cloudstream3.utils.Coroutines.ioSafe -import com.lagradost.cloudstream3.utils.UIHelper.fixSystemBarsPadding +import com.lagradost.cloudstream3.utils.UIHelper +import com.lagradost.cloudstream3.utils.UIHelper.fixPaddingStatusbar import com.lagradost.cloudstream3.utils.UIHelper.getSpanCount -import com.lagradost.cloudstream3.utils.UIHelper.hideKeyboard import com.lagradost.cloudstream3.utils.UIHelper.navigate import com.lagradost.cloudstream3.utils.UIHelper.popCurrentPage import java.util.concurrent.locks.ReentrantLock -class QuickSearchFragment : BaseFragment( - BaseFragment.BindingCreator.Inflate(QuickSearchBinding::inflate) -) { +class QuickSearchFragment : Fragment() { companion object { const val AUTOSEARCH_KEY = "autosearch" const val PROVIDER_KEY = "providers" @@ -90,29 +85,30 @@ class QuickSearchFragment : BaseFragment( private var providers: Set? = null private lateinit var searchViewModel: SearchViewModel + var binding: QuickSearchBinding? = null + private var bottomSheetDialog: BottomSheetDialog? = null - override fun fixLayout(view: View) { - fixSystemBarsPadding(view) - - // Fix grid - HomeFragment.currentSpan = view.context.getSpanCount() - binding?.quickSearchAutofitResults?.spanCount = HomeFragment.currentSpan - HomeFragment.configEvent.invoke() - } - override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? - ): View? { + ): View { activity?.window?.setSoftInputMode( WindowManager.LayoutParams.SOFT_INPUT_STATE_VISIBLE ) searchViewModel = ViewModelProvider(this)[SearchViewModel::class.java] bottomSheetDialog?.ownShow() - return super.onCreateView(inflater, container, savedInstanceState) + val localBinding = QuickSearchBinding.inflate(inflater, container, false) + binding = localBinding + return localBinding.root + //return inflater.inflate(R.layout.quick_search, container, false) + } + + override fun onDestroyView() { + binding = null + super.onDestroyView() } override fun onDestroy() { @@ -134,7 +130,25 @@ class QuickSearchFragment : BaseFragment( return false } - override fun onBindingCreated(binding: QuickSearchBinding) { + private fun fixGrid() { + activity?.getSpanCount()?.let { + HomeFragment.currentSpan = it + } + binding?.quickSearchAutofitResults?.spanCount = HomeFragment.currentSpan + HomeFragment.currentSpan = HomeFragment.currentSpan + HomeFragment.configEvent.invoke(HomeFragment.currentSpan) + } + + override fun onConfigurationChanged(newConfig: Configuration) { + super.onConfigurationChanged(newConfig) + fixGrid() + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + fixPaddingStatusbar(binding?.quickSearchRoot) + fixGrid() + arguments?.getStringArray(PROVIDER_KEY)?.let { providers = it.toSet() } @@ -144,101 +158,55 @@ class QuickSearchFragment : BaseFragment( getApiFromNameNull(providers?.first())?.hasQuickSearch ?: false } else false - val firstProvider = providers?.firstOrNull() - if (isSingleProvider && firstProvider != null) { - binding.quickSearchAutofitResults.apply { - setRecycledViewPool(SearchAdapter.sharedPool) + if (isSingleProvider) { + binding?.quickSearchAutofitResults?.apply { adapter = SearchAdapter( + ArrayList(), this, ) { callback -> SearchHelper.handleSearchClickCallback(callback) } } - binding.quickSearchAutofitResults.addOnScrollListener(object : - RecyclerView.OnScrollListener() { - var expandCount = 0 - - override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) { - super.onScrollStateChanged(recyclerView, newState) - - val adapter = recyclerView.adapter - if (adapter !is SearchAdapter) return - - val count = adapter.itemCount - val currentHasNext = adapter.hasNext - - if (!recyclerView.isRecyclerScrollable() && currentHasNext && expandCount != count) { - expandCount = count - ioSafe { - searchViewModel.expandAndReturn(firstProvider) - } - } - } - }) - try { - binding.quickSearch.queryHint = - getString(R.string.search_hint_site).format(firstProvider) + binding?.quickSearch?.queryHint = + getString(R.string.search_hint_site).format(providers?.first()) } catch (e: Exception) { logError(e) } } else { - binding.quickSearchMasterRecycler.setRecycledViewPool(ParentItemAdapter.sharedPool) - binding.quickSearchMasterRecycler.adapter = - ParentItemAdapter( - id = "quickSearchMasterRecycler".hashCode(), - { callback -> - SearchHelper.handleSearchClickCallback(callback) - //when (callback.action) { - //SEARCH_ACTION_LOAD -> { - // clickCallback?.invoke(callback) - //} - // else -> SearchHelper.handleSearchClickCallback(activity, callback) - //} - }, - { item -> - bottomSheetDialog = activity?.loadHomepageList(item, dismissCallback = { - bottomSheetDialog = null - }, expandCallback = { searchViewModel.expandAndReturn(it) }) - }, - expandCallback = { name -> - ioSafe { - searchViewModel.expandAndReturn(name) - } + binding?.quickSearchMasterRecycler?.adapter = + ParentItemAdapter(fragment = this, id = "quickSearchMasterRecycler".hashCode(), { callback -> + SearchHelper.handleSearchClickCallback(callback) + //when (callback.action) { + //SEARCH_ACTION_LOAD -> { + // clickCallback?.invoke(callback) + //} + // else -> SearchHelper.handleSearchClickCallback(activity, callback) + //} + }, { item -> + bottomSheetDialog = activity?.loadHomepageList(item, dismissCallback = { + bottomSheetDialog = null }) - binding.quickSearchMasterRecycler.layoutManager = GridLayoutManager(context, 1) + }) + binding?.quickSearchMasterRecycler?.layoutManager = GridLayoutManager(context, 1) } - binding.quickSearchAutofitResults.isVisible = isSingleProvider - binding.quickSearchMasterRecycler.isGone = isSingleProvider + binding?.quickSearchAutofitResults?.isVisible = isSingleProvider + binding?.quickSearchMasterRecycler?.isGone = isSingleProvider val listLock = ReentrantLock() observe(searchViewModel.currentSearch) { list -> try { // https://stackoverflow.com/questions/6866238/concurrent-modification-exception-adding-to-an-arraylist listLock.lock() - (binding.quickSearchMasterRecycler.adapter as? ParentItemAdapter)?.apply { - val newItems = list.map { ongoing -> - val dataList = ongoing.value.list - val dataListFiltered = - context?.filterSearchResultByFilmQuality(dataList) ?: dataList - - val homePageList = HomePageList( - ongoing.key, - dataListFiltered + (binding?.quickSearchMasterRecycler?.adapter as ParentItemAdapter?)?.apply { + updateList(list.map { ongoing -> + val ongoingList = HomePageList( + ongoing.apiName, + if (ongoing.data is Resource.Success) ongoing.data.value else ArrayList() ) - - val expandableList = HomeViewModel.ExpandableHomepageList( - homePageList, - ongoing.value.currentPage, - ongoing.value.hasNext - ) - - expandableList - } - - submitList(newItems) - //notifyDataSetChanged() + ongoingList + }) } } catch (e: Exception) { logError(e) @@ -248,12 +216,24 @@ class QuickSearchFragment : BaseFragment( } val searchExitIcon = - binding.quickSearch.findViewById(androidx.appcompat.R.id.search_close_btn) + binding?.quickSearch?.findViewById(androidx.appcompat.R.id.search_close_btn) - binding.quickSearch.setOnQueryTextListener(object : SearchView.OnQueryTextListener { + //val searchMagIcon = + // binding?.quickSearch?.findViewById(androidx.appcompat.R.id.search_mag_icon) + + // searchMagIcon?.scaleX = 0.65f + // searchMagIcon?.scaleY = 0.65f + + // Set the color for the search exit icon to the correct theme text color + val searchExitIconColor = TypedValue() + + activity?.theme?.resolveAttribute(android.R.attr.textColor, searchExitIconColor, true) + searchExitIcon?.setColorFilter(searchExitIconColor.data) + + binding?.quickSearch?.setOnQueryTextListener(object : SearchView.OnQueryTextListener { override fun onQueryTextSubmit(query: String): Boolean { if (search(context, query, false)) - hideKeyboard(binding.quickSearch) + UIHelper.hideKeyboard(binding?.quickSearch) return true } @@ -263,37 +243,41 @@ class QuickSearchFragment : BaseFragment( return true } }) - binding.quickSearchLoadingBar.alpha = 0f + binding?.quickSearchLoadingBar?.alpha = 0f observe(searchViewModel.searchResponse) { when (it) { is Resource.Success -> { it.value.let { data -> - val adapter = - (binding.quickSearchAutofitResults.adapter as? SearchAdapter) - adapter?.submitList( - context?.filterSearchResultByFilmQuality(data.list) ?: data.list + (binding?.quickSearchAutofitResults?.adapter as? SearchAdapter)?.updateList( + context?.filterSearchResultByFilmQuality(data) ?: data ) - adapter?.hasNext = data.hasNext } searchExitIcon?.alpha = 1f - binding.quickSearchLoadingBar.alpha = 0f + binding?.quickSearchLoadingBar?.alpha = 0f } is Resource.Failure -> { // Toast.makeText(activity, "Server error", Toast.LENGTH_LONG).show() searchExitIcon?.alpha = 1f - binding.quickSearchLoadingBar.alpha = 0f + binding?.quickSearchLoadingBar?.alpha = 0f } is Resource.Loading -> { searchExitIcon?.alpha = 0f - binding.quickSearchLoadingBar.alpha = 1f + binding?.quickSearchLoadingBar?.alpha = 1f } } } + + //quick_search.setOnQueryTextFocusChangeListener { _, b -> + // if (b) { + // // https://stackoverflow.com/questions/12022715/unable-to-show-keyboard-automatically-in-the-searchview + // UIHelper.showInputMethod(view.findFocus()) + // } + //} if (isLayout(PHONE or EMULATOR)) { - binding.quickSearchBack.apply { + binding?.quickSearchBack?.apply { isVisible = true setOnClickListener { activity?.popCurrentPage() @@ -302,11 +286,11 @@ class QuickSearchFragment : BaseFragment( } if (isLayout(TV)) { - binding.quickSearch.requestFocus() + binding?.quickSearch?.requestFocus() } arguments?.getString(AUTOSEARCH_KEY)?.let { - binding.quickSearch.setQuery(it, true) + binding?.quickSearch?.setQuery(it, true) arguments?.remove(AUTOSEARCH_KEY) } } diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/result/ActorAdaptor.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/result/ActorAdaptor.kt index 056588d0b..0ca326ddf 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 @@ -1,140 +1,160 @@ package com.lagradost.cloudstream3.ui.result -import android.app.SearchManager -import android.content.Intent 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 +import com.lagradost.cloudstream3.utils.UIHelper.setImage class ActorAdaptor( private var nextFocusUpId: Int? = null, private val focusCallback: (View?) -> Unit = {} -) : NoStateAdapter(diffCallback = BaseDiffCallback(itemSame = { a, b -> - a.actor.name == b.actor.name -})) { - companion object { - val sharedPool = - newSharedPool { setMaxRecycledViews(CONTENT, 10) } - } +) : RecyclerView.Adapter() { + data class ActorMetaData( + var isInverted: Boolean, + val actor: ActorData, + ) - // Easier to store it here than to store it in the ActorData - val inverted: HashMap = hashMapOf() + private val actors: MutableList = mutableListOf() - override fun onCreateContent(parent: ViewGroup): ViewHolderState { - return ViewHolderState( - CastItemBinding.inflate(LayoutInflater.from(parent.context), parent, false) + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { + return CardViewHolder( + CastItemBinding.inflate(LayoutInflater.from(parent.context), parent, false), + focusCallback ) } - override fun onClearView(holder: ViewHolderState) { - when (val binding = holder.view) { - is CastItemBinding -> { - clearImage(binding.actorImage) + override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { + when (holder) { + is CardViewHolder -> { + holder.bind(actors[position].actor, actors[position].isInverted, position) { + actors[position].isInverted = !actors[position].isInverted + this.notifyItemChanged(position) + } } } } - override fun onBindContent(holder: ViewHolderState, item: ActorData, position: Int) { - when (val binding = holder.view) { - is CastItemBinding -> { - val itemView = binding.root - val isInverted = inverted.getOrDefault(item, false) + override fun getItemCount(): Int { + return actors.size + } - val (mainImg, vaImage) = if (!isInverted || item.voiceActor?.image.isNullOrBlank()) { - Pair(item.actor.image, item.voiceActor?.image) - } else { - Pair(item.voiceActor?.image, item.actor.image) - } + private fun updateActorList(newList: List) { + val diffResult = DiffUtil.calculateDiff( + ActorDiffCallback(this.actors, newList) + ) - // Fix tv focus escaping the recyclerview - if (position == 0) { - itemView.nextFocusLeftId = R.id.result_cast_items - } else if ((position - 1) == itemCount) { - itemView.nextFocusRightId = R.id.result_cast_items - } - nextFocusUpId?.let { - itemView.nextFocusUpId = it - } + actors.clear() + actors.addAll(newList) - itemView.setOnFocusChangeListener { v, hasFocus -> - if (hasFocus) { - focusCallback(v) - } - } + diffResult.dispatchUpdatesTo(this) + } - itemView.setOnClickListener { - inverted[item] = !isInverted - this.onUpdateContent(holder, getItem(position), position) - } + fun updateList(newList: List) { + if (actors.size >= newList.size) { + updateActorList(newList.mapIndexed { i, data -> actors[i].copy(actor = data) }) + } else { + updateActorList(newList.mapIndexed { i, data -> + if (i < actors.size) + actors[i].copy(actor = data) + else ActorMetaData(isInverted = false, actor = data) + }) + } + } - itemView.setOnLongClickListener { - if (isLayout(PHONE)) { - Intent(Intent.ACTION_WEB_SEARCH).apply { - putExtra(SearchManager.QUERY, item.actor.name) - }.also { intent -> - itemView.context.packageManager?.let { pm -> - if (intent.resolveActivity(pm) != null) { - itemView.context.startActivity(intent) - } + private inner class CardViewHolder( + val binding: CastItemBinding, + private val focusCallback: (View?) -> Unit = {} + ) : + RecyclerView.ViewHolder(binding.root) { + + fun bind(actor: ActorData, isInverted: Boolean, position: Int, callback: (Int) -> Unit) { + val (mainImg, vaImage) = if (!isInverted || actor.voiceActor?.image.isNullOrBlank()) { + Pair(actor.actor.image, actor.voiceActor?.image) + } else { + Pair(actor.voiceActor?.image, actor.actor.image) + } + + // Fix tv focus escaping the recyclerview + if (position == 0) { + itemView.nextFocusLeftId = R.id.result_cast_items + } else if ((position - 1) == itemCount) { + itemView.nextFocusRightId = R.id.result_cast_items + } + nextFocusUpId?.let { + itemView.nextFocusUpId = it + } + + itemView.setOnFocusChangeListener { v, hasFocus -> + if (hasFocus) { + focusCallback(v) + } + } + + itemView.setOnClickListener { + callback(position) + } + + binding.apply { + actorImage.setImage(mainImg) + + actorName.text = actor.actor.name + actor.role?.let { + actorExtra.context?.getString( + when (it) { + ActorRole.Main -> { + R.string.actor_main + } + + ActorRole.Supporting -> { + R.string.actor_supporting + } + + ActorRole.Background -> { + R.string.actor_background } } - } - true - } - - binding.apply { - actorImage.loadImage(mainImg) - - actorName.text = item.actor.name - item.role?.let { - actorExtra.context?.getString( - when (it) { - ActorRole.Main -> { - R.string.actor_main - } - - ActorRole.Supporting -> { - R.string.actor_supporting - } - - ActorRole.Background -> { - R.string.actor_background - } - } - )?.let { text -> - actorExtra.isVisible = true - actorExtra.text = text - } - } ?: item.roleString?.let { + )?.let { text -> actorExtra.isVisible = true - actorExtra.text = it - } ?: run { - actorExtra.isVisible = false + actorExtra.text = text } + } ?: actor.roleString?.let { + actorExtra.isVisible = true + actorExtra.text = it + } ?: run { + actorExtra.isVisible = false + } - if (item.voiceActor == null) { - voiceActorImageHolder.isVisible = false - voiceActorName.isVisible = false - } else { - voiceActorName.text = item.voiceActor?.name - if (!vaImage.isNullOrEmpty()) - voiceActorImageHolder.isVisible = true - voiceActorImage.loadImage(vaImage) - } + if (actor.voiceActor == null) { + voiceActorImageHolder.isVisible = false + voiceActorName.isVisible = false + } else { + voiceActorName.text = actor.voiceActor?.name + voiceActorImageHolder.isVisible = voiceActorImage.setImage(vaImage) } } } } +} + +class ActorDiffCallback( + private val oldList: List, + private val newList: List +) : + DiffUtil.Callback() { + override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int) = + oldList[oldItemPosition].actor.actor.name == newList[newItemPosition].actor.actor.name + + override fun getOldListSize() = oldList.size + + override fun getNewListSize() = newList.size + + override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int) = + oldList[oldItemPosition] == newList[newItemPosition] } \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/result/EpisodeAdapter.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/result/EpisodeAdapter.kt index 5e5504164..2dd8e2ab4 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,39 +1,31 @@ package com.lagradost.cloudstream3.ui.result +import android.annotation.SuppressLint import android.content.Context import android.view.LayoutInflater import android.view.ViewGroup import androidx.core.view.isGone import androidx.core.view.isVisible -import androidx.core.view.setPadding import androidx.preference.PreferenceManager -import coil3.dispose +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.RecyclerView import com.lagradost.cloudstream3.APIHolder.unixTimeMS -import com.lagradost.cloudstream3.CommonActivity import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.actions.VideoClickActionHolder import com.lagradost.cloudstream3.databinding.ResultEpisodeBinding import com.lagradost.cloudstream3.databinding.ResultEpisodeLargeBinding import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.secondsToReadable -import com.lagradost.cloudstream3.ui.BaseDiffCallback -import com.lagradost.cloudstream3.ui.NoStateAdapter -import com.lagradost.cloudstream3.ui.ViewHolderState import com.lagradost.cloudstream3.ui.download.DOWNLOAD_ACTION_DOWNLOAD import com.lagradost.cloudstream3.ui.download.DOWNLOAD_ACTION_LONG_CLICK import com.lagradost.cloudstream3.ui.download.DownloadClickEvent -import com.lagradost.cloudstream3.ui.newSharedPool import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR import com.lagradost.cloudstream3.ui.settings.Globals.PHONE import com.lagradost.cloudstream3.ui.settings.Globals.TV import com.lagradost.cloudstream3.ui.settings.Globals.isLayout import com.lagradost.cloudstream3.utils.AppContextUtils.html -import com.lagradost.cloudstream3.utils.Coroutines.main -import com.lagradost.cloudstream3.utils.ImageLoader.loadImage +import com.lagradost.cloudstream3.utils.UIHelper.setImage import com.lagradost.cloudstream3.utils.UIHelper.toPx -import com.lagradost.cloudstream3.utils.downloader.DownloadObjects -import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager -import com.lagradost.cloudstream3.utils.setText -import com.lagradost.cloudstream3.utils.txt +import com.lagradost.cloudstream3.utils.VideoDownloadHelper import java.text.DateFormat import java.text.SimpleDateFormat import java.util.Date @@ -44,6 +36,7 @@ import java.util.Locale * @see VideoClickActionHolder */ const val ACTION_PLAY_EPISODE_IN_PLAYER = 1 + const val ACTION_CHROME_CAST_EPISODE = 4 const val ACTION_CHROME_CAST_MIRROR = 5 @@ -64,74 +57,82 @@ const val ACTION_DOWNLOAD_EPISODE_SUBTITLE_MIRROR = 14 const val ACTION_MARK_AS_WATCHED = 18 const val TV_EP_SIZE = 400 -const val ACTION_MARK_WATCHED_UP_TO_THIS_EPISODE = 19 -data class EpisodeClickEvent(val position: Int?, val action: Int, val data: ResultEpisode) { - constructor(action: Int, data: ResultEpisode) : this(null, action, data) -} +data class EpisodeClickEvent(val action: Int, val data: ResultEpisode) class EpisodeAdapter( private val hasDownloadSupport: Boolean, private val clickCallback: (EpisodeClickEvent) -> Unit, private val downloadClickCallback: (DownloadClickEvent) -> Unit, -) : NoStateAdapter(diffCallback = BaseDiffCallback(itemSame = { a, b -> - a.id == b.id -}, contentSame = { a, b -> - a == b -})) { +) : RecyclerView.Adapter() { companion object { - const val HAS_POSTER: Int = 0 - const val HAS_NO_POSTER: Int = 1 fun getPlayerAction(context: Context): Int { val settingsManager = PreferenceManager.getDefaultSharedPreferences(context) - val playerPref = - settingsManager.getString(context.getString(R.string.player_default_key), "") - + val playerPref = settingsManager.getString(context.getString(R.string.player_default_key), "") return VideoClickActionHolder.uniqueIdToId(playerPref) ?: ACTION_PLAY_EPISODE_IN_PLAYER } - - val sharedPool = - newSharedPool { - setMaxRecycledViews(HAS_POSTER or CONTENT, 10) - setMaxRecycledViews(HAS_NO_POSTER or CONTENT, 10) - } } - override fun onClearView(holder: ViewHolderState) { + var cardList: MutableList = mutableListOf() + + override fun onViewDetachedFromWindow(holder: RecyclerView.ViewHolder) { if (holder.itemView.hasFocus()) { holder.itemView.clearFocus() } - - when (val binding = holder.view) { - is ResultEpisodeLargeBinding -> { - clearImage(binding.episodePoster) - } - } - super.onClearView(holder) } - override fun customContentViewType(item: ResultEpisode): Int = - if (item.poster.isNullOrBlank() && item.description.isNullOrBlank()) HAS_NO_POSTER else HAS_POSTER + fun updateList(newList: List) { + val diffResult = DiffUtil.calculateDiff( + ResultDiffCallback(this.cardList, newList) + ) + + cardList.clear() + cardList.addAll(newList) + + diffResult.dispatchUpdatesTo(this) + } + + private fun getItem(position: Int): ResultEpisode { + return cardList[position] + } + + override fun getItemViewType(position: Int): Int { + val item = getItem(position) + return if (item.poster.isNullOrBlank() && item.description.isNullOrBlank()) 0 else 1 + } + + + // private val layout = R.layout.result_episode_both + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { + /*val layout = if (cardList.filter { it.poster != null }.size >= cardList.size / 2) + R.layout.result_episode_large + else R.layout.result_episode*/ - override fun onCreateCustomContent(parent: ViewGroup, viewType: Int): ViewHolderState { return when (viewType) { - HAS_NO_POSTER -> { - ViewHolderState( + 0 -> { + EpisodeCardViewHolderSmall( ResultEpisodeBinding.inflate( LayoutInflater.from(parent.context), parent, false - ) + ), + hasDownloadSupport, + clickCallback, + downloadClickCallback ) } - HAS_POSTER -> { - ViewHolderState( + 1 -> { + EpisodeCardViewHolderLarge( ResultEpisodeLargeBinding.inflate( LayoutInflater.from(parent.context), parent, false - ) + ), + hasDownloadSupport, + clickCallback, + downloadClickCallback ) } @@ -139,223 +140,252 @@ class EpisodeAdapter( } } - override fun onBindContent(holder: ViewHolderState, item: ResultEpisode, position: Int) { - val itemView = holder.itemView - when (val binding = holder.view) { - is ResultEpisodeLargeBinding -> { - val setWidth = - if (isLayout(TV or EMULATOR)) TV_EP_SIZE.toPx else ViewGroup.LayoutParams.MATCH_PARENT + override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { + when (holder) { + is EpisodeCardViewHolderLarge -> { + holder.bind(getItem(position)) + } - binding.apply { - episodeLinHolder.layoutParams.width = setWidth - episodeHolderLarge.layoutParams.width = setWidth - episodeHolder.layoutParams.width = setWidth + is EpisodeCardViewHolderSmall -> { + holder.bind(getItem(position)) + } + } + } - if (isLayout(PHONE or EMULATOR) && CommonActivity.appliedTheme == R.style.AmoledMode) { - episodeHolderLarge.radius = 0.0f - episodeHolder.setPadding(0) - } + override fun getItemCount(): Int { + return cardList.size + } - downloadButton.isVisible = hasDownloadSupport - downloadButton.setDefaultClickListener( - DownloadObjects.DownloadEpisodeCached( - name = item.name, - poster = item.poster, - episode = item.episode, - season = item.season, - id = item.id, - parentId = item.parentId, - score = item.score, - description = item.description, - cacheTime = System.currentTimeMillis(), - ), null - ) { - when (it.action) { - DOWNLOAD_ACTION_DOWNLOAD -> { - clickCallback.invoke( - EpisodeClickEvent( - position, - ACTION_DOWNLOAD_EPISODE, - item - ) - ) - } + class EpisodeCardViewHolderLarge( + val binding: ResultEpisodeLargeBinding, + private val hasDownloadSupport: Boolean, + private val clickCallback: (EpisodeClickEvent) -> Unit, + private val downloadClickCallback: (DownloadClickEvent) -> Unit, + ) : RecyclerView.ViewHolder(binding.root) { + var localCard: ResultEpisode? = null - DOWNLOAD_ACTION_LONG_CLICK -> { - clickCallback.invoke( - EpisodeClickEvent( - position, - ACTION_DOWNLOAD_MIRROR, - item - ) - ) - } + @SuppressLint("SetTextI18n") + fun bind(card: ResultEpisode) { + localCard = card + val setWidth = + if (isLayout(TV or EMULATOR)) TV_EP_SIZE.toPx else ViewGroup.LayoutParams.MATCH_PARENT - else -> { - downloadClickCallback.invoke(it) - } - } - } + binding.episodeLinHolder.layoutParams.width = setWidth + binding.episodeHolderLarge.layoutParams.width = setWidth + binding.episodeHolder.layoutParams.width = setWidth - val status = VideoDownloadManager.downloadStatus[item.id] - downloadButton.resetView() - downloadButton.setPersistentId(item.id) - downloadButton.setStatus(status) - val name = - if (item.name == null) "${episodeText.context.getString(R.string.episode)} ${item.episode}" else "${item.episode}. ${item.name}" - episodeFiller.isVisible = item.isFiller == true - episodeText.text = - name//if(card.isFiller == true) episodeText.context.getString(R.string.filler).format(name) else name - episodeText.isSelected = true // is needed for text repeating - - if (item.videoWatchState == VideoWatchState.Watched) { - // This cannot be done in getDisplayPosition() as when you have not watched something - // the duration and position is 0 - //episodeProgress.max = 1 - //episodeProgress.progress = 1 - episodePlayIcon.setImageResource(R.drawable.ic_baseline_check_24) - episodeProgress.isVisible = false - } else { - val displayPos = item.getDisplayPosition() - val durationSec = (item.duration / 1000).toInt() - val progressSec = (displayPos / 1000).toInt() - - if (displayPos >= item.duration && displayPos > 0) { - episodePlayIcon.setImageResource(R.drawable.ic_baseline_check_24) - episodeProgress.isVisible = false - } else { - episodePlayIcon.setImageResource(R.drawable.netflix_play) - episodeProgress.apply { - max = durationSec - progress = progressSec - isVisible = displayPos > 0L - } - } - } - - val posterVisible = !item.poster.isNullOrBlank() - if (posterVisible) { - val isUpcoming = item.airDate != null && unixTimeMS < item.airDate - episodePoster.loadImage(item.poster) { - if (isUpcoming) { - error { - // If the poster has an url, but it is faulty then - // we use the episodeUpcomingIcon if it is an upcoming episode - main { - // Make sure it is on the main thread - episodeUpcomingIcon.isVisible = true - } - - null // We only care about the runnable - } - } - } - } else { - // Clear the image - episodePoster.dispose() - } - episodePoster.isVisible = posterVisible - - val rating10p = item.score?.toFloat(10) - if (rating10p != null && rating10p > 0.1) { - episodeRating.text = episodeRating.context?.getString(R.string.rated_format) - ?.format(rating10p) // TODO Change rated_format to use card.score.toString() - } else { - episodeRating.text = "" - } - - episodeRating.isGone = episodeRating.text.isNullOrBlank() - - episodeDescript.apply { - text = item.description.html() - isGone = text.isNullOrBlank() - - var isExpanded = false - setOnClickListener { - if (isLayout(TV)) { - clickCallback.invoke( - EpisodeClickEvent( - position, - ACTION_SHOW_DESCRIPTION, - item - ) - ) - } else { - isExpanded = !isExpanded - maxLines = if (isExpanded) { - Integer.MAX_VALUE - } else 4 - } - } - } - - if (item.airDate != null) { - val isUpcoming = unixTimeMS < item.airDate - - if (isUpcoming) { - episodeProgress.isVisible = false - episodePlayIcon.isVisible = false - episodeUpcomingIcon.isVisible = !posterVisible - episodeDate.setText( - txt( - R.string.episode_upcoming_format, - secondsToReadable( - item.airDate.minus(unixTimeMS).div(1000).toInt(), - "" - ) - ) - ) - } else { - episodePlayIcon.isVisible = true - episodeUpcomingIcon.isVisible = false - - val formattedAirDate = SimpleDateFormat.getDateInstance( - DateFormat.LONG, - Locale.getDefault() - ).apply { - }.format(Date(item.airDate)) - - episodeDate.setText(txt(formattedAirDate)) - } - } else { - episodeUpcomingIcon.isVisible = false - episodePlayIcon.isVisible = true - episodeDate.isVisible = false - } - - episodeRuntime.setText( - txt( - item.runTime?.times(60L)?.toInt()?.let { secondsToReadable(it, "") } - ) - ) - - if (isLayout(EMULATOR or PHONE)) { - episodePoster.setOnClickListener { - clickCallback.invoke( - EpisodeClickEvent( - position, - ACTION_CLICK_DEFAULT, - item - ) - ) + binding.apply { + downloadButton.isVisible = hasDownloadSupport + downloadButton.setDefaultClickListener( + VideoDownloadHelper.DownloadEpisodeCached( + name = card.name, + poster = card.poster, + episode = card.episode, + season = card.season, + id = card.id, + parentId = card.parentId, + rating = card.rating, + description = card.description, + cacheTime = System.currentTimeMillis(), + ), null + ) { + when (it.action) { + DOWNLOAD_ACTION_DOWNLOAD -> { + clickCallback.invoke(EpisodeClickEvent(ACTION_DOWNLOAD_EPISODE, card)) } - episodePoster.setOnLongClickListener { - clickCallback.invoke( - EpisodeClickEvent( - position, - ACTION_SHOW_TOAST, - item - ) - ) - return@setOnLongClickListener true + DOWNLOAD_ACTION_LONG_CLICK -> { + clickCallback.invoke(EpisodeClickEvent(ACTION_DOWNLOAD_MIRROR, card)) + } + + else -> { + downloadClickCallback.invoke(it) } } } + val name = + if (card.name == null) "${episodeText.context.getString(R.string.episode)} ${card.episode}" else "${card.episode}. ${card.name}" + episodeFiller.isVisible = card.isFiller == true + episodeText.text = + name//if(card.isFiller == true) episodeText.context.getString(R.string.filler).format(name) else name + episodeText.isSelected = true // is needed for text repeating + + if (card.videoWatchState == VideoWatchState.Watched) { + // This cannot be done in getDisplayPosition() as when you have not watched something + // the duration and position is 0 + episodeProgress.max = 1 + episodeProgress.progress = 1 + episodeProgress.isVisible = true + } else { + val displayPos = card.getDisplayPosition() + episodeProgress.max = (card.duration / 1000).toInt() + episodeProgress.progress = (displayPos / 1000).toInt() + episodeProgress.isVisible = displayPos > 0L + } + + episodePoster.isVisible = episodePoster.setImage(card.poster) == true + + 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() + + var isExpanded = false + setOnClickListener { + if (isLayout(TV)) { + clickCallback.invoke(EpisodeClickEvent(ACTION_SHOW_DESCRIPTION, card)) + } else { + isExpanded = !isExpanded + maxLines = if (isExpanded) { + Integer.MAX_VALUE + } else 4 + } + } + } + + if (card.airDate != null) { + val isUpcoming = unixTimeMS < card.airDate + + if (isUpcoming) { + episodePlayIcon.isVisible = false + episodeUpcomingIcon.isVisible = !episodePoster.isVisible + episodeDate.setText( + txt( + R.string.episode_upcoming_format, + secondsToReadable( + card.airDate.minus(unixTimeMS).div(1000).toInt(), + "" + ) + ) + ) + } else { + episodeUpcomingIcon.isVisible = false + + val formattedAirDate = SimpleDateFormat.getDateInstance( + DateFormat.LONG, + Locale.getDefault() + ).apply { + }.format(Date(card.airDate)) + + episodeDate.setText(txt(formattedAirDate)) + } + } else { + episodeDate.isVisible = false + } + + episodeRuntime.setText( + txt( + card.runTime?.times(60L)?.toInt()?.let { secondsToReadable(it, "") } + ) + ) + + if (isLayout(EMULATOR or PHONE)) { + episodePoster.setOnClickListener { + clickCallback.invoke(EpisodeClickEvent(ACTION_CLICK_DEFAULT, card)) + } + + episodePoster.setOnLongClickListener { + clickCallback.invoke(EpisodeClickEvent(ACTION_SHOW_TOAST, card)) + return@setOnLongClickListener true + } + } + } + + itemView.setOnClickListener { + clickCallback.invoke(EpisodeClickEvent(ACTION_CLICK_DEFAULT, card)) + } + + if (isLayout(TV)) { + itemView.isFocusable = true + itemView.isFocusableInTouchMode = true + //itemView.touchscreenBlocksFocus = false + } + + itemView.setOnLongClickListener { + clickCallback.invoke(EpisodeClickEvent(ACTION_SHOW_OPTIONS, card)) + return@setOnLongClickListener true + } + + //binding.resultEpisodeDownload.isVisible = hasDownloadSupport + //binding.resultEpisodeProgressDownloaded.isVisible = hasDownloadSupport + } + } + + class EpisodeCardViewHolderSmall( + val binding: ResultEpisodeBinding, + private val hasDownloadSupport: Boolean, + private val clickCallback: (EpisodeClickEvent) -> Unit, + private val downloadClickCallback: (DownloadClickEvent) -> Unit, + ) : RecyclerView.ViewHolder(binding.root) { + @SuppressLint("SetTextI18n") + fun bind(card: ResultEpisode) { + binding.episodeHolder.layoutParams.apply { + width = + if (isLayout(TV or EMULATOR)) TV_EP_SIZE.toPx else ViewGroup.LayoutParams.MATCH_PARENT + } + + binding.apply { + downloadButton.isVisible = hasDownloadSupport + downloadButton.setDefaultClickListener( + VideoDownloadHelper.DownloadEpisodeCached( + name = card.name, + poster = card.poster, + episode = card.episode, + season = card.season, + id = card.id, + parentId = card.parentId, + rating = card.rating, + description = card.description, + cacheTime = System.currentTimeMillis(), + ), null + ) { + when (it.action) { + DOWNLOAD_ACTION_DOWNLOAD -> { + clickCallback.invoke(EpisodeClickEvent(ACTION_DOWNLOAD_EPISODE, card)) + } + + DOWNLOAD_ACTION_LONG_CLICK -> { + clickCallback.invoke(EpisodeClickEvent(ACTION_DOWNLOAD_MIRROR, card)) + } + + else -> { + downloadClickCallback.invoke(it) + } + } + } + + val name = + if (card.name == null) "${episodeText.context.getString(R.string.episode)} ${card.episode}" else "${card.episode}. ${card.name}" + episodeFiller.isVisible = card.isFiller == true + episodeText.text = + name//if(card.isFiller == true) episodeText.context.getString(R.string.filler).format(name) else name + episodeText.isSelected = true // is needed for text repeating + + if (card.videoWatchState == VideoWatchState.Watched) { + // This cannot be done in getDisplayPosition() as when you have not watched something + // the duration and position is 0 + episodeProgress.max = 1 + episodeProgress.progress = 1 + episodeProgress.isVisible = true + } else { + val displayPos = card.getDisplayPosition() + episodeProgress.max = (card.duration / 1000).toInt() + episodeProgress.progress = (displayPos / 1000).toInt() + episodeProgress.isVisible = displayPos > 0L + } + itemView.setOnClickListener { - clickCallback.invoke(EpisodeClickEvent(position, ACTION_CLICK_DEFAULT, item)) + clickCallback.invoke(EpisodeClickEvent(ACTION_CLICK_DEFAULT, card)) } if (isLayout(TV)) { @@ -365,117 +395,29 @@ class EpisodeAdapter( } itemView.setOnLongClickListener { - clickCallback.invoke(EpisodeClickEvent(position, ACTION_SHOW_OPTIONS, item)) + clickCallback.invoke(EpisodeClickEvent(ACTION_SHOW_OPTIONS, card)) return@setOnLongClickListener true } - } - is ResultEpisodeBinding -> { - binding.episodeHolder.layoutParams.apply { - width = - if (isLayout(TV or EMULATOR)) TV_EP_SIZE.toPx else ViewGroup.LayoutParams.MATCH_PARENT - } - - binding.apply { - downloadButton.isVisible = hasDownloadSupport - downloadButton.setDefaultClickListener( - DownloadObjects.DownloadEpisodeCached( - name = item.name, - poster = item.poster, - episode = item.episode, - season = item.season, - id = item.id, - parentId = item.parentId, - score = item.score, - description = item.description, - cacheTime = System.currentTimeMillis(), - ), null - ) { - when (it.action) { - DOWNLOAD_ACTION_DOWNLOAD -> { - clickCallback.invoke( - EpisodeClickEvent( - position, - ACTION_DOWNLOAD_EPISODE, - item - ) - ) - } - - DOWNLOAD_ACTION_LONG_CLICK -> { - clickCallback.invoke( - EpisodeClickEvent( - position, - ACTION_DOWNLOAD_MIRROR, - item - ) - ) - } - - else -> { - downloadClickCallback.invoke(it) - } - } - } - - val status = VideoDownloadManager.downloadStatus[item.id] - downloadButton.resetView() - downloadButton.setPersistentId(item.id) - downloadButton.setStatus(status) - - val name = - if (item.name == null) "${episodeText.context.getString(R.string.episode)} ${item.episode}" else "${item.episode}. ${item.name}" - episodeFiller.isVisible = item.isFiller == true - episodeText.text = - name//if(card.isFiller == true) episodeText.context.getString(R.string.filler).format(name) else name - episodeText.isSelected = true // is needed for text repeating - - if (item.videoWatchState == VideoWatchState.Watched) { - episodePlayIcon.setImageResource(R.drawable.ic_baseline_check_24) - episodeProgress.isVisible = false - } else { - val displayPos = item.getDisplayPosition() - val durationSec = (item.duration / 1000).toInt() - val progressSec = (displayPos / 1000).toInt() - - if (displayPos >= item.duration && displayPos > 0) { - episodePlayIcon.setImageResource(R.drawable.ic_baseline_check_24) - episodeProgress.isVisible = false - } else { - episodePlayIcon.setImageResource(R.drawable.play_button_transparent) - episodeProgress.apply { - max = durationSec - progress = progressSec - isVisible = displayPos > 0L - } - } - } - - itemView.setOnClickListener { - clickCallback.invoke( - EpisodeClickEvent( - position, - ACTION_CLICK_DEFAULT, - item - ) - ) - } - - if (isLayout(TV)) { - itemView.isFocusable = true - itemView.isFocusableInTouchMode = true - //itemView.touchscreenBlocksFocus = false - } - - itemView.setOnLongClickListener { - clickCallback.invoke(EpisodeClickEvent(position, ACTION_SHOW_OPTIONS, item)) - return@setOnLongClickListener true - } - - //binding.resultEpisodeDownload.isVisible = hasDownloadSupport - //binding.resultEpisodeProgressDownloaded.isVisible = hasDownloadSupport - } + //binding.resultEpisodeDownload.isVisible = hasDownloadSupport + //binding.resultEpisodeProgressDownloaded.isVisible = hasDownloadSupport } } } -} \ No newline at end of file +} + +class ResultDiffCallback( + private val oldList: List, + private val newList: List +) : + DiffUtil.Callback() { + override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int) = + oldList[oldItemPosition].id == newList[newItemPosition].id + + override fun getOldListSize() = oldList.size + + override fun getNewListSize() = newList.size + + override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int) = + oldList[oldItemPosition] == newList[newItemPosition] +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/result/ImageAdapter.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/result/ImageAdapter.kt index 54657ed57..eecd6262f 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/result/ImageAdapter.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/result/ImageAdapter.kt @@ -2,14 +2,11 @@ package com.lagradost.cloudstream3.ui.result import android.view.LayoutInflater import android.view.ViewGroup +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.RecyclerView import com.lagradost.cloudstream3.databinding.ResultMiniImageBinding -import com.lagradost.cloudstream3.ui.BaseDiffCallback -import com.lagradost.cloudstream3.ui.NoStateAdapter -import com.lagradost.cloudstream3.ui.ViewHolderState -import com.lagradost.cloudstream3.ui.newSharedPool import com.lagradost.cloudstream3.ui.settings.Globals.TV import com.lagradost.cloudstream3.ui.settings.Globals.isLayout -import com.lagradost.cloudstream3.utils.ImageLoader.loadImage const val IMAGE_CLICK = 0 const val IMAGE_LONG_CLICK = 1 @@ -19,54 +16,89 @@ class ImageAdapter( val nextFocusUp: Int? = null, val nextFocusDown: Int? = null, ) : - NoStateAdapter( - diffCallback = BaseDiffCallback( - itemSame = Int::equals, - contentSame = Int::equals - ) - ) { - companion object { - val sharedPool = - newSharedPool { setMaxRecycledViews(CONTENT, 10) } - } + RecyclerView.Adapter() { + private val images: MutableList = mutableListOf() - override fun onCreateContent(parent: ViewGroup): ViewHolderState { - return ViewHolderState( + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { + return ImageViewHolder( + //result_mini_image ResultMiniImageBinding.inflate(LayoutInflater.from(parent.context), parent, false) + // LayoutInflater.from(parent.context).inflate(layout, parent, false) ) } - override fun onClearView(holder: ViewHolderState) { - val binding = holder.view as? ResultMiniImageBinding ?: return - clearImage(binding.root) + override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { + when (holder) { + is ImageViewHolder -> { + holder.bind(images[position], clickCallback, nextFocusUp, nextFocusDown) + } + } } - override fun onBindContent(holder: ViewHolderState, item: Int, position: Int) { - val binding = holder.view as? ResultMiniImageBinding ?: return + override fun getItemCount(): Int { + return images.size + } - binding.root.apply { - loadImage(item) - if (nextFocusDown != null) { - this.nextFocusDownId = nextFocusDown - } - if (nextFocusUp != null) { - this.nextFocusUpId = nextFocusUp - } - if (clickCallback != null) { - if (isLayout(TV)) { - isClickable = true - isLongClickable = true - isFocusable = true - isFocusableInTouchMode = true + override fun getItemId(position: Int): Long { + return images[position].toLong() + } + + fun updateList(newList: List) { + val diffResult = DiffUtil.calculateDiff( + DiffCallback(this.images, newList) + ) + + images.clear() + images.addAll(newList) + + diffResult.dispatchUpdatesTo(this) + } + + class ImageViewHolder(val binding: ResultMiniImageBinding) : + RecyclerView.ViewHolder(binding.root) { + fun bind( + img: Int, + clickCallback: ((Int) -> Unit)?, + nextFocusUp: Int?, + nextFocusDown: Int?, + ) { + binding.root.apply { + setImageResource(img) + if (nextFocusDown != null) { + this.nextFocusDownId = nextFocusDown } - setOnClickListener { - clickCallback.invoke(IMAGE_CLICK) + if (nextFocusUp != null) { + this.nextFocusUpId = nextFocusUp } - setOnLongClickListener { - clickCallback.invoke(IMAGE_LONG_CLICK) - return@setOnLongClickListener true + if (clickCallback != null) { + if (isLayout(TV)) { + isClickable = true + isLongClickable = true + isFocusable = true + isFocusableInTouchMode = true + } + setOnClickListener { + clickCallback.invoke(IMAGE_CLICK) + } + setOnLongClickListener { + clickCallback.invoke(IMAGE_LONG_CLICK) + return@setOnLongClickListener true + } } } } } +} + +class DiffCallback(private val oldList: List, private val newList: List) : + DiffUtil.Callback() { + override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int) = + oldList[oldItemPosition] == newList[newItemPosition] + + override fun getOldListSize() = oldList.size + + override fun getNewListSize() = newList.size + + override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int) = + oldList[oldItemPosition] == newList[newItemPosition] } \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/result/LinearListLayout.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/result/LinearListLayout.kt index 3a0edba2f..b4e3062b4 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/result/LinearListLayout.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/result/LinearListLayout.kt @@ -8,8 +8,6 @@ import com.lagradost.cloudstream3.CommonActivity import com.lagradost.cloudstream3.CommonActivity.activity import com.lagradost.cloudstream3.FocusDirection import com.lagradost.cloudstream3.mvvm.logError -import com.lagradost.cloudstream3.ui.settings.Globals.TV -import com.lagradost.cloudstream3.ui.settings.Globals.isLayout const val FOCUS_SELF = View.NO_ID - 1 const val FOCUS_INHERIT = FOCUS_SELF - 1 @@ -23,17 +21,18 @@ fun RecyclerView?.setLinearListLayout( ) { if (this == null) return val ctx = this.context ?: return - this.layoutManager = (this.layoutManager as? LinearListLayout ?: LinearListLayout(ctx)).apply { - if (isHorizontal) setHorizontal() else setVertical() - nextFocusLeft = - if (nextLeft == FOCUS_INHERIT) this@setLinearListLayout.nextFocusLeftId else nextLeft - nextFocusRight = - if (nextRight == FOCUS_INHERIT) this@setLinearListLayout.nextFocusRightId else nextRight - nextFocusUp = - if (nextUp == FOCUS_INHERIT) this@setLinearListLayout.nextFocusUpId else nextUp - nextFocusDown = - if (nextDown == FOCUS_INHERIT) this@setLinearListLayout.nextFocusDownId else nextDown - } + this.layoutManager = + LinearListLayout(ctx).apply { + if (isHorizontal) setHorizontal() else setVertical() + nextFocusLeft = + if (nextLeft == FOCUS_INHERIT) this@setLinearListLayout.nextFocusLeftId else nextLeft + nextFocusRight = + if (nextRight == FOCUS_INHERIT) this@setLinearListLayout.nextFocusRightId else nextRight + nextFocusUp = + if (nextUp == FOCUS_INHERIT) this@setLinearListLayout.nextFocusUpId else nextUp + nextFocusDown = + if (nextDown == FOCUS_INHERIT) this@setLinearListLayout.nextFocusDownId else nextDown + } } open class LinearListLayout(context: Context?) : @@ -105,33 +104,13 @@ open class LinearListLayout(context: Context?) : } } - fun redirectRecycleToFirstItem(focused: View): View? { - return when (focused) { - is RecyclerView -> { - (focused.layoutManager as? LinearListLayout)?.let { focusedLayoutManager -> - val firstPosition = focusedLayoutManager.findFirstVisibleItemPosition() - val firstView = focusedLayoutManager.findViewByPosition(firstPosition) - firstView - } ?: focused - } - - else -> focused - } - } - override fun onInterceptFocusSearch(focused: View, direction: Int): View? { val dir = if (orientation == HORIZONTAL) { - if (direction == View.FOCUS_DOWN) getNextDirection( - focused, - FocusDirection.Down - )?.let { newFocus -> - return redirectRecycleToFirstItem(newFocus) + if (direction == View.FOCUS_DOWN) getNextDirection(focused, FocusDirection.Down)?.let { newFocus -> + return newFocus } - if (direction == View.FOCUS_UP) getNextDirection( - focused, - FocusDirection.Up - )?.let { newFocus -> - return redirectRecycleToFirstItem(newFocus) + if (direction == View.FOCUS_UP) getNextDirection(focused, FocusDirection.Up)?.let { newFocus -> + return newFocus } if (direction == View.FOCUS_DOWN || direction == View.FOCUS_UP) { @@ -150,16 +129,10 @@ open class LinearListLayout(context: Context?) : } ret } else { - if (direction == View.FOCUS_RIGHT) getNextDirection( - focused, - FocusDirection.End - )?.let { newFocus -> + if (direction == View.FOCUS_RIGHT) getNextDirection(focused, FocusDirection.End)?.let { newFocus -> return newFocus } - if (direction == View.FOCUS_LEFT) getNextDirection( - focused, - FocusDirection.Start - )?.let { newFocus -> + if (direction == View.FOCUS_LEFT) getNextDirection(focused, FocusDirection.Start)?.let { newFocus -> return newFocus } @@ -178,15 +151,9 @@ open class LinearListLayout(context: Context?) : // if out of bounds then refocus as specified return if (lookFor >= itemCount) { - getNextDirection( - focused, - if (orientation == HORIZONTAL) FocusDirection.End else FocusDirection.Down - ) + getNextDirection(focused, if(orientation == HORIZONTAL) FocusDirection.End else FocusDirection.Down) } else if (lookFor < 0) { - getNextDirection( - focused, - if (orientation == HORIZONTAL) FocusDirection.Start else FocusDirection.Up - ) + getNextDirection(focused, if(orientation == HORIZONTAL) FocusDirection.Start else FocusDirection.Up) } else { getViewFromPos(lookFor) ?: run { scrollToPosition(lookFor) @@ -199,38 +166,6 @@ open class LinearListLayout(context: Context?) : } } - override fun requestChildRectangleOnScreen( - parent: RecyclerView, - child: View, - rect: android.graphics.Rect, - immediate: Boolean, - focusedChildVisible: Boolean - ): Boolean { - if (isLayout(TV) && orientation == HORIZONTAL) { - val dx = when { - isLayoutRTL -> getDecoratedRight(child) - (parent.width - parent.paddingRight) - else -> getDecoratedLeft(child) - parent.paddingLeft - } - return if (dx != 0) { - when { - immediate -> parent.scrollBy(dx, 0) - else -> parent.smoothScrollBy(dx, 0) - } - true - } else { - false - } - } else { - return super.requestChildRectangleOnScreen( - parent, - child, - rect, - immediate, - focusedChildVisible - ) - } - } - /*override fun onRequestChildFocus( parent: RecyclerView, state: RecyclerView.State, diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultFragment.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultFragment.kt index cbf94fd97..3eab0c714 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultFragment.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultFragment.kt @@ -1,17 +1,11 @@ package com.lagradost.cloudstream3.ui.result import android.os.Bundle -import android.widget.ImageView -import android.widget.TextView -import androidx.core.view.isVisible import androidx.fragment.app.Fragment import androidx.preference.PreferenceManager -import coil3.dispose import com.lagradost.cloudstream3.DubStatus import com.lagradost.cloudstream3.R -import com.lagradost.cloudstream3.Score import com.lagradost.cloudstream3.SearchResponse -import com.lagradost.cloudstream3.SeasonData import com.lagradost.cloudstream3.TvType import com.lagradost.cloudstream3.ui.result.EpisodeAdapter.Companion.getPlayerAction import com.lagradost.cloudstream3.utils.AppContextUtils.getApiDubstatusSettings @@ -19,8 +13,6 @@ import com.lagradost.cloudstream3.utils.DataStoreHelper import com.lagradost.cloudstream3.utils.DataStoreHelper.getVideoWatchState import com.lagradost.cloudstream3.utils.DataStoreHelper.getViewPos import com.lagradost.cloudstream3.utils.Event -import com.lagradost.cloudstream3.utils.ImageLoader.loadImage -import com.lagradost.cloudstream3.utils.UiImage const val START_ACTION_RESUME_LATEST = 1 const val START_ACTION_LOAD_EP = 2 @@ -47,7 +39,7 @@ data class ResultEpisode( val index: Int, val position: Long, // time in MS val duration: Long, // duration in MS - val score: Score?, + val rating: Int?, val description: String?, val isFiller: Boolean?, val tvType: TvType, @@ -60,7 +52,6 @@ data class ResultEpisode( val totalEpisodeIndex: Int? = null, val airDate: Long? = null, val runTime: Int? = null, - val seasonData: SeasonData? = null, ) fun ResultEpisode.getRealPosition(): Long { @@ -90,7 +81,7 @@ fun buildResultEpisode( apiName: String, id: Int, index: Int, - rating: Score? = null, + rating: Int? = null, description: String? = null, isFiller: Boolean? = null, tvType: TvType, @@ -98,33 +89,31 @@ fun buildResultEpisode( totalEpisodeIndex: Int? = null, airDate: Long? = null, runTime: Int? = null, - seasonData: SeasonData? = null, ): ResultEpisode { val posDur = getViewPos(id) val videoWatchState = getVideoWatchState(id) ?: VideoWatchState.None return ResultEpisode( - headerName = headerName, - name = name, - poster = poster, - episode = episode, - seasonIndex = seasonIndex, - season = season, - data = data, - apiName = apiName, - id = id, - index = index, - position = posDur?.position ?: 0, - duration = posDur?.duration ?: 0, - score = rating, - description = description, - isFiller = isFiller, - tvType = tvType, - parentId = parentId, - videoWatchState = videoWatchState, - totalEpisodeIndex = totalEpisodeIndex, - airDate = airDate, - runTime = runTime, - seasonData = seasonData + headerName, + name, + poster, + episode, + seasonIndex, + season, + data, + apiName, + id, + index, + posDur?.position ?: 0, + posDur?.duration ?: 0, + rating, + description, + isFiller, + tvType, + parentId, + videoWatchState, + totalEpisodeIndex, + airDate, + runTime, ) } @@ -135,7 +124,6 @@ fun ResultEpisode.getWatchProgress(): Float { object ResultFragment { private const val URL_BUNDLE = "url" - private const val NAME_BUNDLE = "name" private const val API_NAME_BUNDLE = "apiName" private const val SEASON_BUNDLE = "season" private const val EPISODE_BUNDLE = "episode" @@ -149,7 +137,6 @@ object ResultFragment { return Bundle().apply { putString(URL_BUNDLE, card.url) putString(API_NAME_BUNDLE, card.apiName) - putString(NAME_BUNDLE, card.name) if (card is DataStoreHelper.ResumeWatchingResult) { if (card.season != null) putInt(SEASON_BUNDLE, card.season) @@ -168,14 +155,12 @@ object ResultFragment { fun newInstance( url: String, apiName: String, - name: String, startAction: Int = 0, startValue: Int = 0 ): Bundle { return Bundle().apply { putString(URL_BUNDLE, url) putString(API_NAME_BUNDLE, apiName) - putString(NAME_BUNDLE, name) putInt(START_ACTION_BUNDLE, startAction) putInt(START_VALUE_BUNDLE, startValue) putBoolean(RESTART_BUNDLE, true) @@ -183,10 +168,9 @@ object ResultFragment { } fun updateUI(id: Int? = null) { - // updateUIListener?.invoke() + // updateUIListener?.invoke() updateUIEvent.invoke(id) } - val updateUIEvent = Event() //private var updateUIListener: (() -> Unit)? = null @@ -214,7 +198,10 @@ object ResultFragment { override fun onResume() { afterPluginsLoadedEvent += ::reloadViewModel super.onResume() - activity?.setNavigationBarColorCompat(R.attr.primaryBlackBackground) + activity?.let { + it.window?.navigationBarColor = + it.colorFromAttribute(R.attr.primaryBlackBackground) + } } override fun onDestroy() { @@ -231,58 +218,18 @@ object ResultFragment { data class StoredData( val url: String, val apiName: 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) val url = arguments?.getString(URL_BUNDLE) ?: return null val apiName = arguments?.getString(API_NAME_BUNDLE) ?: return null - val name = arguments?.getString(NAME_BUNDLE) ?: return null val showFillers = settingsManager.getBoolean(context.getString(R.string.show_fillers_key), false) val dubStatus = if (context.getApiDubstatusSettings() @@ -311,7 +258,7 @@ object ResultFragment { season = resumeSeason ) } - return StoredData(url, apiName, name, showFillers, dubStatus, start, playerAction, restart) + return StoredData(url, apiName, showFillers, dubStatus, start, playerAction, restart) } /*private fun reloadViewModel(forceReload: Boolean) { @@ -346,6 +293,8 @@ object ResultFragment { context?.updateHasTrailers() activity?.loadCache() + //activity?.fixPaddingStatusbar(result_barstatus) + /* val backParameter = result_back.layoutParams as FrameLayout.LayoutParams backParameter.setMargins( backParameter.leftMargin, @@ -355,6 +304,8 @@ object ResultFragment { ) result_back.layoutParams = backParameter*/ + // activity?.fixPaddingStatusbar(result_toolbar) + val storedData = (activity ?: context)?.let { getStoredData(it) } diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultFragmentPhone.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultFragmentPhone.kt index 38b24b265..97bc49eae 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,100 +17,68 @@ 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 import com.lagradost.cloudstream3.CommonActivity.showToast 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.normalSafeApiCall import com.lagradost.cloudstream3.mvvm.observe import com.lagradost.cloudstream3.mvvm.observeNullable -import com.lagradost.cloudstream3.mvvm.safe import com.lagradost.cloudstream3.services.SubscriptionWorkManager -import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.APP_STRING_SHARE -import com.lagradost.cloudstream3.ui.BaseFragment import com.lagradost.cloudstream3.ui.WatchType import com.lagradost.cloudstream3.ui.download.DOWNLOAD_ACTION_DOWNLOAD import com.lagradost.cloudstream3.ui.download.DOWNLOAD_ACTION_LONG_CLICK import com.lagradost.cloudstream3.ui.download.DownloadButtonSetup -import com.lagradost.cloudstream3.ui.player.CS3IPlayer import com.lagradost.cloudstream3.ui.player.CSPlayerEvent -import com.lagradost.cloudstream3.ui.player.IPlayer -import com.lagradost.cloudstream3.ui.player.PlayerView -import com.lagradost.cloudstream3.ui.player.source_priority.QualityProfileDialog +import com.lagradost.cloudstream3.ui.player.FullScreenPlayer import com.lagradost.cloudstream3.ui.quicksearch.QuickSearchFragment -import com.lagradost.cloudstream3.ui.result.ResultFragment.bindLogo import com.lagradost.cloudstream3.ui.result.ResultFragment.getStoredData import com.lagradost.cloudstream3.ui.result.ResultFragment.updateUIEvent import com.lagradost.cloudstream3.ui.search.SearchAdapter import com.lagradost.cloudstream3.ui.search.SearchHelper -import com.lagradost.cloudstream3.ui.setRecycledViewPool -import com.lagradost.cloudstream3.ui.settings.SettingsGeneral.Companion.pickDownloadPath -import com.lagradost.cloudstream3.ui.settings.utils.getChooseFolderLauncher import com.lagradost.cloudstream3.utils.AppContextUtils.getNameFull import com.lagradost.cloudstream3.utils.AppContextUtils.isCastApiAvailable import com.lagradost.cloudstream3.utils.AppContextUtils.loadCache import com.lagradost.cloudstream3.utils.AppContextUtils.openBrowser import com.lagradost.cloudstream3.utils.AppContextUtils.updateHasTrailers -import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.attachBackPressedCallback -import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.detachBackPressedCallback import com.lagradost.cloudstream3.utils.BatteryOptimizationChecker.openBatteryOptimizationSettings -import com.lagradost.cloudstream3.utils.Coroutines.ioSafe import com.lagradost.cloudstream3.utils.ExtractorLink -import com.lagradost.cloudstream3.utils.ImageLoader.loadImage import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showBottomDialog import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showBottomDialogInstant import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showDialog +import com.lagradost.cloudstream3.utils.UIHelper import com.lagradost.cloudstream3.utils.UIHelper.clipboardHelper import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute import com.lagradost.cloudstream3.utils.UIHelper.dismissSafe -import com.lagradost.cloudstream3.utils.UIHelper.fixSystemBarsPadding import com.lagradost.cloudstream3.utils.UIHelper.hideKeyboard import com.lagradost.cloudstream3.utils.UIHelper.popCurrentPage import com.lagradost.cloudstream3.utils.UIHelper.populateChips import com.lagradost.cloudstream3.utils.UIHelper.popupMenuNoIconsAndNoStringRes -import com.lagradost.cloudstream3.utils.UIHelper.setListViewHeightBasedOnItems -import com.lagradost.cloudstream3.utils.UIHelper.setNavigationBarColorCompat -import com.lagradost.cloudstream3.utils.downloader.DownloadFileManagement.getBasePath -import com.lagradost.cloudstream3.utils.downloader.DownloadObjects -import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager -import com.lagradost.cloudstream3.utils.getImageFromDrawable -import com.lagradost.cloudstream3.utils.setText -import com.lagradost.cloudstream3.utils.setTextHtml -import com.lagradost.cloudstream3.utils.txt -import java.net.URLEncoder -import java.util.concurrent.ConcurrentLinkedDeque -import kotlin.math.roundToInt +import com.lagradost.cloudstream3.utils.UIHelper.setImage +import com.lagradost.cloudstream3.utils.VideoDownloadHelper -open class ResultFragmentPhone : BaseFragment( - BindingCreator.Inflate(FragmentResultSwipeBinding::inflate) -), PlayerView.Callbacks { +open class ResultFragmentPhone : FullScreenPlayer() { private val gestureRegionsListener = object : PanelsChildGestureRegionObserver.GestureRegionsListener { override fun onGestureRegionsUpdate(gestureRegions: List) { @@ -118,115 +86,48 @@ open class ResultFragmentPhone : BaseFragment( } } - /** Queue of pending actions that is deferred to after a custom path is set */ - private val pendingPathActions = ConcurrentLinkedDeque>() - - /** - * Appends all actions to a queue, and asks for a user to enter the download folder if not already set up. - * - * Then processes the queue in the given order, only after the user has selected a folder. - * This is to defer the download to after a file path is set, due to perms. - * */ - private fun requirePathForActions(list: Collection>) { - pendingPathActions.addAll(list) - val (_, path) = context?.getBasePath() ?: return - if (path == null) { - /** If we have not set any download path, then ask the user for it before we download it */ - try { - /** Give the user some info of what we are doing and why, even if it may be missed */ - showToast(R.string.download_path_pref) - pathPicker.launch(Uri.EMPTY) - } catch (t: Throwable) { - logError(t) - /** Something went wrong, TV Device? - * Use the fallback behavior of just downloading it even if no path is selected, - * and hope it works */ - processPendingActions() - } - } else { - /** - * Otherwise dispatch everything, as we already have a valid download path - * Even if this is "wrong", we do not care as the user has entered something - * */ - processPendingActions() - } - } - - /** Clear all the items in the queue and dispatch them to the viewmodel in order */ - private fun processPendingActions() = viewModel.viewModelScope.launchSafe { - while (!pendingPathActions.isEmpty()) { - try { - val (action, data) = pendingPathActions.pop() - viewModel.handleAction( - EpisodeClickEvent( - action, - data - ) - ) - } catch (_: NoSuchElementException) { - /** In case of a race */ - } - } - } - - private val pathPicker = getChooseFolderLauncher { uri, path -> - if (uri == null) { - /** No path selected, clear the list without acting on it, canceling */ - if (!pendingPathActions.isEmpty()) { - /** Only show on non-empty, just in case */ - showToast(R.string.download_canceled) - pendingPathActions.clear() - } - } else { - /** Select the folder, and dispatch everything */ - pickDownloadPath(uri, path) - processPendingActions() - } - } - protected lateinit var viewModel: ResultViewModel2 protected lateinit var syncModel: SyncViewModel + protected var binding: FragmentResultSwipeBinding? = null protected var resultBinding: FragmentResultBinding? = null protected var recommendationBinding: ResultRecommendationsBinding? = null protected var syncBinding: ResultSyncBinding? = null - var player: IPlayer = CS3IPlayer() - protected open var hasPipModeSupport: Boolean = false - protected open var isFullScreenPlayer: Boolean = true - protected open var lockRotation: Boolean = true - protected var playerBinding: TrailerCustomLayoutBinding? = null - protected var isShowing: Boolean = false + override var layout = R.layout.fragment_result_swipe - protected var playerHostView: PlayerView? = null + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { + viewModel = + ViewModelProvider(this)[ResultViewModel2::class.java] + syncModel = + ViewModelProvider(this)[SyncViewModel::class.java] + updateUIEvent += ::updateUI - open fun updateUIVisibility() {} + val root = super.onCreateView(inflater, container, savedInstanceState) ?: return null + FragmentResultSwipeBinding.bind(root).let { bind -> + resultBinding = + bind.fragmentResult//FragmentResultBinding.bind(binding.root.findViewById(R.id.fragment_result)) + recommendationBinding = bind.resultRecommendations + syncBinding = bind.resultSync + binding = bind + } - protected fun uiReset() { - isShowing = false - updateUIVisibility() - } - - open fun showMirrorsDialogue() {} - open fun showTracksDialogue() {} - open fun openOnlineSubPicker( - context: android.content.Context, - loadResponse: LoadResponse?, - dismissCallback: () -> Unit - ) {} - - override fun fixLayout(view: View) { - fixSystemBarsPadding(view) + return root } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + PanelsChildGestureRegionObserver.Provider.get().apply { resultBinding?.resultCastItems?.let { register(it) } } } - var currentTrailers: List> = emptyList() + var currentTrailers: List = emptyList() var currentTrailerIndex = 0 override fun nextMirror() { @@ -240,35 +141,33 @@ open class ResultFragmentPhone : BaseFragment( override fun playerError(exception: Throwable) { if (player.getIsPlaying()) { // because we don't want random toasts in player - playerHostView?.playerError(exception) + super.playerError(exception) } else { nextMirror() } } private fun loadTrailer(index: Int? = null) { - val isSuccess = - currentTrailers.getOrNull(index ?: currentTrailerIndex) - ?.let { (extractedTrailerLink, _) -> - context?.let { ctx -> - player.onPause() - player.loadPlayer( - ctx, - false, - extractedTrailerLink, - null, - startPosition = 0L, - subtitles = emptySet(), - subtitle = null, - autoPlay = false, - preview = false - ) - true - } ?: run { - false - } + currentTrailers.getOrNull(index ?: currentTrailerIndex)?.let { trailer -> + context?.let { ctx -> + player.onPause() + player.loadPlayer( + ctx, + false, + trailer, + null, + startPosition = 0L, + subtitles = emptySet(), + subtitle = null, + autoPlay = false, + preview = false + ) + true } ?: run { + false + } + } ?: run { false } //result_trailer_thumbnail?.setImageBitmap(result_poster_background?.drawable?.toBitmap()) @@ -277,17 +176,6 @@ open class ResultFragmentPhone : BaseFragment( // result_trailer_loading?.isVisible = isSuccess val turnVis = !isSuccess && !isFullScreenPlayer resultBinding?.apply { - // If we load a trailer, then cancel the big logo and only show the small title - if (isSuccess) { - // This is still a bit of a race condition, but it should work if we have the - // trailers observe after the page observe! - bindLogo( - url = null, - headers = null, - logoView = backgroundPosterWatermarkBadge, - titleView = resultTitle - ) - } resultSmallscreenHolder.isVisible = turnVis resultPosterBackgroundHolder.apply { val fadeIn: Animation = AlphaAnimation(alpha, if (turnVis) 1.0f else 0.0f).apply { @@ -323,10 +211,10 @@ open class ResultFragmentPhone : BaseFragment( //} } - private fun setTrailers(trailers: List>?) { + private fun setTrailers(trailers: List?) { context?.updateHasTrailers() if (!LoadResponse.isTrailersEnabled) return - currentTrailers = trailers?.sortedBy { -it.first.quality } ?: emptyList() + currentTrailers = trailers?.sortedBy { -it.quality } ?: emptyList() loadTrailer() } @@ -340,13 +228,10 @@ open class ResultFragmentPhone : BaseFragment( } updateUIEvent -= ::updateUI - playerHostView?.release() - playerBinding = null - resultBinding?.resultScroll?.setOnClickListener(null) + binding = null resultBinding = null syncBinding = null recommendationBinding = null - activity?.detachBackPressedCallback(this@ResultFragmentPhone.toString()) super.onDestroyView() } @@ -406,10 +291,9 @@ open class ResultFragmentPhone : BaseFragment( override fun onResume() { afterPluginsLoadedEvent += ::reloadViewModel - activity?.setNavigationBarColorCompat(R.attr.primaryBlackBackground) - context?.let { ctx -> - playerHostView?.onResume(ctx) - playerHostView?.setupKeyEventListener() + activity?.let { + it.window?.navigationBarColor = + it.colorFromAttribute(R.attr.primaryBlackBackground) } super.onResume() PanelsChildGestureRegionObserver.Provider.get() @@ -418,44 +302,25 @@ open class ResultFragmentPhone : BaseFragment( override fun onStop() { afterPluginsLoadedEvent -= ::reloadViewModel - playerHostView?.onStop() super.onStop() } - @Suppress("UNUSED_PARAMETER") private fun updateUI(id: Int?) { syncModel.updateUserData() viewModel.reloadEpisodes() } - override fun onBindingCreated(binding: FragmentResultSwipeBinding, savedInstanceState: Bundle?) { - // Set up sub-binding references - viewModel = ViewModelProvider(this)[ResultViewModel2::class.java] - syncModel = ViewModelProvider(this)[SyncViewModel::class.java] - updateUIEvent += ::updateUI - - resultBinding = binding.fragmentResult - recommendationBinding = binding.resultRecommendations - syncBinding = binding.resultSync - - // Set up trailer player - val ctx = context ?: return - playerHostView = PlayerView(ctx) - playerHostView?.player = player - playerHostView?.hasPipModeSupport = hasPipModeSupport - playerHostView?.callbacks = this - playerHostView?.bindViews(binding.root) - playerBinding = binding.root.findViewById(R.id.player_holder)?.let { - TrailerCustomLayoutBinding.bind(it) - } - playerHostView?.initialize() + @SuppressLint("SetTextI18n") + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) // ===== setup ===== + UIHelper.fixPaddingStatusbar(binding?.resultTopBar) val storedData = getStoredData() ?: return activity?.window?.decorView?.clearFocus() activity?.loadCache() context?.updateHasTrailers() - hideKeyboard(binding.root) + hideKeyboard() if (storedData.restart || !viewModel.hasLoaded()) viewModel.load( activity, @@ -473,7 +338,7 @@ open class ResultFragmentPhone : BaseFragment( // This may not be 100% reliable, and may delay for small period // before resultCastItems will be scrollable again, but this does work // most of the time. - binding.resultOverlappingPanels.registerEndPanelStateListeners( + binding?.resultOverlappingPanels?.registerEndPanelStateListeners( object : OverlappingPanelsLayout.PanelStateListener { override fun onPanelStateChange(panelState: PanelState) { PanelsChildGestureRegionObserver.Provider.get().apply { @@ -485,11 +350,6 @@ open class ResultFragmentPhone : BaseFragment( // ===== ===== ===== - binding.resultSearch.isGone = storedData.name.isBlank() - binding.resultSearch.setOnClickListener { - QuickSearchFragment.pushSearch(activity, storedData.name) - } - resultBinding?.apply { resultReloadConnectionerror.setOnClickListener { viewModel.load( @@ -515,7 +375,7 @@ open class ResultFragmentPhone : BaseFragment( focused: View? ): Boolean { // Make the cast always focus the first visible item when focused - // from somewhere else. Otherwise, it jumps to the last item. + // from somewhere else. Otherwise it jumps to the last item. return if (parent.focusedChild == null) { scrollToPosition(this.findFirstCompletelyVisibleItemPosition()) true @@ -526,57 +386,26 @@ open class ResultFragmentPhone : BaseFragment( }.apply { this.orientation = RecyclerView.HORIZONTAL }*/ - resultCastItems.setRecycledViewPool(ActorAdaptor.sharedPool) resultCastItems.adapter = ActorAdaptor() - resultEpisodes.setRecycledViewPool(EpisodeAdapter.sharedPool) + resultEpisodes.adapter = EpisodeAdapter( api?.hasDownloadSupport == true, { episodeClick -> - when (episodeClick.action) { - ACTION_DOWNLOAD_EPISODE, ACTION_DOWNLOAD_MIRROR -> { - requirePathForActions(listOf(episodeClick.action to episodeClick.data)) - } - - else -> viewModel.handleAction(episodeClick) - } + viewModel.handleAction(episodeClick) }, { downloadClickEvent -> DownloadButtonSetup.handleDownloadClick(downloadClickEvent) } - ) - observeNullable(viewModel.selectedSorting) { - resultSortButton.setText(it) - } - - observe(viewModel.sortSelections) { sort -> - resultBinding?.resultSortButton?.setOnClickListener { view -> - view?.context?.let { ctx -> - val names = sort - .mapNotNull { (text, r) -> - r to (text.asStringNull(ctx) ?: return@mapNotNull null) - } - - activity?.showDialog( - names.map { it.second }, - viewModel.selectedSortingIndex.value ?: -1, - ctx.getString(R.string.sort_by), - false, - {}) { itemId -> - viewModel.setSort(names[itemId].first) - } - } - } - } resultScroll.setOnScrollChangeListener(NestedScrollView.OnScrollChangeListener { _, _, scrollY, _, oldScrollY -> val dy = scrollY - oldScrollY if (dy > 0) { //check for scroll down - binding.resultBookmarkFab.shrink() + binding?.resultBookmarkFab?.shrink() } else if (dy < -5) { - binding.resultBookmarkFab.extend() + binding?.resultBookmarkFab?.extend() } if (!isFullScreenPlayer && player.getIsPlaying()) { if (scrollY > (resultBinding?.fragmentTrailer?.playerBackground?.height @@ -588,37 +417,25 @@ open class ResultFragmentPhone : BaseFragment( }) } - binding.apply { + binding?.apply { resultOverlappingPanels.setStartPanelLockState(OverlappingPanelsLayout.LockState.CLOSE) resultOverlappingPanels.setEndPanelLockState(OverlappingPanelsLayout.LockState.CLOSE) resultBack.setOnClickListener { activity?.popCurrentPage() } - activity?.attachBackPressedCallback(this@ResultFragmentPhone.toString()) { - if (resultOverlappingPanels.getSelectedPanel().ordinal == 1) { - runDefault() - } else resultOverlappingPanels.closePanels() - } - resultMiniSync.setOnClickListener { - if (resultOverlappingPanels.getSelectedPanel().ordinal == 1) { - resultOverlappingPanels.openStartPanel() - } else resultOverlappingPanels.closePanels() - } - - /* - resultMiniSync.setRecycledViewPool(ImageAdapter.sharedPool) resultMiniSync.adapter = ImageAdapter( nextFocusDown = R.id.result_sync_set_score, clickCallback = { action -> if (action == IMAGE_CLICK || action == IMAGE_LONG_CLICK) { - if (resultOverlappingPanels.getSelectedPanel().ordinal == 1) { - resultOverlappingPanels.openStartPanel() - } else resultOverlappingPanels.closePanels() + if (binding?.resultOverlappingPanels?.getSelectedPanel()?.ordinal == 1) { + binding?.resultOverlappingPanels?.openStartPanel() + } else { + binding?.resultOverlappingPanels?.closePanels() + } } }) - */ resultSubscribe.setOnClickListener { viewModel.toggleSubscriptionStatus(context) { newStatus: Boolean? -> if (newStatus == null) return@toggleSubscriptionStatus @@ -632,12 +449,8 @@ open class ResultFragmentPhone : BaseFragment( } val name = (viewModel.page.value as? Resource.Success)?.value?.title - ?: com.lagradost.cloudstream3.utils.txt(R.string.no_data) - .asStringNull(context) ?: "" - showToast( - com.lagradost.cloudstream3.utils.txt(message, name), - Toast.LENGTH_SHORT - ) + ?: txt(R.string.no_data).asStringNull(context) ?: "" + showToast(txt(message, name), Toast.LENGTH_SHORT) } context?.let { openBatteryOptimizationSettings(it) } } @@ -652,12 +465,8 @@ open class ResultFragmentPhone : BaseFragment( } val name = (viewModel.page.value as? Resource.Success)?.value?.title - ?: com.lagradost.cloudstream3.utils.txt(R.string.no_data) - .asStringNull(context) ?: "" - showToast( - com.lagradost.cloudstream3.utils.txt(message, name), - Toast.LENGTH_SHORT - ) + ?: txt(R.string.no_data).asStringNull(context) ?: "" + showToast(txt(message, name), Toast.LENGTH_SHORT) } } mediaRouteButton.apply { @@ -678,7 +487,12 @@ open class ResultFragmentPhone : BaseFragment( CastContext.getSharedInstance(act.applicationContext) { it.run() }.addOnCompleteListener { - isGone = !it.isSuccessful + isGone = if (it.isSuccessful) { + it.result.castState == CastState.NO_DEVICES_AVAILABLE + } else { + true + } + } // this shit leaks for some reason //castContext.addCastStateListener { state -> @@ -694,8 +508,8 @@ open class ResultFragmentPhone : BaseFragment( playerBinding?.apply { playerOpenSource.setOnClickListener { - currentTrailers.getOrNull(currentTrailerIndex)?.let { (_, ogTrailerLink) -> - context?.openBrowser(ogTrailerLink) + currentTrailers.getOrNull(currentTrailerIndex)?.let { + context?.openBrowser(it.url) } } } @@ -703,9 +517,9 @@ open class ResultFragmentPhone : BaseFragment( recommendationBinding?.apply { resultRecommendationsList.apply { spanCount = 3 - setRecycledViewPool(SearchAdapter.sharedPool) adapter = SearchAdapter( + ArrayList(), this, ) { callback -> SearchHelper.handleSearchClickCallback(callback) @@ -729,13 +543,10 @@ open class ResultFragmentPhone : BaseFragment( resultBinding?.apply { if (resume == null) { resultResumeParent.isVisible = false - resultPlayParent.isVisible = true - resultResumeProgressHolder.isVisible = false return@observeNullable } resultResumeParent.isVisible = true resume.progress?.let { progress -> - resultNextSeriesButton.isVisible = false resultResumeSeriesTitle.apply { isVisible = !resume.isMovie text = @@ -745,11 +556,8 @@ open class ResultFragmentPhone : BaseFragment( resume.result.season ) } - if (resume.isMovie) { - resultPlayParent.isGone = true - resultResumeSeriesProgressText.isVisible = true - resultResumeSeriesProgressText.setText(progress.progressLeft) - } + + resultResumeSeriesProgressText.setText(progress.progressLeft) resultResumeSeriesProgress.apply { isVisible = true this.max = progress.maxProgress @@ -758,30 +566,25 @@ open class ResultFragmentPhone : BaseFragment( resultResumeProgressHolder.isVisible = true } ?: run { resultResumeProgressHolder.isVisible = false - if (!resume.isMovie) { - resultNextSeriesButton.isVisible = true - resultNextSeriesButton.text = context?.getNameFull( - resume.result.name, - resume.result.episode, - resume.result.season - ) - } resultResumeSeriesProgress.isVisible = false resultResumeSeriesTitle.isVisible = false resultResumeSeriesProgressText.isVisible = false } + resultResumeSeriesButton.isVisible = !resume.isMovie resultResumeSeriesButton.setOnClickListener { - resumeAction(storedData, resume) - } - resultNextSeriesButton.setOnClickListener { - resumeAction(storedData, resume) + viewModel.handleAction( + EpisodeClickEvent( + storedData.playerAction, //?: ACTION_PLAY_EPISODE_IN_PLAYER, + resume.result + ) + ) } } } observeNullable(viewModel.subscribeStatus) { isSubscribed -> - binding.resultSubscribe.isVisible = isSubscribed != null + binding?.resultSubscribe?.isVisible = isSubscribed != null if (isSubscribed == null) return@observeNullable val drawable = if (isSubscribed) { @@ -790,11 +593,11 @@ open class ResultFragmentPhone : BaseFragment( R.drawable.baseline_notifications_none_24 } - binding.resultSubscribe.setImageResource(drawable) + binding?.resultSubscribe?.setImageResource(drawable) } observeNullable(viewModel.favoriteStatus) { isFavorite -> - binding.resultFavorite.isVisible = isFavorite != null + binding?.resultFavorite?.isVisible = isFavorite != null if (isFavorite == null) return@observeNullable val drawable = if (isFavorite) { @@ -803,7 +606,11 @@ open class ResultFragmentPhone : BaseFragment( R.drawable.ic_baseline_favorite_border_24 } - binding.resultFavorite.setImageResource(drawable) + binding?.resultFavorite?.setImageResource(drawable) + } + + observe(viewModel.trailers) { trailers -> + setTrailers(trailers.flatMap { it.mirros }) // I dont care about subtitles yet! } observeNullable(viewModel.episodes) { episodes -> @@ -811,58 +618,8 @@ open class ResultFragmentPhone : BaseFragment( // no failure? resultEpisodeLoading.isVisible = episodes is Resource.Loading resultEpisodes.isVisible = episodes is Resource.Success - resultBatchDownloadButton.isVisible = - episodes is Resource.Success && episodes.value.isNotEmpty() - if (episodes is Resource.Success) { - (resultEpisodes.adapter as? EpisodeAdapter)?.submitList(episodes.value) - - // Show quality dialog with all sources - resultBatchDownloadButton.setOnLongClickListener { - ioSafe { - val defaultSources = QualityProfileDialog.getAllDefaultSources() - val activity = activity ?: return@ioSafe - activity.runOnUiThread { - QualityProfileDialog( - activity, - R.style.DialogFullscreenPlayer, - defaultSources, - ).show() - } - } - - true - } - - resultBatchDownloadButton.setOnClickListener { view -> - val episodeStart = - episodes.value.firstOrNull()?.episode ?: return@setOnClickListener - val episodeEnd = - episodes.value.lastOrNull()?.episode ?: return@setOnClickListener - - val episodeRange = if (episodeStart == episodeEnd) { - episodeStart.toString() - } else { - txt( - R.string.episodes_range, - episodeStart, - episodeEnd - ).asString(view.context) - } - - val rangeMessage = txt( - R.string.download_episode_range, - episodeRange - ).asString(view.context) - - AlertDialog.Builder(view.context, R.style.AlertDialogCustom) - .setTitle(R.string.download_all) - .setMessage(rangeMessage) - .setPositiveButton(R.string.yes) { _, _ -> - requirePathForActions(episodes.value.map { ACTION_DOWNLOAD_EPISODE to it }) - } - .setNegativeButton(R.string.cancel) { _, _ -> }.show() - } + (resultEpisodes.adapter as? EpisodeAdapter)?.updateList(episodes.value) } } } @@ -886,25 +643,16 @@ open class ResultFragmentPhone : BaseFragment( ) return@setOnLongClickListener true } - resultResumeSeriesButton.setOnLongClickListener { - viewModel.handleAction( - EpisodeClickEvent(ACTION_SHOW_OPTIONS, ep) - ) - return@setOnLongClickListener true - } - - val status = VideoDownloadManager.downloadStatus[ep.id] - downloadButton.setStatus(status) downloadButton.setDefaultClickListener( - DownloadObjects.DownloadEpisodeCached( + VideoDownloadHelper.DownloadEpisodeCached( name = ep.name, poster = ep.poster, episode = 0, season = null, id = ep.id, parentId = ep.id, - score = ep.score, - description = ep.description, + rating = null, + description = null, cacheTime = System.currentTimeMillis(), ), null @@ -913,11 +661,18 @@ open class ResultFragmentPhone : BaseFragment( when (click.action) { DOWNLOAD_ACTION_DOWNLOAD -> { - requirePathForActions(listOf(ACTION_DOWNLOAD_EPISODE to ep)) + viewModel.handleAction( + EpisodeClickEvent(ACTION_DOWNLOAD_EPISODE, ep) + ) } DOWNLOAD_ACTION_LONG_CLICK -> { - requirePathForActions(listOf(ACTION_DOWNLOAD_MIRROR to ep)) + viewModel.handleAction( + EpisodeClickEvent( + ACTION_DOWNLOAD_MIRROR, + ep + ) + ) } else -> DownloadButtonSetup.handleDownloadClick(click) @@ -948,32 +703,8 @@ open class ResultFragmentPhone : BaseFragment( resultCastText.setText(d.actorsText) resultNextAiring.setText(d.nextAiringEpisode) resultNextAiringTime.setText(d.nextAiringDate) - resultPoster.loadImage(d.posterImage, headers = d.posterHeaders) { - error { - getImageFromDrawable( - context ?: return@error null, - R.drawable.default_cover - ) - } - } - resultPosterBackground.loadImage( - d.posterBackgroundImage, - headers = d.posterHeaders - ) { - error { - getImageFromDrawable( - context ?: return@error null, - R.drawable.default_cover - ) - } - } - - bindLogo( - url = d.logoUrl, - headers = d.posterHeaders, - titleView = resultTitle, - logoView = backgroundPosterWatermarkBadge - ) + resultPoster.setImage(d.posterImage) + resultPosterBackground.setImage(d.posterBackgroundImage) var isExpanded = false resultDescription.apply { @@ -991,15 +722,8 @@ open class ResultFragmentPhone : BaseFragment( resultComingSoon.isVisible = d.comingSoon resultDataHolder.isGone = d.comingSoon - val prefs = - androidx.preference.PreferenceManager.getDefaultSharedPreferences(root.context) - val showCast = prefs.getBoolean( - root.context.getString(R.string.show_cast_in_details_key), - true - ) - - resultCastItems.isGone = !showCast || d.actors.isNullOrEmpty() - (resultCastItems.adapter as? ActorAdaptor)?.submitList(if (showCast) d.actors else emptyList()) + resultCastItems.isGone = d.actors.isNullOrEmpty() + (resultCastItems.adapter as? ActorAdaptor)?.updateList(d.actors ?: emptyList()) if (d.contentRatingText == null) { // If there is no rating to display, we don't want an empty gap @@ -1013,8 +737,7 @@ open class ResultFragmentPhone : BaseFragment( syncModel.addFromUrl(d.url) } - binding.apply { - resultSearch.isGone = d.title.isBlank() + binding?.apply { resultSearch.setOnClickListener { QuickSearchFragment.pushSearch(activity, d.title) } @@ -1022,23 +745,15 @@ open class ResultFragmentPhone : BaseFragment( resultShare.setOnClickListener { try { val i = Intent(Intent.ACTION_SEND) - val nameBase64 = - base64Encode(d.apiName.toString().toByteArray(Charsets.UTF_8)) - val urlBase64 = base64Encode(d.url.toByteArray(Charsets.UTF_8)) - val encodedUri = URLEncoder.encode( - "$APP_STRING_SHARE:$nameBase64?$urlBase64", - "UTF-8" - ) - val redirectUrl = - "https://recloudstream.github.io/csredirect?redirectto=$encodedUri" i.type = "text/plain" i.putExtra(Intent.EXTRA_SUBJECT, d.title) - i.putExtra(Intent.EXTRA_TEXT, redirectUrl) + i.putExtra(Intent.EXTRA_TEXT, d.url) startActivity(Intent.createChooser(i, d.title)) } catch (e: Exception) { logError(e) } } + setUrl(d.url) resultBookmarkFab.apply { isVisible = true @@ -1048,11 +763,10 @@ open class ResultFragmentPhone : BaseFragment( } (data as? Resource.Failure)?.let { data -> - @SuppressLint("SetTextI18n") resultErrorText.text = storedData.url.plus("\n") + data.errorString } - binding.resultBookmarkFab.isVisible = data is Resource.Success + binding?.resultBookmarkFab?.isVisible = data is Resource.Success resultFinishLoading.isVisible = data is Resource.Success resultLoading.isVisible = data is Resource.Loading @@ -1062,10 +776,7 @@ open class ResultFragmentPhone : BaseFragment( resultReloadConnectionOpenInBrowser.isVisible = data is Resource.Failure resultTitle.setOnLongClickListener { - clipboardHelper( - com.lagradost.cloudstream3.utils.txt(R.string.title), - resultTitle.text - ) + clipboardHelper(txt(R.string.title), resultTitle.text) true } } @@ -1099,17 +810,14 @@ open class ResultFragmentPhone : BaseFragment( } } - observe(viewModel.trailers) { trailers -> - setTrailers(trailers.flatMap { it.mirros }) // I don't care about subtitles yet! - } - observe(syncModel.synced) { list -> syncBinding?.resultSyncNames?.text = list.filter { it.isSynced && it.hasAccount }.joinToString { it.name } val newList = list.filter { it.isSynced && it.hasAccount } - binding.resultMiniSync.isVisible = newList.isNotEmpty() + binding?.resultMiniSync?.isVisible = newList.isNotEmpty() + (binding?.resultMiniSync?.adapter as? ImageAdapter)?.updateList(newList.mapNotNull { it.icon }) } @@ -1117,7 +825,7 @@ open class ResultFragmentPhone : BaseFragment( fun setSyncMaxEpisodes(totalEpisodes: Int?) { syncBinding?.resultSyncEpisodes?.max = (totalEpisodes ?: 0) * 1000 - safe { + normalSafeApiCall { val ctx = syncBinding?.resultSyncEpisodes?.context syncBinding?.resultSyncMaxEpisodes?.text = totalEpisodes?.let { episodes -> @@ -1170,11 +878,7 @@ open class ResultFragmentPhone : BaseFragment( resultSyncHolder.isVisible = true val d = status.value - val desiredScore = d.score?.toFloat(1) ?: 0.0f - val totalSteps = (resultSyncRating.valueTo / resultSyncRating.stepSize) - val desiredStep = (totalSteps * desiredScore).roundToInt() - resultSyncRating.value = desiredStep * resultSyncRating.stepSize - + resultSyncRating.value = d.score?.toFloat() ?: 0.0f resultSyncCheck.setItemChecked(d.status.internalId + 1, true) val watchedEpisodes = d.watchedEpisodes ?: 0 currentSyncProgress = watchedEpisodes @@ -1191,11 +895,11 @@ open class ResultFragmentPhone : BaseFragment( } resultSyncCurrentEpisodes.text = Editable.Factory.getInstance()?.newEditable(watchedEpisodes.toString()) - safe { // format might fail - val text = d.score?.toFloat(10)?.roundToInt()?.let { - context?.getString(R.string.sync_score_format)?.format(it) - } ?: "?" - resultSyncScoreText.text = text + normalSafeApiCall { // format might fail + context?.getString(R.string.sync_score_format)?.format(d.score ?: 0) + ?.let { + resultSyncScoreText.text = it + } } } @@ -1204,7 +908,7 @@ open class ResultFragmentPhone : BaseFragment( } } } - binding.resultOverlappingPanels.setStartPanelLockState(if (closed) OverlappingPanelsLayout.LockState.CLOSE else OverlappingPanelsLayout.LockState.UNLOCKED) + binding?.resultOverlappingPanels?.setStartPanelLockState(if (closed) OverlappingPanelsLayout.LockState.CLOSE else OverlappingPanelsLayout.LockState.UNLOCKED) } observe(viewModel.recommendations) { recommendations -> setRecommendations(recommendations, null) @@ -1233,14 +937,14 @@ open class ResultFragmentPhone : BaseFragment( syncBinding?.apply { resultSyncCheck.choiceMode = AbsListView.CHOICE_MODE_SINGLE resultSyncCheck.adapter = arrayAdapter - setListViewHeightBasedOnItems(resultSyncCheck) + UIHelper.setListViewHeightBasedOnItems(resultSyncCheck) resultSyncCheck.setOnItemClickListener { _, _, which, _ -> syncModel.setStatus(which - 1) } - resultSyncRating.addOnChangeListener { it, value, fromUser -> - if (fromUser) syncModel.setScore(Score.from(value, it.valueTo.roundToInt())) + resultSyncRating.addOnChangeListener { _, value, _ -> + syncModel.setScore(value.toInt()) } resultSyncAddEpisode.setOnClickListener { @@ -1265,7 +969,7 @@ open class ResultFragmentPhone : BaseFragment( } observe(viewModel.watchStatus) { watchType -> - binding.resultBookmarkFab.apply { + binding?.resultBookmarkFab?.apply { setText(watchType.stringRes) if (watchType == WatchType.NONE) { context?.colorFromAttribute(R.attr.white) @@ -1302,28 +1006,20 @@ open class ResultFragmentPhone : BaseFragment( loadingDialog = null } loadingDialog = loadingDialog ?: context?.let { ctx -> - val builder = BottomSheetDialog(ctx) + val builder = + BottomSheetDialog(ctx) builder.setContentView(R.layout.bottom_loading) builder.setOnDismissListener { loadingDialog = null viewModel.cancelLinks() } + //builder.setOnCancelListener { + // it?.dismiss() + //} builder.setCanceledOnTouchOutside(true) builder.show() builder } - loadingDialog?.findViewById(R.id.overlay_loading_skip_button)?.apply { - if (load.linksLoaded <= 0) { - isInvisible = true - } else { - setOnClickListener { - viewModel.skipLoading() - } - isVisible = true - @SuppressLint("SetTextI18n") - text = "${context.getString(R.string.skip_loading)} (${load.linksLoaded})" - } - } } observeNullable(viewModel.selectedSeason) { text -> @@ -1365,14 +1061,13 @@ open class ResultFragmentPhone : BaseFragment( observe(viewModel.dubSubSelections) { range -> resultBinding?.resultDubSelect?.setOnClickListener { view -> view?.context?.let { ctx -> - view.popupMenuNoIconsAndNoStringRes( - range - .mapNotNull { (text, status) -> - Pair( - status.ordinal, - text?.asStringNull(ctx) ?: return@mapNotNull null - ) - }) { + view.popupMenuNoIconsAndNoStringRes(range + .mapNotNull { (text, status) -> + Pair( + status.ordinal, + text?.asStringNull(ctx) ?: return@mapNotNull null + ) + }) { viewModel.changeDubStatus(DubStatus.entries[itemId]) } } @@ -1390,7 +1085,7 @@ open class ResultFragmentPhone : BaseFragment( activity?.showDialog( names.map { it.second }, names.indexOfFirst { it.second == selectEpisodeRange }, - ctx.getString(R.string.episodes), + "", false, {}) { itemId -> viewModel.changeRange(names[itemId].first) @@ -1411,7 +1106,7 @@ open class ResultFragmentPhone : BaseFragment( activity?.showDialog( names.map { it.second }, names.indexOfFirst { it.second == selectSeason }, - ctx.getString(R.string.season), + "", false, {}) { itemId -> viewModel.changeSeason(names[itemId].first) @@ -1428,20 +1123,7 @@ open class ResultFragmentPhone : BaseFragment( } } - private fun resumeAction( - storedData: ResultFragment.StoredData, - resume: ResumeWatchingStatus - ) { - viewModel.handleAction( - EpisodeClickEvent( - storedData.playerAction, //?: ACTION_PLAY_EPISODE_IN_PLAYER, - resume.result - ) - ) - } - override fun onPause() { - playerHostView?.releaseKeyEventListener() super.onPause() PanelsChildGestureRegionObserver.Provider.get() .addGestureRegionsUpdateListener(gestureRegionsListener) @@ -1455,7 +1137,7 @@ open class ResultFragmentPhone : BaseFragment( root.isGone = isInvalid root.post { rec?.let { list -> - (resultRecommendationsList.adapter as? SearchAdapter)?.submitList(list.filter { it.apiName == matchAgainst }) + (resultRecommendationsList.adapter as? SearchAdapter)?.updateList(list.filter { it.apiName == matchAgainst }) } } } @@ -1499,4 +1181,4 @@ open class ResultFragmentPhone : BaseFragment( } } } -} +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultFragmentTv.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultFragmentTv.kt index cfbacc5d1..1878f0b8f 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 @@ -11,13 +11,12 @@ import android.view.animation.DecelerateInterpolator 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.fragment.app.Fragment import androidx.lifecycle.ViewModelProvider import androidx.recyclerview.widget.RecyclerView import com.google.android.material.bottomsheet.BottomSheetDialog -import com.google.android.material.button.MaterialButton import com.lagradost.cloudstream3.CommonActivity import com.lagradost.cloudstream3.DubStatus import com.lagradost.cloudstream3.LoadResponse @@ -29,53 +28,40 @@ 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 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.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) -) { +import com.lagradost.cloudstream3.utils.UIHelper.setImage +class ResultFragmentTv : Fragment() { private lateinit var viewModel: ResultViewModel2 + private var binding: FragmentResultTvBinding? = null override fun onDestroyView() { + binding = null updateUIEvent -= ::updateUI - activity?.detachBackPressedCallback(this@ResultFragmentTv.toString()) super.onDestroyView() } @@ -83,13 +69,15 @@ class ResultFragmentTv : BaseFragment( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? - ): View? { + ): View { viewModel = ViewModelProvider(this)[ResultViewModel2::class.java] viewModel.EPISODE_RANGE_SIZE = 50 updateUIEvent += ::updateUI - return super.onCreateView(inflater, container, savedInstanceState) + val localBinding = FragmentResultTvBinding.inflate(inflater, container, false) + binding = localBinding + return localBinding.root } private fun updateUI(id: Int?) { @@ -123,7 +111,7 @@ class ResultFragmentTv : BaseFragment( } private fun RecyclerView?.update(data: List) { - (this?.adapter as? SelectAdaptor?)?.submitList(data) + (this?.adapter as? SelectAdaptor?)?.updateSelectionList(data) this?.isVisible = data.size > 1 } @@ -156,17 +144,13 @@ class ResultFragmentTv : BaseFragment( resultRecommendationsList.isGone = isInvalid resultRecommendationsHolder.isGone = isInvalid val matchAgainst = validApiName ?: rec?.firstOrNull()?.apiName - (resultRecommendationsList.adapter as? SearchAdapter)?.submitList(rec?.filter { it.apiName == matchAgainst } + (resultRecommendationsList.adapter as? SearchAdapter)?.updateList(rec?.filter { it.apiName == matchAgainst } ?: emptyList()) rec?.map { it.apiName }?.distinct()?.let { apiNames -> // very dirty selection resultRecommendationsFilterSelection.isVisible = apiNames.size > 1 - resultRecommendationsFilterSelection.update(apiNames.map { - txt( - it - ) to it - }) + resultRecommendationsFilterSelection.update(apiNames.map { txt(it) to it }) resultRecommendationsFilterSelection.select(apiNames.indexOf(matchAgainst)) } ?: run { resultRecommendationsFilterSelection.isVisible = false @@ -192,7 +176,10 @@ class ResultFragmentTv : BaseFragment( } override fun onResume() { - activity?.setNavigationBarColorCompat(R.attr.primaryBlackBackground) + activity?.let { + it.window?.navigationBarColor = + it.colorFromAttribute(R.attr.primaryBlackBackground) + } afterPluginsLoadedEvent += ::reloadViewModel super.onResume() } @@ -233,13 +220,6 @@ class ResultFragmentTv : BaseFragment( private fun toggleEpisodes(show: Boolean) { binding?.apply { - if (show) { - activity?.attachBackPressedCallback(this@ResultFragmentTv.toString()) { - toggleEpisodes(false) - } - } else { - activity?.detachBackPressedCallback(this@ResultFragmentTv.toString()) - } episodesShadow.fade(show) episodeHolderTv.fade(show) if (episodesShadow.isRtl()) { @@ -250,12 +230,10 @@ class ResultFragmentTv : BaseFragment( } } - override fun fixLayout(view: View) { - fixSystemBarsPadding(view, padTop = false) - } - @SuppressLint("SetTextI18n") - override fun onBindingCreated(binding: FragmentResultTvBinding) { + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + // ===== setup ===== val storedData = getStoredData() ?: return activity?.window?.decorView?.clearFocus() @@ -273,7 +251,7 @@ class ResultFragmentTv : BaseFragment( // ===== ===== ===== var comingSoon = false - binding.apply { + binding?.apply { //episodesShadow.rotationX = 180.0f//if(episodesShadow.isRtl()) 180.0f else 0.0f // parallax on background @@ -285,7 +263,7 @@ class ResultFragmentTv : BaseFragment( if (!hasFocus) return@setOnFocusChangeListener toggleEpisodes(false) - binding.apply { + binding?.apply { val views = listOf( resultPlayMovieButton, resultPlaySeriesButton, @@ -306,7 +284,7 @@ class ResultFragmentTv : BaseFragment( redirectToEpisodes.setOnFocusChangeListener { _, hasFocus -> if (!hasFocus) return@setOnFocusChangeListener toggleEpisodes(true) - binding.apply { + binding?.apply { val views = listOf( resultDubSelection, resultSeasonSelection, @@ -331,7 +309,7 @@ class ResultFragmentTv : BaseFragment( resultSubscribeButton to resultSubscribeText, resultSearchButton to resultSearchText, resultEpisodesShowButton to resultEpisodesShowText - ).forEach { (button, text) -> + ).forEach { (button , text) -> button.setOnFocusChangeListener { view, hasFocus -> if (!hasFocus) { @@ -341,14 +319,13 @@ class ResultFragmentTv : BaseFragment( } text.isSelected = true - if (button.tag == context?.getString(R.string.tv_no_focus_tag)) { - resultFinishLoading.scrollTo(0, 0) + if (button.tag == context?.getString(R.string.tv_no_focus_tag)){ + resultFinishLoading.scrollTo(0,0) } when (button.id) { R.id.result_episodes_show_button -> { toggleEpisodes(true) } - else -> { toggleEpisodes(false) } @@ -410,24 +387,24 @@ class ResultFragmentTv : BaseFragment( resultCastItems.setOnFocusChangeListener { _, hasFocus -> // Always escape focus - if (hasFocus) binding.resultBookmarkButton.requestFocus() + if (hasFocus) binding?.resultBookmarkButton?.requestFocus() } //resultBack.setOnClickListener { // activity?.popCurrentPage() //} resultRecommendationsList.spanCount = 8 - resultRecommendationsList.setRecycledViewPool(SearchAdapter.sharedPool) resultRecommendationsList.adapter = SearchAdapter( + ArrayList(), resultRecommendationsList, ) { callback -> - if (callback.action == SEARCH_ACTION_FOCUSED) { + if (callback.action == SEARCH_ACTION_FOCUSED) toggleEpisodes(false) - } else SearchHelper.handleSearchClickCallback(callback) + else + SearchHelper.handleSearchClickCallback(callback) } - resultEpisodes.setRecycledViewPool(EpisodeAdapter.sharedPool) resultEpisodes.adapter = EpisodeAdapter( false, @@ -439,7 +416,8 @@ class ResultFragmentTv : BaseFragment( } ) - resultCastItems.layoutManager = object : LinearListLayout(root.context) { + resultCastItems.layoutManager = object : LinearListLayout(view.context) { + override fun onRequestChildFocus( parent: RecyclerView, state: RecyclerView.State, @@ -455,48 +433,35 @@ class ResultFragmentTv : BaseFragment( super.onRequestChildFocus(parent, state, child, focused) } } - }.apply { setHorizontal() } - - val aboveCast = listOf( - binding.resultEpisodesShow, - binding.resultBookmark, - binding.resultFavorite, - binding.resultSubscribe, - ).firstOrNull { it.isVisible } - - resultCastItems.setRecycledViewPool(ActorAdaptor.sharedPool) - resultCastItems.adapter = ActorAdaptor(aboveCast?.id) { - toggleEpisodes(false) + }.apply { + setHorizontal() } - if (isLayout(EMULATOR)) { - episodesShadow.setOnClickListener { - toggleEpisodes(false) - } + val aboveCast = listOf( + binding?.resultEpisodesShow, + binding?.resultBookmark, + binding?.resultFavorite, + binding?.resultSubscribe, + ).firstOrNull { + it?.isVisible == true + } + resultCastItems.adapter = ActorAdaptor(aboveCast?.id) { + toggleEpisodes(false) } } observeNullable(viewModel.resumeWatching) { resume -> - binding.apply { + binding?.apply { + if (resume == null) { return@observeNullable } - resultResumeSeries.isVisible = true resultPlayMovie.isVisible = false resultPlaySeries.isVisible = false // show progress no matter if series or movie resume.progress?.let { progress -> - resultResumeSeriesTitle.apply { - isVisible = !resume.isMovie - text = - if (resume.isMovie) null else context?.getNameFull( - resume.result.name, - resume.result.episode, - resume.result.season - ) - } resultResumeSeriesProgressText.setText(progress.progressLeft) resultResumeSeriesProgress.apply { isVisible = true @@ -517,12 +482,7 @@ class ResultFragmentTv : BaseFragment( when { resume.isMovie -> context?.getString(R.string.resume) resume.result.season != null -> - "${getString(R.string.season_short)}${resume.result.season}:${ - getString( - R.string.episode_short - ) - }${resume.result.episode}" - + "${getString(R.string.season_short)}${resume.result.season}:${getString(R.string.episode_short)}${resume.result.episode}" else -> "${getString(R.string.episode)} ${resume.result.episode}" } @@ -548,18 +508,17 @@ class ResultFragmentTv : BaseFragment( observe(viewModel.trailers) { trailersLinks -> context?.updateHasTrailers() if (!LoadResponse.isTrailersEnabled) return@observe - val extractedTrailerLinks = trailersLinks.flatMap { it.mirros } - .map { (extractedTrailerLink, _) -> extractedTrailerLink } - binding.apply { - resultPlayTrailer.isGone = extractedTrailerLinks.isEmpty() + val trailers = trailersLinks.flatMap { it.mirros } + binding?.apply { + resultPlayTrailer.isGone = trailers.isEmpty() resultPlayTrailerButton.setOnClickListener { - if (extractedTrailerLinks.isEmpty()) return@setOnClickListener + if (trailers.isEmpty()) return@setOnClickListener activity.navigate( R.id.global_to_navigation_player, GeneratorPlayer.newInstance( ExtractorLinkGenerator( - extractedTrailerLinks, + trailers, emptyList() - ), 0 + ) ) ) } @@ -567,13 +526,16 @@ class ResultFragmentTv : BaseFragment( } observe(viewModel.watchStatus) { watchType -> - binding.apply { + binding?.apply { resultBookmarkText.setText(watchType.stringRes) resultBookmarkButton.apply { + val drawable = if (watchType.stringRes == R.string.type_none) { R.drawable.outline_bookmark_add_24 - } else R.drawable.ic_baseline_bookmark_24 + } else { + R.drawable.ic_baseline_bookmark_24 + } setIconResource(drawable) setOnClickListener { view -> @@ -591,13 +553,19 @@ class ResultFragmentTv : BaseFragment( } observeNullable(viewModel.favoriteStatus) { isFavorite -> - binding.resultFavorite.isVisible = isFavorite != null - binding.resultFavoriteButton.apply { + + binding?.resultFavorite?.isVisible = isFavorite != null + + binding?.resultFavoriteButton?.apply { + if (isFavorite == null) return@observeNullable val drawable = if (isFavorite) { R.drawable.ic_baseline_favorite_24 - } else R.drawable.ic_baseline_favorite_border_24 + } else { + R.drawable.ic_baseline_favorite_border_24 + } + setIconResource(drawable) setOnClickListener { @@ -606,37 +574,39 @@ class ResultFragmentTv : BaseFragment( val message = if (newStatus) { R.string.favorite_added - } else R.string.favorite_removed + } else { + R.string.favorite_removed + } val name = (viewModel.page.value as? Resource.Success)?.value?.title - ?: txt(R.string.no_data) - .asStringNull(context) ?: "" - CommonActivity.showToast( - txt( - message, - name - ), Toast.LENGTH_SHORT - ) + ?: txt(R.string.no_data).asStringNull(context) ?: "" + CommonActivity.showToast(txt(message, name), Toast.LENGTH_SHORT) } } } - binding.resultFavoriteText.apply { + binding?.resultFavoriteText?.apply { val text = if (isFavorite == true) { R.string.unfavorite - } else R.string.favorite + } else { + R.string.favorite + } setText(text) } } observeNullable(viewModel.subscribeStatus) { isSubscribed -> - binding.resultSubscribe.isVisible = isSubscribed != null && isLayout(EMULATOR) - binding.resultSubscribeButton.apply { + binding?.resultSubscribe?.isVisible = isSubscribed != null && isLayout(EMULATOR) + binding?.resultSubscribeButton?.apply { + if (isSubscribed == null) return@observeNullable val drawable = if (isSubscribed) { R.drawable.ic_baseline_notifications_active_24 - } else R.drawable.baseline_notifications_none_24 + } else { + R.drawable.baseline_notifications_none_24 + } + setIconResource(drawable) setOnClickListener { @@ -647,36 +617,36 @@ class ResultFragmentTv : BaseFragment( // Kinda icky to have this here, but it works. SubscriptionWorkManager.enqueuePeriodicWork(context) R.string.subscription_new - } else R.string.subscription_deleted + } else { + R.string.subscription_deleted + } val name = (viewModel.page.value as? Resource.Success)?.value?.title - ?: txt(R.string.no_data) - .asStringNull(context) ?: "" - CommonActivity.showToast( - txt( - message, - name - ), Toast.LENGTH_SHORT - ) + ?: txt(R.string.no_data).asStringNull(context) ?: "" + CommonActivity.showToast(txt(message, name), Toast.LENGTH_SHORT) } } - 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) } } } observeNullable(viewModel.movie) { data -> - if (data == null) { + if (data == null ) { return@observeNullable } - binding.apply { + binding?.apply { + (data as? Resource.Success)?.value?.let { (_, ep) -> + resultPlayMovieButton.setOnClickListener { viewModel.handleAction( EpisodeClickEvent(ACTION_CLICK_DEFAULT, ep) @@ -690,9 +660,10 @@ class ResultFragmentTv : BaseFragment( } resultPlayMovie.isVisible = !comingSoon && resultResumeSeries.isGone - if (comingSoon) { + if (comingSoon) resultBookmarkButton.requestFocus() - } else resultPlayMovieButton.requestFocus() + else + resultPlayMovieButton.requestFocus() // Stops last button right focus resultSearchButton.nextFocusRightId = R.id.result_search_Button @@ -742,45 +713,38 @@ class ResultFragmentTv : BaseFragment( loadingDialog = null viewModel.cancelLinks() } + //builder.setOnCancelListener { + // it?.dismiss() + //} builder.setCanceledOnTouchOutside(true) builder.show() builder } - loadingDialog?.findViewById(R.id.overlay_loading_skip_button)?.apply { - if (load.linksLoaded <= 0) { - isInvisible = true - } else { - setOnClickListener { - viewModel.skipLoading() - } - isVisible = true - text = "${context.getString(R.string.skip_loading)} (${load.linksLoaded})" - } - } + } observeNullable(viewModel.episodesCountText) { count -> - binding.resultEpisodesText.setText(count) + binding?.resultEpisodesText.setText(count) } observe(viewModel.selectedRangeIndex) { selected -> - binding.resultRangeSelection.select(selected) + binding?.resultRangeSelection.select(selected) } observe(viewModel.selectedSeasonIndex) { selected -> - binding.resultSeasonSelection.select(selected) + binding?.resultSeasonSelection.select(selected) } observe(viewModel.selectedDubStatusIndex) { selected -> - binding.resultDubSelection.select(selected) + binding?.resultDubSelection.select(selected) } observe(viewModel.rangeSelections) { - binding.resultRangeSelection.update(it) + binding?.resultRangeSelection.update(it) } observe(viewModel.dubSubSelections) { - binding.resultDubSelection.update(it) + binding?.resultDubSelection.update(it) } observe(viewModel.seasonSelections) { - binding.resultSeasonSelection.update(it) + binding?.resultSeasonSelection.update(it) } observe(viewModel.recommendations) { recommendations -> setRecommendations(recommendations, null) @@ -788,7 +752,7 @@ class ResultFragmentTv : BaseFragment( if (isLayout(TV)) { observe(viewModel.episodeSynopsis) { description -> - context?.let { ctx -> + view.context?.let { ctx -> val builder: AlertDialog.Builder = AlertDialog.Builder(ctx, R.style.AlertDialogCustom) builder.setMessage(description.html()) @@ -805,28 +769,26 @@ class ResultFragmentTv : BaseFragment( var hasLoadedEpisodesOnce = false observeNullable(viewModel.episodes) { episodes -> if (episodes == null) return@observeNullable - binding.apply { - if (comingSoon) resultBookmarkButton.requestFocus() + + binding?.apply { + + if (comingSoon) + resultBookmarkButton.requestFocus() // resultEpisodeLoading.isVisible = episodes is Resource.Loading if (episodes is Resource.Success) { + val lastWatchedIndex = episodes.value.indexOfLast { ep -> ep.getWatchProgress() >= NEXT_WATCH_EPISODE_PERCENTAGE.toFloat() / 100.0f || ep.videoWatchState == VideoWatchState.Watched } - val firstUnwatched = - episodes.value.getOrElse(lastWatchedIndex + 1) { episodes.value.firstOrNull() } + val firstUnwatched = episodes.value.getOrElse(lastWatchedIndex + 1) { episodes.value.firstOrNull() } if (firstUnwatched != null) { resultPlaySeriesText.text = when { firstUnwatched.season != null -> - "${getString(R.string.season_short)}${firstUnwatched.season}:${ - getString( - R.string.episode_short - ) - }${firstUnwatched.episode}" - + "${getString(R.string.season_short)}${firstUnwatched.season}:${getString(R.string.episode_short)}${firstUnwatched.episode}" else -> "${getString(R.string.episode)} ${firstUnwatched.episode}" } resultPlaySeriesButton.setOnClickListener { @@ -852,14 +814,14 @@ class ResultFragmentTv : BaseFragment( } - (resultEpisodes.adapter as? EpisodeAdapter)?.submitList(episodes.value) + (resultEpisodes.adapter as? EpisodeAdapter)?.updateList(episodes.value) } } } observeNullable(viewModel.page) { data -> if (data == null) return@observeNullable - binding.apply { + binding?.apply { when (data) { is Resource.Success -> { val d = data.value @@ -877,7 +839,7 @@ class ResultFragmentTv : BaseFragment( resultCastText.setText(d.actorsText) resultNextAiring.setText(d.nextAiringEpisode) resultNextAiringTime.setText(d.nextAiringDate) - resultPoster.loadImage(d.posterImage) + resultPoster.setImage(d.posterImage) var isExpanded = false resultDescription.apply { @@ -889,7 +851,7 @@ class ResultFragmentTv : BaseFragment( Integer.MAX_VALUE } else 10 } else { - context?.let { ctx -> + view.context?.let { ctx -> val builder: AlertDialog.Builder = AlertDialog.Builder(ctx, R.style.AlertDialogCustom) builder.setMessage(d.plotText.asString(ctx).html()) @@ -909,32 +871,23 @@ class ResultFragmentTv : BaseFragment( R.drawable.profile_bg_red, R.drawable.profile_bg_teal ).random() - - backgroundPoster.loadImage(d.posterBackgroundImage) { - error { getImageFromDrawable(context ?: return@error null, error) } - } - - bindLogo( - url = d.logoUrl, - headers = d.posterHeaders, - titleView = resultTitle, - logoView = backgroundPosterWatermarkBadgeHolder + //Change poster crop area to 20% from Top + backgroundPoster.cropYCenterOffsetPct = 0.20F + + backgroundPoster.setImage( + d.posterBackgroundImage ?: UiImage.Drawable(error), + radius = 0, + errorImageDrawable = error ) - comingSoon = d.comingSoon resultTvComingSoon.isVisible = d.comingSoon - populateChips(resultTag, d.tags) - val prefs = - androidx.preference.PreferenceManager.getDefaultSharedPreferences(root.context) - val showCast = prefs.getBoolean( - root.context.getString(R.string.show_cast_in_details_key), - true + UIHelper.populateChips(resultTag, d.tags) + resultCastItems.isGone = d.actors.isNullOrEmpty() + (resultCastItems.adapter as? ActorAdaptor)?.updateList( + d.actors ?: emptyList() ) - resultCastItems.isGone = !showCast || d.actors.isNullOrEmpty() - (resultCastItems.adapter as? ActorAdaptor)?.submitList(if (showCast) d.actors else emptyList()) - if (d.contentRatingText == null) { // If there is no rating to display, we don't want an empty gap resultMetaContentRating.width = 0 @@ -945,7 +898,9 @@ class ResultFragmentTv : BaseFragment( } } - is Resource.Loading -> {} + is Resource.Loading -> { + + } is Resource.Failure -> { resultErrorText.text = @@ -962,4 +917,4 @@ class ResultFragmentTv : BaseFragment( } } } -} +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultTrailerPlayer.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultTrailerPlayer.kt index 3b1471e6a..df4b6323d 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultTrailerPlayer.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultTrailerPlayer.kt @@ -3,76 +3,40 @@ package com.lagradost.cloudstream3.ui.result import android.animation.ValueAnimator import android.content.Context import android.content.res.Configuration -import android.os.Build import android.os.Bundle +import android.view.View import android.view.ViewGroup import android.widget.FrameLayout -import androidx.core.view.ViewCompat import androidx.core.view.isGone import androidx.core.view.isVisible import com.lagradost.cloudstream3.CommonActivity.screenHeight import com.lagradost.cloudstream3.CommonActivity.screenWidth import com.lagradost.cloudstream3.LoadResponse import com.lagradost.cloudstream3.R -import com.lagradost.cloudstream3.databinding.FragmentResultSwipeBinding import com.lagradost.cloudstream3.ui.player.CSPlayerEvent -import com.lagradost.cloudstream3.ui.player.CSPlayerLoading import com.lagradost.cloudstream3.ui.player.PlayerEventSource import com.lagradost.cloudstream3.ui.player.SubtitleData import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.attachBackPressedCallback import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.detachBackPressedCallback -import com.lagradost.cloudstream3.utils.UIHelper.fixSystemBarsPadding -class ResultTrailerPlayer : ResultFragmentPhone() { +open class ResultTrailerPlayer : ResultFragmentPhone() { override var lockRotation = false override var isFullScreenPlayer = false override var hasPipModeSupport = false companion object { - const val TAG = "ResultTrailerPlayer" + const val TAG = "RESULT_TRAILER" } private var playerWidthHeight: Pair? = null - private var introVisible = true - - // Single-tap on empty player area: toggle controls. - override fun onSingleTap() { - if (introVisible) return - if (isShowing) uiReset() else showControls() - } - - private fun showControls() { - if (introVisible) return - isShowing = true - updateUIVisibility() - playerHostView?.scheduleAutoHide() - } - - override fun isUIShowing(): Boolean = isShowing - - override fun onAutoHideUI() { - if (player.getIsPlaying()) uiReset() - } - - override fun onHidePlayerUI() = uiReset() - - // When the hold-speedup gesture fires, hide controls so the video is unobstructed. - // The speedup button show/hide and speed change are handled by PlayerView. - override fun onHoldSpeedUp(show: Boolean) { - if (show && isShowing) uiReset() - } - - override fun onGestureEnd(hadSwipe: Boolean, wasUiShowing: Boolean) { - if (!player.getIsPlaying() && hadSwipe && wasUiShowing && !isShowing) { - isShowing = true - showControls() - } else playerHostView?.scheduleAutoHide() - } override fun nextEpisode() {} + override fun prevEpisode() {} - override fun playerPositionChanged(position: Long, duration: Long) {} + + override fun playerPositionChanged(position: Long, duration : Long) {} + override fun nextMirror() {} override fun onConfigurationChanged(newConfig: Configuration) { @@ -82,28 +46,18 @@ class ResultTrailerPlayer : ResultFragmentPhone() { } private fun fixPlayerSize() { - binding?.apply { - if (isFullScreenPlayer) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - ViewCompat.setOnApplyWindowInsetsListener(root, null) - root.overlay.clear() - } - root.setPadding(0, 0, 0, 0) - } else { - fixSystemBarsPadding(root) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - ViewCompat.requestApplyInsets(root) - } - } - } - playerWidthHeight?.let { (w, h) -> - if (w <= 0 || h <= 0) return@let + if(w <= 0 || h <= 0) return@let val orientation = context?.resources?.configuration?.orientation ?: return - val sw = if (orientation == Configuration.ORIENTATION_LANDSCAPE) screenWidth else screenHeight + val sw = if (orientation == Configuration.ORIENTATION_LANDSCAPE) { + screenWidth + } else { + screenHeight + } + //result_trailer_loading?.isVisible = false resultBinding?.resultSmallscreenHolder?.isVisible = !isFullScreenPlayer binding?.resultFullscreenHolder?.isVisible = isFullScreenPlayer @@ -111,30 +65,35 @@ class ResultTrailerPlayer : ResultFragmentPhone() { resultBinding?.fragmentTrailer?.playerBackground?.apply { isVisible = true - layoutParams = FrameLayout.LayoutParams( - FrameLayout.LayoutParams.MATCH_PARENT, - if (isFullScreenPlayer) FrameLayout.LayoutParams.MATCH_PARENT else to - ) + layoutParams = + FrameLayout.LayoutParams( + FrameLayout.LayoutParams.MATCH_PARENT, + if (isFullScreenPlayer) FrameLayout.LayoutParams.MATCH_PARENT else to + ) } playerBinding?.playerIntroPlay?.apply { - layoutParams = FrameLayout.LayoutParams( - FrameLayout.LayoutParams.MATCH_PARENT, - resultBinding?.resultTopHolder?.measuredHeight ?: FrameLayout.LayoutParams.MATCH_PARENT - ) + layoutParams = + FrameLayout.LayoutParams( + FrameLayout.LayoutParams.MATCH_PARENT, + resultBinding?.resultTopHolder?.measuredHeight + ?: FrameLayout.LayoutParams.MATCH_PARENT + ) } if (playerBinding?.playerIntroPlay?.isGone == true) { resultBinding?.resultTopHolder?.apply { + val anim = ValueAnimator.ofInt( measuredHeight, if (isFullScreenPlayer) ViewGroup.LayoutParams.MATCH_PARENT else to ) - anim.addUpdateListener { va -> - val v = va.animatedValue as Int - val lp: ViewGroup.LayoutParams = layoutParams - lp.height = v - layoutParams = lp + anim.addUpdateListener { valueAnimator -> + val `val` = valueAnimator.animatedValue as Int + val layoutParams: ViewGroup.LayoutParams = + layoutParams + layoutParams.height = `val` + setLayoutParams(layoutParams) } anim.duration = 200 anim.start() @@ -143,14 +102,9 @@ class ResultTrailerPlayer : ResultFragmentPhone() { } } - override fun playerDimensionsLoaded(width: Int, height: Int) { + override fun playerDimensionsLoaded(width: Int, height : Int) { playerWidthHeight = width to height fixPlayerSize() - // Apply autorotation when fullscreen (lockRotation = true). - // PlayerView already set isVerticalOrientation before this callback fires. - if (lockRotation) { - activity?.requestedOrientation = playerHostView?.dynamicOrientation() ?: return - } } override fun showMirrorsDialogue() {} @@ -160,39 +114,33 @@ class ResultTrailerPlayer : ResultFragmentPhone() { context: Context, loadResponse: LoadResponse?, dismissCallback: () -> Unit - ) {} - - override fun subtitlesChanged() {} - override fun embeddedSubtitlesFetched(subtitles: List) {} - override fun onTracksInfoChanged() {} - override fun exitedPipMode() {} - - override fun onSeekPreviewText(text: String?) { - playerBinding?.playerTimeText?.apply { - isVisible = text != null - if (text != null) this.text = text - } + ) { } + override fun subtitlesChanged() {} + + override fun embeddedSubtitlesFetched(subtitles: List) {} + override fun onTracksInfoChanged() {} + + override fun exitedPipMode() {} private fun updateFullscreen(fullscreen: Boolean) { isFullScreenPlayer = fullscreen lockRotation = fullscreen - playerHostView?.isFullScreen = fullscreen - playerBinding?.playerFullscreen?.setImageResource( - if (fullscreen) R.drawable.baseline_fullscreen_exit_24 else R.drawable.baseline_fullscreen_24 - ) + playerBinding?.playerFullscreen?.setImageResource(if (fullscreen) R.drawable.baseline_fullscreen_exit_24 else R.drawable.baseline_fullscreen_24) if (fullscreen) { - playerHostView?.enterFullscreen() + enterFullscreen() binding?.apply { resultTopBar.isVisible = false resultFullscreenHolder.isVisible = true resultMainHolder.isVisible = false } + resultBinding?.fragmentTrailer?.playerBackground?.let { view -> (view.parent as ViewGroup?)?.removeView(view) binding?.resultFullscreenHolder?.addView(view) } + } else { binding?.apply { resultTopBar.isVisible = true @@ -203,55 +151,36 @@ class ResultTrailerPlayer : ResultFragmentPhone() { resultBinding?.resultSmallscreenHolder?.addView(view) } } - playerHostView?.exitFullscreen() + exitFullscreen() } fixPlayerSize() uiReset() if (isFullScreenPlayer) { - activity?.attachBackPressedCallback("ResultTrailerPlayer") { updateFullscreen(false) } - } else { - activity?.detachBackPressedCallback("ResultTrailerPlayer") - } + activity?.attachBackPressedCallback { + updateFullscreen(false) + } + } else activity?.detachBackPressedCallback() } override fun updateUIVisibility() { super.updateUIVisibility() - playerBinding?.apply { - playerGoBackHolder.isVisible = false - val controlsVisible = isShowing && !introVisible - playerTopHolder.isVisible = controlsVisible - playerVideoHolder.isVisible = controlsVisible - shadowOverlay.isVisible = controlsVisible - playerPausePlayHolderHolder.isVisible = - controlsVisible && playerHostView?.currentPlayerStatus != CSPlayerLoading.IsBuffering - } - // Fade center controls in/out; also resets stale fillAfter alpha from seek animations. - playerHostView?.gestureHelper?.animateCenterControls(if (isShowing && !introVisible) 1f else 0f) + playerBinding?.playerGoBackHolder?.isVisible = false } - override fun playerStatusChanged() { - if (introVisible) { - playerBinding?.playerPausePlayHolderHolder?.isVisible = false + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + playerBinding?.playerFullscreen?.setOnClickListener { + updateFullscreen(!isFullScreenPlayer) } - } - - override fun onBindingCreated(binding: FragmentResultSwipeBinding, savedInstanceState: Bundle?) { - super.onBindingCreated(binding, savedInstanceState) - - playerHostView?.videoOutline = playerBinding?.videoOutline - playerHostView?.requestUpdateBrightnessOverlayOnNextLayout() - - playerBinding?.playerFullscreen?.setOnClickListener { updateFullscreen(!isFullScreenPlayer) } updateFullscreen(isFullScreenPlayer) uiReset() playerBinding?.playerIntroPlay?.setOnClickListener { playerBinding?.playerIntroPlay?.isGone = true - introVisible = false player.handleEvent(CSPlayerEvent.Play, PlayerEventSource.UI) + updateUIVisibility() fixPlayerSize() - showControls() } } -} +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultViewModel2.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultViewModel2.kt index c519e0de2..b5f83201e 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultViewModel2.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultViewModel2.kt @@ -1,8 +1,8 @@ package com.lagradost.cloudstream3.ui.result import android.app.Activity -import android.content.Context -import android.content.DialogInterface +import android.content.* +import android.text.format.Formatter.formatFileSize import android.util.Log import android.widget.Toast import androidx.annotation.MainThread @@ -11,67 +11,32 @@ import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.lagradost.cloudstream3.APIHolder +import com.lagradost.cloudstream3.* import com.lagradost.cloudstream3.APIHolder.apis import com.lagradost.cloudstream3.APIHolder.getApiFromNameNull import com.lagradost.cloudstream3.APIHolder.unixTime import com.lagradost.cloudstream3.APIHolder.unixTimeMS -import com.lagradost.cloudstream3.ActorData -import com.lagradost.cloudstream3.AnimeLoadResponse -import com.lagradost.cloudstream3.CloudStreamApp.Companion.context -import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey +import com.lagradost.cloudstream3.AcraApplication.Companion.setKey import com.lagradost.cloudstream3.CommonActivity.activity import com.lagradost.cloudstream3.CommonActivity.getCastSession import com.lagradost.cloudstream3.CommonActivity.showToast -import com.lagradost.cloudstream3.DubStatus -import com.lagradost.cloudstream3.EpisodeResponse -import com.lagradost.cloudstream3.IDownloadableMinimum -import com.lagradost.cloudstream3.LiveStreamLoadResponse -import com.lagradost.cloudstream3.LoadResponse import com.lagradost.cloudstream3.LoadResponse.Companion.addTrailer import com.lagradost.cloudstream3.LoadResponse.Companion.getAniListId -import com.lagradost.cloudstream3.LoadResponse.Companion.getKitsuId import com.lagradost.cloudstream3.LoadResponse.Companion.getMalId import com.lagradost.cloudstream3.LoadResponse.Companion.isMovie import com.lagradost.cloudstream3.LoadResponse.Companion.readIdFromString -import com.lagradost.cloudstream3.MainActivity -import com.lagradost.cloudstream3.MovieLoadResponse -import com.lagradost.cloudstream3.ProviderType -import com.lagradost.cloudstream3.R -import com.lagradost.cloudstream3.Score -import com.lagradost.cloudstream3.SearchResponse -import com.lagradost.cloudstream3.SeasonData -import com.lagradost.cloudstream3.ShowStatus -import com.lagradost.cloudstream3.SimklSyncServices -import com.lagradost.cloudstream3.SubtitleFile -import com.lagradost.cloudstream3.TorrentLoadResponse -import com.lagradost.cloudstream3.TrackerType -import com.lagradost.cloudstream3.TrailerData -import com.lagradost.cloudstream3.TvSeriesLoadResponse -import com.lagradost.cloudstream3.TvType -import com.lagradost.cloudstream3.VPNStatus -import com.lagradost.cloudstream3.actions.AlwaysAskAction import com.lagradost.cloudstream3.actions.VideoClickActionHolder -import com.lagradost.cloudstream3.amap -import com.lagradost.cloudstream3.isEpisodeBased -import com.lagradost.cloudstream3.isLiveStream import com.lagradost.cloudstream3.metaproviders.SyncRedirector -import com.lagradost.cloudstream3.mvvm.Resource -import com.lagradost.cloudstream3.mvvm.debugAssert -import com.lagradost.cloudstream3.mvvm.debugException -import com.lagradost.cloudstream3.mvvm.launchSafe -import com.lagradost.cloudstream3.mvvm.logError -import com.lagradost.cloudstream3.mvvm.safe -import com.lagradost.cloudstream3.mvvm.safeApiCall -import com.lagradost.cloudstream3.runAllAsync -import com.lagradost.cloudstream3.sortUrls +import com.lagradost.cloudstream3.mvvm.* import com.lagradost.cloudstream3.syncproviders.AccountManager import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.secondsToReadable import com.lagradost.cloudstream3.syncproviders.SyncAPI import com.lagradost.cloudstream3.syncproviders.providers.Kitsu import com.lagradost.cloudstream3.ui.APIRepository import com.lagradost.cloudstream3.ui.WatchType +import com.lagradost.cloudstream3.ui.download.DOWNLOAD_NAVIGATE_TO import com.lagradost.cloudstream3.ui.player.GeneratorPlayer +import com.lagradost.cloudstream3.ui.player.IGenerator import com.lagradost.cloudstream3.ui.player.LOADTYPE_ALL import com.lagradost.cloudstream3.ui.player.LOADTYPE_CHROMECAST import com.lagradost.cloudstream3.ui.player.LOADTYPE_INAPP @@ -79,23 +44,18 @@ import com.lagradost.cloudstream3.ui.player.LOADTYPE_INAPP_DOWNLOAD import com.lagradost.cloudstream3.ui.player.RepoLinkGenerator import com.lagradost.cloudstream3.ui.player.SubtitleData import com.lagradost.cloudstream3.ui.result.EpisodeAdapter.Companion.getPlayerAction +import com.lagradost.cloudstream3.ui.subtitles.SubtitlesFragment +import com.lagradost.cloudstream3.utils.* import com.lagradost.cloudstream3.utils.AppContextUtils.getNameFull import com.lagradost.cloudstream3.utils.AppContextUtils.isConnectedToChromecast import com.lagradost.cloudstream3.utils.AppContextUtils.setDefaultFocus import com.lagradost.cloudstream3.utils.AppContextUtils.sortSubs -import com.lagradost.cloudstream3.utils.AppUtils.toJson import com.lagradost.cloudstream3.utils.CastHelper.startCast import com.lagradost.cloudstream3.utils.Coroutines.ioSafe import com.lagradost.cloudstream3.utils.Coroutines.ioWork import com.lagradost.cloudstream3.utils.Coroutines.ioWorkSafe import com.lagradost.cloudstream3.utils.Coroutines.main -import com.lagradost.cloudstream3.utils.DOWNLOAD_HEADER_CACHE -import com.lagradost.cloudstream3.utils.DataStore -import com.lagradost.cloudstream3.utils.DataStore.editor -import com.lagradost.cloudstream3.utils.DataStore.getFolderName import com.lagradost.cloudstream3.utils.DataStore.setKey -import com.lagradost.cloudstream3.utils.DataStoreHelper -import com.lagradost.cloudstream3.utils.DataStoreHelper.currentAccount import com.lagradost.cloudstream3.utils.DataStoreHelper.deleteBookmarkedData import com.lagradost.cloudstream3.utils.DataStoreHelper.getAllBookmarkedData import com.lagradost.cloudstream3.utils.DataStoreHelper.getAllFavorites @@ -121,30 +81,9 @@ 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.clipboardHelper import com.lagradost.cloudstream3.utils.UIHelper.navigate -import com.lagradost.cloudstream3.utils.UiText -import com.lagradost.cloudstream3.utils.VIDEO_WATCH_STATE -import com.lagradost.cloudstream3.utils.downloader.DownloadFileManagement.sanitizeFilename -import com.lagradost.cloudstream3.utils.downloader.DownloadObjects -import com.lagradost.cloudstream3.utils.downloader.DownloadQueueManager -import com.lagradost.cloudstream3.utils.downloader.DownloadUtils.downloadSubtitle -import com.lagradost.cloudstream3.utils.loadExtractor -import com.lagradost.cloudstream3.utils.newExtractorLink -import com.lagradost.cloudstream3.utils.txt -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Job -import kotlinx.coroutines.cancelChildren -import kotlinx.coroutines.coroutineScope -import kotlinx.coroutines.isActive -import kotlinx.coroutines.job -import kotlinx.coroutines.launch +import kotlinx.coroutines.* import java.util.concurrent.TimeUnit /** This starts at 1 */ @@ -175,9 +114,8 @@ data class ResultData( val title: String, var syncData: Map, - val posterImage: String?, - val posterBackgroundImage: String?, - val logoUrl: String?, + val posterImage: UiImage?, + val posterBackgroundImage: UiImage?, val plotText: UiText, val apiName: UiText, val ratingText: UiText?, @@ -193,7 +131,6 @@ data class ResultData( val nextAiringDate: UiText?, val nextAiringEpisode: UiText?, val plotHeaderText: UiText, - val posterHeaders: Map? = null, ) data class CheckDuplicateData( @@ -208,15 +145,6 @@ enum class LibraryListType { SUBSCRIPTIONS } -enum class EpisodeSortType { - NUMBER_ASC, - NUMBER_DESC, - RATING_HIGH_LOW, - RATING_LOW_HIGH, - DATE_NEWEST, - DATE_OLDEST -} - fun txt(status: DubStatus?): UiText? { return txt( when (status) { @@ -287,9 +215,12 @@ fun LoadResponse.toResultData(repo: APIRepository): ResultData { ), nextAiringDate = nextAiringDate, nextAiringEpisode = nextAiringEpisode, - posterImage = posterUrl ?: backgroundPosterUrl, - posterHeaders = posterHeaders, - posterBackgroundImage = backgroundPosterUrl ?: posterUrl, + posterImage = img( + posterUrl, posterHeaders + ) ?: img(R.drawable.default_cover), + posterBackgroundImage = img( + backgroundPosterUrl ?: posterUrl, posterHeaders + ) ?: img(R.drawable.default_cover), titleText = txt(name), url = url, tags = tags ?: emptyList(), @@ -299,11 +230,10 @@ fun LoadResponse.toResultData(repo: APIRepository): ResultData { R.string.cast_format, actors?.joinToString { it.actor.name }), plotText = - if (plot.isNullOrBlank()) txt(if (this is TorrentLoadResponse) R.string.torrent_no_plot else R.string.normal_no_plot) else txt( - plot!! - ), + if (plot.isNullOrBlank()) txt(if (this is TorrentLoadResponse) R.string.torrent_no_plot else R.string.normal_no_plot) else txt( + plot!! + ), backgroundPosterUrl = backgroundPosterUrl, - logoUrl = logoUrl, title = name, typeText = txt( when (type) { @@ -319,18 +249,15 @@ fun LoadResponse.toResultData(repo: APIRepository): ResultData { TvType.Live -> R.string.live_singular TvType.Others -> R.string.other_singular TvType.NSFW -> R.string.nsfw_singular - TvType.Music -> R.string.music_singular + TvType.Music -> R.string.music_singlar TvType.AudioBook -> R.string.audio_book_singular - TvType.CustomMedia -> R.string.custom_media_singular - TvType.Audio -> R.string.audio_singular - TvType.Podcast -> R.string.podcast_singular - TvType.Video -> R.string.video_singular + TvType.CustomMedia -> R.string.custom_media_singluar } ), yearText = txt(year?.toString()), apiName = txt(apiName), - ratingText = score?.toStringNull(0.1, 10, 1, false, '.') - ?.let { txt(R.string.rating_format, it) }, + ratingText = rating?.div(1000f) + ?.let { if (it <= 0.1f) null else txt(R.string.rating_format, it) }, contentRatingText = txt(contentRating), vpnText = txt( when (repo.vpnStatus) { @@ -340,7 +267,7 @@ fun LoadResponse.toResultData(repo: APIRepository): ResultData { } ), metaText = - if (repo.providerType == ProviderType.MetaProvider) txt(R.string.provider_info_meta) else null, + if (repo.providerType == ProviderType.MetaProvider) txt(R.string.provider_info_meta) else null, durationText = if (dur == null || dur <= 0) null else txt( secondsToReadable(dur * 60, "0 mins") ), @@ -354,9 +281,9 @@ fun LoadResponse.toResultData(repo: APIRepository): ResultData { ) } else null, noEpisodesFoundText = - if ((this is TvSeriesLoadResponse && this.episodes.isEmpty()) || (this is AnimeLoadResponse && !this.episodes.any { it.value.isNotEmpty() })) txt( - R.string.no_episodes_found - ) else null + if ((this is TvSeriesLoadResponse && this.episodes.isEmpty()) || (this is AnimeLoadResponse && !this.episodes.any { it.value.isNotEmpty() })) txt( + R.string.no_episodes_found + ) else null ) } @@ -370,7 +297,7 @@ data class ExtractorSubtitleLink( fun LoadResponse.getId(): Int { // this fixes an issue with outdated api as getLoadResponseIdFromUrl might be fucked return (if (this is ResultViewModel2.LoadResponseFromSearch) this.id else null) - ?: getLoadResponseIdFromUrl(uniqueUrl, apiName) + ?: getLoadResponseIdFromUrl(url, apiName) } private fun getLoadResponseIdFromUrl(url: String, apiName: String): Int { @@ -398,7 +325,6 @@ data class ResumeWatchingStatus( data class LinkLoadingResult( val links: List, val subs: List, - val syncData: HashMap ) sealed class SelectPopup { @@ -449,7 +375,7 @@ fun SelectPopup.getOptions(context: Context): List { } data class ExtractedTrailerData( - var mirros: List>,//Pair of extracted trailer link and original trailer link + var mirros: List, var subtitles: List = emptyList(), ) @@ -474,13 +400,12 @@ class ResultViewModel2 : ViewModel() { private var currentMeta: SyncAPI.SyncResult? = null private var currentSync: Map? = null private var currentIndex: EpisodeIndexer? = null - private var currentSorting: EpisodeSortType? = null private var currentRange: EpisodeRange? = null private var currentShowFillers: Boolean = false var currentRepo: APIRepository? = null private var currentId: Int? = null - private var fillers: HashSet = hashSetOf() - private var generator: RepoLinkGenerator? = null + private var fillers: Map = emptyMap() + private var generator: IGenerator? = null private var preferDubStatus: DubStatus? = null private var preferStartEpisode: Int? = null private var preferStartSeason: Int? = null @@ -528,18 +453,6 @@ class ResultViewModel2 : ViewModel() { MutableLiveData(null) val selectedRange: LiveData = _selectedRange - private val _selectedSorting: MutableLiveData = - MutableLiveData(null) - val selectedSorting: LiveData = _selectedSorting - - private val _selectedSortingIndex: MutableLiveData = - MutableLiveData(-1) - val selectedSortingIndex: LiveData = _selectedSortingIndex - - private val _sortSelections: MutableLiveData>> = - MutableLiveData(emptyList()) - val sortSelections: LiveData>> = _sortSelections - private val _selectedSeason: MutableLiveData = MutableLiveData(null) val selectedSeason: LiveData = _selectedSeason @@ -584,32 +497,9 @@ 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 { + Regex("[eE]pisode [0-9]*(.*)").find(name)?.groupValues?.get(1)?.let { if (it.isEmpty()) return null } @@ -725,6 +615,236 @@ 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 { + val sanitizedFileName = VideoDownloadManager.sanitizeFilename(titleName) + return when (currentType) { + TvType.Anime -> "Anime/$sanitizedFileName" + TvType.Movie -> "Movies" + TvType.AnimeMovie -> "Movies" + TvType.TvSeries -> "TVSeries/$sanitizedFileName" + TvType.OVA -> "OVA" + TvType.Cartoon -> "Cartoons/$sanitizedFileName" + TvType.Torrent -> "Torrent" + TvType.Documentary -> "Documentaries" + TvType.AsianDrama -> "AsianDrama" + TvType.Live -> "LiveStreams" + TvType.NSFW -> "NSFW" + TvType.Others -> "Others" + TvType.Music -> "Music" + TvType.AudioBook -> "AudioBooks" + TvType.CustomMedia -> "Media" + } + } + + 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) @@ -794,7 +914,7 @@ class ResultViewModel2 : ViewModel() { response.syncData, plot = response.plot, tags = response.tags, - score = response.score + rating = response.rating ) ) } @@ -816,12 +936,7 @@ class ResultViewModel2 : ViewModel() { isVisible: Boolean = true ) { if (activity == null) return - loadLinks( - result, - isVisible = isVisible, - sourceTypes = LOADTYPE_CHROMECAST, - isCasting = true - ) { data -> + loadLinks(result, isVisible = isVisible, sourceTypes = LOADTYPE_CHROMECAST, isCasting = true) { data -> startChromecast(activity, result, data.links, data.subs, 0) } } @@ -891,7 +1006,7 @@ class ResultViewModel2 : ViewModel() { response.year, response.syncData, plot = response.plot, - score = response.score, + rating = response.rating, tags = response.tags ) ) @@ -903,28 +1018,6 @@ class ResultViewModel2 : ViewModel() { } } - private fun getMeta( - episode: ResultEpisode, - titleName: String, - apiName: String, - currentPoster: String?, - currentIsMovie: Boolean, - tvType: TvType, - ): DownloadObjects.DownloadEpisodeMetadata { - return DownloadObjects.DownloadEpisodeMetadata( - episode.id, - episode.parentId, - sanitizeFilename(titleName), - apiName, - episode.poster ?: currentPoster, - episode.name, - if (currentIsMovie) null else episode.season, - if (currentIsMovie) null else episode.episode, - tvType, - ) - } - - /** * Toggles the favorite status of an item. * @@ -984,7 +1077,7 @@ class ResultViewModel2 : ViewModel() { response.year, response.syncData, plot = response.plot, - score = response.score, + rating = response.rating, tags = response.tags ) ) @@ -1104,20 +1197,18 @@ class ResultViewModel2 : ViewModel() { } private fun getImdbIdFromSyncData(syncData: Map?): String? { - return safe { - val imdbId = readIdFromString( + return normalSafeApiCall { + readIdFromString( syncData?.get(AccountManager.simklApi.idPrefix) )[SimklSyncServices.Imdb] - if (imdbId == "null") null else imdbId } } private fun getTMDbIdFromSyncData(syncData: Map?): String? { - return safe { - val tmdbId = readIdFromString( + return normalSafeApiCall { + readIdFromString( syncData?.get(AccountManager.simklApi.idPrefix) )[SimklSyncServices.Tmdb] - if (tmdbId == "null") null else tmdbId } } @@ -1198,22 +1289,15 @@ class ResultViewModel2 : ViewModel() { ) { currentLoadLinkJob?.cancel() currentLoadLinkJob = ioSafe { - val parentJob = this.coroutineContext.job - launch { - val links = loadLinks( - result, - isVisible = isVisible, - sourceTypes = sourceTypes, - clearCache = clearCache, - isCasting = isCasting - ) - // Cancel child = skip link loading - // Cancel parent = dismiss dialog - if (parentJob.isCancelled) { - return@launch - } - work(links) - } + val links = loadLinks( + result, + isVisible = isVisible, + sourceTypes = sourceTypes, + clearCache = clearCache, + isCasting = isCasting + ) + if (!this.isActive) return@ioSafe + work(links) } } @@ -1225,18 +1309,16 @@ class ResultViewModel2 : ViewModel() { isCasting: Boolean = false, callback: (Pair) -> Unit ) { - // TODO Add skip loading here loadLinks(result, isVisible = true, sourceTypes, isCasting = isCasting) { links -> // Could not find a better way to do this - //val context = CloudStreamApp.context + val context = AcraApplication.context postPopup( text, - links.links.map { txt("${it.name} ${Qualities.getStringByInt(it.quality)}") } - /*.amap { - val size = - it.getVideoSize()?.let { size -> " " + formatFileSize(context, size) } ?: "" - txt("${it.name} ${Qualities.getStringByInt(it.quality)}$size") - }*/) { + links.links.apmap { + val size = + it.getVideoSize()?.let { size -> " " + formatFileSize(context, size) } ?: "" + txt("${it.name} ${Qualities.getStringByInt(it.quality)}$size") + }) { callback.invoke(links to (it ?: return@postPopup)) } } @@ -1257,11 +1339,6 @@ class ResultViewModel2 : ViewModel() { } } - fun skipLoading() { - currentLoadLinkJob?.cancelChildren() - currentLoadLinkJob = null - } - private suspend fun CoroutineScope.loadLinks( result: ResultEpisode, isVisible: Boolean, @@ -1280,9 +1357,8 @@ class ResultViewModel2 : ViewModel() { } try { updatePage() - tempGenerator.generateLinks( - clearCache, - sourceTypes = sourceTypes, + tempGenerator.generateLinks(clearCache, + allowedTypes = sourceTypes, callback = { (link, _) -> if (link != null) { links += link @@ -1290,25 +1366,17 @@ class ResultViewModel2 : ViewModel() { } }, subtitleCallback = { sub -> - subs += sub - updatePage() - }, - isCasting = isCasting, - offset = 0 - ) - } catch (_: CancellationException) { - // Do nothing + subs += sub + updatePage() + }, + isCasting = isCasting) } catch (e: Exception) { logError(e) } finally { _loadedLinks.postValue(null) } - return LinkLoadingResult( - sortUrls(links), - sortSubs(subs), - HashMap(currentResponse?.syncData ?: emptyMap()) - ) + return LinkLoadingResult(sortUrls(links), sortSubs(subs)) } fun handleAction(click: EpisodeClickEvent) = @@ -1320,40 +1388,6 @@ class ResultViewModel2 : ViewModel() { _episodeSynopsis.postValue(null) } - private fun markEpisodes( - editor: Editor, - episodeIds: Array, - watchState: VideoWatchState - ) { - val watchStateString = watchState.toJson() - episodeIds.forEach { - if (getVideoWatchState(it.toInt()) != watchState) { - editor.setKeyRaw( - getFolderName("$currentAccount/$VIDEO_WATCH_STATE", it), - watchStateString - ) - } - } - } - - private fun getEpisodesIdsBySeason(season: Int): HashMap> { - val result = currentEpisodes.entries - .asSequence() - .filter { it.key.season <= season && it.key.dubStatus == preferDubStatus } - .flatMap { entry -> - entry.value.asSequence().map { entry.key.season to it.id.toString() } - } - .groupBy({ it.first }, { it.second }) - .mapValues { (_, ids) -> ids.toTypedArray() } - .toMap(HashMap()) - - if (season != 0) { - result.remove(0) - } - return result - } - - private suspend fun handleEpisodeClickEvent(click: EpisodeClickEvent) { when (click.action) { ACTION_SHOW_OPTIONS -> { @@ -1369,6 +1403,7 @@ class ResultViewModel2 : ViewModel() { } options.add(txt(R.string.episode_action_play_in_app) to ACTION_PLAY_EPISODE_IN_PLAYER) + options.addAll( listOf( txt(R.string.episode_action_auto_download) to ACTION_DOWNLOAD_EPISODE, @@ -1390,14 +1425,9 @@ class ResultViewModel2 : ViewModel() { val watchedText = if (isWatched) R.string.action_remove_from_watched else R.string.action_mark_as_watched - val markUpToText = - if (isWatched) R.string.action_remove_mark_watched_up_to_this_episode - else R.string.action_mark_watched_up_to_this_episode - options.add(txt(watchedText) to ACTION_MARK_AS_WATCHED) - - options.add(txt(markUpToText) to ACTION_MARK_WATCHED_UP_TO_THIS_EPISODE) } + postPopup( txt( activity?.getNameFull( @@ -1471,17 +1501,16 @@ class ResultViewModel2 : ViewModel() { ACTION_DOWNLOAD_EPISODE -> { val response = currentResponse ?: return - DownloadQueueManager.addToQueue( - DownloadObjects.DownloadQueueItem( - click.data, - response.isMovie(), - response.name, - response.type, - response.posterUrl, - response.apiName, - response.getId(), - response.url, - ).toWrapper() + downloadEpisode( + activity, + click.data, + response.isMovie(), + response.name, + response.type, + response.posterUrl, + response.apiName, + response.getId(), + response.url ) } @@ -1492,8 +1521,9 @@ class ResultViewModel2 : ViewModel() { LOADTYPE_INAPP_DOWNLOAD, txt(R.string.episode_action_download_mirror) ) { (result, index) -> - DownloadQueueManager.addToQueue( - DownloadObjects.DownloadQueueItem( + ioSafe { + startDownload( + activity, click.data, response.isMovie(), response.name, @@ -1504,8 +1534,8 @@ class ResultViewModel2 : ViewModel() { response.url, listOf(result.links[index]), result.subs, - ).toWrapper() - ) + ) + } showToast( R.string.download_started, Toast.LENGTH_SHORT @@ -1544,25 +1574,28 @@ class ResultViewModel2 : ViewModel() { } ACTION_PLAY_EPISODE_IN_PLAYER -> { - val list = HashMap(currentResponse?.syncData ?: emptyMap()) - val generator = generator ?: return - - // I know kinda shit to iterate all, but it is 100% sure to work - val index = generator.videos.indexOfFirst { value -> value.id == click.data.id } - + val data = currentResponse?.syncData?.toList() ?: emptyList() + val list = + HashMap().apply { putAll(data) } + generator?.also { + it.getAll() // I know kinda shit to iterate all, but it is 100% sure to work + ?.indexOfFirst { value -> value is ResultEpisode && value.id == click.data.id } + ?.let { index -> + if (index >= 0) + it.goto(index) + } + } if (currentResponse?.type == TvType.CustomMedia) { - generator.generateLinks( - offset = index, + generator?.generateLinks( clearCache = true, - isCasting = false, - sourceTypes = LOADTYPE_ALL, + LOADTYPE_ALL, callback = {}, subtitleCallback = {}) } else { activity?.navigate( R.id.global_to_navigation_player, GeneratorPlayer.newInstance( - generator, index,list + generator ?: return, list ) ) } @@ -1571,70 +1604,20 @@ 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 - // Special handling for AlwaysAskAction - show player selection dialog - if (action is AlwaysAskAction) { - activity?.let { ctx -> - // 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) - }) - - postPopup( - txt(R.string.player_pref), - options - ) { selectedAction -> - if (selectedAction != null) { - handleEpisodeClickEvent( - click.copy(action = selectedAction) - ) - } - } - } - return - } - activity?.setKey("last_click_action", action.uniqueId()) if (action.oneSource) { acquireSingleLink( @@ -1642,7 +1625,7 @@ class ResultViewModel2 : ViewModel() { action.sourceTypes, action.name ) { (result, index) -> - action.runActionSafe( + action.runAction( activity, click.data, result, @@ -1651,7 +1634,7 @@ class ResultViewModel2 : ViewModel() { } } else { loadLinks(click.data, isVisible = true, action.sourceTypes) { links -> - action.runActionSafe( + action.runAction( activity, click.data, links, @@ -1675,7 +1658,7 @@ class ResultViewModel2 : ViewModel() { if (meta != null) { duration = duration ?: meta.duration - score = score ?: meta.publicScore + rating = rating ?: meta.publicScore tags = tags ?: meta.genres plot = if (plot.isNullOrBlank()) meta.synopsis else plot posterUrl = posterUrl ?: meta.posterUrl ?: meta.backgroundPosterUrl @@ -1686,13 +1669,14 @@ class ResultViewModel2 : ViewModel() { } val realRecommendations = ArrayList() - val apiNames = apis.filter { - it.name.contains("gogoanime", true) || - it.name.contains("9anime", true) - }.map { - it.name + val apiNames = synchronized(apis) { + apis.filter { + it.name.contains("gogoanime", true) || + it.name.contains("9anime", true) + }.map { + it.name + } } - meta.recommendations?.forEach { rec -> apiNames.forEach { name -> realRecommendations.add(rec.copy(apiName = name)) @@ -1707,11 +1691,11 @@ class ResultViewModel2 : ViewModel() { syncData[k] = v } - runAllAsync( + argamap( { - if (this !is AnimeLoadResponse) return@runAllAsync + if (this !is AnimeLoadResponse) return@argamap // already exist, no need to run getTracker - if (this.getAniListId() != null && this.getKitsuId() != null && this.getMalId() != null) return@runAllAsync + if (this.getAniListId() != null && this.getMalId() != null) return@argamap val res = APIHolder.getTracker( listOfNotNull( @@ -1729,12 +1713,9 @@ class ResultViewModel2 : ViewModel() { this.year ) - val kitsuId = AccountManager.kitsuApi.getAnimeIdByTitle(this.name) - val ids = arrayOf( AccountManager.malApi.idPrefix to res?.malId?.toString(), - AccountManager.aniListApi.idPrefix to res?.aniId, - AccountManager.kitsuApi.idPrefix to kitsuId + AccountManager.aniListApi.idPrefix to res?.aniId ) if (ids.any { (id, new) -> @@ -1743,7 +1724,7 @@ class ResultViewModel2 : ViewModel() { } ) { // getTracker fucked up as it conflicts with current implementation - return@runAllAsync + return@argamap } // set all the new data, prioritise old correct data @@ -1756,20 +1737,19 @@ 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 + if (meta == null) return@argamap addTrailer(meta.trailers) }, { - if (this !is AnimeLoadResponse) return@runAllAsync + if (this !is AnimeLoadResponse) return@argamap val map = Kitsu.getEpisodesDetails( getMalId(), getAniListId(), isResponseRequired = false ) - if (map.isNullOrEmpty()) return@runAllAsync + if (map.isNullOrEmpty()) return@argamap updateEpisodes = DubStatus.entries.map { dubStatus -> val current = this.episodes[dubStatus]?.mapIndexed { index, episode -> @@ -1831,36 +1811,23 @@ class ResultViewModel2 : ViewModel() { } - private suspend fun updateFillers(data: LoadResponse) { - fillers = ioWorkSafe { - FillerEpisodeCheck.getFillerEpisodes(data) - } ?: hashSetOf() + private suspend fun updateFillers(name: String) { + fillers = + ioWorkSafe { + FillerEpisodeCheck.getFillerEpisodes(name) + } ?: emptyMap() } fun changeDubStatus(status: DubStatus) { - postEpisodeRange( - currentIndex?.copy(dubStatus = status), - currentRange, - currentSorting ?: DataStoreHelper.resultsSortingMode - ) + postEpisodeRange(currentIndex?.copy(dubStatus = status), currentRange) } fun changeRange(range: EpisodeRange) { - postEpisodeRange(currentIndex, range, currentSorting ?: DataStoreHelper.resultsSortingMode) + postEpisodeRange(currentIndex, range) } fun changeSeason(season: Int) { - postEpisodeRange( - currentIndex?.copy(season = season), - currentRange, - currentSorting ?: DataStoreHelper.resultsSortingMode - ) - } - - fun setSort(sortType: EpisodeSortType) { - // we only update here as postEpisodeRange might change the sorting mode if it does not fit - DataStoreHelper.resultsSortingMode = sortType - postEpisodeRange(currentIndex, currentRange, sortType) + postEpisodeRange(currentIndex?.copy(season = season), currentRange) } private fun getMovie(): ResultEpisode? { @@ -1870,40 +1837,26 @@ class ResultViewModel2 : ViewModel() { } } - private fun getEpisodes( - indexer: EpisodeIndexer, - range: EpisodeRange, - ): List { - return currentEpisodes[indexer]?.let { list -> - val start = minOf(list.size, range.startIndex) - val end = minOf(list.size, start + range.length) - list.subList(start, end).map { - val posDur = getViewPos(it.id) - val watchState = getVideoWatchState(it.id) ?: VideoWatchState.None - it.copy( - position = posDur?.position ?: 0, - duration = posDur?.duration ?: 0, - videoWatchState = watchState - ) - } - } ?: emptyList() - } + private fun getEpisodes(indexer: EpisodeIndexer, range: EpisodeRange): List { + val startIndex = range.startIndex + val length = range.length - private fun getSortedEpisodes( - episodes: List, - sorting: EpisodeSortType - ): List { - return when (sorting) { - EpisodeSortType.NUMBER_ASC -> episodes.sortedBy { it.episode } - EpisodeSortType.NUMBER_DESC -> episodes.sortedByDescending { it.episode } - EpisodeSortType.RATING_HIGH_LOW -> episodes.sortedByDescending { - it.score?.toDouble() ?: 0.0 + return currentEpisodes[indexer] + ?.let { list -> + val start = minOf(list.size, startIndex) + val end = minOf(list.size, start + length) + list.subList(start, end).map { + val posDur = getViewPos(it.id) + val watchState = + getVideoWatchState(it.id) ?: VideoWatchState.None + it.copy( + position = posDur?.position ?: 0, + duration = posDur?.duration ?: 0, + videoWatchState = watchState + ) + } } - - 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 } - } + ?: emptyList() } private fun postMovie() { @@ -1918,7 +1871,6 @@ class ResultViewModel2 : ViewModel() { val text = txt( when (response.type) { TvType.Torrent -> R.string.play_torrent_button - TvType.TvSeries -> R.string.play_full_series_button else -> { if (response.type.isLiveStream()) R.string.play_livestream_button @@ -1943,11 +1895,9 @@ class ResultViewModel2 : ViewModel() { } else { _episodes.postValue( Resource.Success( - getSortedEpisodes( - getEpisodes( - currentIndex ?: return, - currentRange ?: return, - ), currentSorting ?: return + getEpisodes( + currentIndex ?: return, + currentRange ?: return ) ) ) @@ -1975,24 +1925,8 @@ class ResultViewModel2 : ViewModel() { _favoriteStatus.postValue(isFavorite) } - private fun shouldEnableSort(type: EpisodeSortType, episodes: List?): Boolean { - if (episodes.isNullOrEmpty()) return false - return when (type) { - EpisodeSortType.NUMBER_ASC, EpisodeSortType.NUMBER_DESC -> true - EpisodeSortType.RATING_HIGH_LOW, EpisodeSortType.RATING_LOW_HIGH -> - episodes.any { it.score != null } - - EpisodeSortType.DATE_NEWEST, EpisodeSortType.DATE_OLDEST -> - episodes.any { it.airDate != null } - } - } - - private fun postEpisodeRange( - indexer: EpisodeIndexer?, - range: EpisodeRange?, - sorting: EpisodeSortType? - ) { - if (range == null || indexer == null || sorting == null) { + private fun postEpisodeRange(indexer: EpisodeIndexer?, range: EpisodeRange?) { + if (range == null || indexer == null) { return } @@ -2000,10 +1934,10 @@ class ResultViewModel2 : ViewModel() { if (ranges?.contains(range) != true) { // if the current ranges does not include the range then select the range with the closest matching start episode - // this usually happens when dub has less episodes then sub -> the range does not exist + // this usually happends when dub has less episodes then sub -> the range does not exist ranges?.minByOrNull { kotlin.math.abs(it.startEpisode - range.startEpisode) } ?.let { r -> - postEpisodeRange(indexer, r, sorting) + postEpisodeRange(indexer, r) return } } @@ -2034,8 +1968,29 @@ class ResultViewModel2 : ViewModel() { ) _selectedSeason.postValue( + if (isMovie || currentSeasons.size <= 1) null else - (currentResponse as? EpisodeResponse)?.seasonNames.getSeasonTxt(indexer.season) + when (indexer.season) { + 0 -> txt(R.string.no_season) + else -> { + val seasonNames = (currentResponse as? EpisodeResponse)?.seasonNames + val seasonData = seasonNames.getSeason(indexer.season) + + // If displaySeason is null then only show the name! + if (seasonData?.name != null && seasonData.displaySeason == null) { + txt(seasonData.name) + } else { + val suffix = seasonData?.name?.let { " $it" } ?: "" + txt( + R.string.season_format, + txt(R.string.season), + seasonData?.displaySeason ?: indexer.season, + suffix + ) + } + } + } + ) _selectedRangeIndex.postValue( @@ -2085,64 +2040,16 @@ class ResultViewModel2 : ViewModel() { } if (isMovie) { - _sortSelections.postValue(emptyList()) - _selectedSortingIndex.postValue(-1) - _selectedSorting.postValue(null) - postMovie() } else { val ret = getEpisodes(indexer, range) + /*if (ret.isEmpty()) { + val index = ranges?.indexOf(range) + if(index != null && index > 0) { - if (ret.size <= 1) { - // we cant sort on an empty list or a list with only 1 episode - _sortSelections.postValue(emptyList()) - _selectedSortingIndex.postValue(-1) - _selectedSorting.postValue(null) - _episodes.postValue(Resource.Success(ret)) - } else { - val sortOptions = mutableListOf>().apply { - // Episode number sorting is always available - add(txt(R.string.sort_episodes_number_asc) to EpisodeSortType.NUMBER_ASC) - add(txt(R.string.sort_episodes_number_desc) to EpisodeSortType.NUMBER_DESC) - - // Only add rating options if any episodes have ratings - if (shouldEnableSort(EpisodeSortType.RATING_HIGH_LOW, ret)) { - add(txt(R.string.sort_episodes_rating_high_low) to EpisodeSortType.RATING_HIGH_LOW) - add(txt(R.string.sort_episodes_rating_low_high) to EpisodeSortType.RATING_LOW_HIGH) - } - - // Only add air date options if any episodes have air dates - if (shouldEnableSort(EpisodeSortType.DATE_NEWEST, ret)) { - add(txt(R.string.sort_episodes_date_newest) to EpisodeSortType.DATE_NEWEST) - add(txt(R.string.sort_episodes_date_oldest) to EpisodeSortType.DATE_OLDEST) - } } - - var sortIndex = sortOptions.indexOfFirst { it.second == sorting } - - // correct the sorting order so if we have a selected that is not possible we just choose the default NUMBER_ASC - val correctedSorting = if (sortIndex == -1) { - sortIndex = 0 - EpisodeSortType.NUMBER_ASC - } else { - sorting - } - - currentSorting = correctedSorting - _sortSelections.postValue(sortOptions) - _selectedSortingIndex.postValue(sortIndex) - _selectedSorting.postValue( - when (correctedSorting) { - EpisodeSortType.NUMBER_ASC -> txt(R.string.sort_button_episode, "↑") - EpisodeSortType.NUMBER_DESC -> txt(R.string.sort_button_episode, "↓") - EpisodeSortType.RATING_HIGH_LOW -> txt(R.string.sort_button_rating, "↓") - EpisodeSortType.RATING_LOW_HIGH -> txt(R.string.sort_button_rating, "↑") - EpisodeSortType.DATE_NEWEST -> txt(R.string.sort_button_date, "↓") - EpisodeSortType.DATE_OLDEST -> txt(R.string.sort_button_date, "↑") - } - ) - _episodes.postValue(Resource.Success(getSortedEpisodes(ret, correctedSorting))) - } + }*/ + _episodes.postValue(Resource.Success(ret)) } } @@ -2171,8 +2078,8 @@ class ResultViewModel2 : ViewModel() { ) { _episodes.postValue(Resource.Loading()) - if (updateFillers) { - updateFillers(loadResponse) + if (updateFillers && loadResponse is AnimeLoadResponse) { + updateFillers(loadResponse.name) } val allEpisodes = when (loadResponse) { @@ -2205,21 +2112,20 @@ class ResultViewModel2 : ViewModel() { filterName(i.name), i.posterUrl, episode, - i.season, + seasonData?.season ?: i.season, if (seasonData != null) seasonData.displaySeason else i.season, i.data, loadResponse.apiName, id, index, - i.score, + i.rating, i.description, - fillers.contains(episode), + fillers.getOrDefault(episode, false), loadResponse.type, mainId, totalIndex, airDate = i.date, runTime = i.runTime, - seasonData = seasonData, ) val season = eps.seasonIndex ?: 0 @@ -2262,13 +2168,13 @@ class ResultViewModel2 : ViewModel() { filterName(episode.name), episode.posterUrl, episodeIndex, - episode.season, + seasonData?.season ?: episode.season, if (seasonData != null) seasonData.displaySeason else episode.season, episode.data, loadResponse.apiName, id, index, - episode.score, + episode.rating, episode.description, null, loadResponse.type, @@ -2276,7 +2182,6 @@ class ResultViewModel2 : ViewModel() { totalIndex, airDate = episode.date, runTime = episode.runTime, - seasonData = seasonData, ) val season = ep.seasonIndex ?: 0 @@ -2375,7 +2280,21 @@ class ResultViewModel2 : ViewModel() { _dubSubSelections.postValue(dubSelection.map { txt(it) to it }) if (loadResponse is EpisodeResponse) { _seasonSelections.postValue(seasonsSelection.map { seasonNumber -> - loadResponse.seasonNames.getSeasonTxt(seasonNumber) to seasonNumber + val seasonData = loadResponse.seasonNames.getSeason(seasonNumber) + val fixedSeasonNumber = seasonData?.displaySeason ?: seasonNumber + val suffix = seasonData?.name?.let { " $it" } ?: "" + // If displaySeason is null then only show the name! + val name = if (seasonData?.name != null && seasonData.displaySeason == null) { + txt(seasonData.name) + } else { + txt( + R.string.season_format, + txt(R.string.season), + fixedSeasonNumber, + suffix + ) + } + name to seasonNumber }) } @@ -2397,7 +2316,7 @@ class ResultViewModel2 : ViewModel() { it.startEpisode >= (preferStartEpisode ?: 0) } ?: ranger?.lastOrNull() - postEpisodeRange(min, range, DataStoreHelper.resultsSortingMode) + postEpisodeRange(min, range) postResume() } @@ -2453,33 +2372,22 @@ class ResultViewModel2 : ViewModel() { loadResponse.trailers.windowed(limit, limit, true).takeWhile { list -> list.amap { trailerData -> try { - val links = arrayListOf>() + val links = arrayListOf() val subs = arrayListOf() if (!loadExtractor( trailerData.extractorUrl, trailerData.referer, { subs.add(it) }, - { - links.add( - Pair( - it, - trailerData.extractorUrl - ) - ) - }) && trailerData.raw + { links.add(it) }) && trailerData.raw ) { arrayListOf( - Pair( - newExtractorLink( - "", - "Trailer", - trailerData.extractorUrl, - type = INFER_TYPE - ) { - this.referer = trailerData.referer ?: "" - this.quality = Qualities.Unknown.value - this.headers = trailerData.headers - }, trailerData.extractorUrl + ExtractorLink( + "", + "Trailer", + trailerData.extractorUrl, + trailerData.referer ?: "", + Qualities.Unknown.value, + type = INFER_TYPE ) ) to arrayListOf() } else { @@ -2554,7 +2462,7 @@ class ResultViewModel2 : ViewModel() { override var posterUrl: String?, override var year: Int? = null, override var plot: String? = null, - override var score: Score? = null, + override var rating: Int? = null, override var tags: List? = null, override var duration: Int? = null, override var trailers: MutableList = mutableListOf(), @@ -2564,9 +2472,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?, ) : LoadResponse @@ -2589,12 +2495,12 @@ class ResultViewModel2 : ViewModel() { ).apply { if (searchResponse is SyncAPI.LibraryItem) { this.plot = searchResponse.plot - this.score = searchResponse.personalRating ?: searchResponse.score + this.rating = searchResponse.personalRating?.times(100) ?: searchResponse.rating this.tags = searchResponse.tags } if (searchResponse is DataStoreHelper.BookmarkedData) { this.plot = searchResponse.plot - this.score = searchResponse.score + this.rating = searchResponse.rating this.tags = searchResponse.tags } } @@ -2631,6 +2537,8 @@ class ResultViewModel2 : ViewModel() { _page.postValue( Resource.Failure( false, + null, + null, "This provider does not exist" ) ) @@ -2678,7 +2586,7 @@ class ResultViewModel2 : ViewModel() { setKey( DOWNLOAD_HEADER_CACHE, mainId.toString(), - DownloadObjects.DownloadHeaderCached( + VideoDownloadHelper.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 4231819dd..8752e275c 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,68 +2,102 @@ 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 -import com.lagradost.cloudstream3.utils.setText typealias SelectData = Pair -class SelectAdaptor(val callback: (Any) -> Unit) : - NoStateAdapter(diffCallback = BaseDiffCallback(itemSame = { a, b -> - a.second == b.second - }, contentSame = { a, b -> - a == b - })) { +class SelectAdaptor(val callback: (Any) -> Unit) : RecyclerView.Adapter() { + private val selection: MutableList = mutableListOf() private var selectedIndex: Int = -1 - override fun onCreateContent(parent: ViewGroup): ViewHolderState { - return ViewHolderState( - ResultSelectionBinding.inflate( - LayoutInflater.from(parent.context), - parent, - false - ) + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { + return SelectViewHolder( + ResultSelectionBinding.inflate(LayoutInflater.from(parent.context), parent, false), + + //LayoutInflater.from(parent.context).inflate(R.layout.result_selection, parent, false), ) } - override fun onBindContent(holder: ViewHolderState, item: SelectData, position: Int) { - when (val binding = holder.view) { - is ResultSelectionBinding -> { - binding.root.apply { - if (isLayout(TV)) { - isFocusable = true - isFocusableInTouchMode = true - } - - isSelected = position == selectedIndex - setText(item.first) - setOnClickListener { - callback.invoke(item.second) - } - } + override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { + when (holder) { + is SelectViewHolder -> { + holder.bind(selection[position], position == selectedIndex, callback) } } } - override fun onViewDetachedFromWindow(holder: ViewHolderState) { - if (holder.itemView.hasFocus()) { + override fun onViewDetachedFromWindow(holder: RecyclerView.ViewHolder) { + if(holder.itemView.hasFocus()) { holder.itemView.clearFocus() } } + override fun getItemCount(): Int { + return selection.size + } + fun select(newIndex: Int, recyclerView: RecyclerView?) { - if (recyclerView == null) return - if (newIndex == selectedIndex) return + if(recyclerView == null) return + if(newIndex == selectedIndex) return val oldIndex = selectedIndex selectedIndex = newIndex notifyItemChanged(selectedIndex) notifyItemChanged(oldIndex) } + + fun updateSelectionList(newList: List) { + val diffResult = DiffUtil.calculateDiff( + SelectDataCallback(this.selection, newList) + ) + + selection.clear() + selection.addAll(newList) + + diffResult.dispatchUpdatesTo(this) + } + + + private class SelectViewHolder( + binding: ResultSelectionBinding, + ) : + RecyclerView.ViewHolder(binding.root) { + private val item: MaterialButton = binding.root + + fun bind( + data: SelectData, isSelected: Boolean, callback: (Any) -> Unit + ) { + if (isLayout(TV)) { + item.isFocusable = true + item.isFocusableInTouchMode = true + } + + item.isSelected = isSelected + item.setText(data.first) + item.setOnClickListener { + callback.invoke(data.second) + } + } + } } + +class SelectDataCallback( + private val oldList: List, + private val newList: List +) : + DiffUtil.Callback() { + override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int) = + oldList[oldItemPosition].second == newList[newItemPosition].second + + override fun getOldListSize() = oldList.size + + override fun getNewListSize() = newList.size + + override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int) = + oldList[oldItemPosition] == newList[newItemPosition] +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/result/SyncViewModel.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/result/SyncViewModel.kt index 6c5c64ff8..51d3f50ca 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,14 +4,11 @@ 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.mvvm.throwAbleToResource -import com.lagradost.cloudstream3.syncproviders.AccountManager +import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.SyncApis 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 @@ -34,7 +31,7 @@ class SyncViewModel : ViewModel() { const val TAG = "SYNCVM" } - private val repos = AccountManager.syncApis + private val repos = SyncApis private val _metaResponse: MutableLiveData?> = MutableLiveData(null) @@ -68,7 +65,7 @@ class SyncViewModel : ViewModel() { it.name, it.idPrefix, syncs.containsKey(it.idPrefix), - it.authUser() != null, + it.hasAccount(), it.icon, ) } @@ -160,7 +157,7 @@ class SyncViewModel : ViewModel() { } } - fun setScore(score: Score?) { + fun setScore(score: Int) { Log.i(TAG, "setScore = $score") val user = userData.value if (user is Resource.Success) { @@ -184,7 +181,7 @@ class SyncViewModel : ViewModel() { val user = userData.value if (user is Resource.Success) { syncs.forEach { (prefix, id) -> - repos.firstOrNull { it.idPrefix == prefix }?.updateStatus(id, user.value) + repos.firstOrNull { it.idPrefix == prefix }?.score(id, user.value) } } updateUserData() @@ -206,10 +203,17 @@ class SyncViewModel : ViewModel() { ioSafe { syncs.amap { (prefix, id) -> repos.firstOrNull { it.idPrefix == prefix }?.let { repo -> - val result = - update(repo.status(id).getOrNull() ?: return@let null) ?: return@let null - Log.i(TAG, "modifyData ${repo.name} => $result") - repo.updateStatus(id, result) + 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}") + } + } } } } @@ -217,24 +221,29 @@ class SyncViewModel : ViewModel() { fun updateUserData() = ioSafe { Log.i(TAG, "updateUserData") _userDataResponse.postValue(Resource.Loading()) - - 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)) + var lastError: Resource = Resource.Failure(false, null, null, "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 + } + } + } } + _userDataResponse.postValue(lastError) } private fun updateMetadata() = ioSafe { Log.i(TAG, "updateMetadata") _metaResponse.postValue(Resource.Loading()) - var lastError: Resource = Resource.Failure(false, "No data") + var lastError: Resource = Resource.Failure(false, null, null, "No data") val current = ArrayList(syncs.toList()) // shitty way to sort anilist first, as it has trailers while mal does not @@ -252,20 +261,19 @@ class SyncViewModel : ViewModel() { current.forEach { (prefix, id) -> repos.firstOrNull { it.idPrefix == prefix }?.let { repo -> - 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) + 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 + } } } } @@ -273,11 +281,10 @@ class SyncViewModel : ViewModel() { setEpisodesDelta(0) } - fun syncName(syncName: String): String? { + fun syncName(syncName: String) : String? { // fix because of bad old data :pensive: - val realName = when (syncName) { + val realName = when(syncName) { "MAL" -> malApi.idPrefix - "Kitsu" -> kitsuApi.idPrefix "Simkl" -> simklApi.idPrefix "AniList" -> aniListApi.idPrefix else -> syncName @@ -285,7 +292,7 @@ class SyncViewModel : ViewModel() { return repos.firstOrNull { it.idPrefix == realName }?.idPrefix } - fun setSync(syncName: String, syncId: String) { + fun setSync(syncName : String, syncId : String) { syncs.clear() syncs[syncName] = syncId } diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/TextUtil.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/result/UiText.kt similarity index 63% rename from app/src/main/java/com/lagradost/cloudstream3/utils/TextUtil.kt rename to app/src/main/java/com/lagradost/cloudstream3/ui/result/UiText.kt index 4f3a74737..709199430 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/TextUtil.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/result/UiText.kt @@ -1,13 +1,17 @@ -package com.lagradost.cloudstream3.utils +package com.lagradost.cloudstream3.ui.result import android.content.Context +import android.graphics.Bitmap import android.util.Log +import android.widget.ImageView import android.widget.TextView +import androidx.annotation.DrawableRes import androidx.annotation.StringRes import androidx.core.view.isGone import androidx.core.view.isVisible import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.utils.AppContextUtils.html +import com.lagradost.cloudstream3.utils.UIHelper.setImage sealed class UiText { companion object { @@ -73,6 +77,71 @@ sealed class UiText { } } +sealed class UiImage { + data class Image( + val url: String, + val headers: Map? = null, + @DrawableRes val errorDrawable: Int? = null + ) : UiImage() + + data class Drawable(@DrawableRes val resId: Int) : UiImage() + data class Bitmap(val bitmap: android.graphics.Bitmap) : UiImage() +} + +fun ImageView?.setImage(value: UiImage?, fadeIn: Boolean = true) { + when (value) { + is UiImage.Image -> setImageImage(value, fadeIn) + is UiImage.Drawable -> setImageDrawable(value) + is UiImage.Bitmap -> setImageBitmap(value) + null -> { + this?.isVisible = false + } + } +} + +fun ImageView?.setImageImage(value: UiImage.Image, fadeIn: Boolean = true) { + if (this == null) return + this.isVisible = setImage(value.url, value.headers, value.errorDrawable, fadeIn) +} + +fun ImageView?.setImageDrawable(value: UiImage.Drawable) { + if (this == null) return + this.isVisible = true + this.setImage(UiImage.Drawable(value.resId)) +} + +fun ImageView?.setImageBitmap(value: UiImage.Bitmap) { + if (this == null) return + this.isVisible = true + this.setImageBitmap(value.bitmap) +} + +@JvmName("imgNull") +fun img( + url: String?, + headers: Map? = null, + @DrawableRes errorDrawable: Int? = null +): UiImage? { + if (url.isNullOrBlank()) return null + return UiImage.Image(url, headers, errorDrawable) +} + +fun img( + url: String, + headers: Map? = null, + @DrawableRes errorDrawable: Int? = null +): UiImage { + return UiImage.Image(url, headers, errorDrawable) +} + +fun img(@DrawableRes drawable: Int): UiImage { + return UiImage.Drawable(drawable) +} + +fun img(bitmap: Bitmap): UiImage { + return UiImage.Bitmap(bitmap) +} + fun txt(value: String): UiText { return UiText.DynamicString(value) } diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchAdaptor.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchAdaptor.kt index 7b63b6ede..f318401c0 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchAdaptor.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchAdaptor.kt @@ -4,15 +4,15 @@ import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.widget.FrameLayout +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.RecyclerView +import androidx.viewbinding.ViewBinding import com.lagradost.cloudstream3.SearchResponse import com.lagradost.cloudstream3.databinding.SearchResultGridBinding import com.lagradost.cloudstream3.databinding.SearchResultGridExpandedBinding import com.lagradost.cloudstream3.ui.AutofitRecyclerView -import com.lagradost.cloudstream3.ui.BaseDiffCallback -import com.lagradost.cloudstream3.ui.NoStateAdapter -import com.lagradost.cloudstream3.ui.ViewHolderState -import com.lagradost.cloudstream3.ui.newSharedPool import com.lagradost.cloudstream3.utils.UIHelper.isBottomLayout +import com.lagradost.cloudstream3.utils.UIHelper.toPx import kotlin.math.roundToInt /** Click */ @@ -31,28 +31,13 @@ class SearchClickCallback( ) class SearchAdapter( + private val cardList: MutableList, private val resView: AutofitRecyclerView, - private val isHorizontal:Boolean = false, private val clickCallback: (SearchClickCallback) -> Unit, -) : NoStateAdapter(diffCallback = BaseDiffCallback(itemSame = { a, b -> - if (a.id != null || b.id != null) { - a.id == b.id - } else { - a.name == b.name - } -})) { - companion object { - val sharedPool = - newSharedPool { setMaxRecycledViews(CONTENT, 10) } - } - +) : RecyclerView.Adapter() { var hasNext: Boolean = false - private val coverRatio = if(isHorizontal) 1.8 else 0.68 - - private val coverHeight: Int get() = (resView.itemWidth / coverRatio).roundToInt() - - override fun onCreateContent(parent: ViewGroup): ViewHolderState { + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { val inflater = LayoutInflater.from(parent.context) val layout = @@ -64,36 +49,84 @@ class SearchAdapter( inflater, parent, false - ) - return ViewHolderState(layout) - } + ) //R.layout.search_result_grid_expanded else R.layout.search_result_grid - override fun onClearView(holder: ViewHolderState) { - clearImage( - when (val binding = holder.view) { - is SearchResultGridExpandedBinding -> binding.imageView - is SearchResultGridBinding -> binding.imageView - else -> null - } + + + return CardViewHolder( + layout, + clickCallback, + resView ) } - override fun onBindContent(holder: ViewHolderState, item: SearchResponse, position: Int) { - val imageView = when (val binding = holder.view) { + override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { + when (holder) { + is CardViewHolder -> { + holder.bind(cardList[position], position) + } + } + } + + override fun getItemCount(): Int { + return cardList.size + } + + fun updateList(newList: List) { + val diffResult = DiffUtil.calculateDiff( + SearchResponseDiffCallback(this.cardList, newList) + ) + + cardList.clear() + cardList.addAll(newList) + + diffResult.dispatchUpdatesTo(this) + } + + class CardViewHolder( + val binding: ViewBinding, + private val clickCallback: (SearchClickCallback) -> Unit, + resView: AutofitRecyclerView + ) : + RecyclerView.ViewHolder(binding.root) { + + private val compactView = false//itemView.context.getGridIsCompact() + private val coverHeight: Int = + if (compactView) 80.toPx else (resView.itemWidth / 0.68).roundToInt() + + private val cardView = when(binding) { is SearchResultGridExpandedBinding -> binding.imageView is SearchResultGridBinding -> binding.imageView else -> null } - if (imageView != null) { - val params = FrameLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - coverHeight - ) - if (imageView.layoutParams.width != params.width || imageView.layoutParams.height != params.height) { - imageView.layoutParams = params + fun bind(card: SearchResponse, position: Int) { + if (!compactView) { + cardView?.apply { + layoutParams = FrameLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + coverHeight + ) + } } + + SearchResultBuilder.bind(clickCallback, card, position, itemView) } - SearchResultBuilder.bind(clickCallback, item, position, holder.view.root) } +} + +class SearchResponseDiffCallback( + private val oldList: List, + private val newList: List +) : + DiffUtil.Callback() { + override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int) = + oldList[oldItemPosition].name == newList[newItemPosition].name + + override fun getOldListSize() = oldList.size + + override fun getNewListSize() = newList.size + + override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int) = + oldList[oldItemPosition] == newList[newItemPosition] } \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchFragment.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchFragment.kt index 5f5b064b5..ef10fcee5 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 @@ -1,11 +1,9 @@ package com.lagradost.cloudstream3.ui.search -import android.app.Activity -import android.content.Intent import android.content.DialogInterface -import android.speech.RecognizerIntent -import android.speech.SpeechRecognizer +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 @@ -18,21 +16,19 @@ import android.widget.TextView import androidx.appcompat.app.AlertDialog import androidx.appcompat.widget.SearchView import androidx.core.view.isVisible +import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels import androidx.preference.PreferenceManager import androidx.recyclerview.widget.GridLayoutManager -import androidx.recyclerview.widget.LinearLayoutManager -import androidx.activity.result.contract.ActivityResultContracts -import androidx.core.view.doOnLayout +import androidx.recyclerview.widget.RecyclerView 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 import com.lagradost.cloudstream3.MainActivity @@ -47,21 +43,16 @@ import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.mvvm.observe import com.lagradost.cloudstream3.ui.APIRepository import com.lagradost.cloudstream3.ui.BaseAdapter -import com.lagradost.cloudstream3.ui.BaseFragment import com.lagradost.cloudstream3.ui.home.HomeFragment import com.lagradost.cloudstream3.ui.home.HomeFragment.Companion.bindChips import com.lagradost.cloudstream3.ui.home.HomeFragment.Companion.currentSpan import com.lagradost.cloudstream3.ui.home.HomeFragment.Companion.loadHomepageList import com.lagradost.cloudstream3.ui.home.HomeFragment.Companion.updateChips -import com.lagradost.cloudstream3.ui.home.HomeViewModel import com.lagradost.cloudstream3.ui.home.ParentItemAdapter import com.lagradost.cloudstream3.ui.result.FOCUS_SELF import com.lagradost.cloudstream3.ui.result.setLinearListLayout -import com.lagradost.cloudstream3.ui.setRecycledViewPool import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR -import com.lagradost.cloudstream3.ui.settings.Globals.PHONE import com.lagradost.cloudstream3.ui.settings.Globals.TV -import com.lagradost.cloudstream3.ui.settings.Globals.isLandscape import com.lagradost.cloudstream3.ui.settings.Globals.isLayout import com.lagradost.cloudstream3.utils.AppContextUtils.filterProviderByPreferredMedia import com.lagradost.cloudstream3.utils.AppContextUtils.filterSearchResultByFilmQuality @@ -70,23 +61,17 @@ import com.lagradost.cloudstream3.utils.AppContextUtils.getApiSettings import com.lagradost.cloudstream3.utils.AppContextUtils.ownHide import com.lagradost.cloudstream3.utils.AppContextUtils.ownShow import com.lagradost.cloudstream3.utils.AppContextUtils.setDefaultFocus -import com.lagradost.cloudstream3.utils.Coroutines.ioSafe import com.lagradost.cloudstream3.utils.Coroutines.main import com.lagradost.cloudstream3.utils.DataStoreHelper import com.lagradost.cloudstream3.utils.DataStoreHelper.currentAccount import com.lagradost.cloudstream3.utils.SubtitleHelper -import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.attachBackPressedCallback -import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.detachBackPressedCallback import com.lagradost.cloudstream3.utils.UIHelper.dismissSafe -import com.lagradost.cloudstream3.utils.UIHelper.fixSystemBarsPadding +import com.lagradost.cloudstream3.utils.UIHelper.fixPaddingStatusbar import com.lagradost.cloudstream3.utils.UIHelper.getSpanCount import com.lagradost.cloudstream3.utils.UIHelper.hideKeyboard -import java.util.Locale import java.util.concurrent.locks.ReentrantLock -class SearchFragment : BaseFragment( - BaseFragment.BindingCreator.Bind(FragmentSearchBinding::bind) -) { +class SearchFragment : Fragment() { companion object { fun List.filterSearchResponse(): List { return this.filter { response -> @@ -105,28 +90,14 @@ class SearchFragment : BaseFragment( fun newInstance(query: String): Bundle { return Bundle().apply { - if (query.isNotBlank()) putString(SEARCH_QUERY, query) + if(query.isNotBlank()) putString(SEARCH_QUERY, query) } } } private val searchViewModel: SearchViewModel by activityViewModels() private var bottomSheetDialog: BottomSheetDialog? = null - - private val speechRecognizerLauncher = - registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> - if (result.resultCode == Activity.RESULT_OK) { - val data: Intent? = result.data - val matches = data?.getStringArrayListExtra(RecognizerIntent.EXTRA_RESULTS) - if (!matches.isNullOrEmpty()) { - val recognizedText = matches[0] - binding?.mainSearch?.setQuery(recognizedText, true) - } - } - } - - override fun pickLayout(): Int? = - if (isLayout(TV or EMULATOR)) R.layout.fragment_search_tv else R.layout.fragment_search + var binding: FragmentSearchBinding? = null override fun onCreateView( inflater: LayoutInflater, @@ -137,13 +108,37 @@ class SearchFragment : BaseFragment( WindowManager.LayoutParams.SOFT_INPUT_STATE_VISIBLE ) bottomSheetDialog?.ownShow() - return super.onCreateView(inflater, container, savedInstanceState) + + + binding = try { + val layout = if (isLayout(TV or EMULATOR)) R.layout.fragment_search_tv else R.layout.fragment_search + val root = inflater.inflate(layout, container, false) + FragmentSearchBinding.bind(root) + } catch (t : Throwable) { + FragmentSearchBinding.inflate(inflater) + } + + return binding?.root + } + + private fun fixGrid() { + activity?.getSpanCount()?.let { + currentSpan = it + } + binding?.searchAutofitResults?.spanCount = currentSpan + currentSpan = currentSpan + HomeFragment.configEvent.invoke(currentSpan) + } + + override fun onConfigurationChanged(newConfig: Configuration) { + super.onConfigurationChanged(newConfig) + fixGrid() } override fun onDestroyView() { hideKeyboard() bottomSheetDialog?.ownHide() - activity?.detachBackPressedCallback("SearchFragment") + binding = null super.onDestroyView() } @@ -168,7 +163,7 @@ class SearchFragment : BaseFragment( fun search(query: String?) { if (query == null) return // don't resume state from prev search - (binding?.searchMasterRecycler?.adapter as? BaseAdapter<*, *>)?.clearState() + (binding?.searchMasterRecycler?.adapter as? BaseAdapter<*,*>)?.clear() context?.let { ctx -> val default = enumValues().sorted().filter { it != TvType.NSFW } .map { it.ordinal.toString() }.toSet() @@ -217,71 +212,45 @@ class SearchFragment : BaseFragment( } } - override fun fixLayout(view: View) { - fixSystemBarsPadding( - view, - padBottom = isLandscape(), - padLeft = isLayout(TV or EMULATOR) - ) - // Fix grid - currentSpan = view.context.getSpanCount() - binding?.searchAutofitResults?.spanCount = currentSpan - HomeFragment.configEvent.invoke() - } + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) - override fun onBindingCreated( - binding: FragmentSearchBinding, - savedInstanceState: Bundle? - ) { + fixPaddingStatusbar(binding?.searchRoot) + fixGrid() reloadRepos() - binding.apply { - val adapter = + + binding?.apply { + val adapter: RecyclerView.Adapter = SearchAdapter( + ArrayList(), searchAutofitResults, ) { callback -> SearchHelper.handleSearchClickCallback(callback) } - searchRoot.findViewById(androidx.appcompat.R.id.search_src_text)?.tag = - "tv_no_focus_tag" - searchAutofitResults.setRecycledViewPool(SearchAdapter.sharedPool) + searchRoot.findViewById(R.id.search_src_text)?.tag = "tv_no_focus_tag" searchAutofitResults.adapter = adapter searchLoadingBar.alpha = 0f } - 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, Locale.getDefault()) - putExtra( - RecognizerIntent.EXTRA_PROMPT, - ctx.getString(R.string.begin_speaking) - ) - } - speechRecognizerLauncher.launch(intent) - } - } catch (_: Throwable) { - // launch may throw - showToast(R.string.speech_recognition_unavailable) - } - } - } val searchExitIcon = - binding.mainSearch.findViewById(androidx.appcompat.R.id.search_close_btn) + binding?.mainSearch?.findViewById(androidx.appcompat.R.id.search_close_btn) + // val searchMagIcon = + // binding?.mainSearch?.findViewById(androidx.appcompat.R.id.search_mag_icon) + // searchMagIcon.scaleX = 0.65f + // searchMagIcon.scaleY = 0.65f + + // Set the color for the search exit icon to the correct theme text color + val searchExitIconColor = TypedValue() + + activity?.theme?.resolveAttribute(android.R.attr.textColor, searchExitIconColor, true) + searchExitIcon?.setColorFilter(searchExitIconColor.data) selectedApis = DataStoreHelper.searchPreferenceProviders.toMutableSet() - binding.searchFilter.setOnClickListener { searchView -> + binding?.searchFilter?.setOnClickListener { searchView -> searchView?.context?.let { ctx -> val validAPIs = ctx.filterProviderByPreferredMedia(hasHomePageIsRequired = false) var currentValidApis = listOf() @@ -293,12 +262,11 @@ class SearchFragment : BaseFragment( builder.behavior.state = BottomSheetBehavior.STATE_EXPANDED - val selectMainpageBinding: HomeSelectMainpageBinding = - HomeSelectMainpageBinding.inflate( - builder.layoutInflater, - null, - false - ) + val selectMainpageBinding: HomeSelectMainpageBinding = HomeSelectMainpageBinding.inflate( + builder.layoutInflater, + null, + false + ) builder.setContentView(selectMainpageBinding.root) builder.show() builder.let { dialog -> @@ -367,10 +335,7 @@ class SearchFragment : BaseFragment( if (selectedSearchTypes.toSet() != list.toSet()) { selectedSearchTypes.clear() selectedSearchTypes.addAll(list) - updateChips( - binding.tvtypesChipsScroll.tvtypesChips, - selectedSearchTypes - ) + updateChips(binding?.tvtypesChipsScroll?.tvtypesChips, selectedSearchTypes) } } @@ -396,8 +361,8 @@ class SearchFragment : BaseFragment( selectedApis = currentSelectedApis // run search when dialog is close - if (previousSelectedApis != selectedApis.toSet() || previousSelectedSearchTypes != selectedSearchTypes.toSet()) { - search(binding.mainSearch.query.toString()) + if(previousSelectedApis != selectedApis.toSet() || previousSelectedSearchTypes != selectedSearchTypes.toSet()) { + search(binding?.mainSearch?.query?.toString()) } } updateList(selectedSearchTypes.toList()) @@ -407,31 +372,19 @@ class SearchFragment : BaseFragment( val settingsManager = context?.let { PreferenceManager.getDefaultSharedPreferences(it) } val isAdvancedSearch = settingsManager?.getBoolean("advanced_search", true) ?: true - val isSearchSuggestionsEnabled = settingsManager?.getBoolean("search_suggestions_enabled", true) ?: true selectedSearchTypes = DataStoreHelper.searchPreferenceTags.toMutableList() - if (!isLayout(PHONE)) { - binding.searchFilter.isFocusable = true - binding.searchFilter.isFocusableInTouchMode = true + if (isLayout(TV)) { + binding?.searchFilter?.isFocusable = true + binding?.searchFilter?.isFocusableInTouchMode = true } - // Hide suggestions when search view loses focus (phone only) - if (isLayout(PHONE)) { - binding.mainSearch.setOnQueryTextFocusChangeListener { _, hasFocus -> - if (!hasFocus) { - searchViewModel.clearSuggestions() - } - } - } - - - binding.mainSearch.setOnQueryTextListener(object : SearchView.OnQueryTextListener { + binding?.mainSearch?.setOnQueryTextListener(object : SearchView.OnQueryTextListener { override fun onQueryTextSubmit(query: String): Boolean { search(query) - searchViewModel.clearSuggestions() - binding.mainSearch.let { + binding?.mainSearch?.let { hideKeyboard(it) } @@ -444,49 +397,76 @@ class SearchFragment : BaseFragment( if (showHistory) { searchViewModel.clearSearch() searchViewModel.updateHistory() - searchViewModel.clearSuggestions() - } else { - // Fetch suggestions when user is typing (if enabled) - if (isSearchSuggestionsEnabled) { - searchViewModel.fetchSuggestions(newText) - } } - binding.apply { - searchHistoryRecycler.isVisible = showHistory + binding?.apply { + searchHistoryHolder.isVisible = showHistory searchMasterRecycler.isVisible = !showHistory && isAdvancedSearch searchAutofitResults.isVisible = !showHistory && !isAdvancedSearch - // Hide suggestions when showing history or showing search results - searchSuggestionsRecycler.isVisible = !showHistory && isSearchSuggestionsEnabled } return true } }) + binding?.searchClearCallHistory?.setOnClickListener { + activity?.let { ctx -> + val builder: AlertDialog.Builder = AlertDialog.Builder(ctx) + val dialogClickListener = + DialogInterface.OnClickListener { _, which -> + when (which) { + DialogInterface.BUTTON_POSITIVE -> { + removeKeys("$currentAccount/$SEARCH_HISTORY_KEY") + searchViewModel.updateHistory() + } + DialogInterface.BUTTON_NEGATIVE -> { + } + } + } + + try { + builder.setTitle(R.string.clear_history).setMessage( + ctx.getString(R.string.delete_message).format( + ctx.getString(R.string.history) + ) + ) + .setPositiveButton(R.string.sort_clear, dialogClickListener) + .setNegativeButton(R.string.cancel, dialogClickListener) + .show().setDefaultFocus() + } catch (e: Exception) { + logError(e) + // ye you somehow fucked up formatting did you? + } + } + + + } + + observe(searchViewModel.currentHistory) { list -> + binding?.searchClearCallHistory?.isVisible = list.isNotEmpty() + (binding?.searchHistoryRecycler?.adapter as? SearchHistoryAdaptor?)?.updateList(list) + } + + searchViewModel.updateHistory() + observe(searchViewModel.searchResponse) { when (it) { is Resource.Success -> { it.value.let { data -> - val list = data.list - if (list.isNotEmpty()) { - (binding.searchAutofitResults.adapter as? SearchAdapter)?.submitList( - list - ) + if (data.isNotEmpty()) { + (binding?.searchAutofitResults?.adapter as? SearchAdapter)?.updateList(data) } } searchExitIcon?.alpha = 1f - binding.searchLoadingBar.alpha = 0f + binding?.searchLoadingBar?.alpha = 0f } - is Resource.Failure -> { // Toast.makeText(activity, "Server error", Toast.LENGTH_LONG).show() searchExitIcon?.alpha = 1f - binding.searchLoadingBar.alpha = 0f + binding?.searchLoadingBar?.alpha = 0f } - is Resource.Loading -> { searchExitIcon?.alpha = 0f - binding.searchLoadingBar.alpha = 1f + binding?.searchLoadingBar?.alpha = 1f } } } @@ -496,33 +476,20 @@ class SearchFragment : BaseFragment( try { // https://stackoverflow.com/questions/6866238/concurrent-modification-exception-adding-to-an-arraylist listLock.lock() - - val pinnedOrder = DataStoreHelper.pinnedProviders.reversedArray() - - val sortedList = list.toList().sortedWith(compareBy { (providerName, _) -> - val index = pinnedOrder.indexOf(providerName) - if (index == -1) Int.MAX_VALUE else index - }) - - (binding.searchMasterRecycler.adapter as? ParentItemAdapter)?.apply { - val newItems = sortedList.map { (providerName, providerData) -> - val dataList = providerData.list + (binding?.searchMasterRecycler?.adapter as ParentItemAdapter?)?.apply { + val newItems = list.map { ongoing -> + val dataList = + if (ongoing.data is Resource.Success) ongoing.data.value else ArrayList() val dataListFiltered = context?.filterSearchResultByFilmQuality(dataList) ?: dataList - - val homePageList = HomePageList( - providerName, + val ongoingList = HomePageList( + ongoing.apiName, dataListFiltered ) - - HomeViewModel.ExpandableHomepageList( - homePageList, - providerData.currentPage, - providerData.hasNext - ) + ongoingList } + updateList(newItems) - submitList(newItems) //notifyDataSetChanged() } } catch (e: Exception) { @@ -542,123 +509,52 @@ class SearchFragment : BaseFragment( //main_search.onActionViewExpanded()*/ val masterAdapter = - ParentItemAdapter(id = "masterAdapter".hashCode(), { callback -> + ParentItemAdapter(fragment = this, id = "masterAdapter".hashCode(), { callback -> SearchHelper.handleSearchClickCallback(callback) }, { item -> bottomSheetDialog = activity?.loadHomepageList(item, dismissCallback = { bottomSheetDialog = null - }, expandCallback = { name -> searchViewModel.expandAndReturn(name) }) - }, expandCallback = { name -> - ioSafe { - searchViewModel.expandAndReturn(name) - } + }) }) - val historyAdapter = SearchHistoryAdaptor { click -> + val historyAdapter = SearchHistoryAdaptor(mutableListOf()) { click -> val searchItem = click.item when (click.clickAction) { SEARCH_HISTORY_OPEN -> { - if (searchItem == null) return@SearchHistoryAdaptor searchViewModel.clearSearch() if (searchItem.type.isNotEmpty()) - updateChips( - binding.tvtypesChipsScroll.tvtypesChips, - searchItem.type.toMutableList() - ) - binding.mainSearch.setQuery(searchItem.searchText, true) + updateChips(binding?.tvtypesChipsScroll?.tvtypesChips, searchItem.type.toMutableList()) + binding?.mainSearch?.setQuery(searchItem.searchText, true) } - SEARCH_HISTORY_REMOVE -> { - if (searchItem == null) return@SearchHistoryAdaptor removeKey("$currentAccount/$SEARCH_HISTORY_KEY", searchItem.key) searchViewModel.updateHistory() } - - SEARCH_HISTORY_CLEAR -> { - // Show confirmation dialog (from footer button) - activity?.let { ctx -> - val builder: AlertDialog.Builder = AlertDialog.Builder(ctx) - val dialogClickListener = - DialogInterface.OnClickListener { _, which -> - when (which) { - DialogInterface.BUTTON_POSITIVE -> { - removeKeys("$currentAccount/$SEARCH_HISTORY_KEY") - searchViewModel.updateHistory() - } - - DialogInterface.BUTTON_NEGATIVE -> { - } - } - } - - try { - builder.setTitle(R.string.clear_history).setMessage( - ctx.getString(R.string.delete_message).format( - ctx.getString(R.string.history) - ) - ) - .setPositiveButton(R.string.sort_clear, dialogClickListener) - .setNegativeButton(R.string.cancel, dialogClickListener) - .show().setDefaultFocus() - } catch (e: Exception) { - logError(e) - } - } - } - else -> { // wth are you doing??? } } } - val suggestionAdapter = SearchSuggestionAdapter { callback -> - when (callback.clickAction) { - SEARCH_SUGGESTION_CLICK -> { - // Search directly - binding.mainSearch.setQuery(callback.suggestion, true) - searchViewModel.clearSuggestions() - } - SEARCH_SUGGESTION_FILL -> { - // Fill the search box without searching - binding.mainSearch.setQuery(callback.suggestion, false) - } - SEARCH_SUGGESTION_CLEAR -> { - // Clear suggestions (from footer button) - searchViewModel.clearSuggestions() - } - } - } - - binding.apply { + binding?.apply { searchHistoryRecycler.adapter = historyAdapter searchHistoryRecycler.setLinearListLayout(isHorizontal = false, nextRight = FOCUS_SELF) //searchHistoryRecycler.layoutManager = GridLayoutManager(context, 1) - // Setup suggestions RecyclerView - searchSuggestionsRecycler.adapter = suggestionAdapter - searchSuggestionsRecycler.layoutManager = LinearLayoutManager(context) - - searchMasterRecycler.setRecycledViewPool(ParentItemAdapter.sharedPool) searchMasterRecycler.adapter = masterAdapter //searchMasterRecycler.setLinearListLayout(isHorizontal = false, nextRight = FOCUS_SELF) searchMasterRecycler.layoutManager = GridLayoutManager(context, 1) // Automatically search the specified query, this allows the app search to launch from intent - var sq = - arguments?.getString(SEARCH_QUERY) ?: savedInstanceState?.getString(SEARCH_QUERY) - if (sq.isNullOrBlank()) { + var sq = arguments?.getString(SEARCH_QUERY) ?: savedInstanceState?.getString(SEARCH_QUERY) + if(sq.isNullOrBlank()) { sq = MainActivity.nextSearchQuery } sq?.let { query -> if (query.isBlank()) return@let - - // Queries are dropped if you are submitted before layout finishes - mainSearch.doOnLayout { - mainSearch.setQuery(query, true) - } + mainSearch.setQuery(query, true) // Clear the query as to not make it request the same query every time the page is opened arguments?.remove(SEARCH_QUERY) savedInstanceState?.remove(SEARCH_QUERY) @@ -666,37 +562,18 @@ class SearchFragment : BaseFragment( } } - observe(searchViewModel.currentHistory) { list -> - (binding.searchHistoryRecycler.adapter as? SearchHistoryAdaptor?)?.submitList(list) - // Scroll to top to show newest items (list is sorted by newest first) - if (list.isNotEmpty()) { - binding.searchHistoryRecycler.scrollToPosition(0) - } - } - // Observe search suggestions - observe(searchViewModel.searchSuggestions) { suggestions -> - val hasSuggestions = suggestions.isNotEmpty() - binding.searchSuggestionsRecycler.isVisible = hasSuggestions - (binding.searchSuggestionsRecycler.adapter as? SearchSuggestionAdapter?)?.submitList(suggestions) - - // On non-phone layouts, redirect focus and handle back button - if (!isLayout(PHONE)) { - if (hasSuggestions) { - binding.tvtypesChipsScroll.tvtypesChips.root.nextFocusDownId = R.id.search_suggestions_recycler - // Attach back button callback to clear suggestions - activity?.attachBackPressedCallback("SearchFragment") { - searchViewModel.clearSuggestions() - } - } else { - // Reset to default focus target (history) - binding.tvtypesChipsScroll.tvtypesChips.root.nextFocusDownId = R.id.search_history_recycler - // Detach back button callback when no suggestions - activity?.detachBackPressedCallback("SearchFragment") - } - } - } - - searchViewModel.updateHistory() + // SubtitlesFragment.push(activity) + //searchViewModel.search("iron man") + //(activity as AppCompatActivity).loadResult("https://shiro.is/overlord-dubbed", "overlord-dubbed", "Shiro") +/* + (activity as AppCompatActivity?)?.supportFragmentManager.beginTransaction() + .setCustomAnimations(R.anim.enter_anim, + R.anim.exit_anim, + R.anim.pop_enter, + R.anim.pop_exit) + .add(R.id.homeRoot, PlayerFragment.newInstance(PlayerData(0, null,0))) + .commit()*/ } + } diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchHelper.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchHelper.kt index 449a04bf8..ef1b87194 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 @@ -9,9 +9,11 @@ import com.lagradost.cloudstream3.ui.download.DOWNLOAD_ACTION_PLAY_FILE import com.lagradost.cloudstream3.ui.download.DownloadButtonSetup.handleDownloadClick import com.lagradost.cloudstream3.ui.download.DownloadClickEvent import com.lagradost.cloudstream3.ui.result.START_ACTION_LOAD_EP +import com.lagradost.cloudstream3.ui.settings.Globals.PHONE +import com.lagradost.cloudstream3.ui.settings.Globals.isLayout import com.lagradost.cloudstream3.utils.AppContextUtils.loadSearchResult import com.lagradost.cloudstream3.utils.DataStoreHelper -import com.lagradost.cloudstream3.utils.downloader.DownloadObjects +import com.lagradost.cloudstream3.utils.VideoDownloadHelper object SearchHelper { fun handleSearchClickCallback(callback: SearchClickCallback) { @@ -20,7 +22,6 @@ object SearchHelper { SEARCH_ACTION_LOAD -> { loadSearchResult(card) } - SEARCH_ACTION_PLAY_FILE -> { if (card is DataStoreHelper.ResumeWatchingResult) { val id = card.id @@ -31,14 +32,14 @@ object SearchHelper { handleDownloadClick( DownloadClickEvent( DOWNLOAD_ACTION_PLAY_FILE, - DownloadObjects.DownloadEpisodeCached( + VideoDownloadHelper.DownloadEpisodeCached( name = card.name, poster = card.posterUrl, episode = card.episode ?: 0, season = card.season, id = id, parentId = card.parentId ?: return, - score = null, + rating = null, description = null, cacheTime = System.currentTimeMillis(), ) @@ -54,11 +55,14 @@ object SearchHelper { ) } } - SEARCH_ACTION_SHOW_METADATA -> { - (activity as? MainActivity?)?.apply { - loadPopup(callback.card) - } ?: kotlin.run { + if(isLayout(PHONE)) { // we only want this on phone as UI is not done yet on tv + (activity as? MainActivity?)?.apply { + loadPopup(callback.card) + } ?: kotlin.run { + showToast(callback.card.name, Toast.LENGTH_SHORT) + } + } else { showToast(callback.card.name, Toast.LENGTH_SHORT) } } diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchHistoryAdaptor.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchHistoryAdaptor.kt index 4868abb3d..4ef5fa698 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchHistoryAdaptor.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchHistoryAdaptor.kt @@ -2,17 +2,11 @@ package com.lagradost.cloudstream3.ui.search import android.view.LayoutInflater import android.view.ViewGroup -import androidx.core.view.isGone +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.RecyclerView import com.fasterxml.jackson.annotation.JsonProperty import com.lagradost.cloudstream3.TvType -import com.lagradost.cloudstream3.databinding.SearchHistoryFooterBinding import com.lagradost.cloudstream3.databinding.SearchHistoryItemBinding -import com.lagradost.cloudstream3.ui.BaseDiffCallback -import com.lagradost.cloudstream3.ui.NoStateAdapter -import com.lagradost.cloudstream3.ui.ViewHolderState -import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR -import com.lagradost.cloudstream3.ui.settings.Globals.TV -import com.lagradost.cloudstream3.ui.settings.Globals.isLayout data class SearchHistoryItem( @JsonProperty("searchedAt") val searchedAt: Long, @@ -22,73 +16,84 @@ data class SearchHistoryItem( ) data class SearchHistoryCallback( - val item: SearchHistoryItem?, + val item: SearchHistoryItem, val clickAction: Int, ) const val SEARCH_HISTORY_OPEN = 0 const val SEARCH_HISTORY_REMOVE = 1 -const val SEARCH_HISTORY_CLEAR = 2 class SearchHistoryAdaptor( + private val cardList: MutableList, private val clickCallback: (SearchHistoryCallback) -> Unit, -) : NoStateAdapter(diffCallback = BaseDiffCallback(itemSame = { a,b -> - a.searchedAt == b.searchedAt && a.searchText == b.searchText -})) { - - // Add footer for all layouts - override val footers = 1 - - override fun submitList(list: Collection?, commitCallback: Runnable?) { - super.submitList(list, commitCallback) - // Notify footer to rebind when list changes to update visibility - if (footers > 0) { - notifyItemChanged(itemCount - 1) - } - } - - override fun onCreateContent(parent: ViewGroup): ViewHolderState { - return ViewHolderState( +) : RecyclerView.Adapter() { + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { + return CardViewHolder( SearchHistoryItemBinding.inflate(LayoutInflater.from(parent.context), parent, false), + clickCallback, ) } - override fun onBindContent( - holder: ViewHolderState, - item: SearchHistoryItem, - position: Int - ) { - val binding = holder.view as? SearchHistoryItemBinding ?: return - binding.apply { - homeHistoryTitle.text = item.searchText - - homeHistoryRemove.setOnClickListener { - clickCallback.invoke(SearchHistoryCallback(item, SEARCH_HISTORY_REMOVE)) - } - homeHistoryTab.setOnClickListener { - clickCallback.invoke(SearchHistoryCallback(item, SEARCH_HISTORY_OPEN)) + override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { + when (holder) { + is CardViewHolder -> { + holder.bind(cardList[position]) } } } - - override fun onCreateFooter(parent: ViewGroup): ViewHolderState { - return ViewHolderState( - SearchHistoryFooterBinding.inflate(LayoutInflater.from(parent.context), parent, false) - ) + + override fun getItemCount(): Int { + return cardList.size } - - override fun onBindFooter(holder: ViewHolderState) { - val binding = holder.view as? SearchHistoryFooterBinding ?: return - // Hide footer when list is empty - binding.searchClearCallHistory.apply { - isGone = immutableCurrentList.isEmpty() - if (isLayout(TV or EMULATOR)) { - isFocusable = true - isFocusableInTouchMode = true - } - setOnClickListener { - clickCallback.invoke(SearchHistoryCallback(null, SEARCH_HISTORY_CLEAR)) + + fun updateList(newList: List) { + val diffResult = DiffUtil.calculateDiff( + SearchHistoryDiffCallback(this.cardList, newList) + ) + + cardList.clear() + cardList.addAll(newList) + + diffResult.dispatchUpdatesTo(this) + } + + class CardViewHolder( + val binding: SearchHistoryItemBinding, + private val clickCallback: (SearchHistoryCallback) -> Unit, + ) : + RecyclerView.ViewHolder(binding.root) { + // private val removeButton: ImageView = itemView.home_history_remove + // private val openButton: View = itemView.home_history_tab + // private val title: TextView = itemView.home_history_title + + fun bind(card: SearchHistoryItem) { + binding.apply { + homeHistoryTitle.text = card.searchText + + homeHistoryRemove.setOnClickListener { + clickCallback.invoke(SearchHistoryCallback(card, SEARCH_HISTORY_REMOVE)) + } + homeHistoryTab.setOnClickListener { + clickCallback.invoke(SearchHistoryCallback(card, SEARCH_HISTORY_OPEN)) + } } } } } + +class SearchHistoryDiffCallback( + private val oldList: List, + private val newList: List +) : + DiffUtil.Callback() { + override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int) = + oldList[oldItemPosition].searchText == newList[newItemPosition].searchText + + override fun getOldListSize() = oldList.size + + override fun getNewListSize() = newList.size + + override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int) = + oldList[oldItemPosition] == newList[newItemPosition] +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchResultBuilder.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchResultBuilder.kt index fd99b8d4b..92575e58f 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchResultBuilder.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchResultBuilder.kt @@ -2,7 +2,6 @@ package com.lagradost.cloudstream3.ui.search import android.annotation.SuppressLint import android.content.Context -import android.content.res.ColorStateList import android.view.View import android.widget.ImageView import android.widget.ProgressBar @@ -22,13 +21,10 @@ import com.lagradost.cloudstream3.syncproviders.SyncAPI import com.lagradost.cloudstream3.ui.settings.Globals.TV import com.lagradost.cloudstream3.ui.settings.Globals.isLayout import com.lagradost.cloudstream3.utils.AppContextUtils.getNameFull -import com.lagradost.cloudstream3.utils.AppContextUtils.getShortSeasonText import com.lagradost.cloudstream3.utils.DataStoreHelper import com.lagradost.cloudstream3.utils.DataStoreHelper.fixVisual -import com.lagradost.cloudstream3.utils.ImageLoader.loadImage import com.lagradost.cloudstream3.utils.SubtitleHelper -import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute -import com.lagradost.cloudstream3.utils.getImageFromDrawable +import com.lagradost.cloudstream3.utils.UIHelper.setImage object SearchResultBuilder { private val showCache: MutableMap = mutableMapOf() @@ -50,7 +46,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,7 +63,6 @@ object SearchResultBuilder { val bar: ProgressBar? = itemView.findViewById(R.id.watchProgress) val playImg: ImageView? = itemView.findViewById(R.id.search_item_download_play) - val episodeText: TextView? = itemView.findViewById(R.id.episode_text) // Do logic @@ -77,27 +72,20 @@ object SearchResultBuilder { textIsSub?.isVisible = false textFlag?.isVisible = false rating?.isVisible = false - episodeText?.isVisible = false val showSub = showCache[textIsDub?.context?.getString(R.string.show_sub_key)] ?: false val showDub = showCache[textIsDub?.context?.getString(R.string.show_dub_key)] ?: false val showTitle = showCache[cardText?.context?.getString(R.string.show_title_key)] ?: false - val showEpisodeText = showCache[cardText?.context?.getString(R.string.show_episode_text_key)] ?: false val showHd = showCache[textQuality?.context?.getString(R.string.show_hd_key)] ?: false - val showRatingView = - showCache[textQuality?.context?.getString(R.string.show_rating_key)] ?: false - 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() + + if(card is SyncAPI.LibraryItem) { + val showRating = (card.personalRating ?: 0) != 0 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 } } @@ -132,11 +120,10 @@ object SearchResultBuilder { cardText?.text = card.name cardText?.isVisible = showTitle cardView.isVisible = true - if (!card.posterUrl.isNullOrEmpty()) { - cardView.loadImage(card.posterUrl, card.posterHeaders) { - error { getImageFromDrawable(itemView.context, R.drawable.default_cover) } - } - } else cardView.loadImage(R.drawable.default_cover) + + if (!cardView.setImage(card.posterUrl, card.posterHeaders, colorCallback = colorCallback)) { + cardView.setImageResource(R.drawable.default_cover) + } fun click(view: View?) { clickCallback.invoke( @@ -175,7 +162,7 @@ object SearchResultBuilder { bg.isFocusable = false bg.isFocusableInTouchMode = false - if (!isLayout(TV)) { + if(!isLayout(TV)) { bg.setOnClickListener { click(it) } @@ -185,7 +172,7 @@ object SearchResultBuilder { } } // - // + // // itemView.setOnClickListener { @@ -219,9 +206,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 } @@ -250,7 +237,6 @@ object SearchResultBuilder { } } } - is DataStoreHelper.ResumeWatchingResult -> { val pos = card.watchPos?.fixVisual() if (pos != null) { @@ -258,15 +244,14 @@ object SearchResultBuilder { bar?.progress = (pos.position / 1000).toInt() bar?.visibility = View.VISIBLE } + playImg?.visibility = View.VISIBLE - if (card.type?.isMovieType() == false && showEpisodeText) { - episodeText?.context?.getShortSeasonText(card.episode, card.season)?.let {text-> - episodeText.text = text - episodeText.isVisible = true - } + + if (card.type?.isMovieType() == false) { + cardText?.text = + cardText?.context?.getNameFull(card.name, card.episode, card.season) } } - is AnimeSearchResponse -> { val dubStatus = card.dubStatus if (!dubStatus.isNullOrEmpty()) { @@ -302,29 +287,5 @@ object SearchResultBuilder { } } } - - // This is the logic for making the rounded corners more round on the top and bottom element - // a bit dirty to do memory allocation, but it makes it more extensible and is easier to reason about - // then a large if statement - - // Requires that the ordering here is the same as in the xml - val boxes = arrayListOf() - for (view in arrayOf(textIsDub, textIsSub, rating)) { - if (view?.isVisible == true) { - boxes.add(view) - } - } - if (boxes.size == 1) { - boxes[0].setBackgroundResource(R.drawable.bg_color_both) - } else if (boxes.size > 1) { - boxes[0].setBackgroundResource(R.drawable.bg_color_top) - for (i in 1 until boxes.size) { - boxes[i].setBackgroundResource(R.drawable.bg_color_center) - } - boxes[boxes.size - 1].setBackgroundResource(R.drawable.bg_color_bottom) - } - textIsDub?.apply { - backgroundTintList = ColorStateList.valueOf(context.colorFromAttribute(R.attr.textColor)) - } } -} +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchSuggestionAdapter.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchSuggestionAdapter.kt deleted file mode 100644 index 74d5e7b08..000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchSuggestionAdapter.kt +++ /dev/null @@ -1,85 +0,0 @@ -package com.lagradost.cloudstream3.ui.search - -import android.view.LayoutInflater -import android.view.ViewGroup -import androidx.core.view.isGone -import com.lagradost.cloudstream3.databinding.SearchSuggestionFooterBinding -import com.lagradost.cloudstream3.databinding.SearchSuggestionItemBinding -import com.lagradost.cloudstream3.ui.BaseDiffCallback -import com.lagradost.cloudstream3.ui.NoStateAdapter -import com.lagradost.cloudstream3.ui.ViewHolderState -import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR -import com.lagradost.cloudstream3.ui.settings.Globals.TV -import com.lagradost.cloudstream3.ui.settings.Globals.isLayout - -const val SEARCH_SUGGESTION_CLICK = 0 -const val SEARCH_SUGGESTION_FILL = 1 -const val SEARCH_SUGGESTION_CLEAR = 2 - -data class SearchSuggestionCallback( - val suggestion: String, - val clickAction: Int, -) - -class SearchSuggestionAdapter( - private val clickCallback: (SearchSuggestionCallback) -> Unit, -) : NoStateAdapter(diffCallback = BaseDiffCallback(itemSame = { a, b -> a == b })) { - - // Add footer for all layouts - override val footers = 1 - - override fun submitList(list: Collection?, commitCallback: Runnable?) { - super.submitList(list, commitCallback) - // Notify footer to rebind when list changes to update visibility - if (footers > 0) { - notifyItemChanged(itemCount - 1) - } - } - - override fun onCreateContent(parent: ViewGroup): ViewHolderState { - return ViewHolderState( - SearchSuggestionItemBinding.inflate(LayoutInflater.from(parent.context), parent, false), - ) - } - - override fun onBindContent( - holder: ViewHolderState, - item: String, - position: Int - ) { - val binding = holder.view as? SearchSuggestionItemBinding ?: return - binding.apply { - suggestionText.text = item - - // Click on the whole item to search - suggestionItem.setOnClickListener { - clickCallback.invoke(SearchSuggestionCallback(item, SEARCH_SUGGESTION_CLICK)) - } - - // Click on the arrow to fill the search box without searching - suggestionFill.setOnClickListener { - clickCallback.invoke(SearchSuggestionCallback(item, SEARCH_SUGGESTION_FILL)) - } - } - } - - override fun onCreateFooter(parent: ViewGroup): ViewHolderState { - return ViewHolderState( - SearchSuggestionFooterBinding.inflate(LayoutInflater.from(parent.context), parent, false) - ) - } - - override fun onBindFooter(holder: ViewHolderState) { - val binding = holder.view as? SearchSuggestionFooterBinding ?: return - binding.clearSuggestionsButton.apply { - isGone = immutableCurrentList.isEmpty() - if (isLayout(TV or EMULATOR)) { - isFocusable = true - isFocusableInTouchMode = true - } - setOnClickListener { - clickCallback.invoke(SearchSuggestionCallback("", SEARCH_SUGGESTION_CLEAR)) - } - } - } -} diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchSuggestionApi.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchSuggestionApi.kt deleted file mode 100644 index 8dbd78178..000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchSuggestionApi.kt +++ /dev/null @@ -1,74 +0,0 @@ -package com.lagradost.cloudstream3.ui.search - -import com.fasterxml.jackson.annotation.JsonProperty -import com.lagradost.cloudstream3.app -import com.lagradost.cloudstream3.mvvm.logError -import com.lagradost.nicehttp.NiceResponse - -/** - * API for fetching search suggestions from external sources. - * Uses TheMovieDB API to provide movie/show/anime related suggestions. - */ -object SearchSuggestionApi { - private const val TMDB_API_URL = "https://api.themoviedb.org/3/search/multi" - private const val TMDB_API_KEY = "e6333b32409e02a4a6eba6fb7ff866bb" - - data class TmdbSearchResult( - @JsonProperty("results") val results: List? - ) - - data class TmdbSearchItem( - @JsonProperty("media_type") val mediaType: String?, - @JsonProperty("title") val title: String?, - @JsonProperty("name") val name: String?, - @JsonProperty("original_title") val originalTitle: String?, - @JsonProperty("original_name") val originalName: String? - ) - - /** - * Fetches search suggestions from TheMovieDB multi search API. - * Returns suggestions for movies, TV series, and anime. - * - * @param query The search query to get suggestions for - * @return List of suggestion strings, empty list on failure - */ - suspend fun getSuggestions(query: String): List { - if (query.isBlank() || query.length < 2) return emptyList() - - return try { - val response = app.get( - TMDB_API_URL, - params = mapOf( - "api_key" to TMDB_API_KEY, - "query" to query, - "language" to "en-US" - ), - cacheTime = 60 * 24 // Cache for 1 day (cacheUnit default is Minutes) - ) - - parseSuggestions(response) - } catch (e: Exception) { - logError(e) - emptyList() - } - } - - /** - * Parses the TMDB search response and extracts movie/TV show titles. - * Filters to only include movies, TV shows, and anime. - */ - private fun parseSuggestions(response: NiceResponse): List { - return try { - val parsed = response.parsed() - parsed.results - ?.filter { it.mediaType == "movie" || it.mediaType == "tv" } - ?.mapNotNull { it.title ?: it.name } - ?.distinct() - ?.take(10) - ?: emptyList() - } catch (e: Exception) { - logError(e) - emptyList() - } - } -} diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchViewModel.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchViewModel.kt index f60588e35..839b9d3f8 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchViewModel.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchViewModel.kt @@ -5,70 +5,51 @@ import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.lagradost.cloudstream3.APIHolder.apis -import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey -import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKeys -import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey -import com.lagradost.cloudstream3.HomePageList +import com.lagradost.cloudstream3.AcraApplication.Companion.getKey +import com.lagradost.cloudstream3.AcraApplication.Companion.getKeys +import com.lagradost.cloudstream3.AcraApplication.Companion.setKey import com.lagradost.cloudstream3.SearchResponse import com.lagradost.cloudstream3.amap import com.lagradost.cloudstream3.mvvm.Resource -import com.lagradost.cloudstream3.mvvm.debugAssert -import com.lagradost.cloudstream3.mvvm.debugWarning import com.lagradost.cloudstream3.mvvm.launchSafe import com.lagradost.cloudstream3.ui.APIRepository -import com.lagradost.cloudstream3.ui.home.HomeViewModel import com.lagradost.cloudstream3.utils.Coroutines.ioSafe import com.lagradost.cloudstream3.utils.DataStoreHelper.currentAccount import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job -import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.withContext - -data class ExpandableSearchList( - var list: List, var currentPage: Int, var hasNext: Boolean, +data class OnGoingSearch( + val apiName: String, + val data: Resource> ) const val SEARCH_HISTORY_KEY = "search_history" class SearchViewModel : ViewModel() { - private val _searchResponse: MutableLiveData> = + private val _searchResponse: MutableLiveData>> = MutableLiveData() - val searchResponse: LiveData> get() = _searchResponse + val searchResponse: LiveData>> get() = _searchResponse - private val _currentSearch: MutableLiveData> = - MutableLiveData() - val currentSearch: LiveData> get() = _currentSearch + private val _currentSearch: MutableLiveData> = MutableLiveData() + val currentSearch: LiveData> get() = _currentSearch private val _currentHistory: MutableLiveData> = MutableLiveData() val currentHistory: LiveData> get() = _currentHistory - private val _searchSuggestions: MutableLiveData> = MutableLiveData() - val searchSuggestions: LiveData> get() = _searchSuggestions - - private var suggestionJob: Job? = null - - private var repos = apis.withLock { apis.map { APIRepository(it) } } + private var repos = synchronized(apis) { apis.map { APIRepository(it) } } fun clearSearch() { - _searchResponse.postValue(Resource.Success(ExpandableSearchList(emptyList(), 0, false))) - _currentSearch.postValue(emptyMap()) - expandableSearches.clear() + _searchResponse.postValue(Resource.Success(ArrayList())) + _currentSearch.postValue(emptyList()) } - var lastQuery: String? = null - - /** Save which providers can searched again and which search result page they are on. - * Maps provider name to search list. - * @see [HomeViewModel.expandable] */ - private val expandableSearches: MutableMap = mutableMapOf() - private var currentSearchIndex = 0 private var onGoingSearch: Job? = null fun reloadRepos() { - repos = apis.withLock { apis.map { APIRepository(it) } } + repos = synchronized(apis) { apis.map { APIRepository(it) } } } fun searchAndCancel( @@ -82,117 +63,13 @@ class SearchViewModel : ViewModel() { onGoingSearch = search(query, providersActive, ignoreSettings, isQuickSearch) } - fun updateHistory() = ioSafe { - val items = getKeys("$currentAccount/$SEARCH_HISTORY_KEY")?.mapNotNull { - getKey(it) - }?.sortedByDescending { it.searchedAt } ?: emptyList() - _currentHistory.postValue(items) - } - - /** - * Fetches search suggestions with debouncing. - * Waits 300ms before making the API call to avoid too many requests. - * - * @param query The search query to get suggestions for - */ - fun fetchSuggestions(query: String) { - suggestionJob?.cancel() - - if (query.isBlank() || query.length < 2) { - _searchSuggestions.postValue(emptyList()) - return + fun updateHistory() = viewModelScope.launch { + ioSafe { + val items = getKeys("$currentAccount/$SEARCH_HISTORY_KEY")?.mapNotNull { + getKey(it) + }?.sortedByDescending { it.searchedAt } ?: emptyList() + _currentHistory.postValue(items) } - - suggestionJob = ioSafe { - delay(300) // Debounce - val suggestions = SearchSuggestionApi.getSuggestions(query) - _searchSuggestions.postValue(suggestions) - } - } - - /** - * Clears the current search suggestions. - */ - fun clearSuggestions() { - suggestionJob?.cancel() - _searchSuggestions.postValue(emptyList()) - } - - private val lock: MutableSet = mutableSetOf() - - // ExpandableHomepageList because the home adapter is reused in the search fragment - suspend fun expandAndReturn(name: String): HomeViewModel.ExpandableHomepageList? { - if (lock.contains(name)) return null - val query = lastQuery ?: return null - val repo = repos.find { it.name == name } ?: return null - - lock += name - - expandableSearches[name]?.let { current -> - debugAssert({ !current.hasNext }) { - "Expand called when not needed" - } - - val nextPage = current.currentPage + 1 - val next = repo.search(query, nextPage) - if (next is Resource.Success) { - val nextValue = next.value - expandableSearches[name]?.apply { - this.hasNext = nextValue.hasNext - this.currentPage = nextPage - - debugWarning({ nextValue.items.any { outer -> this.list.any { it.url == outer.url } } }) { - "Expanded search contained an item that was previously already in the list.\nQuery = $query, ${nextValue.items} = ${this.list}" - } - - // just to be sure we are not adding the same shit for some reason - // Avoids weird behavior in the recyclerview by recreating the list - this.list = (this.list + nextValue.items).distinctBy { it.url } - } ?: debugWarning { - "Expanded an item not in search load named $name, current list is ${expandableSearches.keys}" - } - } else { - current.hasNext = false - } - - _searchResponse.postValue(Resource.Success(bundleSearch(expandableSearches))) - _currentSearch.postValue(expandableSearches) - } - - lock -= name - - val item = expandableSearches[name] ?: return null - return HomeViewModel.ExpandableHomepageList( - HomePageList(name, item.list), - item.currentPage, - item.hasNext - ) - } - - private fun bundleSearch(lists: MutableMap): ExpandableSearchList { - if (lists.size == 1) { - return lists.values.first() - } - - val list = ArrayList() - val nestedList = - lists.map { it.value.list } - - // I do it this way to move the relevant search results to the top - var index = 0 - while (true) { - var added = 0 - for (sublist in nestedList) { - if (sublist.size > index) { - list.add(sublist[index]) - added++ - } - } - if (added == 0) break - index++ - } - - return ExpandableSearchList(list, 1, false) } private fun search( @@ -223,30 +100,43 @@ class SearchViewModel : ViewModel() { } _searchResponse.postValue(Resource.Loading()) - _currentSearch.postValue(emptyMap()) - expandableSearches.clear() - lastQuery = query + + _currentSearch.postValue(ArrayList()) withContext(Dispatchers.IO) { // This interrupts UI otherwise + val currentList = ArrayList() + repos.filter { a -> (ignoreSettings || (providersActive.isEmpty() || providersActive.contains(a.name))) && (!isQuickSearch || a.hasQuickSearch) }.amap { a -> // Parallel - val search = if (isQuickSearch) a.quickSearch(query) else a.search(query, 1) + val search = if (isQuickSearch) a.quickSearch(query) else a.search(query) if (currentSearchIndex != currentIndex) return@amap - if (search is Resource.Success) { - val searchValue = search.value - expandableSearches[a.name] = - ExpandableSearchList(searchValue.items, 1, searchValue.hasNext) - } - - _currentSearch.postValue(expandableSearches) + currentList.add(OnGoingSearch(a.name, search)) + _currentSearch.postValue(currentList) } if (currentSearchIndex != currentIndex) return@withContext // this should prevent rewrite of existing data bug - _currentSearch.postValue(expandableSearches) - val list = bundleSearch(expandableSearches) + _currentSearch.postValue(currentList) + val list = ArrayList() + val nestedList = + currentList.map { it.data } + .filterIsInstance>>().map { it.value } + + // I do it this way to move the relevant search results to the top + var index = 0 + while (true) { + var added = 0 + for (sublist in nestedList) { + if (sublist.size > index) { + list.add(sublist[index]) + added++ + } + } + if (added == 0) break + index++ + } _searchResponse.postValue(Resource.Success(list)) } diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/search/SyncSearchViewModel.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/search/SyncSearchViewModel.kt index 938b870bb..71077e91f 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,6 +1,5 @@ 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 @@ -16,6 +15,5 @@ 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 be8b4180c..d7bd69f11 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/AccountAdapter.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/AccountAdapter.kt @@ -1,56 +1,64 @@ package com.lagradost.cloudstream3.ui.settings +import android.annotation.SuppressLint import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.core.view.isVisible +import androidx.recyclerview.widget.RecyclerView import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.databinding.AccountSingleBinding -import com.lagradost.cloudstream3.syncproviders.AuthData -import com.lagradost.cloudstream3.ui.BaseDiffCallback -import com.lagradost.cloudstream3.ui.NoStateAdapter -import com.lagradost.cloudstream3.ui.ViewHolderState -import com.lagradost.cloudstream3.utils.ImageLoader.loadImage +import com.lagradost.cloudstream3.syncproviders.AuthAPI +import com.lagradost.cloudstream3.utils.UIHelper.setImage -class AccountClickCallback(val action: Int, val view: View, val card: AuthData) +class AccountClickCallback(val action: Int, val view: View, val card: AuthAPI.LoginInfo) class AccountAdapter( + private val cardList: List, private val clickCallback: (AccountClickCallback) -> Unit ) : - NoStateAdapter( - diffCallback = BaseDiffCallback(itemSame = { a, b -> - a.user.id == b.user.id - }) - ) { + RecyclerView.Adapter() { - override fun onCreateContent(parent: ViewGroup): ViewHolderState { - return ViewHolderState( - AccountSingleBinding.inflate( - LayoutInflater.from(parent.context), - parent, - false - ) + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { + return CardViewHolder( + AccountSingleBinding.inflate(LayoutInflater.from(parent.context), parent, false), //LayoutInflater.from(parent.context).inflate(layout, parent, false), + + clickCallback ) } - override fun onClearView(holder: ViewHolderState) { - val binding = holder.view as? AccountSingleBinding ?: return - clearImage(binding.accountProfilePicture) + override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { + when (holder) { + is CardViewHolder -> { + holder.bind(cardList[position]) + } + } } - override fun onBindContent(holder: ViewHolderState, item: AuthData, position: Int) { - val binding = holder.view as? AccountSingleBinding ?: return - binding.apply { - accountName.text = item.user.name - ?: "${binding.accountName.context.getString(R.string.account)} ${position + 1}" - accountProfilePicture.isVisible = true - accountProfilePicture.loadImage( - item.user.profilePicture, - headers = item.user.profilePictureHeaders - ) + override fun getItemCount(): Int { + return cardList.size + } - root.setOnClickListener { - clickCallback.invoke(AccountClickCallback(0, root, item)) + override fun getItemId(position: Int): Long { + return cardList[position].accountIndex.toLong() + } + + class CardViewHolder(val binding: AccountSingleBinding, private val clickCallback: (AccountClickCallback) -> Unit) : + RecyclerView.ViewHolder(binding.root) { + // private val pfp: ImageView = itemView.findViewById(R.id.account_profile_picture)!! + // private val accountName: TextView = itemView.findViewById(R.id.account_name)!! + + @SuppressLint("StringFormatInvalid") + fun bind(card: AuthAPI.LoginInfo) { + // just in case name is null account index will show, should never happened + binding.accountName.text = card.name ?: "%s %d".format( + binding.accountName.context.getString(R.string.account), + card.accountIndex + ) + binding.accountProfilePicture.isVisible = binding.accountProfilePicture.setImage(card.profilePicture) + + itemView.setOnClickListener { + clickCallback.invoke(AccountClickCallback(0, itemView, card)) } } } diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/Globals.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/Globals.kt index 93e469a4d..aa513d87a 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/Globals.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/Globals.kt @@ -3,7 +3,6 @@ package com.lagradost.cloudstream3.ui.settings import android.app.UiModeManager import android.content.Context import android.content.res.Configuration -import android.content.res.Resources import android.os.Build import androidx.preference.PreferenceManager import com.lagradost.cloudstream3.R @@ -45,11 +44,6 @@ object Globals { layoutId = layoutIntCorrected() } - /** Returns true if the current orientation is landscape. */ - fun isLandscape(): Boolean = - isLayout(TV or EMULATOR) || - Resources.getSystem().configuration.orientation == Configuration.ORIENTATION_LANDSCAPE - /** Returns true if the layout is any of the flags, * so isLayout(TV or EMULATOR) is a valid statement for checking if the layout is in the emulator * or tv. Auto will become the "TV" or the "PHONE" layout. diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/LogcatAdapter.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/LogcatAdapter.kt deleted file mode 100644 index 365990646..000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/LogcatAdapter.kt +++ /dev/null @@ -1,31 +0,0 @@ -package com.lagradost.cloudstream3.ui.settings - -import android.view.LayoutInflater -import android.view.ViewGroup -import com.lagradost.cloudstream3.databinding.ItemLogcatBinding -import com.lagradost.cloudstream3.ui.BaseDiffCallback -import com.lagradost.cloudstream3.ui.NoStateAdapter -import com.lagradost.cloudstream3.ui.ViewHolderState - -class LogcatAdapter() : NoStateAdapter( - diffCallback = BaseDiffCallback( - itemSame = String::equals, - contentSame = String::equals - ) -) { - override fun onCreateContent(parent: ViewGroup): ViewHolderState { - return ViewHolderState( - ItemLogcatBinding.inflate( - LayoutInflater.from(parent.context), - parent, - false - ) - ) - } - - override fun onBindContent(holder: ViewHolderState, item: String, position: Int) { - (holder.view as? ItemLogcatBinding)?.apply { - logText.text = item - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsAccount.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsAccount.kt index 8d96a6b14..15f8735fe 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,6 +1,5 @@ package com.lagradost.cloudstream3.ui.settings -import android.annotation.SuppressLint import android.graphics.Bitmap import android.os.Bundle import android.os.CountDownTimer @@ -10,40 +9,38 @@ import android.view.inputmethod.EditorInfo import android.widget.TextView import androidx.annotation.UiThread import androidx.appcompat.app.AlertDialog -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.preference.SwitchPreferenceCompat import androidx.recyclerview.widget.RecyclerView -import com.lagradost.cloudstream3.CloudStreamApp.Companion.openBrowser +import com.lagradost.cloudstream3.AcraApplication.Companion.openBrowser import com.lagradost.cloudstream3.CommonActivity.onDialogDismissedEvent import com.lagradost.cloudstream3.CommonActivity.showToast -import com.lagradost.cloudstream3.ErrorLoadingException 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.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.syncproviders.AuthAPI +import com.lagradost.cloudstream3.syncproviders.InAppAuthAPI +import com.lagradost.cloudstream3.syncproviders.OAuth2API +import com.lagradost.cloudstream3.ui.result.img +import com.lagradost.cloudstream3.ui.result.setImage +import com.lagradost.cloudstream3.ui.result.setText +import com.lagradost.cloudstream3.ui.result.txt import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR -import com.lagradost.cloudstream3.ui.settings.Globals.TV 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.settings.SettingsFragment.Companion.getPref import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.hideOn @@ -60,24 +57,20 @@ import com.lagradost.cloudstream3.utils.BiometricAuthenticator.isAuthEnabled import com.lagradost.cloudstream3.utils.BiometricAuthenticator.promptInfo import com.lagradost.cloudstream3.utils.BiometricAuthenticator.startBiometricAuthentication import com.lagradost.cloudstream3.utils.Coroutines.ioSafe -import com.lagradost.cloudstream3.utils.ImageLoader.loadImage import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showBottomDialogText import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute import com.lagradost.cloudstream3.utils.UIHelper.dismissSafe import com.lagradost.cloudstream3.utils.UIHelper.hideKeyboard -import com.lagradost.cloudstream3.utils.setText -import com.lagradost.cloudstream3.utils.txt +import com.lagradost.cloudstream3.utils.UIHelper.setImage import qrcode.QRCode -class SettingsAccount : BasePreferenceFragmentCompat(), BiometricCallback { +class SettingsAccount : PreferenceFragmentCompat(), BiometricCallback { companion object { /** Used by nginx plugin too */ - @SuppressLint("StringFormatInvalid") fun showLoginInfo( activity: FragmentActivity?, - api: AuthRepo, - info: AuthUser?, - index: Int, + api: AccountManager, + info: AuthAPI.LoginInfo ) { if (activity == null) return val binding: AccountManagmentBinding = @@ -88,24 +81,15 @@ class SettingsAccount : BasePreferenceFragmentCompat(), BiometricCallback { val dialog = builder.show() binding.accountMainProfilePictureHolder.isVisible = - !info?.profilePicture.isNullOrEmpty() - binding.accountMainProfilePicture.loadImage(info?.profilePicture) + binding.accountMainProfilePicture.setImage(info.profilePicture) - binding.accountLogout.isVisible = info != null binding.accountLogout.setOnClickListener { - if (info != null) { - ioSafe { api.logout(info) } - } + api.logOut() dialog.dismissSafe(activity) } - 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) + (info.name ?: activity.getString(R.string.no_data)).let { + dialog.findViewById(R.id.account_name)?.text = it } binding.accountSite.text = api.name @@ -119,8 +103,8 @@ class SettingsAccount : BasePreferenceFragmentCompat(), BiometricCallback { } } - private fun showAccountSwitch(activity: FragmentActivity, api: AuthRepo) { - val accounts = api.accounts + private fun showAccountSwitch(activity: FragmentActivity, api: AccountManager) { + val accounts = api.getAccounts() ?: return val binding: AccountSwitchBinding = AccountSwitchBinding.inflate(activity.layoutInflater, null, false) @@ -134,272 +118,250 @@ class SettingsAccount : BasePreferenceFragmentCompat(), BiometricCallback { dialog?.dismissSafe(activity) } - binding.accountNone.setOnClickListener { - api.accountId = -1 - dialog?.dismissSafe(activity) - } + val ogIndex = api.accountIndex - val adapter = AccountAdapter { + val items = ArrayList() + + for (index in accounts) { + api.accountIndex = index + val accountInfo = api.loginInfo() + if (accountInfo != null) { + items.add(accountInfo) + } + } + api.accountIndex = ogIndex + val adapter = AccountAdapter(items) { dialog?.dismissSafe(activity) - api.accountId = it.card.user.id - }.apply { - submitList(accounts.toList()) + api.changeAccount(it.card.accountIndex) } val list = dialog.findViewById(R.id.account_list) list?.adapter = adapter } - @UiThread - fun showPin(activity: FragmentActivity, api: AuthRepo) { - val binding: DeviceAuthBinding = - DeviceAuthBinding.inflate(activity.layoutInflater, null, false) + 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 builder = - AlertDialog.Builder(activity) - .setView(binding.root) + val binding: DeviceAuthBinding = + DeviceAuthBinding.inflate(activity.layoutInflater, null, false) - builder.apply { - setNegativeButton(R.string.cancel) { _, _ -> } - if (api.hasOAuth2) { - setPositiveButton(R.string.auth_locally) { _, _ -> - api.openOAuth2PageWithToast() - } - } - } + val builder = + AlertDialog.Builder(activity) + .setView(binding.root) - val dialog = builder.create() + builder.apply { + setNegativeButton(R.string.cancel) { _, _ -> } + setPositiveButton(R.string.auth_locally) { _, _ -> + api.authenticate(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 - ) - ) - } - 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) - ) - ) + val dialog = builder.create() ioSafe { - if (secondsUntilFinished.rem(pinCodeData.interval) == 0 && api.login( - pinCodeData - ) - ) { - showToast( - txt( - R.string.authenticated_user, - api.name - ) - ) - dialog.dismissSafe(activity) - cancel() + 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.setImage( + img(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) } } } - - 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() - } + 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 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 - } - - 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 - ) + val visibilityMap = listOf( + binding.loginEmailInput to api.requiresEmail, + binding.loginPasswordInput to api.requiresPassword, + binding.loginServerInput to api.requiresServer, + binding.loginUsernameInput to api.requiresUsername ) + + 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 + ) + 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 + } + + binding.text1.text = api.name + + 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 ?: "") + } + } + + 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, + ) + ioSafe { + try { + showToast( + txt( + if (api.login(loginData)) R.string.authenticated_user else R.string.authenticated_user_fail, + api.name + ) + ) + } catch (e: Exception) { + logError(e) + } + } + dialog.dismissSafe(activity) + } + binding.cancelBtt.setOnClickListener { + dialog.dismissSafe(activity) + } + } + + else -> { + throw NotImplementedError("You are trying to add an account that has an unknown login method") } } - } - 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) + } catch (e: Exception) { + logError(e) } } } @@ -407,10 +369,9 @@ class SettingsAccount : BasePreferenceFragmentCompat(), BiometricCallback { private fun updateAuthPreference(enabled: Boolean) { val biometricKey = getString(R.string.biometric_key) - PreferenceManager.getDefaultSharedPreferences(context ?: return).edit { - putBoolean(biometricKey, enabled) - } - findPreference(biometricKey)?.isChecked = enabled + PreferenceManager.getDefaultSharedPreferences(context ?: return).edit() + .putBoolean(biometricKey, enabled).apply() + findPreference(biometricKey)?.isChecked = enabled } override fun onAuthenticationError() { @@ -418,7 +379,7 @@ class SettingsAccount : BasePreferenceFragmentCompat(), BiometricCallback { } override fun onAuthenticationSuccess() { - if (isAuthEnabled(context ?: return)) { + if (isAuthEnabled(context?: return)) { updateAuthPreference(true) BackupUtils.backup(activity) activity?.showBottomDialogText( @@ -449,10 +410,10 @@ class SettingsAccount : BasePreferenceFragmentCompat(), 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) @@ -464,24 +425,20 @@ class SettingsAccount : BasePreferenceFragmentCompat(), BiometricCallback { val syncApis = listOf( - R.string.mal_key to SyncRepo(malApi), - R.string.kitsu_key to SyncRepo(kitsuApi), - R.string.anilist_key to SyncRepo(aniListApi), - R.string.simkl_key to SyncRepo(simklApi), - R.string.opensubtitles_key to SubtitleRepo(openSubtitlesApi), - R.string.subdl_key to SubtitleRepo(subDlApi), - R.string.animeskip_key to PlainAuthRepo(animeSkipApi), + 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, ) for ((key, api) in syncApis) { getPref(key)?.apply { title = api.name setOnPreferenceClickListener { - 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) + val info = api.loginInfo() + if (info != null) { + showLoginInfo(activity, api, info) } 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 e41109b59..88335eeaf 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsFragment.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsFragment.kt @@ -2,7 +2,9 @@ package com.lagradost.cloudstream3.ui.settings import android.os.Bundle import android.util.Log +import android.view.LayoutInflater import android.view.View +import android.view.ViewGroup import android.widget.ImageView import androidx.annotation.StringRes import androidx.core.view.children @@ -16,25 +18,20 @@ import com.lagradost.cloudstream3.BuildConfig import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.databinding.MainSettingsBinding import com.lagradost.cloudstream3.mvvm.logError -import com.lagradost.cloudstream3.mvvm.safe import com.lagradost.cloudstream3.syncproviders.AccountManager -import com.lagradost.cloudstream3.syncproviders.AuthRepo -import com.lagradost.cloudstream3.ui.BaseFragment -import com.lagradost.cloudstream3.ui.home.HomeFragment.Companion.errorProfilePic +import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.accountManagers +import com.lagradost.cloudstream3.ui.home.HomeFragment +import com.lagradost.cloudstream3.ui.result.txt 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.setImage import com.lagradost.cloudstream3.utils.UIHelper.toPx -import com.lagradost.cloudstream3.utils.getImageFromDrawable -import com.lagradost.cloudstream3.utils.txt import java.io.File import java.text.DateFormat import java.text.SimpleDateFormat @@ -42,12 +39,12 @@ import java.util.Date import java.util.Locale import java.util.TimeZone -class SettingsFragment : BaseFragment( - BaseFragment.BindingCreator.Inflate(MainSettingsBinding::inflate) -) { +class SettingsFragment : Fragment() { companion object { + fun PreferenceFragmentCompat?.getPref(id: Int): Preference? { if (this == null) return null + return try { findPreference(getString(id)) } catch (e: Exception) { @@ -72,16 +69,12 @@ class SettingsFragment : BaseFragment( } /** - * Hide the [Preference] on selected layouts. - * @return [Preference] if visible otherwise null. - * - * [hideOn] is usually followed by some actions on the preference which are mostly - * unnecessary when the preference is disabled for the said layout thus returning null. + * Hide the Preference on selected layouts. **/ fun Preference?.hideOn(layoutFlags: Int): Preference? { if (this == null) return null this.isVisible = !isLayout(layoutFlags) - return if(this.isVisible) this else null + return this } /** @@ -92,7 +85,6 @@ class SettingsFragment : BaseFragment( listView?.setPadding(0, 0, 0, 100.toPx) } } - fun PreferenceFragmentCompat.setToolBarScrollFlags() { if (isLayout(TV or EMULATOR)) { val settingsAppbar = view?.findViewById(R.id.settings_toolbar) @@ -102,7 +94,6 @@ class SettingsFragment : BaseFragment( } } } - fun Fragment?.setToolBarScrollFlags() { if (isLayout(TV or EMULATOR)) { val settingsAppbar = this?.view?.findViewById(R.id.settings_toolbar) @@ -112,7 +103,6 @@ class SettingsFragment : BaseFragment( } } } - fun Fragment?.setUpToolbar(title: String) { if (this == null) return val settingsToolbar = view?.findViewById(R.id.settings_toolbar) ?: return @@ -126,6 +116,7 @@ class SettingsFragment : BaseFragment( } } } + UIHelper.fixPaddingStatusbar(settingsToolbar) } fun Fragment?.setUpToolbar(@StringRes title: Int) { @@ -138,20 +129,11 @@ class SettingsFragment : BaseFragment( setNavigationIcon(R.drawable.ic_baseline_arrow_back_24) children.firstOrNull { it is ImageView }?.tag = getString(R.string.tv_no_focus_tag) setNavigationOnClickListener { - safe { activity?.onBackPressedDispatcher?.onBackPressed() } + activity?.onBackPressedDispatcher?.onBackPressed() } } } - } - - fun Fragment.setSystemBarsPadding() { - view?.let { - fixSystemBarsPadding( - it, - padLeft = isLayout(TV or EMULATOR), - padBottom = isLandscape() - ) - } + UIHelper.fixPaddingStatusbar(settingsToolbar) } fun getFolderSize(dir: File): Long { @@ -168,16 +150,23 @@ class SettingsFragment : BaseFragment( return size } } - - override fun fixLayout(view: View) { - fixSystemBarsPadding( - view, - padBottom = isLandscape(), - padLeft = isLayout(TV or EMULATOR) - ) + override fun onDestroyView() { + binding = null + super.onDestroyView() } - override fun onBindingCreated(binding: MainSettingsBinding) { + var binding: MainSettingsBinding? = null + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ): View { + val localBinding = MainSettingsBinding.inflate(inflater, container, false) + binding = localBinding + return localBinding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { fun navigate(id: Int) { activity?.navigate(id, Bundle()) } @@ -186,25 +175,25 @@ class SettingsFragment : BaseFragment( showToast(activity,"${VideoDownloadManager.downloadStatusEvent.size} : ${VideoDownloadManager.downloadProgressEvent.size}") **/ - fun hasProfilePictureFromAccountManagers(accountManagers: Array): Boolean { + fun hasProfilePictureFromAccountManagers(accountManagers: List): Boolean { for (syncApi in accountManagers) { - val login = syncApi.authUser() + val login = syncApi.loginInfo() val pic = login?.profilePicture ?: continue - binding.settingsProfilePic.let { imageView -> - imageView.loadImage(pic) { - // Fallback to random error drawable - error { getImageFromDrawable(context ?: return@error null, errorProfilePic) } - } + if (binding?.settingsProfilePic?.setImage( + pic, + errorImageDrawable = HomeFragment.errorProfilePic + ) == true + ) { + binding?.settingsProfileText?.text = login.name + return true // sync profile exists } - binding.settingsProfileText.text = login.name - return true // sync profile exists } return false // not syncing } // display local account information if not syncing - if (!hasProfilePictureFromAccountManagers(AccountManager.allApis)) { + if (!hasProfilePictureFromAccountManagers(accountManagers)) { val activity = activity ?: return val currentAccount = try { DataStoreHelper.accounts.firstOrNull { @@ -216,11 +205,11 @@ class SettingsFragment : BaseFragment( null } - binding.settingsProfilePic.loadImage(currentAccount?.image) - binding.settingsProfileText.text = currentAccount?.name + binding?.settingsProfilePic?.setImage(currentAccount?.image) + binding?.settingsProfileText?.text = currentAccount?.name } - binding.apply { + binding?.apply { listOf( settingsGeneral to R.id.action_navigation_global_to_navigation_settings_general, settingsPlayer to R.id.action_navigation_global_to_navigation_settings_player, @@ -247,19 +236,17 @@ class SettingsFragment : BaseFragment( } } - val appVersion = BuildConfig.VERSION_NAME - val commitHash = activity?.currentCommitHash() ?: "" + val appVersion = getString(R.string.app_version) + val commitInfo = getString(R.string.commit_hash) val buildTimestamp = SimpleDateFormat.getDateTimeInstance(DateFormat.LONG, DateFormat.LONG, Locale.getDefault() ).apply { timeZone = TimeZone.getTimeZone("UTC") }.format(Date(BuildConfig.BUILD_DATE)).replace("UTC", "") - binding.appVersion.text = appVersion - binding.buildDate.text = buildTimestamp - binding.commitHash.text = commitHash - binding.appVersionInfo.setOnLongClickListener { - clipboardHelper(txt(R.string.extension_version), "$appVersion $commitHash $buildTimestamp") + binding?.buildDate?.text = buildTimestamp + binding?.appVersionInfo?.setOnLongClickListener { + clipboardHelper(txt(R.string.extension_version), "$appVersion $commitInfo $buildTimestamp") true } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsGeneral.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsGeneral.kt index 57f5aa870..7cb1a848f 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 @@ -1,20 +1,21 @@ package com.lagradost.cloudstream3.ui.settings import android.content.Context +import android.content.Intent import android.net.Uri +import android.os.Build import android.os.Bundle import android.view.View import android.widget.Toast +import androidx.activity.result.contract.ActivityResultContracts import androidx.appcompat.app.AlertDialog -import androidx.core.content.edit -import androidx.core.os.ConfigurationCompat -import androidx.fragment.app.Fragment +import androidx.preference.PreferenceFragmentCompat import androidx.preference.PreferenceManager import com.fasterxml.jackson.annotation.JsonProperty import com.lagradost.cloudstream3.APIHolder.allProviders -import com.lagradost.cloudstream3.CloudStreamApp -import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey -import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey +import com.lagradost.cloudstream3.AcraApplication +import com.lagradost.cloudstream3.AcraApplication.Companion.getKey +import com.lagradost.cloudstream3.AcraApplication.Companion.setKey import com.lagradost.cloudstream3.CommonActivity import com.lagradost.cloudstream3.CommonActivity.showToast import com.lagradost.cloudstream3.MainActivity @@ -23,9 +24,9 @@ import com.lagradost.cloudstream3.app import com.lagradost.cloudstream3.databinding.AddRemoveSitesBinding import com.lagradost.cloudstream3.databinding.AddSiteInputBinding import com.lagradost.cloudstream3.mvvm.logError -import com.lagradost.cloudstream3.mvvm.safe +import com.lagradost.cloudstream3.mvvm.normalSafeApiCall import com.lagradost.cloudstream3.network.initClient -import com.lagradost.cloudstream3.ui.BasePreferenceFragmentCompat +import com.lagradost.cloudstream3.ui.EasterEggMonke import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR import com.lagradost.cloudstream3.ui.settings.Globals.TV import com.lagradost.cloudstream3.ui.settings.Globals.beneneCount @@ -34,7 +35,6 @@ import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.hideOn import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setPaddingBottom import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setToolBarScrollFlags import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setUpToolbar -import com.lagradost.cloudstream3.ui.settings.utils.getChooseFolderLauncher import com.lagradost.cloudstream3.utils.BatteryOptimizationChecker.isAppRestricted import com.lagradost.cloudstream3.utils.BatteryOptimizationChecker.showBatteryOptimizationDialog import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showBottomDialog @@ -43,101 +43,91 @@ import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showMultiDialog import com.lagradost.cloudstream3.utils.SubtitleHelper import com.lagradost.cloudstream3.utils.UIHelper.dismissSafe import com.lagradost.cloudstream3.utils.UIHelper.hideKeyboard -import com.lagradost.cloudstream3.utils.UIHelper.navigate import com.lagradost.cloudstream3.utils.USER_PROVIDER_API -import com.lagradost.cloudstream3.utils.downloader.DownloadFileManagement -import com.lagradost.cloudstream3.utils.downloader.DownloadFileManagement.getBasePath -import com.lagradost.cloudstream3.utils.downloader.DownloadQueueManager -import java.util.Locale +import com.lagradost.cloudstream3.utils.VideoDownloadManager +import com.lagradost.cloudstream3.utils.VideoDownloadManager.getBasePath +import com.lagradost.safefile.SafeFile // Change local language settings in the app. fun getCurrentLocale(context: Context): String { - val conf = context.resources.configuration - return ConfigurationCompat.getLocales(conf).get(0)?.toLanguageTag() ?: "en" + val res = context.resources + val conf = res.configuration + + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + conf?.locales?.get(0)?.toString() ?: "en" + } else { + @Suppress("DEPRECATION") + conf?.locale?.toString() ?: "en" + } } -/** - * List of app supported languages. - * Language code shall be a IETF BCP 47 conformant tag - * - * See locales on: - * https://github.com/unicode-org/cldr-json/blob/main/cldr-json/cldr-core/availableLocales.json - * https://www.iana.org/assignments/language-subtag-registry/language-subtag-registry - * https://android.googlesource.com/platform/frameworks/base/+/android-16.0.0_r2/core/res/res/values/locale_config.xml - * https://iso639-3.sil.org/code_tables/639/data/all -*/ +// idk, if you find a way of automating this it would be great +// https://www.iemoji.com/view/emoji/1794/flags/antarctica +// Emoji Character Encoding Data --> C/C++/Java Src +// https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes leave blank for auto val appLanguages = arrayListOf( /* begin language list */ - Pair("Afrikaans", "af"), - Pair("Azərbaycan dili", "az"), - Pair("Bahasa Indonesia", "in"), - Pair("Bahasa Melayu", "ms"), - Pair("Deutsch", "de"), - Pair("English", "en"), - Pair("Español", "es"), - Pair("Esperanto", "eo"), - Pair("Français", "fr"), - Pair("Galego", "gl"), - Pair("hrvatski", "hr"), - Pair("Italiano", "it"), - Pair("Latviešu valoda", "lv"), - Pair("Lietuvių kalba", "lt"), - Pair("Magyar", "hu"), - Pair("Malti", "mt"), - Pair("mmmm... monke", "qt"), - Pair("Nederlands", "nl"), - Pair("Norsk bokmål", "no"), - Pair("Norsk nynorsk", "nn"), - Pair("Polski", "pl"), - Pair("Português", "pt"), - Pair("Português (Brasil)", "pt-BR"), - Pair("Română", "ro"), - Pair("Slovenčina", "sk"), - Pair("Soomaaliga", "so"), - Pair("Svenska", "sv"), - Pair("Tagalog", "tl"), - Pair("Tiếng Việt", "vi"), - Pair("Türkçe", "tr"), - Pair("Wikang Filipino", "fil"), - Pair("Čeština", "cs"), - Pair("Ελληνικά", "el"), - Pair("български", "bg"), - Pair("македонски", "mk"), - Pair("русский", "ru"), - Pair("українська", "uk"), - Pair("עברית", "iw"), - Pair("اردو", "ur"), - Pair("العربية", "ar"), - Pair("اللهجة النجدية", "ars"), - Pair("عربي شامي", "apc"), - Pair("فارسی", "fa"), - Pair("کوردیی ناوەندی", "ckb"), - Pair("नेपाली", "ne"), - Pair("हिन्दी", "hi"), - Pair("অসমীয়া", "as"), - Pair("বাংলা", "bn"), - Pair("ଓଡ଼ିଆ", "or"), - Pair("தமிழ்", "ta"), - Pair("ಕನ್ನಡ", "kn"), - Pair("മലയാളം", "ml"), - Pair("ဗမာစာ", "my"), - Pair("ትግርኛ", "ti"), - Pair("አማርኛ", "am"), - Pair("中文", "zh"), - Pair("日本語 (にほんご)", "ja"), - Pair("正體中文(臺灣)", "zh-TW"), - Pair("한국어", "ko"), + Triple("", "Afrikaans", "af"), + Triple("", "عربي شامي", "ajp"), + Triple("", "አማርኛ", "am"), + Triple("", "العربية", "ar"), + Triple("", "اللهجة النجدية", "ars"), + Triple("", "অসমীয়া", "as"), + Triple("", "български", "bg"), + Triple("", "বাংলা", "bn"), + Triple("\uD83C\uDDE7\uD83C\uDDF7", "português brasileiro", "bp"), + Triple("", "čeština", "cs"), + Triple("", "Deutsch", "de"), + Triple("", "Ελληνικά", "el"), + Triple("", "English", "en"), + Triple("", "Esperanto", "eo"), + Triple("", "español", "es"), + Triple("", "فارسی", "fa"), + Triple("", "fil", "fil"), + Triple("", "français", "fr"), + Triple("", "galego", "gl"), + Triple("", "हिन्दी", "hi"), + Triple("", "hrvatski", "hr"), + Triple("", "magyar", "hu"), + Triple("\uD83C\uDDEE\uD83C\uDDE9", "Bahasa Indonesia", "in"), + Triple("", "italiano", "it"), + Triple("\uD83C\uDDEE\uD83C\uDDF1", "עברית", "iw"), + Triple("", "日本語 (にほんご)", "ja"), + Triple("", "ಕನ್ನಡ", "kn"), + Triple("", "한국어", "ko"), + Triple("", "lietuvių kalba", "lt"), + Triple("", "latviešu valoda", "lv"), + Triple("", "македонски", "mk"), + Triple("", "മലയാളം", "ml"), + Triple("", "bahasa Melayu", "ms"), + Triple("", "Malti", "mt"), + Triple("", "ဗမာစာ", "my"), + Triple("", "नेपाली", "ne"), + Triple("", "Nederlands", "nl"), + Triple("", "norsk nynorsk", "nn"), + Triple("", "norsk bokmål", "no"), + Triple("", "ଓଡ଼ିଆ", "or"), + Triple("", "polski", "pl"), + Triple("\uD83C\uDDF5\uD83C\uDDF9", "português", "pt"), + Triple("\uD83E\uDD8D", "mmmm... monke", "qt"), + Triple("", "română", "ro"), + Triple("", "русский", "ru"), + Triple("", "slovenčina", "sk"), + Triple("", "Soomaaliga", "so"), + Triple("", "svenska", "sv"), + Triple("", "தமிழ்", "ta"), + Triple("", "ትግርኛ", "ti"), + Triple("", "Tagalog", "tl"), + Triple("", "Türkçe", "tr"), + Triple("", "українська", "uk"), + Triple("", "اردو", "ur"), + Triple("", "Tiếng Việt", "vi"), + Triple("", "中文", "zh"), + Triple("\uD83C\uDDF9\uD83C\uDDFC", "正體中文(臺灣)", "zh-rTW"), /* end language list */ -).sortedBy { it.first.lowercase(Locale.ROOT) } // ye, we go alphabetical, so ppl don't put their lang on top +).sortedBy { it.second.lowercase() } //ye, we go alphabetical, so ppl don't put their lang on top -fun Pair.nameNextToFlagEmoji(): String { - // fallback to [A][A] -> [?] question mak flag - val flag = SubtitleHelper.getFlagFromIso(this.second) ?: "\ud83c\udde6\ud83c\udde6" - - return "$flag\u00a0${this.first}" // \u00a0 non-breaking space -} - -class SettingsGeneral : BasePreferenceFragmentCompat() { +class SettingsGeneral : PreferenceFragmentCompat() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) setUpToolbar(R.string.category_general) @@ -156,22 +146,34 @@ class SettingsGeneral : BasePreferenceFragmentCompat() { val lang: String, ) - companion object { - fun Fragment.pickDownloadPath(uri: Uri?, path: String?) { - if (uri == null) return + // Open file picker + private val pathPicker = + 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 + // RW perms for the path + val flags = Intent.FLAG_GRANT_READ_URI_PERMISSION or + Intent.FLAG_GRANT_WRITE_URI_PERMISSION - 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) + context.contentResolver.takePersistableUriPermission(uri, flags) + + val file = SafeFile.fromUri(context, uri) + val filePath = file?.filePath() + println("Selected URI path: $uri - Full path: $filePath") + + // Stores the real URI using download_path_key + // Important that the URI is stored instead of filepath due to permissions. + PreferenceManager.getDefaultSharedPreferences(context) + .edit().putString(getString(R.string.download_path_key), uri.toString()).apply() + + // From URI -> File path + // File path here is purely for cosmetic purposes in settings + (filePath ?: uri.toString()).let { + PreferenceManager.getDefaultSharedPreferences(context) + .edit().putString(getString(R.string.download_path_pref), it).apply() } } - } - - private val pathPicker = getChooseFolderLauncher { uri, path -> - pickDownloadPath(uri, path) - } override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { hideKeyboard() @@ -184,20 +186,22 @@ class SettingsGeneral : BasePreferenceFragmentCompat() { } getPref(R.string.locale_key)?.setOnPreferenceClickListener { pref -> + val tempLangs = appLanguages.toMutableList() val current = getCurrentLocale(pref.context) - val languageTagsIETF = appLanguages.map { it.second } - val languageNames = appLanguages.map { it.nameNextToFlagEmoji() } - val currentIndex = languageTagsIETF.indexOf(current) + val languageCodes = tempLangs.map { (_, _, iso) -> iso } + val languageNames = tempLangs.map { (emoji, name, iso) -> + val flag = emoji.ifBlank { SubtitleHelper.getFlagFromIso(iso) ?: "ERROR" } + "$flag $name" + } + val index = languageCodes.indexOf(current) activity?.showDialog( - languageNames, currentIndex, getString(R.string.app_language), true, { } - ) { selectedLangIndex -> + languageNames, index, getString(R.string.app_language), true, { } + ) { languageIndex -> try { - val langTagIETF = languageTagsIETF[selectedLangIndex] - CommonActivity.setLocale(activity, langTagIETF) - settingsManager.edit { - putString(getString(R.string.locale_key), langTagIETF) - } + val code = languageCodes[languageIndex] + CommonActivity.setLocale(activity, code) + settingsManager.edit().putString(getString(R.string.locale_key), code).apply() activity?.recreate() } catch (e: Exception) { logError(e) @@ -210,7 +214,7 @@ class SettingsGeneral : BasePreferenceFragmentCompat() { val ctx = context ?: return@setOnPreferenceClickListener false if (isAppRestricted(ctx)) { - ctx.showBatteryOptimizationDialog() + showBatteryOptimizationDialog(ctx) } else { showToast(R.string.app_unrestricted_toast) } @@ -219,7 +223,7 @@ class SettingsGeneral : BasePreferenceFragmentCompat() { } fun showAdd() { - val providers = allProviders.distinctBy { it::class }.sortedBy { it.name } + val providers = synchronized(allProviders) { allProviders.distinctBy { it.javaClass }.sortedBy { it.name } } activity?.showDialog( providers.map { "${it.name} (${it.mainUrl})" }, -1, @@ -243,7 +247,7 @@ class SettingsGeneral : BasePreferenceFragmentCompat() { val url = binding.siteUrlInput.text?.toString() val lang = binding.siteLangInput.text?.toString() val realLang = if (lang.isNullOrBlank()) provider.lang else lang - if (url.isNullOrBlank() || name.isNullOrBlank()) { + if (url.isNullOrBlank() || name.isNullOrBlank() || realLang.length != 2) { showToast(R.string.error_invalid_data, Toast.LENGTH_SHORT) return@setOnClickListener } @@ -328,16 +332,16 @@ class SettingsGeneral : BasePreferenceFragmentCompat() { getString(R.string.dns_pref), true, {}) { - settingsManager.edit { putInt(getString(R.string.dns_pref), prefValues[it]) } - (context ?: CloudStreamApp.context)?.let { ctx -> app.initClient(ctx) } + settingsManager.edit().putInt(getString(R.string.dns_pref), prefValues[it]).apply() + (context ?: AcraApplication.context)?.let { ctx -> app.initClient(ctx) } } return@setOnPreferenceClickListener true } fun getDownloadDirs(): List { - return safe { + return normalSafeApiCall { context?.let { ctx -> - val defaultDir = DownloadFileManagement.getDefaultDir(ctx)?.filePath() + val defaultDir = VideoDownloadManager.getDefaultDir(ctx)?.filePath() val first = listOf(defaultDir) (try { @@ -353,27 +357,21 @@ class SettingsGeneral : BasePreferenceFragmentCompat() { } ?: emptyList() } - settingsManager.edit { putBoolean(getString(R.string.jsdelivr_proxy_key), getKey(getString(R.string.jsdelivr_proxy_key), false) ?: false) } + settingsManager.edit().putBoolean(getString(R.string.jsdelivr_proxy_key), getKey(getString(R.string.jsdelivr_proxy_key), false) ?: false).apply() getPref(R.string.jsdelivr_proxy_key)?.setOnPreferenceChangeListener { _, newValue -> setKey(getString(R.string.jsdelivr_proxy_key), newValue) return@setOnPreferenceChangeListener true } - getPref(R.string.download_parallel_key)?.setOnPreferenceChangeListener { _, _ -> - // Notify that the queue logic has been changed - DownloadQueueManager.forceRefreshQueue() - return@setOnPreferenceChangeListener true - } - getPref(R.string.download_path_key)?.setOnPreferenceClickListener { val dirs = getDownloadDirs() val currentDir = - settingsManager.getString(getString(R.string.download_path_key_visual), null) - ?: context?.let { ctx -> DownloadFileManagement.getDefaultDir(ctx)?.filePath() } + settingsManager.getString(getString(R.string.download_path_pref), null) + ?: context?.let { ctx -> VideoDownloadManager.getDefaultDir(ctx)?.filePath() } activity?.showBottomDialog( - dirs + listOf(getString(R.string.custom)), + dirs + listOf("Custom"), dirs.indexOf(currentDir), getString(R.string.download_path_pref), true, @@ -388,11 +386,11 @@ class SettingsGeneral : BasePreferenceFragmentCompat() { } else { // 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]) - } + // pref = visual path + settingsManager.edit() + .putString(getString(R.string.download_path_key), dirs[it]).apply() + settingsManager.edit() + .putString(getString(R.string.download_path_pref), dirs[it]).apply() } } return@setOnPreferenceClickListener true @@ -413,15 +411,16 @@ class SettingsGeneral : BasePreferenceFragmentCompat() { try { beneneCount++ if (beneneCount%20 == 0) { - activity?.navigate(R.id.action_navigation_settings_general_to_easterEggMonkeFragment) + val intent = Intent(context, EasterEggMonke::class.java) + startActivity(intent) } - settingsManager.edit { - putInt( - getString(R.string.benene_count), - beneneCount - ) - } - it.summary = getString(R.string.benene_count_text).format(beneneCount) + settingsManager.edit().putInt( + getString(R.string.benene_count), + beneneCount + ) + .apply() + it.summary = + getString(R.string.benene_count_text).format(beneneCount) } catch (e: Exception) { logError(e) } diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsPlayer.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsPlayer.kt index 0a0fb33c8..17580236f 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsPlayer.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsPlayer.kt @@ -3,13 +3,11 @@ package com.lagradost.cloudstream3.ui.settings import android.os.Bundle import android.text.format.Formatter.formatShortFileSize import android.view.View -import androidx.core.content.edit +import androidx.preference.PreferenceFragmentCompat import androidx.preference.PreferenceManager import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.actions.VideoClickActionHolder import com.lagradost.cloudstream3.mvvm.logError -import com.lagradost.cloudstream3.ui.BasePreferenceFragmentCompat -import com.lagradost.cloudstream3.ui.player.source_priority.QualityProfileDialog import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR import com.lagradost.cloudstream3.ui.settings.Globals.PHONE import com.lagradost.cloudstream3.ui.settings.Globals.TV @@ -22,21 +20,18 @@ import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setTool import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setUpToolbar import com.lagradost.cloudstream3.ui.subtitles.ChromecastSubtitlesFragment import com.lagradost.cloudstream3.ui.subtitles.SubtitlesFragment -import com.lagradost.cloudstream3.utils.Coroutines.ioSafe import com.lagradost.cloudstream3.utils.Qualities import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showBottomDialog import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showDialog -import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showMultiDialog import com.lagradost.cloudstream3.utils.UIHelper.hideKeyboard -class SettingsPlayer : BasePreferenceFragmentCompat() { +class SettingsPlayer : PreferenceFragmentCompat() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) setUpToolbar(R.string.category_player) setPaddingBottom() setToolBarScrollFlags() } - override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { hideKeyboard() setPreferencesFromResource(R.xml.settings_player, rootKey) @@ -47,12 +42,11 @@ class SettingsPlayer : BasePreferenceFragmentCompat() { listOf( R.string.pref_category_gestures_key, R.string.rotate_video_key, - R.string.auto_rotate_video_key, - R.string.speedup_key + R.string.auto_rotate_video_key ), TV or EMULATOR ) - + getPref(R.string.preview_seekbar_key)?.hideOn(TV) getPref(R.string.pref_category_android_tv_key)?.hideOn(PHONE) @@ -68,11 +62,10 @@ class SettingsPlayer : BasePreferenceFragmentCompat() { prefValues.indexOf(currentPrefSize), getString(R.string.video_buffer_length_settings), true, - {} - ) { - settingsManager.edit { - putInt(getString(R.string.video_buffer_length_key), prefValues[it]) - } + {}) { + settingsManager.edit() + .putInt(getString(R.string.video_buffer_length_key), prefValues[it]) + .apply() } return@setOnPreferenceClickListener true } @@ -87,69 +80,32 @@ class SettingsPlayer : BasePreferenceFragmentCompat() { prefValues.indexOf(current), getString(R.string.limit_title), true, - {} - ) { - settingsManager.edit { - putInt(getString(R.string.prefer_limit_title_key), prefValues[it]) - } + {}) { + settingsManager.edit() + .putInt(getString(R.string.prefer_limit_title_key), prefValues[it]) + .apply() } return@setOnPreferenceClickListener true } - getPref(R.string.software_decoding_key)?.setOnPreferenceClickListener { - val prefNames = resources.getStringArray(R.array.software_decoding_switch) - val prefValues = resources.getIntArray(R.array.software_decoding_switch_values) - val current = settingsManager.getInt(getString(R.string.software_decoding_key), -1) + 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) activity?.showBottomDialog( prefNames.toList(), prefValues.indexOf(current), - getString(R.string.software_decoding), + getString(R.string.limit_title_rez), true, - {} - ) { - settingsManager.edit { - putInt(getString(R.string.software_decoding_key), prefValues[it]) - } + {}) { + settingsManager.edit() + .putInt(getString(R.string.prefer_limit_title_rez_key), prefValues[it]) + .apply() } return@setOnPreferenceClickListener true } - getPref(R.string.prefer_limit_show_player_info)?.setOnPreferenceClickListener { - val ctx = context ?: return@setOnPreferenceClickListener false - - 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(), - selectedIndices, - getString(R.string.limit_title_rez), - {} - ) { selected -> - settingsManager.edit { - for ((index, key) in keys.withIndex()) { - putBoolean(key, selected.contains(index)) - } - } - } - - true - } - getPref(R.string.hide_player_control_names_key)?.hideOn(TV) getPref(R.string.quality_pref_key)?.setOnPreferenceClickListener { @@ -169,11 +125,9 @@ class SettingsPlayer : BasePreferenceFragmentCompat() { prefValues.indexOf(currentQuality), getString(R.string.watch_quality_pref), true, - {} - ) { - settingsManager.edit { - putInt(getString(R.string.quality_pref_key), prefValues[it]) - } + {}) { + settingsManager.edit().putInt(getString(R.string.quality_pref_key), prefValues[it]) + .apply() } return@setOnPreferenceClickListener true } @@ -195,11 +149,9 @@ class SettingsPlayer : BasePreferenceFragmentCompat() { prefValues.indexOf(currentQuality), getString(R.string.watch_quality_pref_data), true, - {} - ) { - settingsManager.edit { - putInt(getString(R.string.quality_pref_mobile_data_key), prefValues[it]) - } + {}) { + settingsManager.edit().putInt(getString(R.string.quality_pref_mobile_data_key), prefValues[it]) + .apply() } return@setOnPreferenceClickListener true } @@ -214,19 +166,15 @@ class SettingsPlayer : BasePreferenceFragmentCompat() { add("") addAll(players.map { it.uniqueId() }) } - val current = - settingsManager.getString(getString(R.string.player_default_key), "") ?: "" + val current = settingsManager.getString(getString(R.string.player_default_key), "") ?: "" activity?.showBottomDialog( prefNames.toList(), prefValues.indexOf(current), getString(R.string.player_pref), true, - {} - ) { - settingsManager.edit { - putString(getString(R.string.player_default_key), prefValues[it]) - } + {}) { + settingsManager.edit().putString(getString(R.string.player_default_key), prefValues[it]).apply() } return@setOnPreferenceClickListener true } @@ -241,21 +189,6 @@ class SettingsPlayer : BasePreferenceFragmentCompat() { return@setOnPreferenceClickListener true } - getPref(R.string.player_source_priority_key)?.setOnPreferenceClickListener { - ioSafe { - val defaultSources = QualityProfileDialog.getAllDefaultSources() - val activity = activity ?: return@ioSafe - activity.runOnUiThread { - QualityProfileDialog( - activity, - R.style.DialogFullscreenPlayer, - defaultSources, - ).show() - } - } - return@setOnPreferenceClickListener true - } - getPref(R.string.video_buffer_disk_key)?.setOnPreferenceClickListener { val prefNames = resources.getStringArray(R.array.video_buffer_size_names) val prefValues = resources.getIntArray(R.array.video_buffer_size_values) @@ -268,11 +201,10 @@ class SettingsPlayer : BasePreferenceFragmentCompat() { prefValues.indexOf(currentPrefSize), getString(R.string.video_buffer_disk_settings), true, - {} - ) { - settingsManager.edit { - putInt(getString(R.string.video_buffer_disk_key), prefValues[it]) - } + {}) { + settingsManager.edit() + .putInt(getString(R.string.video_buffer_disk_key), prefValues[it]) + .apply() } return@setOnPreferenceClickListener true } @@ -288,11 +220,10 @@ class SettingsPlayer : BasePreferenceFragmentCompat() { prefValues.indexOf(currentPrefSize), getString(R.string.video_buffer_size_settings), true, - {} - ) { - settingsManager.edit { - putInt(getString(R.string.video_buffer_size_key), prefValues[it]) - } + {}) { + settingsManager.edit() + .putInt(getString(R.string.video_buffer_size_key), prefValues[it]) + .apply() } return@setOnPreferenceClickListener true } @@ -300,20 +231,20 @@ class SettingsPlayer : BasePreferenceFragmentCompat() { getPref(R.string.video_buffer_clear_key)?.let { pref -> val cacheDir = context?.cacheDir ?: return@let - fun updateSummary() { + fun updateSummery() { try { - pref.summary = formatShortFileSize(pref.context, getFolderSize(cacheDir)) + pref.summary = formatShortFileSize(view?.context, getFolderSize(cacheDir)) } catch (e: Exception) { logError(e) } } - updateSummary() + updateSummery() pref.setOnPreferenceClickListener { try { cacheDir.deleteRecursively() - updateSummary() + updateSummery() } catch (e: Exception) { logError(e) } diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsProviders.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsProviders.kt index c8478a840..cb7d25fd7 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsProviders.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsProviders.kt @@ -2,13 +2,12 @@ package com.lagradost.cloudstream3.ui.settings import android.os.Bundle import android.view.View -import androidx.core.content.edit -import androidx.navigation.fragment.findNavController import androidx.navigation.NavOptions +import androidx.navigation.fragment.findNavController +import androidx.preference.PreferenceFragmentCompat import androidx.preference.PreferenceManager import com.lagradost.cloudstream3.* import com.lagradost.cloudstream3.ui.APIRepository -import com.lagradost.cloudstream3.ui.BasePreferenceFragmentCompat import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.getPref import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setPaddingBottom import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setToolBarScrollFlags @@ -17,10 +16,10 @@ import com.lagradost.cloudstream3.utils.AppContextUtils.getApiDubstatusSettings import com.lagradost.cloudstream3.utils.AppContextUtils.getApiProviderLangSettings import com.lagradost.cloudstream3.utils.DataStoreHelper import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showMultiDialog -import com.lagradost.cloudstream3.utils.SubtitleHelper.getNameNextToFlagEmoji +import com.lagradost.cloudstream3.utils.SubtitleHelper import com.lagradost.cloudstream3.utils.UIHelper.hideKeyboard -class SettingsProviders : BasePreferenceFragmentCompat() { +class SettingsProviders : PreferenceFragmentCompat() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) setUpToolbar(R.string.category_providers) @@ -47,15 +46,13 @@ class SettingsProviders : BasePreferenceFragmentCompat() { names, currentList, getString(R.string.display_subbed_dubbed_settings), - {} - ) { selectedList -> + {}) { selectedList -> APIRepository.dubStatusActive = selectedList.map { dublist[it] }.toHashSet() - settingsManager.edit { - putStringSet( - getString(R.string.display_sub_key), - selectedList.map { names[it] }.toMutableSet() - ) - } + + settingsManager.edit().putStringSet( + this.getString(R.string.display_sub_key), + selectedList.map { names[it] }.toMutableSet() + ).apply() } } @@ -94,46 +91,50 @@ class SettingsProviders : BasePreferenceFragmentCompat() { names, currentList, getString(R.string.preferred_media_settings), - {} - ) { selectedList -> - settingsManager.edit { - putStringSet( - getString(R.string.prefer_media_type_key), - selectedList.map { it.toString() }.toMutableSet() - ) - } + {}) { selectedList -> + settingsManager.edit().putStringSet( + this.getString(R.string.prefer_media_type_key), + selectedList.map { it.toString() }.toMutableSet() + ).apply() DataStoreHelper.currentHomePage = null - //(context ?: CloudStreamApp.context)?.let { ctx -> app.initClient(ctx) } + //(context ?: AcraApplication.context)?.let { ctx -> app.initClient(ctx) } } return@setOnPreferenceClickListener true } getPref(R.string.provider_lang_key)?.setOnPreferenceClickListener { - activity?.getApiProviderLangSettings()?.let { currentLangTags -> - val languagesTagName = APIHolder.apis.withLock { - listOf(Pair(AllLanguagesName, getString(R.string.all_languages_preference))) + - APIHolder.apis.map { Pair(it.lang, getNameNextToFlagEmoji(it.lang) ?: it.lang) } - .toSet().sortedBy { it.second.substringAfter("\u00a0").lowercase() } + activity?.getApiProviderLangSettings()?.let { current -> + val languages = synchronized(APIHolder.apis) { + APIHolder.apis.map { it.lang }.toSet() + .sortedBy { SubtitleHelper.fromTwoLettersToLanguage(it) } + AllLanguagesName } - val currentIndexList = currentLangTags.map { langTag -> - languagesTagName.indexOfFirst { lang -> lang.first == langTag } + val currentList = current.map { + languages.indexOf(it) + } + + val names = languages.map { + if (it == AllLanguagesName) { + Pair(it, getString(R.string.all_languages_preference)) + } else { + val emoji = SubtitleHelper.getFlagFromIso(it) + val name = SubtitleHelper.fromTwoLettersToLanguage(it) + val fullName = "$emoji $name" + Pair(it, fullName) + } } activity?.showMultiDialog( - languagesTagName.map { it.second }, - currentIndexList, + names.map { it.second }, + currentList, getString(R.string.provider_lang_settings), - {} - ) { selectedList -> - settingsManager.edit { - putStringSet( - getString(R.string.provider_lang_key), - selectedList.map { languagesTagName[it].first }.toSet() - ) - } - // APIRepository.providersActive = it.context.getApiSettings() + {}) { selectedList -> + settingsManager.edit().putStringSet( + this.getString(R.string.provider_lang_key), + selectedList.map { names[it].first }.toMutableSet() + ).apply() + //APIRepository.providersActive = it.context.getApiSettings() } } diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsUI.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsUI.kt index f4c522bf9..8c3ad0ade 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,25 +3,14 @@ package com.lagradost.cloudstream3.ui.settings import android.os.Build import android.os.Bundle import android.view.View -import androidx.core.content.edit +import androidx.preference.PreferenceFragmentCompat import androidx.preference.PreferenceManager -import androidx.preference.SeekBarPreference -import com.lagradost.cloudstream3.CloudStreamApp.Companion.getActivity -import com.lagradost.cloudstream3.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.updateTv 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 import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setToolBarScrollFlags import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setUpToolbar @@ -29,9 +18,8 @@ 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 -import com.lagradost.cloudstream3.utils.UIHelper.toPx -class SettingsUI : BasePreferenceFragmentCompat() { +class SettingsUI : PreferenceFragmentCompat() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) setUpToolbar(R.string.category_ui) @@ -44,27 +32,6 @@ class SettingsUI : BasePreferenceFragmentCompat() { setPreferencesFromResource(R.xml.settings_ui, rootKey) val settingsManager = PreferenceManager.getDefaultSharedPreferences(requireContext()) - (getPref(R.string.overscan_key)?.hideOn(PHONE or EMULATOR) as? SeekBarPreference)?.setOnPreferenceChangeListener { pref, newValue -> - val padding = (newValue as? Int)?.toPx ?: return@setOnPreferenceChangeListener true - (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) @@ -80,13 +47,12 @@ class SettingsUI : BasePreferenceFragmentCompat() { prefNames.toList(), prefValues, getString(R.string.poster_ui_settings), - {} - ) { list -> - settingsManager.edit { - for ((i, key) in keys.withIndex()) { - putBoolean(key, list.contains(i)) - } + {}) { list -> + val edit = settingsManager.edit() + for ((i, key) in keys.withIndex()) { + edit.putBoolean(key, list.contains(i)) } + edit.apply() SearchResultBuilder.updateCache(it.context) } @@ -101,23 +67,21 @@ class SettingsUI : BasePreferenceFragmentCompat() { settingsManager.getInt(getString(R.string.app_layout_key), -1) activity?.showBottomDialog( - items = prefNames.toList(), - selectedIndex = prefValues.indexOf(currentLayout), - name = getString(R.string.app_layout), - showApply = true, - dismissCallback = {}, - callback = { - try { - settingsManager.edit { - putInt(getString(R.string.app_layout_key), prefValues[it]) - } - context?.updateTv() - activity?.recreate() - } catch (e: Exception) { - logError(e) - } + prefNames.toList(), + prefValues.indexOf(currentLayout), + getString(R.string.app_layout), + true, + {}) { + try { + settingsManager.edit() + .putInt(getString(R.string.app_layout_key), prefValues[it]) + .apply() + context?.updateTv() + activity?.recreate() + } catch (e: Exception) { + logError(e) } - ) + } return@setOnPreferenceClickListener true } @@ -150,12 +114,11 @@ class SettingsUI : BasePreferenceFragmentCompat() { prefValues.indexOf(currentLayout), getString(R.string.app_theme_settings), true, - {} - ) { + {}) { try { - settingsManager.edit { - putString(getString(R.string.app_theme_key), prefValues[it]) - } + settingsManager.edit() + .putString(getString(R.string.app_theme_key), prefValues[it]) + .apply() activity?.recreate() } catch (e: Exception) { logError(e) @@ -188,12 +151,11 @@ class SettingsUI : BasePreferenceFragmentCompat() { prefValues.indexOf(currentLayout), getString(R.string.primary_color_settings), true, - {} - ) { + {}) { try { - settingsManager.edit { - putString(getString(R.string.primary_color_key), prefValues[it]) - } + settingsManager.edit() + .putString(getString(R.string.primary_color_key), prefValues[it]) + .apply() activity?.recreate() } catch (e: Exception) { logError(e) @@ -215,37 +177,15 @@ class SettingsUI : BasePreferenceFragmentCompat() { names, currentList, getString(R.string.pref_filter_search_quality), - {} - ) { selectedList -> - settingsManager.edit { - putStringSet( - getString(R.string.pref_filter_search_quality_key), - selectedList.map { it.toString() }.toMutableSet() - ) - } + {}) { selectedList -> + settingsManager.edit().putStringSet( + this.getString(R.string.pref_filter_search_quality_key), + selectedList.map { it.toString() }.toMutableSet() + ).apply() } return@setOnPreferenceClickListener true } - getPref(R.string.confirm_exit_key)?.setOnPreferenceClickListener { - val prefNames = resources.getStringArray(R.array.confirm_exit) - val prefValues = resources.getIntArray(R.array.confirm_exit_values) - val confirmExit = settingsManager.getInt(getString(R.string.confirm_exit_key), -1) - - activity?.showBottomDialog( - items = prefNames.toList(), - selectedIndex = prefValues.indexOf(confirmExit), - name = getString(R.string.confirm_before_exiting_title), - showApply = true, - dismissCallback = {}, - callback = { selectedOption -> - settingsManager.edit { - putInt(getString(R.string.confirm_exit_key), prefValues[selectedOption]) - } - } - ) - return@setOnPreferenceClickListener true - } } } \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsUpdates.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsUpdates.kt index c04215594..260c66747 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 @@ -1,55 +1,45 @@ package com.lagradost.cloudstream3.ui.settings -import android.net.Uri 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 import com.lagradost.cloudstream3.databinding.LogcatBinding import com.lagradost.cloudstream3.mvvm.logError -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.result.txt 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 import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setToolBarScrollFlags import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setUpToolbar -import com.lagradost.cloudstream3.ui.settings.utils.getChooseFolderLauncher import com.lagradost.cloudstream3.utils.BackupUtils import com.lagradost.cloudstream3.utils.BackupUtils.restorePrompt import com.lagradost.cloudstream3.utils.Coroutines.ioSafe -import com.lagradost.cloudstream3.utils.InAppUpdater.installPreReleaseIfNeeded -import com.lagradost.cloudstream3.utils.InAppUpdater.runAutoUpdate +import com.lagradost.cloudstream3.utils.InAppUpdater.Companion.runAutoUpdate import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showBottomDialog import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showDialog import com.lagradost.cloudstream3.utils.UIHelper.clipboardHelper import com.lagradost.cloudstream3.utils.UIHelper.dismissSafe import com.lagradost.cloudstream3.utils.UIHelper.hideKeyboard -import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager -import com.lagradost.cloudstream3.utils.txt +import com.lagradost.cloudstream3.utils.VideoDownloadManager +import okhttp3.internal.closeQuietly import java.io.BufferedReader import java.io.InputStreamReader import java.io.OutputStream import java.lang.System.currentTimeMillis import java.text.SimpleDateFormat -import java.util.Date -import java.util.Locale +import java.util.* -class SettingsUpdates : BasePreferenceFragmentCompat() { +class SettingsUpdates : PreferenceFragmentCompat() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) setUpToolbar(R.string.category_updates) @@ -57,22 +47,10 @@ class SettingsUpdates : BasePreferenceFragmentCompat() { setToolBarScrollFlags() } - private val pathPicker = getChooseFolderLauncher { uri, path -> - if(uri == null) return@getChooseFolderLauncher - - val context = context ?: CloudStreamApp.context ?: return@getChooseFolderLauncher - (path ?: uri.toString()).let { - PreferenceManager.getDefaultSharedPreferences(context).edit { - putString(getString(R.string.backup_path_key), uri.toString()) - putString(getString(R.string.backup_dir_key), it) - } - } - } - override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { hideKeyboard() setPreferencesFromResource(R.xml.settings_updates, rootKey) - val settingsManager = PreferenceManager.getDefaultSharedPreferences(requireContext()) + //val settingsManager = PreferenceManager.getDefaultSharedPreferences(requireContext()) getPref(R.string.backup_key)?.setOnPreferenceClickListener { BackupUtils.backup(activity) @@ -80,6 +58,8 @@ class SettingsUpdates : BasePreferenceFragmentCompat() { } getPref(R.string.automatic_backup_key)?.setOnPreferenceClickListener { + val settingsManager = PreferenceManager.getDefaultSharedPreferences(requireContext()) + val prefNames = resources.getStringArray(R.array.periodic_work_names) val prefValues = resources.getIntArray(R.array.periodic_work_values) val current = settingsManager.getInt(getString(R.string.automatic_backup_key), 0) @@ -89,13 +69,11 @@ class SettingsUpdates : BasePreferenceFragmentCompat() { prefValues.indexOf(current), getString(R.string.backup_frequency), true, - {} - ) { index -> - settingsManager.edit { - putInt(getString(R.string.automatic_backup_key), prefValues[index]) - } + {}) { index -> + settingsManager.edit() + .putInt(getString(R.string.automatic_backup_key), prefValues[index]).apply() BackupWorkManager.enqueuePeriodicWork( - context ?: CloudStreamApp.context, + context ?: AcraApplication.context, prefValues[index].toLong() ) } @@ -111,64 +89,36 @@ class SettingsUpdates : BasePreferenceFragmentCompat() { activity?.restorePrompt() return@setOnPreferenceClickListener true } - getPref(R.string.backup_path_key)?.hideOn(EMULATOR)?.setOnPreferenceClickListener { - val dirs = getBackupDirsForDisplay() - val currentDir = - settingsManager.getString(getString(R.string.backup_dir_key), null) - ?: context?.let { ctx -> BackupUtils.getDefaultBackupDir(ctx)?.filePath() } - - activity?.showBottomDialog( - dirs + listOf(getString(R.string.custom)), - dirs.indexOf(currentDir), - getString(R.string.backup_path_title), - true, - {} - ) { - // Last = custom - if (it == dirs.size) { - try { - pathPicker.launch(Uri.EMPTY) - } catch (e: Exception) { - logError(e) - } - } else { - // 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]) - } - } - } - return@setOnPreferenceClickListener true - } - getPref(R.string.show_logcat_key)?.setOnPreferenceClickListener { pref -> - val builder = AlertDialog.Builder(pref.context, R.style.AlertDialogCustom) + val builder = + AlertDialog.Builder(pref.context, R.style.AlertDialogCustom) val binding = LogcatBinding.inflate(layoutInflater, null, false) builder.setView(binding.root) val dialog = builder.create() dialog.show() - - val logList = mutableListOf() + val log = StringBuilder() try { - // https://developer.android.com/studio/command-line/logcat + //https://developer.android.com/studio/command-line/logcat val process = Runtime.getRuntime().exec("logcat -d") - val bufferedReader = BufferedReader(InputStreamReader(process.inputStream)) - bufferedReader.lineSequence().forEach { logList.add(it) } + val bufferedReader = BufferedReader( + InputStreamReader(process.inputStream) + ) + + var line: String? + while (bufferedReader.readLine().also { line = it } != null) { + log.append("${line}\n") + } } catch (e: Exception) { logError(e) // kinda ironic } - val adapter = LogcatAdapter().apply { submitList(logList) } - binding.logcatRecyclerView.layoutManager = LinearLayoutManager(pref.context) - binding.logcatRecyclerView.adapter = adapter + val text = log.toString() + binding.text1.text = text binding.copyBtt.setOnClickListener { - clipboardHelper(txt("Logcat"), logList.joinToString("\n")) + clipboardHelper(txt("Logcat"), text) dialog.dismissSafe(activity) } @@ -182,17 +132,19 @@ class SettingsUpdates : BasePreferenceFragmentCompat() { var fileStream: OutputStream? = null try { fileStream = VideoDownloadManager.setupStream( - it.context, - "logcat_${date}", - null, - "txt", - false - ).openNew() - fileStream.writer().use { writer -> writer.write(logList.joinToString("\n")) } + it.context, + "logcat_${date}", + null, + "txt", + false + ).openNew() + fileStream.writer().write(text) dialog.dismissSafe(activity) } catch (t: Throwable) { logError(t) showToast(t.message) + } finally { + fileStream?.closeQuietly() } } @@ -204,24 +156,24 @@ class SettingsUpdates : BasePreferenceFragmentCompat() { } getPref(R.string.apk_installer_key)?.setOnPreferenceClickListener { + val settingsManager = PreferenceManager.getDefaultSharedPreferences(it.context) + val prefNames = resources.getStringArray(R.array.apk_installer_pref) val prefValues = resources.getIntArray(R.array.apk_installer_values) - // Use legacy installer as default until we make the new installer completely reliable val currentInstaller = - settingsManager.getInt(getString(R.string.apk_installer_key), 1) + settingsManager.getInt(getString(R.string.apk_installer_key), 0) activity?.showBottomDialog( prefNames.toList(), prefValues.indexOf(currentInstaller), getString(R.string.apk_installer_settings), true, - {} - ) { num -> + {}) { num -> try { - settingsManager.edit { - putInt(getString(R.string.apk_installer_key), prefValues[num]) - } + settingsManager.edit() + .putInt(getString(R.string.apk_installer_key), prefValues[num]) + .apply() } catch (e: Exception) { logError(e) } @@ -229,32 +181,23 @@ class SettingsUpdates : BasePreferenceFragmentCompat() { return@setOnPreferenceClickListener true } - getPref(R.string.manual_check_update_key)?.let { pref -> - pref.summary = BuildConfig.VERSION_NAME - pref.setOnPreferenceClickListener { - ioSafe { - if (activity?.runAutoUpdate(false) == false) { - activity?.runOnUiThread { - showToast( - R.string.no_update_found, - Toast.LENGTH_SHORT - ) - } + getPref(R.string.manual_check_update_key)?.setOnPreferenceClickListener { + ioSafe { + if (activity?.runAutoUpdate(false) == false) { + activity?.runOnUiThread { + showToast( + R.string.no_update_found, + Toast.LENGTH_SHORT + ) } } - return@setOnPreferenceClickListener true - } - } - - getPref(R.string.install_prerelease_key)?.let { pref -> - pref.isVisible = BuildConfig.FLAVOR == "stable" - pref.setOnPreferenceClickListener { - activity?.installPreReleaseIfNeeded() - return@setOnPreferenceClickListener true } + return@setOnPreferenceClickListener true } getPref(R.string.auto_download_plugins_key)?.setOnPreferenceClickListener { + val settingsManager = PreferenceManager.getDefaultSharedPreferences(it.context) + val prefNames = resources.getStringArray(R.array.auto_download_plugin) val prefValues = enumValues().sortedBy { x -> x.value }.map { x -> x.value } @@ -266,35 +209,12 @@ class SettingsUpdates : BasePreferenceFragmentCompat() { prefValues.indexOf(current), getString(R.string.automatic_plugin_download_mode_title), true, - {} - ) { num -> - settingsManager.edit { - putInt(getString(R.string.auto_download_plugins_key), prefValues[num]) - } - (context ?: CloudStreamApp.context)?.let { ctx -> app.initClient(ctx) } + {}) { num -> + settingsManager.edit() + .putInt(getString(R.string.auto_download_plugins_key), prefValues[num]).apply() + (context ?: AcraApplication.context)?.let { ctx -> app.initClient(ctx) } } return@setOnPreferenceClickListener true } - - getPref(R.string.manual_update_plugins_key)?.setOnPreferenceClickListener { - ioSafe { - PluginManager.___DO_NOT_CALL_FROM_A_PLUGIN_manuallyReloadAndUpdatePlugins(activity ?: return@ioSafe) - } - return@setOnPreferenceClickListener true // Return true for the listener - } - } - - private fun getBackupDirsForDisplay(): List { - return safe { - context?.let { ctx -> - val defaultDir = BackupUtils.getDefaultBackupDir(ctx)?.filePath() - val first = listOf(defaultDir) - (runCatching { - first + BackupUtils.getCurrentBackupDir(ctx).let { - it.first?.filePath() ?: it.second - } - }.getOrNull() ?: first).filterNotNull().distinct() - } - } ?: emptyList() } } diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/ExtensionsFragment.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/ExtensionsFragment.kt index af0d3dfe7..1b4876293 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/ExtensionsFragment.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/ExtensionsFragment.kt @@ -4,8 +4,10 @@ import android.content.ClipboardManager import android.content.Context import android.content.DialogInterface import android.os.Build +import android.os.Bundle import android.view.LayoutInflater import android.view.View +import android.view.ViewGroup import android.widget.LinearLayout import android.widget.Toast import androidx.appcompat.app.AlertDialog @@ -13,6 +15,7 @@ import androidx.core.view.isGone import androidx.core.view.isVisible import androidx.core.view.marginBottom import androidx.core.view.marginTop +import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels import androidx.navigation.fragment.findNavController import com.lagradost.cloudstream3.CommonActivity.showToast @@ -23,12 +26,11 @@ import com.lagradost.cloudstream3.databinding.FragmentExtensionsBinding import com.lagradost.cloudstream3.mvvm.observe import com.lagradost.cloudstream3.mvvm.observeNullable import com.lagradost.cloudstream3.plugins.RepositoryManager -import com.lagradost.cloudstream3.ui.BaseFragment import com.lagradost.cloudstream3.ui.result.FOCUS_SELF import com.lagradost.cloudstream3.ui.result.setLinearListLayout +import com.lagradost.cloudstream3.ui.result.setText import com.lagradost.cloudstream3.ui.settings.Globals.TV import com.lagradost.cloudstream3.ui.settings.Globals.isLayout -import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setSystemBarsPadding import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setToolBarScrollFlags import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setUpToolbar import com.lagradost.cloudstream3.utils.AppContextUtils.addRepositoryDialog @@ -36,13 +38,23 @@ import com.lagradost.cloudstream3.utils.AppContextUtils.setDefaultFocus import com.lagradost.cloudstream3.utils.Coroutines.ioSafe import com.lagradost.cloudstream3.utils.Coroutines.main import com.lagradost.cloudstream3.utils.UIHelper.dismissSafe -import com.lagradost.cloudstream3.utils.setText -class ExtensionsFragment : BaseFragment( - BaseFragment.BindingCreator.Inflate(FragmentExtensionsBinding::inflate) -) { +class ExtensionsFragment : Fragment() { + var binding: FragmentExtensionsBinding? = null + override fun onDestroyView() { + binding = null + super.onDestroyView() + } - private val extensionViewModel: ExtensionsViewModel by activityViewModels() + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ): View { + val localBinding = FragmentExtensionsBinding.inflate(inflater, container, false) + binding = localBinding + return localBinding.root//inflater.inflate(R.layout.fragment_extensions, container, false) + } private fun View.setLayoutWidth(weight: Int) { val param = LinearLayout.LayoutParams( @@ -53,6 +65,8 @@ class ExtensionsFragment : BaseFragment( this.layoutParams = param } + private val extensionViewModel: ExtensionsViewModel by activityViewModels() + override fun onResume() { super.onResume() afterRepositoryLoadedEvent += ::reloadRepositories @@ -68,25 +82,24 @@ class ExtensionsFragment : BaseFragment( extensionViewModel.loadRepositories() } - override fun fixLayout(view: View) { - setSystemBarsPadding() - } + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + //context?.fixPaddingStatusbar(extensions_root) - override fun onBindingCreated(binding: FragmentExtensionsBinding) { setUpToolbar(R.string.extensions) setToolBarScrollFlags() - binding.repoRecyclerView.apply { + binding?.repoRecyclerView?.apply { setLinearListLayout( isHorizontal = false, - nextUp = R.id.settings_toolbar, // FOCUS_SELF, // back has no id so we cant :pensive: + nextUp = R.id.settings_toolbar, //FOCUS_SELF, // back has no id so we cant :pensive: nextDown = R.id.plugin_storage_appbar, nextRight = FOCUS_SELF, nextLeft = R.id.nav_rail_view ) if (!isLayout(TV)) - binding.addRepoButton.let { button -> + binding?.addRepoButton?.let { button -> button.post { setPadding( paddingLeft, @@ -100,10 +113,10 @@ class ExtensionsFragment : BaseFragment( if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { setOnScrollChangeListener { _, _, scrollY, _, oldScrollY -> val dy = scrollY - oldScrollY - if (dy > 0) { // check for scroll down - binding.addRepoButton.shrink() // hide + if (dy > 0) { //check for scroll down + binding?.addRepoButton?.shrink() // hide } else if (dy < -5) { - binding.addRepoButton.extend() // show + binding?.addRepoButton?.extend() // show } } } @@ -119,14 +132,13 @@ class ExtensionsFragment : BaseFragment( }, { repo -> // Prompt user before deleting repo main { - val uiContext = context ?: binding.root.context - val builder = AlertDialog.Builder(uiContext) + val builder = AlertDialog.Builder(context ?: view.context) val dialogClickListener = DialogInterface.OnClickListener { _, which -> when (which) { DialogInterface.BUTTON_POSITIVE -> { ioSafe { - RepositoryManager.removeRepository(uiContext.applicationContext, repo) + RepositoryManager.removeRepository(view.context, repo) extensionViewModel.loadStats() extensionViewModel.loadRepositories() } @@ -137,7 +149,9 @@ class ExtensionsFragment : BaseFragment( } builder.setTitle(R.string.delete_repository) - .setMessage(uiContext.getString(R.string.delete_repository_plugins)) + .setMessage( + context?.getString(R.string.delete_repository_plugins) + ) .setPositiveButton(R.string.delete, dialogClickListener) .setNegativeButton(R.string.cancel, dialogClickListener) .show().setDefaultFocus() @@ -146,15 +160,37 @@ class ExtensionsFragment : BaseFragment( } observe(extensionViewModel.repositories) { - binding.repoRecyclerView.isVisible = it.isNotEmpty() - binding.blankRepoScreen.isVisible = it.isEmpty() - (binding.repoRecyclerView.adapter as? RepoAdapter)?.submitList(it.toList()) + binding?.repoRecyclerView?.isVisible = it.isNotEmpty() + binding?.blankRepoScreen?.isVisible = it.isEmpty() + (binding?.repoRecyclerView?.adapter as? RepoAdapter)?.updateList(it) } + /*binding?.repoRecyclerView?.apply { + context?.let { ctx -> + layoutManager = LinearRecycleViewLayoutManager(ctx, nextFocusUpId, nextFocusDownId) + } + }*/ + +// list_repositories?.setOnClickListener { +// // Open webview on tv if browser fails +// val isTv = isTvSettings() +// openBrowser(PUBLIC_REPOSITORIES_LIST, isTv, this) +// +// // Set clipboard on TV because the browser might not exist or work properly +// if (isTv) { +// val serviceClipboard = +// (activity?.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager?) +// ?: return@setOnClickListener +// val clip = ClipData.newPlainText("Repository url", PUBLIC_REPOSITORIES_LIST) +// serviceClipboard.setPrimaryClip(clip) +// } +// } + observeNullable(extensionViewModel.pluginStats) { value -> - binding.apply { + binding?.apply { if (value == null) { pluginStorageAppbar.isVisible = false + return@observeNullable } @@ -174,7 +210,7 @@ class ExtensionsFragment : BaseFragment( } } - binding.pluginStorageAppbar.setOnClickListener { + binding?.pluginStorageAppbar?.setOnClickListener { findNavController().navigate( R.id.navigation_settings_extensions_to_navigation_settings_plugins, PluginsFragment.newInstance( @@ -194,24 +230,24 @@ class ExtensionsFragment : BaseFragment( val dialog = builder.create() dialog.show() - (activity?.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager)?.primaryClip?.getItemAt( + (activity?.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager?)?.primaryClip?.getItemAt( 0 - )?.text?.toString()?.let { copiedText -> - if (copiedText.contains(RepoAdapter.SHAREABLE_REPO_SEPARATOR)) { - // text is of format : - val (name, url) = copiedText.split(RepoAdapter.SHAREABLE_REPO_SEPARATOR, limit = 2) - binding.repoUrlInput.setText(url.trim()) - binding.repoNameInput.setText(name.trim()) - } else { - binding.repoUrlInput.setText(copiedText) - } + )?.text?.toString()?.let { copy -> + binding.repoUrlInput.setText(copy) } +// dialog.list_repositories?.setOnClickListener { +// // Open webview on tv if browser fails +// openBrowser(PUBLIC_REPOSITORIES_LIST, isTvSettings(), this) +// dialog.dismissSafe() +// } + +// dialog.text2?.text = provider.name binding.applyBtt.setOnClickListener secondListener@{ val name = binding.repoNameInput.text?.toString() - val urlInput = binding.repoUrlInput.text?.toString() ioSafe { - val url = urlInput?.let { it1 -> RepositoryManager.parseRepoUrl(it1) } + val url = binding.repoUrlInput.text?.toString() + ?.let { it1 -> RepositoryManager.parseRepoUrl(it1) } if (url.isNullOrBlank()) { main { showToast(R.string.error_invalid_data, Toast.LENGTH_SHORT) @@ -227,7 +263,8 @@ class ExtensionsFragment : BaseFragment( val fixedName = if (!name.isNullOrBlank()) name else repository.name - val newRepo = RepositoryData(repository.iconUrl,fixedName, url) + + val newRepo = RepositoryData(fixedName, url) RepositoryManager.addRepository(newRepo) extensionViewModel.loadStats() extensionViewModel.loadRepositories() @@ -251,7 +288,7 @@ class ExtensionsFragment : BaseFragment( } val isTv = isLayout(TV) - binding.apply { + binding?.apply { addRepoButton.isGone = isTv addRepoButtonImageviewHolder.isVisible = isTv @@ -264,4 +301,4 @@ class ExtensionsFragment : BaseFragment( } reloadRepositories() } -} +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/ExtensionsViewModel.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/ExtensionsViewModel.kt index 482251b78..866d167c1 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/ExtensionsViewModel.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/ExtensionsViewModel.kt @@ -4,7 +4,7 @@ import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import com.fasterxml.jackson.annotation.JsonProperty -import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey +import com.lagradost.cloudstream3.AcraApplication.Companion.getKey import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.amap import com.lagradost.cloudstream3.mvvm.debugAssert @@ -12,17 +12,14 @@ import com.lagradost.cloudstream3.plugins.PluginManager import com.lagradost.cloudstream3.plugins.PluginManager.getPluginsOnline import com.lagradost.cloudstream3.plugins.RepositoryManager import com.lagradost.cloudstream3.plugins.RepositoryManager.PREBUILT_REPOSITORIES -import com.lagradost.cloudstream3.utils.UiText -import com.lagradost.cloudstream3.utils.txt +import com.lagradost.cloudstream3.ui.result.UiText +import com.lagradost.cloudstream3.ui.result.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 d0f9ff565..d159539d6 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 @@ -5,204 +5,98 @@ import android.text.format.Formatter.formatShortFileSize import android.util.Log import android.view.LayoutInflater import android.view.ViewGroup +import androidx.annotation.VisibleForTesting import androidx.appcompat.app.AppCompatActivity import androidx.core.view.isGone import androidx.core.view.isVisible -import androidx.viewbinding.ViewBinding -import com.lagradost.cloudstream3.CloudStreamApp.Companion.getActivity +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.RecyclerView +import com.lagradost.cloudstream3.AcraApplication.Companion.getActivity import com.lagradost.cloudstream3.PROVIDER_STATUS_DOWN import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.TvType import com.lagradost.cloudstream3.databinding.RepositoryItemBinding import com.lagradost.cloudstream3.plugins.PluginManager -import com.lagradost.cloudstream3.ui.BaseDiffCallback -import com.lagradost.cloudstream3.ui.NoStateAdapter -import com.lagradost.cloudstream3.ui.ViewHolderState -import com.lagradost.cloudstream3.ui.newSharedPool +import com.lagradost.cloudstream3.plugins.VotingApi.getVotes +import com.lagradost.cloudstream3.ui.result.setText +import com.lagradost.cloudstream3.ui.result.txt import com.lagradost.cloudstream3.ui.settings.Globals.TV import com.lagradost.cloudstream3.ui.settings.Globals.isLayout import com.lagradost.cloudstream3.utils.AppContextUtils.html -import com.lagradost.cloudstream3.utils.ImageLoader.loadImage -import com.lagradost.cloudstream3.utils.SubtitleHelper.getNameNextToFlagEmoji +import com.lagradost.cloudstream3.utils.Coroutines.ioSafe +import com.lagradost.cloudstream3.utils.Coroutines.main +import com.lagradost.cloudstream3.utils.SubtitleHelper.fromTwoLettersToLanguage +import com.lagradost.cloudstream3.utils.SubtitleHelper.getFlagFromIso +import com.lagradost.cloudstream3.utils.UIHelper.setImage import com.lagradost.cloudstream3.utils.UIHelper.toPx -import com.lagradost.cloudstream3.utils.getImageFromDrawable -import com.lagradost.cloudstream3.utils.setText -import com.lagradost.cloudstream3.utils.txt import java.text.DecimalFormat import kotlin.math.floor import kotlin.math.log10 import kotlin.math.pow +import org.junit.Test +import org.junit.Assert data class PluginViewData( val plugin: Plugin, val isDownloaded: Boolean, ) -class RepositoryViewHolderState(view: ViewBinding) : ViewHolderState(view) { - // Store how many times this has called recycled, this is used to correctly sync text in jobs - var recycleCount = 0 -} - class PluginAdapter( val iconClickCallback: (Plugin) -> Unit -) : NoStateAdapter(diffCallback = BaseDiffCallback(itemSame = { a, b -> - a.plugin.second.internalName == b.plugin.second.internalName && a.plugin.first == b.plugin.first -})) { - override fun onCreateContent(parent: ViewGroup): ViewHolderState { - val layout = if (isLayout(TV)) R.layout.repository_item_tv else R.layout.repository_item +) : + RecyclerView.Adapter() { + private val plugins: MutableList = mutableListOf() + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { + val layout = if(isLayout(TV)) R.layout.repository_item_tv else R.layout.repository_item val inflated = LayoutInflater.from(parent.context).inflate(layout, parent, false) - return RepositoryViewHolderState( + return PluginViewHolder( RepositoryItemBinding.bind(inflated) // may crash ) } - override fun onClearView(holder: ViewHolderState) { - if (holder is RepositoryViewHolderState) { - holder.recycleCount += 1 - } - when (val binding = holder.view) { - is RepositoryItemBinding -> { - clearImage(binding.entryIcon) + override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { + when (holder) { + is PluginViewHolder -> { + holder.bind(plugins[position]) } } } - @SuppressLint("SetTextI18n") - override fun onBindContent(holder: ViewHolderState, item: PluginViewData, position: Int) { - val binding = holder.view as? RepositoryItemBinding ?: return - val itemView = holder.itemView + override fun getItemCount(): Int { + return plugins.size + } - val metadata = item.plugin.second - val disabled = metadata.status == PROVIDER_STATUS_DOWN - val name = metadata.name.removeSuffix("Provider") - val alpha = if (disabled) 0.6f else 1f - val isLocal = !item.plugin.second.url.startsWith("http") - binding.mainText.alpha = alpha - binding.subText.alpha = alpha - - val drawableInt = if (item.isDownloaded) - R.drawable.ic_baseline_delete_outline_24 - else R.drawable.netflix_download - - binding.nsfwMarker.isVisible = metadata.tvTypes?.contains(TvType.NSFW.name) ?: false - binding.actionButton.setImageResource(drawableInt) - - binding.actionButton.setOnClickListener { - iconClickCallback.invoke(item.plugin) - } - itemView.setOnClickListener { - if (isLocal) return@setOnClickListener - - val sheet = PluginDetailsFragment(item) - val activity = itemView.context.getActivity() as AppCompatActivity - sheet.show(activity.supportFragmentManager, "PluginDetails") - } - //if (itemView.context?.isTrueTvSettings() == false) { - // val siteUrl = metadata.repositoryUrl - // if (siteUrl != null && siteUrl.isNotBlank() && siteUrl != "NONE") { - // itemView.setOnClickListener { - // openBrowser(siteUrl) - // } - // } - //} - - if (item.isDownloaded) { - // On local plugins page the filepath is provided instead of url. - val plugin = - (PluginManager.urlPlugins[metadata.url] - ?: (PluginManager.plugins[metadata.url])) as? com.lagradost.cloudstream3.plugins.Plugin - - if (plugin?.openSettings != null) { - binding.actionSettings.isVisible = true - binding.actionSettings.setOnClickListener { - try { - plugin.openSettings?.invoke(itemView.context) - } catch (e: Throwable) { - Log.e( - "PluginAdapter", - "Failed to open $name settings: ${ - Log.getStackTraceString(e) - }" - ) - } - } - } else { - binding.actionSettings.isVisible = false - } - } else { - binding.actionSettings.isVisible = false - } - - val url = metadata.iconUrl?.replace( - "%size%", - "$iconSize" - )?.replace( - "%exact_size%", - "$iconSizeExact" + fun updateList(newList: List) { + val diffResult = DiffUtil.calculateDiff( + PluginDiffCallback(this.plugins, newList) ) - if (url.isNullOrBlank()) { - binding.entryIcon.loadImage(R.drawable.ic_baseline_extension_24) - } else { - binding.entryIcon.loadImage( - url - ) { error(getImageFromDrawable(itemView.context, R.drawable.ic_baseline_extension_24)) } - } + plugins.clear() + plugins.addAll(newList) - 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 - - // 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 - } - } + // Clear glide image because setImageResource doesn't override + override fun onViewRecycled(holder: RecyclerView.ViewHolder) { + if (holder is PluginViewHolder) { + holder.binding.entryIcon.let { pluginIcon -> + com.bumptech.glide.Glide.with(pluginIcon).clear(pluginIcon) } - }*/ - - if (metadata.fileSize != null) { - binding.extFilesize.isVisible = true - binding.extFilesize.text = formatShortFileSize(itemView.context, metadata.fileSize) - } else { - binding.extFilesize.isVisible = false } - - binding.mainText.setText( - if (disabled) txt( - R.string.single_plugin_disabled, - name - ) else txt(name) - ) - - binding.subText.isGone = metadata.description.isNullOrBlank() - binding.subText.text = metadata.description.html() + super.onViewRecycled(holder) } companion object { - // A high count as we can render in the entire list as the same time - val sharedPool = - newSharedPool { setMaxRecycledViews(CONTENT, 15) } - private tailrec fun findClosestBase2(target: Int, current: Int = 16, max: Int = 512): Int { if (current >= max) return max if (current >= target) return current @@ -211,14 +105,14 @@ class PluginAdapter( // DO NOT MOVE, as running this test will result in ExceptionInInitializerError on prerelease due to static variables using Resources.getSystem() // this test function is only to show how the function works - /*@Test + @Test fun testFindClosestBase2() { Assert.assertEquals(16, findClosestBase2(0)) Assert.assertEquals(256, findClosestBase2(170)) Assert.assertEquals(256, findClosestBase2(256)) Assert.assertEquals(512, findClosestBase2(257)) Assert.assertEquals(512, findClosestBase2(700)) - }*/ + } private val iconSizeExact = 32.toPx private val iconSize by lazy { @@ -239,4 +133,140 @@ 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] + 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 + } + + if (!binding.entryIcon.setImage(//itemView.entry_icon?.height ?: + metadata.iconUrl?.replace( + "%size%", + "$iconSize" + )?.replace( + "%exact_size%", + "$iconSizeExact" + ), + null, + errorImageDrawable = R.drawable.ic_baseline_extension_24 + ) + ) { + binding.entryIcon.setImageResource(R.drawable.ic_baseline_extension_24) + } + + binding.extVersion.isVisible = true + binding.extVersion.text = "v${metadata.version}" + + if (metadata.language.isNullOrBlank()) { + binding.langIcon.isVisible = false + } else { + binding.langIcon.isVisible = true + binding.langIcon.text = + "${getFlagFromIso(metadata.language)} ${fromTwoLettersToLanguage(metadata.language)}" + } + + binding.extVotes.isVisible = false + if (!isLocal) { + ioSafe { + metadata.getVotes().main { + binding.extVotes.setText(txt(R.string.extension_rating, prettyCount(it))) + binding.extVotes.isVisible = true + } + } + } + + + if (metadata.fileSize != null) { + binding.extFilesize.isVisible = true + binding.extFilesize.text = formatShortFileSize(itemView.context, metadata.fileSize) + } else { + binding.extFilesize.isVisible = false + } + binding.mainText.setText( + if (disabled) txt( + R.string.single_plugin_disabled, + name + ) else txt(name) + ) + binding.subText.isGone = metadata.description.isNullOrBlank() + binding.subText.text = metadata.description.html() + } + } +} + +class PluginDiffCallback( + private val oldList: List, + private val newList: List +) : + DiffUtil.Callback() { + override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int) = + oldList[oldItemPosition].plugin.second.internalName == newList[newItemPosition].plugin.second.internalName && oldList[oldItemPosition].plugin.first == newList[newItemPosition].plugin.first + + override fun getOldListSize() = oldList.size + + override fun getNewListSize() = newList.size + + override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int) = + oldList[oldItemPosition] == newList[newItemPosition] } \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/PluginDetailsFragment.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/PluginDetailsFragment.kt index 0dcbece6c..7d733be09 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/PluginDetailsFragment.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/PluginDetailsFragment.kt @@ -1,36 +1,32 @@ package com.lagradost.cloudstream3.ui.settings.extensions import android.content.res.ColorStateList +import android.os.Bundle import android.text.format.Formatter.formatFileSize import android.util.Log +import android.view.LayoutInflater import android.view.View +import android.view.ViewGroup import androidx.core.view.isVisible -import com.lagradost.cloudstream3.CloudStreamApp.Companion.openBrowser +import com.google.android.material.bottomsheet.BottomSheetDialogFragment +import com.lagradost.cloudstream3.AcraApplication.Companion.openBrowser +import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.databinding.FragmentPluginDetailsBinding import com.lagradost.cloudstream3.plugins.PluginManager import com.lagradost.cloudstream3.plugins.VotingApi.canVote import com.lagradost.cloudstream3.plugins.VotingApi.getVotes import com.lagradost.cloudstream3.plugins.VotingApi.hasVoted import com.lagradost.cloudstream3.plugins.VotingApi.vote -import com.lagradost.cloudstream3.R -import com.lagradost.cloudstream3.ui.BaseBottomSheetDialogFragment -import com.lagradost.cloudstream3.ui.BaseFragment -import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR -import com.lagradost.cloudstream3.ui.settings.Globals.TV -import com.lagradost.cloudstream3.ui.settings.Globals.isLandscape -import com.lagradost.cloudstream3.ui.settings.Globals.isLayout import com.lagradost.cloudstream3.utils.Coroutines.ioSafe import com.lagradost.cloudstream3.utils.Coroutines.main -import com.lagradost.cloudstream3.utils.getImageFromDrawable -import com.lagradost.cloudstream3.utils.ImageLoader.loadImage -import com.lagradost.cloudstream3.utils.SubtitleHelper.getNameNextToFlagEmoji +import com.lagradost.cloudstream3.utils.SubtitleHelper.fromTwoLettersToLanguage +import com.lagradost.cloudstream3.utils.SubtitleHelper.getFlagFromIso import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute -import com.lagradost.cloudstream3.utils.UIHelper.fixSystemBarsPadding +import com.lagradost.cloudstream3.utils.UIHelper.setImage import com.lagradost.cloudstream3.utils.UIHelper.toPx -class PluginDetailsFragment(val data: PluginViewData) : BaseBottomSheetDialogFragment( - BaseFragment.BindingCreator.Inflate(FragmentPluginDetailsBinding::inflate) -) { + +class PluginDetailsFragment(val data: PluginViewData) : BottomSheetDialogFragment() { companion object { private tailrec fun findClosestBase2(target: Int, current: Int = 16, max: Int = 512): Int { @@ -45,20 +41,39 @@ class PluginDetailsFragment(val data: PluginViewData) : BaseBottomSheetDialogFra } } - override fun fixLayout(view: View) { - fixSystemBarsPadding( - view, - padBottom = isLandscape(), - padLeft = isLayout(TV or EMULATOR) - ) + override fun onDestroyView() { + binding = null + super.onDestroyView() } - override fun onBindingCreated(binding: FragmentPluginDetailsBinding) { + var binding: FragmentPluginDetailsBinding? = null + override fun onCreateView( + inflater: LayoutInflater, container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + val localBinding = FragmentPluginDetailsBinding.inflate(inflater, container, false) + binding = localBinding + return localBinding.root + //return inflater.inflate(R.layout.fragment_plugin_details, container, false) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) val metadata = data.plugin.second - binding.apply { - pluginIcon.loadImage(metadata.iconUrl?.replace("%size%", "$iconSize") - ?.replace("%exact_size%", "$iconSizeExact")) { - error { getImageFromDrawable(context ?: return@error null , R.drawable.ic_baseline_extension_24) } + binding?.apply { + if (!pluginIcon.setImage(//plugin_icon?.height ?: + metadata.iconUrl?.replace( + "%size%", + "$iconSize" + )?.replace( + "%exact_size%", + "$iconSizeExact" + ), + null, + errorImageDrawable = R.drawable.ic_baseline_extension_24 + ) + ) { + pluginIcon.setImageResource(R.drawable.ic_baseline_extension_24) } pluginName.text = metadata.name.removeSuffix("Provider") pluginVersion.text = metadata.version.toString() @@ -79,9 +94,9 @@ class PluginDetailsFragment(val data: PluginViewData) : BaseBottomSheetDialogFra ", " ) pluginLang.text = if (metadata.language == null) - getString(R.string.no_data) - else - getNameNextToFlagEmoji(metadata.language) ?: metadata.language + getString(R.string.no_data) + else + "${getFlagFromIso(metadata.language)} ${fromTwoLettersToLanguage(metadata.language)}" githubBtn.setOnClickListener { if (metadata.repositoryUrl != null) { @@ -96,7 +111,7 @@ class PluginDetailsFragment(val data: PluginViewData) : BaseBottomSheetDialogFra 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 + PluginManager.urlPlugins[metadata.url] ?: PluginManager.plugins[metadata.url] if (plugin?.openSettings != null && context != null) { actionSettings.isVisible = true actionSettings.setOnClickListener { @@ -144,7 +159,7 @@ class PluginDetailsFragment(val data: PluginViewData) : BaseBottomSheetDialogFra ) } else { upvote.imageTintList = ColorStateList.valueOf( - context?.colorFromAttribute(com.google.android.material.R.attr.colorOnSurface) ?: R.color.white + context?.colorFromAttribute(R.attr.colorOnSurface) ?: R.color.white ) } } diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/PluginsFragment.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/PluginsFragment.kt index 534ffa62a..4878049b4 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/PluginsFragment.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/PluginsFragment.kt @@ -1,62 +1,70 @@ package com.lagradost.cloudstream3.ui.settings.extensions import android.os.Bundle +import android.view.LayoutInflater import android.view.View +import android.view.ViewGroup import androidx.appcompat.widget.SearchView import androidx.core.view.isVisible +import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels import com.lagradost.cloudstream3.AllLanguagesName import com.lagradost.cloudstream3.BuildConfig -import com.lagradost.cloudstream3.databinding.FragmentPluginsBinding -import com.lagradost.cloudstream3.mvvm.observe import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.TvType -import com.lagradost.cloudstream3.ui.BaseFragment +import com.lagradost.cloudstream3.databinding.FragmentPluginsBinding +import com.lagradost.cloudstream3.mvvm.observe import com.lagradost.cloudstream3.ui.home.HomeFragment.Companion.bindChips import com.lagradost.cloudstream3.ui.result.FOCUS_SELF import com.lagradost.cloudstream3.ui.result.setLinearListLayout -import com.lagradost.cloudstream3.ui.setRecycledViewPool import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR import com.lagradost.cloudstream3.ui.settings.Globals.TV import com.lagradost.cloudstream3.ui.settings.Globals.isLayout -import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setSystemBarsPadding import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setToolBarScrollFlags import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setUpToolbar +import com.lagradost.cloudstream3.ui.settings.appLanguages import com.lagradost.cloudstream3.utils.AppContextUtils.getApiProviderLangSettings import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showMultiDialog -import com.lagradost.cloudstream3.utils.SubtitleHelper.getNameNextToFlagEmoji +import com.lagradost.cloudstream3.utils.SubtitleHelper import com.lagradost.cloudstream3.utils.UIHelper.toPx const val PLUGINS_BUNDLE_NAME = "name" const val PLUGINS_BUNDLE_URL = "url" const val PLUGINS_BUNDLE_LOCAL = "isLocal" -class PluginsFragment : BaseFragment( - BaseFragment.BindingCreator.Inflate(FragmentPluginsBinding::inflate) -) { - - private val pluginViewModel: PluginsViewModel by activityViewModels() +class PluginsFragment : Fragment() { + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ): View { + val localBinding = FragmentPluginsBinding.inflate(inflater, container, false) + binding = localBinding + return localBinding.root//inflater.inflate(R.layout.fragment_plugins, container, false) + } override fun onDestroyView() { - pluginViewModel.clear() // clear for the next observe + binding = null super.onDestroyView() } - override fun fixLayout(view: View) { - setSystemBarsPadding() - } + private val pluginViewModel: PluginsViewModel by activityViewModels() + var binding: FragmentPluginsBinding? = null + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) - override fun onBindingCreated(binding: FragmentPluginsBinding) { // Since the ViewModel is getting reused the tvTypes must be cleared between uses pluginViewModel.tvTypes.clear() - pluginViewModel.selectedLanguages = listOf() - pluginViewModel.clear() + pluginViewModel.languages = listOf() + pluginViewModel.search(null) // Filter by language set on preferred media activity?.let { val providerLangs = it.getApiProviderLangSettings().toList() if (!providerLangs.contains(AllLanguagesName)) { - pluginViewModel.selectedLanguages = mutableListOf("none") + providerLangs + pluginViewModel.languages = mutableListOf("none") + providerLangs + //Log.i("DevDebug", "providerLang => ${pluginViewModel.languages.toJson()}") } } @@ -64,16 +72,16 @@ class PluginsFragment : BaseFragment( val url = arguments?.getString(PLUGINS_BUNDLE_URL) val isLocal = arguments?.getBoolean(PLUGINS_BUNDLE_LOCAL) == true // download all extensions button - val downloadAllButton = binding.settingsToolbar.menu?.findItem(R.id.download_all) + val downloadAllButton = binding?.settingsToolbar?.menu?.findItem(R.id.download_all) if (url == null || name == null) { - dispatchBackPressed() + activity?.onBackPressedDispatcher?.onBackPressed() return } setToolBarScrollFlags() setUpToolbar(name) - binding.settingsToolbar.apply { + binding?.settingsToolbar?.apply { setOnMenuItemClickListener { menuItem -> when (menuItem?.itemId) { R.id.download_all -> { @@ -81,35 +89,24 @@ class PluginsFragment : BaseFragment( } R.id.lang_filter -> { - val languagesTagName = pluginViewModel.pluginLanguages - .map { langTag -> - Pair( - langTag, - getNameNextToFlagEmoji(langTag) ?: langTag - ) + val tempLangs = appLanguages.toMutableList() + val languageCodes = + mutableListOf("none") + tempLangs.map { (_, _, iso) -> iso } + val languageNames = + mutableListOf(getString(R.string.no_data)) + tempLangs.map { (emoji, name, iso) -> + val flag = + emoji.ifBlank { SubtitleHelper.getFlagFromIso(iso) ?: "ERROR" } + "$flag $name" } - .sortedBy { - it.second.substringAfter("\u00a0").lowercase() - } // name ignoring flag emoji - .toMutableList() - - // Move "none" to 1st position as it's special code to indicate unknown/missing language - if (languagesTagName.remove(Pair("none", "none"))) { - languagesTagName.add(0, Pair("none", getString(R.string.no_data))) - } - - val currentIndexList = pluginViewModel.selectedLanguages.map { langTag -> - languagesTagName.indexOfFirst { lang -> lang.first == langTag } - } + val selectedList = + pluginViewModel.languages.map { languageCodes.indexOf(it) } activity?.showMultiDialog( - languagesTagName.map { it.second }, - currentIndexList, + languageNames, + selectedList, getString(R.string.provider_lang_settings), - {} - ) { selectedList -> - pluginViewModel.selectedLanguages = - selectedList.map { languagesTagName[it].first } + {}) { newList -> + pluginViewModel.languages = newList.map { languageCodes[it] } pluginViewModel.updateFilteredPlugins() } } @@ -127,7 +124,7 @@ class PluginsFragment : BaseFragment( if (searchView?.isIconified == false) { searchView.isIconified = true } else { - dispatchBackPressed() + activity?.onBackPressedDispatcher?.onBackPressed() } } searchView?.setOnQueryTextFocusChangeListener { _, hasFocus -> @@ -152,46 +149,46 @@ class PluginsFragment : BaseFragment( // Because onActionViewCollapsed doesn't wanna work we need this workaround :( - binding.pluginRecyclerView.apply { - setLinearListLayout( - isHorizontal = false, - nextDown = FOCUS_SELF, - nextRight = FOCUS_SELF, - ) - setRecycledViewPool(PluginAdapter.sharedPool) - adapter = - PluginAdapter { - pluginViewModel.handlePluginAction(activity, url, it, isLocal) - } - } + binding?.pluginRecyclerView?.setLinearListLayout( + isHorizontal = false, + nextDown = FOCUS_SELF, + nextRight = FOCUS_SELF, + ) + + binding?.pluginRecyclerView?.adapter = + PluginAdapter { + pluginViewModel.handlePluginAction(activity, url, it, isLocal) + } if (isLayout(TV or EMULATOR)) { // Scrolling down does not reveal the whole RecyclerView on TV, add to bypass that. - binding.pluginRecyclerView.setPadding(0, 0, 0, 200.toPx) + binding?.pluginRecyclerView?.setPadding(0, 0, 0, 200.toPx) } observe(pluginViewModel.filteredPlugins) { (scrollToTop, list) -> - (binding.pluginRecyclerView.adapter as? PluginAdapter)?.submitList(list) - if (scrollToTop) { - binding.pluginRecyclerView.scrollToPosition(0) - } + (binding?.pluginRecyclerView?.adapter as? PluginAdapter)?.updateList(list) + + if (scrollToTop) + binding?.pluginRecyclerView?.scrollToPosition(0) } if (isLocal) { // No download button and no categories on local downloadAllButton?.isVisible = false - binding.settingsToolbar.menu?.findItem(R.id.lang_filter)?.isVisible = false + binding?.settingsToolbar?.menu?.findItem(R.id.lang_filter)?.isVisible = false pluginViewModel.updatePluginListLocal() - binding.tvtypesChipsScroll.root.isVisible = false + binding?.tvtypesChipsScroll?.root?.isVisible = false } else { pluginViewModel.updatePluginList(context, url) - binding.tvtypesChipsScroll.root.isVisible = true + binding?.tvtypesChipsScroll?.root?.isVisible = true // not needed for users but may be useful for devs downloadAllButton?.isVisible = BuildConfig.DEBUG + + bindChips( - binding.tvtypesChipsScroll.tvtypesChips, + binding?.tvtypesChipsScroll?.tvtypesChips, emptyList(), TvType.entries.toList(), callback = { list -> diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/PluginsViewModel.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/PluginsViewModel.kt index 0cbef9cf2..fd5422b2d 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 @@ -19,14 +19,13 @@ import com.lagradost.cloudstream3.plugins.PluginManager import com.lagradost.cloudstream3.plugins.PluginManager.getPluginPath import com.lagradost.cloudstream3.plugins.RepositoryManager import com.lagradost.cloudstream3.plugins.SitePlugin -import com.lagradost.cloudstream3.utils.txt +import com.lagradost.cloudstream3.ui.result.txt import com.lagradost.cloudstream3.utils.Coroutines.ioSafe import com.lagradost.cloudstream3.utils.Coroutines.main import com.lagradost.cloudstream3.utils.Coroutines.runOnMainThread -import com.lagradost.cloudstream3.utils.Levenshtein +import me.xdrop.fuzzywuzzy.FuzzySearch import java.io.File -// String => repository url typealias Plugin = Pair /** * The boolean signifies if the plugin list should be scrolled to the top, used for searching. @@ -37,28 +36,13 @@ class PluginsViewModel : ViewModel() { /** plugins is an unaltered list of plugins */ private var plugins: List = emptyList() - set(value) { - // Also set all the plugin languages for easier filtering - value.map { pluginViewData -> - val language = pluginViewData.plugin.second.language?.lowercase() - pluginLanguages.add( - when { - language.isNullOrBlank() -> "none" - else -> language.lowercase() - } - ) - // not sorting as most likely this is a language tag instead of name - } - field = value - } - var pluginLanguages = mutableSetOf() // set to avoid duplicates /** filteredPlugins is a subset of plugins following the current search query and tv type selection */ private var _filteredPlugins = MutableLiveData() var filteredPlugins: LiveData = _filteredPlugins val tvTypes = mutableListOf() - var selectedLanguages = listOf() + var languages = listOf() private var currentQuery: String? = null companion object { @@ -128,7 +112,6 @@ class PluginsViewModel : ViewModel() { PluginManager.downloadPlugin( activity, metadata.url, - metadata.fileHash, metadata.internalName, repo, metadata.status != PROVIDER_STATUS_DOWN @@ -180,7 +163,6 @@ class PluginsViewModel : ViewModel() { PluginManager.downloadPlugin( activity, metadata.url, - metadata.fileHash, metadata.internalName, repo, isEnabled @@ -231,12 +213,12 @@ class PluginsViewModel : ViewModel() { } private fun List.filterLang(): List { - if (selectedLanguages.isEmpty()) return this // do not filter + if (languages.isEmpty()) return this return this.filter { if (it.plugin.second.language == null) { - return@filter selectedLanguages.contains("none") + return@filter languages.contains("none") } - selectedLanguages.contains(it.plugin.second.language?.lowercase()) + languages.contains(it.plugin.second.language) } } @@ -245,12 +227,7 @@ class PluginsViewModel : ViewModel() { // Return list to base state if no query this.sortedBy { it.plugin.second.name } } else { - this.sortedBy { - -Levenshtein.partialRatio( - it.plugin.second.name.lowercase(), - query.lowercase() - ) - } + this.sortedBy { -FuzzySearch.partialRatio(it.plugin.second.name.lowercase(), query.lowercase()) } } } @@ -260,13 +237,6 @@ class PluginsViewModel : ViewModel() { ) } - fun clear() { - currentQuery = null - _filteredPlugins.postValue( - false to emptyList() - ) - } - fun updatePluginList(context: Context?, repositoryUrl: String) = viewModelScope.launchSafe { if (context == null) return@launchSafe Log.i(TAG, "updatePluginList = $repositoryUrl") diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/RepoAdapter.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/RepoAdapter.kt index 0f9bf5f58..faf6d38bf 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/RepoAdapter.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/RepoAdapter.kt @@ -2,19 +2,17 @@ package com.lagradost.cloudstream3.ui.settings.extensions import android.view.LayoutInflater import android.view.ViewGroup +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.RecyclerView +import androidx.viewbinding.ViewBinding import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.databinding.RepositoryItemBinding import com.lagradost.cloudstream3.databinding.RepositoryItemTvBinding import com.lagradost.cloudstream3.plugins.RepositoryManager.PREBUILT_REPOSITORIES -import com.lagradost.cloudstream3.ui.BaseDiffCallback -import com.lagradost.cloudstream3.ui.NoStateAdapter -import com.lagradost.cloudstream3.ui.ViewHolderState +import com.lagradost.cloudstream3.ui.result.txt import com.lagradost.cloudstream3.ui.settings.Globals.TV import com.lagradost.cloudstream3.ui.settings.Globals.isLayout -import com.lagradost.cloudstream3.utils.ImageLoader.loadImage import com.lagradost.cloudstream3.utils.UIHelper.clipboardHelper -import com.lagradost.cloudstream3.utils.getImageFromDrawable -import com.lagradost.cloudstream3.utils.txt class RepoAdapter( val isSetup: Boolean, @@ -22,11 +20,10 @@ class RepoAdapter( val imageClickCallback: RepoAdapter.(RepositoryData) -> Unit, /** In setup mode the trash icons will be replaced with download icons */ ) : - NoStateAdapter(diffCallback = BaseDiffCallback(itemSame = { a, b -> - a.url == b.url - })) { + RecyclerView.Adapter() { + private val repositories: MutableList = mutableListOf() - override fun onCreateContent(parent: ViewGroup): ViewHolderState { + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { val layout = if (isLayout(TV)) RepositoryItemTvBinding.inflate( LayoutInflater.from(parent.context), parent, @@ -35,97 +32,117 @@ class RepoAdapter( LayoutInflater.from(parent.context), parent, false + ) //R.layout.repository_item_tv else R.layout.repository_item + return RepoViewHolder( + layout ) - return ViewHolderState(layout) } - override fun onClearView(holder: ViewHolderState) { - when (val binding = holder.view) { - is RepositoryItemBinding -> clearImage(binding.entryIcon) - is RepositoryItemTvBinding -> clearImage(binding.entryIcon) + override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { + when (holder) { + is RepoViewHolder -> { + holder.bind(repositories[position]) + } } } - override fun onBindContent(holder: ViewHolderState, item: RepositoryData, position: Int) { - val isPrebuilt = PREBUILT_REPOSITORIES.contains(item) - val drawable = - if (isSetup) R.drawable.netflix_download else R.drawable.ic_baseline_delete_outline_24 - when (val binding = holder.view) { - is RepositoryItemTvBinding -> { - binding.apply { - // Only shows icon if on setup or if it isn't a prebuilt repo. - // No delete buttons on prebuilt repos. - if (!isPrebuilt || isSetup) { - actionButton.setImageResource(drawable) - } + // Clear glide image because setImageResource doesn't override +// override fun onViewRecycled(holder: RecyclerView.ViewHolder) { +// holder.itemView.entry_icon?.let { repoIcon -> +// GlideApp.with(repoIcon).clear(repoIcon) +// } +// super.onViewRecycled(holder) +// } - actionButton.setOnClickListener { - imageClickCallback(item) - } + override fun getItemCount(): Int { + return repositories.size + } - 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 - ) - ) + 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) } - } else { - entryIcon.loadImage(R.drawable.ic_github_logo) + + actionButton.setOnClickListener { + imageClickCallback(repositoryData) + } + + repositoryItemRoot.setOnClickListener { + clickCallback(repositoryData) + } + mainText.text = repositoryData.name + subText.text = repositoryData.url } } - } - is RepositoryItemBinding -> { - binding.apply { - // Only shows icon if on setup or if it isn't a prebuilt repo. - // No delete buttons on prebuilt repos. - if (!isPrebuilt || isSetup) { - actionButton.setImageResource(drawable) - } - - actionButton.setOnClickListener { - imageClickCallback(item) - } - - repositoryItemRoot.setOnClickListener { - clickCallback(item) - } - - repositoryItemRoot.setOnLongClickListener { - val shareableRepoData = - "${item.name}$SHAREABLE_REPO_SEPARATOR\n ${item.url}" - clipboardHelper(txt(R.string.repo_copy_label), shareableRepoData) - true - } - - mainText.text = item.name - subText.text = item.url - if (!item.iconUrl.isNullOrEmpty()) { - entryIcon.loadImage(item.iconUrl) { - error( - getImageFromDrawable( - binding.root.context, - R.drawable.ic_github_logo - ) - ) + is RepositoryItemBinding -> { + binding.apply { + // Only shows icon if on setup or if it isn't a prebuilt repo. + // No delete buttons on prebuilt repos. + if (!isPrebuilt || isSetup) { + actionButton.setImageResource(drawable) } - } else { - entryIcon.loadImage(R.drawable.ic_github_logo) + + actionButton.setOnClickListener { + imageClickCallback(repositoryData) + } + + repositoryItemRoot.setOnClickListener { + clickCallback(repositoryData) + } + + repositoryItemRoot.setOnLongClickListener { + val shareableRepoData = "${repositoryData.name} : \n ${repositoryData.url}" + clipboardHelper(txt(R.string.repo_copy_label), shareableRepoData) + true + } + + mainText.text = repositoryData.name + subText.text = repositoryData.url } } } } } +} - companion object { - const val SHAREABLE_REPO_SEPARATOR = " : " - } +class RepoDiffCallback( + private val oldList: List, + private val newList: Array +) : + DiffUtil.Callback() { + override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int) = + oldList[oldItemPosition].url == newList[newItemPosition].url + + override fun getOldListSize() = oldList.size + + override fun getNewListSize() = newList.size + + override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int) = + oldList[oldItemPosition] == newList[newItemPosition] } \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/testing/TestFragment.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/testing/TestFragment.kt index 4ec005a09..7878afaac 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/testing/TestFragment.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/testing/TestFragment.kt @@ -1,35 +1,41 @@ package com.lagradost.cloudstream3.ui.settings.testing +import android.os.Bundle +import android.view.LayoutInflater import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.databinding.FragmentTestingBinding -import com.lagradost.cloudstream3.mvvm.safe +import com.lagradost.cloudstream3.mvvm.normalSafeApiCall import com.lagradost.cloudstream3.mvvm.observe import com.lagradost.cloudstream3.mvvm.observeNullable -import com.lagradost.cloudstream3.ui.BaseFragment import com.lagradost.cloudstream3.ui.settings.Globals.TV import com.lagradost.cloudstream3.ui.settings.Globals.isLayout -import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setSystemBarsPadding import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setToolBarScrollFlags import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setUpToolbar -class TestFragment : BaseFragment( - BaseFragment.BindingCreator.Inflate(FragmentTestingBinding::inflate) -) { + +class TestFragment : Fragment() { private val testViewModel: TestViewModel by activityViewModels() + var binding: FragmentTestingBinding? = null - override fun fixLayout(view: View) { - setSystemBarsPadding() + override fun onDestroyView() { + binding = null + super.onDestroyView() } - override fun onBindingCreated(binding: FragmentTestingBinding) { + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { setUpToolbar(R.string.category_provider_test) setToolBarScrollFlags() + super.onViewCreated(view, savedInstanceState) - binding.apply { - providerTestRecyclerView.adapter = TestResultAdapter() + binding?.apply { + providerTestRecyclerView.adapter = TestResultAdapter( + mutableListOf() + ) testViewModel.init() if (testViewModel.isRunningTest) { @@ -40,10 +46,10 @@ class TestFragment : BaseFragment( providerTest.setProgress(passed, failed, total) } - observe(testViewModel.providerResults) { - safe { + observeNullable(testViewModel.providerResults) { + normalSafeApiCall { val newItems = it.sortedBy { api -> api.first.name } - (providerTestRecyclerView.adapter as? TestResultAdapter)?.submitList( + (providerTestRecyclerView.adapter as? TestResultAdapter)?.updateList( newItems ) } @@ -90,4 +96,13 @@ class TestFragment : BaseFragment( } } } + + override fun onCreateView( + inflater: LayoutInflater, container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + val localBinding = FragmentTestingBinding.inflate(inflater, container, false) + binding = localBinding + return localBinding.root//inflater.inflate(R.layout.fragment_testing, container, false) + } } \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/testing/TestResultAdapter.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/testing/TestResultAdapter.kt index c53ff1fcf..bad58a0e7 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/testing/TestResultAdapter.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/testing/TestResultAdapter.kt @@ -7,6 +7,7 @@ import android.widget.ImageView import android.widget.TextView import android.widget.Toast import androidx.core.content.ContextCompat +import androidx.recyclerview.widget.RecyclerView import com.lagradost.cloudstream3.CommonActivity.showToast import com.lagradost.cloudstream3.MainAPI import com.lagradost.cloudstream3.R @@ -14,117 +15,103 @@ import com.lagradost.cloudstream3.databinding.ProviderTestItemBinding import com.lagradost.cloudstream3.mvvm.getAllMessages import com.lagradost.cloudstream3.mvvm.getStackTracePretty import com.lagradost.cloudstream3.plugins.PluginManager -import com.lagradost.cloudstream3.ui.BaseDiffCallback -import com.lagradost.cloudstream3.ui.NoStateAdapter -import com.lagradost.cloudstream3.ui.ViewHolderState +import com.lagradost.cloudstream3.utils.AppContextUtils import com.lagradost.cloudstream3.utils.Coroutines.ioSafe import com.lagradost.cloudstream3.utils.Coroutines.runOnMainThread import com.lagradost.cloudstream3.utils.SubtitleHelper.getFlagFromIso import com.lagradost.cloudstream3.utils.TestingUtils import java.io.File -class TestResultAdapter() : - NoStateAdapter>( - diffCallback = BaseDiffCallback( - itemSame = { a, b -> - a.first.name == b.first.name && a.first.mainUrl == b.first.mainUrl - }, - contentSame = { a, b -> - a == b - }) - ) { - companion object { - private fun String.lastLine(): String? { - return this.lines().lastOrNull { it.isNotBlank() } - } - } - - override fun onClearView(holder: ViewHolderState) { - val binding = holder.view as? ProviderTestItemBinding ?: return - clearImage(binding.actionButton) - } - - override fun onCreateContent(parent: ViewGroup): ViewHolderState { - return ViewHolderState( - ProviderTestItemBinding.inflate( - LayoutInflater.from(parent.context), - parent, - false - ) +class TestResultAdapter(override val items: MutableList>) : + AppContextUtils.DiffAdapter>(items) { + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { + return ProviderTestViewHolder( + ProviderTestItemBinding.inflate(LayoutInflater.from(parent.context), parent, false) + //LayoutInflater.from(parent.context) + // .inflate(R.layout.provider_test_item, parent, false), ) } - override fun onBindContent( - holder: ViewHolderState, - item: Pair, - position: Int - ) { - val binding = holder.view as? ProviderTestItemBinding ?: return - val (api, result) = item - - val itemView = holder.itemView - - val languageText: TextView = binding.langIcon - val providerTitle: TextView = binding.mainText - val statusText: TextView = binding.passedFailedMarker - val failDescription: TextView = binding.failDescription - val logButton: ImageView = binding.actionButton - - languageText.text = getFlagFromIso(api.lang) - providerTitle.text = api.name - - val (resultText, resultColor) = if (result.success) { - if (result.log.any { it.level == TestingUtils.Logger.LogLevel.Warning }) { - R.string.test_warning to R.color.colorTestWarning - } else { - R.string.test_passed to R.color.colorTestPass + override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { + when (holder) { + is ProviderTestViewHolder -> { + val item = items[position] + holder.bind(item.first, item.second) } - } else { - R.string.test_failed to R.color.colorTestFail + } + } + + inner class ProviderTestViewHolder(binding: ProviderTestItemBinding) : + RecyclerView.ViewHolder(binding.root) { + private val languageText: TextView = binding.langIcon + private val providerTitle: TextView = binding.mainText + private val statusText: TextView = binding.passedFailedMarker + private val failDescription: TextView = binding.failDescription + private val logButton: ImageView = binding.actionButton + + private fun String.lastLine(): String? { + return this.lines().lastOrNull { it.isNotBlank() } } - statusText.setText(resultText) - statusText.setTextColor(ContextCompat.getColor(itemView.context, resultColor)) + fun bind(api: MainAPI, result: TestingUtils.TestResultProvider) { + languageText.text = getFlagFromIso(api.lang) + providerTitle.text = api.name - val stackTrace = result.exception?.getStackTracePretty(false)?.ifBlank { null } - val messages = result.exception?.getAllMessages()?.ifBlank { null } - val resultLog = result.log.joinToString("\n") - val fullLog = - resultLog + - (messages?.let { "\n\nError: $it" } ?: "") + - (stackTrace?.let { "\n\n$it" } ?: "") + val (resultText, resultColor) = if (result.success) { + if (result.log.any { it.level == TestingUtils.Logger.LogLevel.Warning }) { + R.string.test_warning to R.color.colorTestWarning + } else { + R.string.test_passed to R.color.colorTestPass + } + } else { + R.string.test_failed to R.color.colorTestFail + } - failDescription.text = messages?.lastLine() ?: resultLog.lastLine() + statusText.setText(resultText) + statusText.setTextColor(ContextCompat.getColor(itemView.context, resultColor)) - logButton.setOnClickListener { - val builder: AlertDialog.Builder = - AlertDialog.Builder(it.context, R.style.AlertDialogCustom) - builder.setMessage(fullLog) - .setTitle(R.string.test_log) - // Ok button just closes the dialog - .setPositiveButton(R.string.ok) { _, _ -> } + val stackTrace = result.exception?.getStackTracePretty(false)?.ifBlank { null } + val messages = result.exception?.getAllMessages()?.ifBlank { null } + val resultLog = result.log.joinToString("\n") + val fullLog = + resultLog + + (messages?.let { "\n\nError: $it" } ?: "") + + (stackTrace?.let { "\n\n$it" } ?: "") - api.sourcePlugin?.let { path -> - val pluginFile = File(path) - // Cannot delete a deleted plugin - if (!pluginFile.exists()) return@let + failDescription.text = messages?.lastLine() ?: resultLog.lastLine() - builder.setNegativeButton(R.string.delete_plugin) { _, _ -> - ioSafe { - val success = PluginManager.deletePlugin(pluginFile) + logButton.setOnClickListener { + val builder: AlertDialog.Builder = + AlertDialog.Builder(it.context, R.style.AlertDialogCustom) + builder.setMessage(fullLog) + .setTitle(R.string.test_log) + // Ok button just closes the dialog + .setPositiveButton(R.string.ok) { _, _ -> } - runOnMainThread { - if (success) { - showToast(R.string.plugin_deleted, Toast.LENGTH_SHORT) - } else { - showToast(R.string.error, Toast.LENGTH_SHORT) + api.sourcePlugin?.let { path -> + val pluginFile = File(path) + // Cannot delete a deleted plugin + if (!pluginFile.exists()) return@let + + builder.setNegativeButton(R.string.delete_plugin) { _, _ -> + ioSafe { + val success = PluginManager.deletePlugin(pluginFile) + + runOnMainThread { + if (success) { + showToast(R.string.plugin_deleted, Toast.LENGTH_SHORT) + } else { + showToast(R.string.error, Toast.LENGTH_SHORT) + } } } } } - } - builder.show() + builder.show() + } } } + + } \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/testing/TestView.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/testing/TestView.kt index 65ed47a54..eea495a26 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/testing/TestView.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/testing/TestView.kt @@ -9,7 +9,6 @@ import androidx.annotation.DrawableRes import androidx.annotation.StringRes import androidx.cardview.widget.CardView import androidx.core.content.ContextCompat -import androidx.core.content.withStyledAttributes import androidx.core.view.isVisible import androidx.core.widget.ContentLoadingProgressBar import com.google.android.material.button.MaterialButton @@ -60,9 +59,10 @@ class TestView @JvmOverloads constructor( playPauseButton = findViewById(R.id.tests_play_pause) attrs?.let { - context.withStyledAttributes(it, R.styleable.TestView) { - mainSectionHeader?.text = getString(R.styleable.TestView_header_text) - } + val typedArray = context.obtainStyledAttributes(it, R.styleable.TestView) + val headerText = typedArray.getString(R.styleable.TestView_header_text) + mainSectionHeader?.text = headerText + typedArray.recycle() } playPauseButton?.setOnClickListener { diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/testing/TestViewModel.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/testing/TestViewModel.kt index 22500d931..818f1fd79 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/testing/TestViewModel.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/testing/TestViewModel.kt @@ -5,7 +5,7 @@ import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import com.lagradost.cloudstream3.APIHolder import com.lagradost.cloudstream3.MainAPI -import com.lagradost.cloudstream3.utils.Coroutines.atomicListOf +import com.lagradost.cloudstream3.utils.Coroutines.threadSafeListOf import com.lagradost.cloudstream3.utils.TestingUtils import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -40,7 +40,7 @@ class TestViewModel : ViewModel() { get() = scope != null private var filter = ProviderFilter.All - private val providers = atomicListOf>() + private val providers = threadSafeListOf>() private var passed = 0 private var failed = 0 private var total = 0 @@ -51,9 +51,9 @@ class TestViewModel : ViewModel() { } private fun postProviders() { - providers.withLock { + synchronized(providers) { val filtered = when (filter) { - ProviderFilter.All -> providers.toList() + ProviderFilter.All -> providers ProviderFilter.Passed -> providers.filter { it.second.success } ProviderFilter.Failed -> providers.filter { !it.second.success } } @@ -68,7 +68,7 @@ class TestViewModel : ViewModel() { } private fun addProvider(api: MainAPI, results: TestingUtils.TestResultProvider) { - providers.withLock { + synchronized(providers) { val index = providers.indexOfFirst { it.first == api } if (index == -1) { providers.add(api to results) @@ -81,14 +81,14 @@ class TestViewModel : ViewModel() { } fun init() { - total = APIHolder.allProviders.withLock { APIHolder.allProviders.size } + total = synchronized(APIHolder.allProviders) { APIHolder.allProviders.size } updateProgress() } fun startTest() { scope = CoroutineScope(Dispatchers.Default) - val apis = APIHolder.allProviders.withLock { APIHolder.allProviders.toTypedArray() } + val apis = synchronized(APIHolder.allProviders) { APIHolder.allProviders.toTypedArray() } total = apis.size failed = 0 passed = 0 diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/utils/DirectoryPicker.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/utils/DirectoryPicker.kt deleted file mode 100644 index dfc931174..000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/utils/DirectoryPicker.kt +++ /dev/null @@ -1,30 +0,0 @@ -package com.lagradost.cloudstream3.ui.settings.utils - -import android.content.Intent -import android.net.Uri -import androidx.activity.result.contract.ActivityResultContracts -import androidx.fragment.app.Fragment -import com.lagradost.cloudstream3.CloudStreamApp -import com.lagradost.safefile.SafeFile - -fun Fragment.getChooseFolderLauncher(dirSelected: (uri: Uri?, path: String?) -> Unit) = - registerForActivityResult(ActivityResultContracts.OpenDocumentTree()) { uri -> - // It lies, it can be null if file manager quits. - if(uri == null) { - dirSelected(null, null) - return@registerForActivityResult - } - val context = context ?: CloudStreamApp.context ?: return@registerForActivityResult - // RW perms for the path - val flags = Intent.FLAG_GRANT_READ_URI_PERMISSION or - Intent.FLAG_GRANT_WRITE_URI_PERMISSION - - context.contentResolver.takePersistableUriPermission(uri, flags) - - val filePath = SafeFile.fromUri(context, uri)?.filePath() - println("Selected URI path: $uri - Full path: $filePath") - - // store the actual URI instead of the path due to permissions. - // filePath should only be used for cosmetic purposes. - dirSelected(uri, filePath) - } \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/setup/SetupFragmentExtensions.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/setup/SetupFragmentExtensions.kt index 8c2e8e344..4369b22f9 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,25 +1,26 @@ 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 import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.databinding.FragmentSetupExtensionsBinding -import com.lagradost.cloudstream3.mvvm.safe +import com.lagradost.cloudstream3.mvvm.normalSafeApiCall import com.lagradost.cloudstream3.plugins.RepositoryManager import com.lagradost.cloudstream3.plugins.RepositoryManager.PREBUILT_REPOSITORIES -import com.lagradost.cloudstream3.ui.BaseFragment import com.lagradost.cloudstream3.ui.settings.extensions.PluginsViewModel import com.lagradost.cloudstream3.ui.settings.extensions.RepoAdapter import com.lagradost.cloudstream3.utils.Coroutines.main -import com.lagradost.cloudstream3.utils.UIHelper.fixSystemBarsPadding +import com.lagradost.cloudstream3.utils.UIHelper.fixPaddingStatusbar -class SetupFragmentExtensions : BaseFragment( - BaseFragment.BindingCreator.Inflate(FragmentSetupExtensionsBinding::inflate) -) { + +class SetupFragmentExtensions : Fragment() { companion object { const val SETUP_EXTENSION_BUNDLE_IS_SETUP = "isSetup" @@ -33,6 +34,24 @@ class SetupFragmentExtensions : BaseFragment( } } + var binding: FragmentSetupExtensionsBinding? = null + + override fun onDestroyView() { + binding = null + super.onDestroyView() + } + + override fun onCreateView( + inflater: LayoutInflater, container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + val localBinding = FragmentSetupExtensionsBinding.inflate(inflater, container, false) + binding = localBinding + return localBinding.root + //return inflater.inflate(R.layout.fragment_setup_extensions, container, false) + } + + override fun onResume() { super.onResume() afterRepositoryLoadedEvent += ::setRepositories @@ -43,21 +62,18 @@ class SetupFragmentExtensions : BaseFragment( afterRepositoryLoadedEvent -= ::setRepositories } - override fun fixLayout(view: View) { - fixSystemBarsPadding(view) - } - private fun setRepositories(success: Boolean = true) { main { val repositories = RepositoryManager.getRepositories() + PREBUILT_REPOSITORIES val hasRepos = repositories.isNotEmpty() binding?.repoRecyclerView?.isVisible = hasRepos binding?.blankRepoScreen?.isVisible = !hasRepos +// view_public_repositories_button?.isVisible = hasRepos if (hasRepos) { binding?.repoRecyclerView?.adapter = RepoAdapter(true, {}, { PluginsViewModel.downloadAll(activity, it.url, null) - }).apply { submitList(repositories.toList()) } + }).apply { updateList(repositories) } } // else { // list_repositories?.setOnClickListener { @@ -68,12 +84,19 @@ class SetupFragmentExtensions : BaseFragment( } } - override fun onBindingCreated(binding: FragmentSetupExtensionsBinding) { + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + fixPaddingStatusbar(binding?.setupRoot) val isSetup = arguments?.getBoolean(SETUP_EXTENSION_BUNDLE_IS_SETUP) ?: false - safe { +// view_public_repositories_button?.setOnClickListener { +// openBrowser(PUBLIC_REPOSITORIES_LIST, isTvSettings(), this) +// } + + normalSafeApiCall { + // val ctx = context ?: return@normalSafeApiCall setRepositories() - binding.apply { + binding?.apply { if (!isSetup) { nextBtt.setText(R.string.setup_done) } @@ -84,7 +107,7 @@ class SetupFragmentExtensions : BaseFragment( if (isSetup) if ( // If any available languages - apis.distinctBy { it.lang }.size > 1 + synchronized(apis) { apis.distinctBy { it.lang }.size > 1 } ) { findNavController().navigate(R.id.action_navigation_setup_extensions_to_navigation_setup_provider_languages) } else { @@ -100,4 +123,6 @@ class SetupFragmentExtensions : BaseFragment( } } } -} + + +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/setup/SetupFragmentLanguage.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/setup/SetupFragmentLanguage.kt index e96a662c3..5c473b731 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,71 +1,90 @@ package com.lagradost.cloudstream3.ui.setup +import android.os.Bundle +import android.view.LayoutInflater import android.view.View +import android.view.ViewGroup import android.widget.AbsListView import android.widget.ArrayAdapter import androidx.core.content.ContextCompat -import androidx.core.content.edit +import androidx.fragment.app.Fragment import androidx.navigation.fragment.findNavController import androidx.preference.PreferenceManager import com.lagradost.cloudstream3.BuildConfig -import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey import com.lagradost.cloudstream3.CommonActivity -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.databinding.FragmentSetupLanguageBinding +import com.lagradost.cloudstream3.mvvm.normalSafeApiCall +import com.lagradost.cloudstream3.plugins.PluginManager import com.lagradost.cloudstream3.ui.settings.appLanguages import com.lagradost.cloudstream3.ui.settings.getCurrentLocale -import com.lagradost.cloudstream3.ui.settings.nameNextToFlagEmoji -import com.lagradost.cloudstream3.utils.UIHelper.fixSystemBarsPadding +import com.lagradost.cloudstream3.utils.SubtitleHelper +import com.lagradost.cloudstream3.utils.UIHelper.fixPaddingStatusbar const val HAS_DONE_SETUP_KEY = "HAS_DONE_SETUP" -class SetupFragmentLanguage : BaseFragment( - BaseFragment.BindingCreator.Inflate(FragmentSetupLanguageBinding::inflate) -) { +class SetupFragmentLanguage : Fragment() { + var binding: FragmentSetupLanguageBinding? = null - override fun fixLayout(view: View) { - fixSystemBarsPadding(view) + override fun onDestroyView() { + binding = null + super.onDestroyView() } - override fun onBindingCreated(binding: FragmentSetupLanguageBinding) { + override fun onCreateView( + inflater: LayoutInflater, container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + val localBinding = FragmentSetupLanguageBinding.inflate(inflater, container, false) + binding = localBinding + return localBinding.root + //return inflater.inflate(R.layout.fragment_setup_language, container, false) + } + + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + // We don't want a crash for all users - safe { - val ctx = context ?: return@safe + normalSafeApiCall { + fixPaddingStatusbar(binding?.setupRoot) + + val ctx = context ?: return@normalSafeApiCall 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 { + normalSafeApiCall { val drawable = when { BuildConfig.DEBUG -> R.drawable.cloud_2_gradient_debug - BuildConfig.FLAVOR == "prerelease" -> R.drawable.cloud_2_gradient_beta + BuildConfig.BUILD_TYPE == "prerelease" -> R.drawable.cloud_2_gradient_beta else -> R.drawable.cloud_2_gradient } appIconImage.setImageDrawable(ContextCompat.getDrawable(ctx, drawable)) } val current = getCurrentLocale(ctx) - val languageTagsIETF = appLanguages.map { it.second } - val languageNames = appLanguages.map { it.nameNextToFlagEmoji() } - val currentIndex = languageTagsIETF.indexOf(current) + val languageCodes = appLanguages.map { it.third } + val languageNames = appLanguages.map { (emoji, name, iso) -> + val flag = emoji.ifBlank { SubtitleHelper.getFlagFromIso(iso) ?: "ERROR" } + "$flag $name" + } + val index = languageCodes.indexOf(current) arrayAdapter.addAll(languageNames) listview1.adapter = arrayAdapter listview1.choiceMode = AbsListView.CHOICE_MODE_SINGLE - listview1.setItemChecked(currentIndex, true) + listview1.setItemChecked(index, true) - listview1.setOnItemClickListener { _, _, selectedLangIndex, _ -> - val langTagIETF = languageTagsIETF[selectedLangIndex] - CommonActivity.setLocale(activity, langTagIETF) - settingsManager.edit { - putString(getString(R.string.locale_key), langTagIETF) - } + listview1.setOnItemClickListener { _, _, position, _ -> + val code = languageCodes[position] + CommonActivity.setLocale(activity, code) + settingsManager.edit().putString(getString(R.string.locale_key), code) + .apply() + activity?.recreate() } nextBtt.setOnClickListener { @@ -84,10 +103,12 @@ class SetupFragmentLanguage : BaseFragment( } 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 4a8e784a1..d8fa46e63 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,29 +1,47 @@ package com.lagradost.cloudstream3.ui.setup +import android.os.Bundle +import android.view.LayoutInflater import android.view.View +import android.view.ViewGroup import android.widget.AbsListView import android.widget.ArrayAdapter -import androidx.core.content.edit +import androidx.fragment.app.Fragment import androidx.navigation.fragment.findNavController import androidx.preference.PreferenceManager -import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey +import com.lagradost.cloudstream3.AcraApplication.Companion.setKey import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.databinding.FragmentSetupLayoutBinding -import com.lagradost.cloudstream3.mvvm.safe -import com.lagradost.cloudstream3.ui.BaseFragment -import com.lagradost.cloudstream3.utils.UIHelper.fixSystemBarsPadding +import com.lagradost.cloudstream3.mvvm.normalSafeApiCall +import com.lagradost.cloudstream3.utils.UIHelper.fixPaddingStatusbar +import org.acra.ACRA -class SetupFragmentLayout : BaseFragment( - BaseFragment.BindingCreator.Inflate(FragmentSetupLayoutBinding::inflate) -) { - override fun fixLayout(view: View) { - fixSystemBarsPadding(view) +class SetupFragmentLayout : Fragment() { + + var binding: FragmentSetupLayoutBinding? = null + + override fun onDestroyView() { + binding = null + super.onDestroyView() } - override fun onBindingCreated(binding: FragmentSetupLayoutBinding) { - safe { - val ctx = context ?: return@safe + 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) + + normalSafeApiCall { + val ctx = context ?: return@normalSafeApiCall val settingsManager = PreferenceManager.getDefaultSharedPreferences(ctx) @@ -37,7 +55,7 @@ class SetupFragmentLayout : BaseFragment( ArrayAdapter(ctx, R.layout.sort_bottom_single_choice) arrayAdapter.addAll(prefNames.toList()) - binding.apply { + binding?.apply { listview1.adapter = arrayAdapter listview1.choiceMode = AbsListView.CHOICE_MODE_SINGLE listview1.setItemChecked( @@ -45,11 +63,28 @@ class SetupFragmentLayout : BaseFragment( ) listview1.setOnItemClickListener { _, _, position, _ -> - settingsManager.edit { - putInt(getString(R.string.app_layout_key), prefValues[position]) - } + settingsManager.edit() + .putInt(getString(R.string.app_layout_key), prefValues[position]) + .apply() activity?.recreate() } + acraSwitch.setOnCheckedChangeListener { _, enableCrashReporting -> + // Use same pref as in settings + settingsManager.edit().putBoolean(ACRA.PREF_DISABLE_ACRA, !enableCrashReporting) + .apply() + val text = + if (enableCrashReporting) R.string.bug_report_settings_off else R.string.bug_report_settings_on + crashReportingText.text = getText(text) + } + + val enableCrashReporting = !settingsManager.getBoolean(ACRA.PREF_DISABLE_ACRA, true) + + acraSwitch.isChecked = enableCrashReporting + crashReportingText.text = + getText( + if (enableCrashReporting) R.string.bug_report_settings_off else R.string.bug_report_settings_on + ) + nextBtt.setOnClickListener { setKey(HAS_DONE_SETUP_KEY, true) @@ -62,4 +97,4 @@ class SetupFragmentLayout : BaseFragment( } } } -} +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/setup/SetupFragmentMedia.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/setup/SetupFragmentMedia.kt index 8da121daa..49a93608c 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,31 +1,48 @@ 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.mvvm.normalSafeApiCall import com.lagradost.cloudstream3.utils.DataStoreHelper -import com.lagradost.cloudstream3.utils.UIHelper.fixSystemBarsPadding +import com.lagradost.cloudstream3.utils.UIHelper.fixPaddingStatusbar -class SetupFragmentMedia : BaseFragment( - BaseFragment.BindingCreator.Inflate(FragmentSetupMediaBinding::inflate) -) { - override fun fixLayout(view: View) { - fixSystemBarsPadding(view) +class SetupFragmentMedia : Fragment() { + var binding: FragmentSetupMediaBinding? = null + + override fun onDestroyView() { + binding = null + super.onDestroyView() } - override fun onBindingCreated(binding: FragmentSetupMediaBinding) { - safe { - val ctx = context ?: return@safe + override fun onCreateView( + inflater: LayoutInflater, container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + val localBinding = FragmentSetupMediaBinding.inflate(inflater, container, false) + binding = localBinding + return localBinding.root + //return inflater.inflate(R.layout.fragment_setup_media, container, false) + } + + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + normalSafeApiCall { + fixPaddingStatusbar(binding?.setupRoot) + + val ctx = context ?: return@normalSafeApiCall val settingsManager = PreferenceManager.getDefaultSharedPreferences(ctx) val arrayAdapter = @@ -35,7 +52,7 @@ class SetupFragmentMedia : BaseFragment( val selected = mutableListOf() arrayAdapter.addAll(names) - binding.apply { + binding?.apply { listview1.let { it.adapter = arrayAdapter it.choiceMode = AbsListView.CHOICE_MODE_MULTIPLE @@ -54,9 +71,9 @@ class SetupFragmentMedia : BaseFragment( val itemVal = TvType.valueOf(item) itemVal.ordinal.toString() }.toSet() - settingsManager.edit { - putStringSet(getString(R.string.prefer_media_type_key), prefValues) - } + settingsManager.edit() + .putStringSet(getString(R.string.prefer_media_type_key), prefValues) + .apply() // Regenerate set homepage DataStoreHelper.currentHomePage = null @@ -73,4 +90,4 @@ class SetupFragmentMedia : BaseFragment( } } } -} +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/setup/SetupFragmentProviderLanguage.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/setup/SetupFragmentProviderLanguage.kt index c18be8a2f..c12e9eb83 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,80 +1,100 @@ package com.lagradost.cloudstream3.ui.setup +import android.os.Bundle +import android.view.LayoutInflater import android.view.View +import android.view.ViewGroup import android.widget.AbsListView import android.widget.ArrayAdapter -import androidx.core.content.edit import androidx.core.util.forEach +import androidx.fragment.app.Fragment import androidx.navigation.fragment.findNavController import androidx.preference.PreferenceManager -import com.lagradost.cloudstream3.AllLanguagesName import com.lagradost.cloudstream3.APIHolder -import com.lagradost.cloudstream3.databinding.FragmentSetupProviderLanguagesBinding -import com.lagradost.cloudstream3.mvvm.safe +import com.lagradost.cloudstream3.AllLanguagesName import com.lagradost.cloudstream3.R -import com.lagradost.cloudstream3.ui.BaseFragment +import com.lagradost.cloudstream3.databinding.FragmentSetupProviderLanguagesBinding +import com.lagradost.cloudstream3.mvvm.normalSafeApiCall import com.lagradost.cloudstream3.utils.AppContextUtils.getApiProviderLangSettings -import com.lagradost.cloudstream3.utils.SubtitleHelper.getNameNextToFlagEmoji -import com.lagradost.cloudstream3.utils.UIHelper.fixSystemBarsPadding +import com.lagradost.cloudstream3.utils.SubtitleHelper +import com.lagradost.cloudstream3.utils.UIHelper.fixPaddingStatusbar -class SetupFragmentProviderLanguage : BaseFragment( - BaseFragment.BindingCreator.Inflate(FragmentSetupProviderLanguagesBinding::inflate) -) { +class SetupFragmentProviderLanguage : Fragment() { + var binding: FragmentSetupProviderLanguagesBinding? = null - override fun fixLayout(view: View) { - fixSystemBarsPadding(view) + override fun onDestroyView() { + binding = null + super.onDestroyView() } - override fun onBindingCreated(binding: FragmentSetupProviderLanguagesBinding) { - safe { - val ctx = context ?: return@safe + 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) + + normalSafeApiCall { + val ctx = context ?: return@normalSafeApiCall val settingsManager = PreferenceManager.getDefaultSharedPreferences(ctx) val arrayAdapter = ArrayAdapter(ctx, R.layout.sort_bottom_single_choice) - val currentLangTags = ctx.getApiProviderLangSettings() + val current = ctx.getApiProviderLangSettings() + val langs = synchronized(APIHolder.apis) { APIHolder.apis.map { it.lang }.toSet() + .sortedBy { SubtitleHelper.fromTwoLettersToLanguage(it) } + AllLanguagesName} - val languagesTagName = APIHolder.apis.withLock { - listOf(Pair(AllLanguagesName, getString(R.string.all_languages_preference))) + - APIHolder.apis.map { Pair(it.lang, getNameNextToFlagEmoji(it.lang) ?: it.lang) } - .toSet().sortedBy { it.second.substringAfter("\u00a0").lowercase() } // name ignoring flag emoji - } + val currentList = + current.map { langs.indexOf(it) }.filter { it != -1 } // TODO LOOK INTO - val currentIndexList = currentLangTags.map { langTag -> - languagesTagName.indexOfFirst { lang -> lang.first == langTag } - }.filter { it > -1 } - - arrayAdapter.addAll(languagesTagName.map { it.second }) - binding.apply { - listview1.adapter = arrayAdapter - listview1.choiceMode = AbsListView.CHOICE_MODE_MULTIPLE - currentIndexList.forEach { - listview1.setItemChecked(it, true) - } - - listview1.setOnItemClickListener { _, _, _, _ -> - val selectedLanguages = mutableSetOf() - listview1.checkedItemPositions?.forEach { key, value -> - if (value) selectedLanguages.add(languagesTagName[key].first) - } - settingsManager.edit { - putStringSet( - ctx.getString(R.string.provider_lang_key), - selectedLanguages.toSet() - ) - } - } - - nextBtt.setOnClickListener { - findNavController().navigate(R.id.navigation_setup_provider_languages_to_navigation_setup_media) - } - - prevBtt.setOnClickListener { - findNavController().popBackStack() + val languageNames = langs.map { + if (it == AllLanguagesName) { + getString(R.string.all_languages_preference) + } else { + val emoji = SubtitleHelper.getFlagFromIso(it) + val name = SubtitleHelper.fromTwoLettersToLanguage(it) + "$emoji $name" } } + + arrayAdapter.addAll(languageNames) + binding?.apply { + listview1.adapter = arrayAdapter + listview1.choiceMode = AbsListView.CHOICE_MODE_MULTIPLE + currentList.forEach { + listview1.setItemChecked(it, true) + } + + listview1.setOnItemClickListener { _, _, _, _ -> + val currentLanguages = mutableListOf() + listview1.checkedItemPositions?.forEach { key, value -> + if (value) currentLanguages.add(langs[key]) + } + settingsManager.edit().putStringSet( + ctx.getString(R.string.provider_lang_key), + currentLanguages.toSet() + ).apply() + } + + nextBtt.setOnClickListener { + findNavController().navigate(R.id.navigation_setup_provider_languages_to_navigation_setup_media) + } + + prevBtt.setOnClickListener { + findNavController().popBackStack() + } } } } -} + + +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/subtitles/ChromecastSubtitlesFragment.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/subtitles/ChromecastSubtitlesFragment.kt index f9b1cb1fe..c76a218e5 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/subtitles/ChromecastSubtitlesFragment.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/subtitles/ChromecastSubtitlesFragment.kt @@ -7,12 +7,13 @@ import android.graphics.Color import android.os.Bundle import android.util.DisplayMetrics import android.util.TypedValue +import android.view.LayoutInflater import android.view.View +import android.view.ViewGroup import android.widget.TextView import android.widget.Toast -import androidx.annotation.OptIn +import androidx.fragment.app.Fragment import androidx.media3.common.text.Cue -import androidx.media3.common.util.UnstableApi import com.fasterxml.jackson.annotation.JsonProperty import com.google.android.gms.cast.TextTrackStyle.EDGE_TYPE_DEPRESSED import com.google.android.gms.cast.TextTrackStyle.EDGE_TYPE_DROP_SHADOW @@ -20,21 +21,19 @@ import com.google.android.gms.cast.TextTrackStyle.EDGE_TYPE_NONE import com.google.android.gms.cast.TextTrackStyle.EDGE_TYPE_OUTLINE import com.google.android.gms.cast.TextTrackStyle.EDGE_TYPE_RAISED import com.jaredrummler.android.colorpicker.ColorPickerDialog -import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey +import com.lagradost.cloudstream3.AcraApplication.Companion.getKey import com.lagradost.cloudstream3.CommonActivity.onColorSelectedEvent import com.lagradost.cloudstream3.CommonActivity.onDialogDismissedEvent import com.lagradost.cloudstream3.CommonActivity.showToast import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.databinding.ChromecastSubtitleSettingsBinding -import com.lagradost.cloudstream3.ui.BaseFragment import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR import com.lagradost.cloudstream3.ui.settings.Globals.TV -import com.lagradost.cloudstream3.ui.settings.Globals.isLandscape import com.lagradost.cloudstream3.ui.settings.Globals.isLayout import com.lagradost.cloudstream3.utils.DataStore.setKey import com.lagradost.cloudstream3.utils.Event import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showDialog -import com.lagradost.cloudstream3.utils.UIHelper.fixSystemBarsPadding +import com.lagradost.cloudstream3.utils.UIHelper.fixPaddingStatusbar import com.lagradost.cloudstream3.utils.UIHelper.hideSystemUI import com.lagradost.cloudstream3.utils.UIHelper.navigate import com.lagradost.cloudstream3.utils.UIHelper.popCurrentPage @@ -51,10 +50,8 @@ data class SaveChromeCaptionStyle( @JsonProperty("fontScale") var fontScale: Float = 1.05f, @JsonProperty("windowColor") var windowColor: Int = Color.TRANSPARENT, ) - -class ChromecastSubtitlesFragment : BaseFragment( - BaseFragment.BindingCreator.Inflate(ChromecastSubtitleSettingsBinding::inflate) -) { +@androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class) +class ChromecastSubtitlesFragment : Fragment() { companion object { val applyStyleEvent = Event() @@ -145,6 +142,23 @@ class ChromecastSubtitlesFragment : BaseFragment + binding?.subsEdgeType?.setFocusableInTv() + binding?.subsEdgeType?.setOnClickListener { textView -> val edgeTypes = listOf( Pair( EDGE_TYPE_NONE, @@ -242,15 +254,15 @@ class ChromecastSubtitlesFragment : BaseFragment + binding?.subsFontSize?.setFocusableInTv() + binding?.subsFontSize?.setOnClickListener { textView -> val fontSizes = listOf( Pair(0.75f, "75%"), Pair(0.80f, "80%"), @@ -283,15 +295,17 @@ class ChromecastSubtitlesFragment : BaseFragment + binding?.subsFontSize?.setOnLongClickListener { _ -> state.fontScale = defaultState.fontScale //textView.context.updateState() // font size not changed showToast(R.string.subs_default_reset_toast, Toast.LENGTH_SHORT) return@setOnLongClickListener true } - binding.subsFont.setFocusableInTv() - binding.subsFont.setOnClickListener { textView -> + + + binding?.subsFont?.setFocusableInTv() + binding?.subsFont?.setOnClickListener { textView -> val fontTypes = listOf( null to textView.context.getString(R.string.normal), "Droid Sans" to "Droid Sans", @@ -315,30 +329,24 @@ class ChromecastSubtitlesFragment : BaseFragment + binding?.subsFont?.setOnLongClickListener { _ -> state.fontFamily = defaultState.fontFamily updateState() showToast(activity, R.string.subs_default_reset_toast, Toast.LENGTH_SHORT) return@setOnLongClickListener true } - binding.cancelBtt.setOnClickListener { + binding?.cancelBtt?.setOnClickListener { activity?.popCurrentPage() } - binding.applyBtt.setOnClickListener { + binding?.applyBtt?.setOnClickListener { it.context.saveStyle(state) applyStyleEvent.invoke(state) //it.context.fromSaveToStyle(state) activity?.popCurrentPage() } - - setSubtitleCues(binding) - } - - @OptIn(UnstableApi::class) - private fun setSubtitleCues(binding: ChromecastSubtitleSettingsBinding) { - binding.subtitleText.apply { + binding?.subtitleText?.apply { setCues( listOf( Cue.Builder() diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/subtitles/SubtitlesFragment.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/subtitles/SubtitlesFragment.kt index 5f716cca3..8821905e3 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 @@ -6,65 +6,51 @@ import android.content.res.Resources import android.graphics.Color import android.graphics.Typeface import android.os.Bundle -import android.text.Layout -import android.text.Spannable -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.Fragment +import androidx.preference.PreferenceManager +import com.fasterxml.jackson.annotation.JsonProperty import androidx.media3.common.text.Cue import androidx.media3.common.util.UnstableApi import androidx.media3.ui.CaptionStyleCompat -import androidx.media3.ui.SubtitleView -import androidx.preference.PreferenceManager -import com.fasterxml.jackson.annotation.JsonProperty import com.jaredrummler.android.colorpicker.ColorPickerDialog -import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey -import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey +import com.lagradost.cloudstream3.AcraApplication.Companion.getKey +import com.lagradost.cloudstream3.AcraApplication.Companion.setKey import com.lagradost.cloudstream3.CommonActivity.onColorSelectedEvent import com.lagradost.cloudstream3.CommonActivity.onDialogDismissedEvent import com.lagradost.cloudstream3.CommonActivity.showToast import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.databinding.SubtitleSettingsBinding -import com.lagradost.cloudstream3.ui.BaseDialogFragment -import com.lagradost.cloudstream3.ui.BaseFragment -import com.lagradost.cloudstream3.ui.player.CustomDecoder -import com.lagradost.cloudstream3.ui.player.CustomDecoder.Companion.setSubtitleAlignment -import com.lagradost.cloudstream3.ui.player.OutlineSpan -import com.lagradost.cloudstream3.ui.player.RoundedBackgroundColorSpan -import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR import com.lagradost.cloudstream3.ui.settings.Globals.TV -import com.lagradost.cloudstream3.ui.settings.Globals.isLandscape import com.lagradost.cloudstream3.ui.settings.Globals.isLayout import com.lagradost.cloudstream3.utils.DataStore.setKey import com.lagradost.cloudstream3.utils.Event import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showDialog import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showMultiDialog -import com.lagradost.cloudstream3.utils.SubtitleHelper.languages -import com.lagradost.cloudstream3.utils.UIHelper.fixSystemBarsPadding +import com.lagradost.cloudstream3.utils.SubtitleHelper +import com.lagradost.cloudstream3.utils.UIHelper.fixPaddingStatusbar import com.lagradost.cloudstream3.utils.UIHelper.hideSystemUI import com.lagradost.cloudstream3.utils.UIHelper.navigate import com.lagradost.cloudstream3.utils.UIHelper.popCurrentPage -import com.lagradost.cloudstream3.utils.UIHelper.toPx import java.io.File const val SUBTITLE_KEY = "subtitle_settings" const val SUBTITLE_AUTO_SELECT_KEY = "subs_auto_select" const val SUBTITLE_DOWNLOAD_KEY = "subs_auto_download" -data class SaveCaptionStyle( +data class SaveCaptionStyle @OptIn(UnstableApi::class) constructor( @JsonProperty("foregroundColor") var foregroundColor: Int, @JsonProperty("backgroundColor") var backgroundColor: Int, @JsonProperty("windowColor") var windowColor: Int, - @OptIn(UnstableApi::class) @JsonProperty("edgeType") var edgeType: @CaptionStyleCompat.EdgeType Int, @JsonProperty("edgeColor") var edgeColor: Int, @FontRes @@ -74,149 +60,23 @@ data class SaveCaptionStyle( @JsonProperty("elevation") var elevation: Int, /**in sp**/ @JsonProperty("fixedTextSize") var fixedTextSize: Float?, - @Px - @JsonProperty("edgeSize") var edgeSize: Float? = null, @JsonProperty("removeCaptions") var removeCaptions: Boolean = false, @JsonProperty("removeBloat") var removeBloat: Boolean = true, /** Apply caps lock to the text **/ @JsonProperty("upperCase") var upperCase: Boolean = false, - /** Apply bold to the text **/ - @JsonProperty("bold") var bold: Boolean = false, - /** Apply italic to the text **/ - @JsonProperty("italic") var italic: Boolean = false, - /** in px, background radius, aka how round the background (backgroundColor) on each row is **/ - @JsonProperty("backgroundRadius") var backgroundRadius: Float? = null, - /** The SSA_ALIGNMENT */ - @JsonProperty("alignment") var alignment: Int? = null, ) const val DEF_SUBS_ELEVATION = 20 -@OptIn(UnstableApi::class) -class SubtitlesFragment : BaseDialogFragment( - BaseFragment.BindingCreator.Inflate(SubtitleSettingsBinding::inflate) -) { +@OptIn(androidx.media3.common.util.UnstableApi::class) +class SubtitlesFragment : Fragment() { companion object { val applyStyleEvent = Event() - private val captionRegex = Regex("""(-\s?|)[\[({][\S\s]*?[])}]\s*""") - 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) - - 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 { - view.setUserDefaultTextSize() - }*/ - } - - fun Cue.Builder.applyStyle(style: SaveCaptionStyle): Cue.Builder { - val edgeSize = style.edgeSize - - /* - This is old code for only applying on non null - - val fixedFontSize = style.fixedTextSize - val absoluteFontSize = - fixedFontSize?.let { getPixels(TypedValue.COMPLEX_UNIT_SP, it).toFloat() } - - // 1. apply override size - if (absoluteFontSize != null) { - setTextSize(absoluteFontSize, Cue.TEXT_SIZE_TYPE_ABSOLUTE) - }*/ - - // 1. remove any subtitle size set by the subtitle file (like ass) - // instead we use the inherit size of the subtitle view - setTextSize(Cue.DIMEN_UNSET, Cue.TYPE_UNSET) - - // 2. apply edge - text?.let { text -> - val customSpan = SpannableString.valueOf(text) - if (edgeSize != null) { - customSpan.setSpan( - OutlineSpan(edgeSize), 0, customSpan.length, - Spannable.SPAN_EXCLUSIVE_EXCLUSIVE - ) - } - setText(customSpan) - } - - // 3. apply bold + italic - text?.let { text -> - val customSpan = SpannableString.valueOf(text) - - val typeface = when (style.bold to style.italic) { - (true to true) -> Typeface.BOLD_ITALIC - (true to false) -> Typeface.BOLD - (false to true) -> Typeface.ITALIC - (false to false) -> Typeface.NORMAL - else -> { - Typeface.NORMAL - } - } - if (typeface != Typeface.NORMAL) { - val styleSpan = StyleSpan(typeface) - customSpan.setSpan( - styleSpan, 0, customSpan.length, - Spannable.SPAN_EXCLUSIVE_EXCLUSIVE - ) - } - setText(customSpan) - } - - // 4. apply radius - text?.let { text -> - val customSpan = SpannableString.valueOf(text) - val radius = style.backgroundRadius - - if (radius != null && style.backgroundColor != Color.TRANSPARENT) { - val styleSpan = RoundedBackgroundColorSpan( - style.backgroundColor, - this.textAlignment ?: Layout.Alignment.ALIGN_CENTER, - 2.0F + radius * 0.5f, - radius - ) - customSpan.setSpan( - styleSpan, 0, customSpan.length, - Spannable.SPAN_EXCLUSIVE_EXCLUSIVE - ) - } - setText(customSpan) - } - - // 5. remove captions - text?.let { text -> - if (style.removeCaptions) { - setText(text.replace(captionRegex, "")) - } - } - - // 6. set alignment - return this.setSubtitleAlignment(style.alignment) - } - - private fun Context.fromSaveToStyle(data: SaveCaptionStyle): CaptionStyleCompat { + fun Context.fromSaveToStyle(data: SaveCaptionStyle): CaptionStyleCompat { return CaptionStyleCompat( data.foregroundColor, - // we actually override with a custom span when backgroundRadius != null - if (data.backgroundRadius == null) data.backgroundColor else Color.TRANSPARENT, + data.backgroundColor, data.windowColor, data.edgeType, data.edgeColor, @@ -240,7 +100,6 @@ class SubtitlesFragment : BaseDialogFragment( fun push(activity: Activity?, hide: Boolean = true) { activity.navigate(R.id.global_to_navigation_subtitles, Bundle().apply { putBoolean("hide", hide) - putBoolean("popFragment", true) }) } @@ -254,25 +113,22 @@ class SubtitlesFragment : BaseDialogFragment( } } - private var cachedSubtitleStyle: SaveCaptionStyle? = null - fun Context.saveStyle(style: SaveCaptionStyle) { - cachedSubtitleStyle = style this.setKey(SUBTITLE_KEY, style) } fun getCurrentSavedStyle(): SaveCaptionStyle { - return cachedSubtitleStyle ?: (getKey(SUBTITLE_KEY) ?: SaveCaptionStyle( - foregroundColor = getDefColor(0), - backgroundColor = getDefColor(2), - windowColor = getDefColor(3), - edgeType = CaptionStyleCompat.EDGE_TYPE_OUTLINE, - edgeColor = getDefColor(1), - typeface = null, - typefaceFilePath = null, - elevation = DEF_SUBS_ELEVATION, - fixedTextSize = null, - )).also { cachedSubtitleStyle = it } + return getKey(SUBTITLE_KEY) ?: SaveCaptionStyle( + getDefColor(0), + getDefColor(2), + getDefColor(3), + CaptionStyleCompat.EDGE_TYPE_OUTLINE, + getDefColor(1), + null, + null, + DEF_SUBS_ELEVATION, + null, + ) } private fun Context.getSavedFonts(): List { @@ -288,16 +144,20 @@ class SubtitlesFragment : BaseDialogFragment( } ?: listOf() } + private fun Context.getCurrentStyle(): CaptionStyleCompat { + return fromSaveToStyle(getCurrentSavedStyle()) + } + private fun getPixels(unit: Int, size: Float): Int { val metrics: DisplayMetrics = Resources.getSystem().displayMetrics return TypedValue.applyDimension(unit, size, metrics).toInt() } - fun getDownloadSubsLanguageTagIETF(): List { + fun getDownloadSubsLanguageISO639_1(): List { return getKey(SUBTITLE_DOWNLOAD_KEY) ?: listOf("en") } - fun getAutoSelectLanguageTagIETF(): String { + fun getAutoSelectLanguageISO639_1(): String { return getKey(SUBTITLE_AUTO_SELECT_KEY) ?: "en" } } @@ -327,15 +187,17 @@ class SubtitlesFragment : BaseDialogFragment( } private fun Context.updateState() { + binding?.subtitleText?.setStyle(fromSaveToStyle(state)) val text = getString(R.string.subtitles_example_text) - val fixedText = SpannableString.valueOf(if (state.upperCase) text.uppercase() else text) - setSubtitleViewStyle(binding?.subtitleText, state, false) - + val fixedText = if (state.upperCase) text.uppercase() else text binding?.subtitleText?.setCues( listOf( Cue.Builder() + .setTextSize( + getPixels(TypedValue.COMPLEX_UNIT_SP, 25.0f).toFloat(), + Cue.TEXT_SIZE_TYPE_ABSOLUTE + ) .setText(fixedText) - .applyStyle(state) .build() ) ) @@ -354,6 +216,23 @@ class SubtitlesFragment : BaseDialogFragment( return if (color == Color.TRANSPARENT) Color.BLACK else color } + override fun onDestroyView() { + binding = null + super.onDestroyView() + } + + var binding: SubtitleSettingsBinding? = null + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + val localBinding = SubtitleSettingsBinding.inflate(inflater, container, false) + binding = localBinding + return localBinding.root + //return inflater.inflate(R.layout.subtitle_settings, container, false) + } + private lateinit var state: SaveCaptionStyle private var hide: Boolean = true @@ -362,37 +241,22 @@ class SubtitlesFragment : BaseDialogFragment( onColorSelectedEvent -= ::onColorSelected } - override fun onStart() { - super.onStart() - dialog?.window?.setWindowAnimations(R.style.DialogFullscreenPlayer) - } - - override fun getTheme(): Int { - return R.style.DialogFullscreenPlayer - } - - var systemBarsAddPadding = isLayout(TV or EMULATOR) - override fun fixLayout(view: View) { - fixSystemBarsPadding( - view, - padBottom = systemBarsAddPadding || isLandscape(), - padLeft = systemBarsAddPadding - ) - } - - override fun onBindingCreated(binding: SubtitleSettingsBinding) { + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) hide = arguments?.getBoolean("hide") ?: true - val popFragment = arguments?.getBoolean("popFragment") ?: false onColorSelectedEvent += ::onColorSelected onDialogDismissedEvent += ::onDialogDismissed - binding.subsImportText.text = getString(R.string.subs_import_text).format( + binding?.subsImportText?.text = getString(R.string.subs_import_text).format( context?.getExternalFilesDir(null)?.absolutePath.toString() + "/Fonts" ) + fixPaddingStatusbar(binding?.subsRoot) + state = getCurrentSavedStyle() context?.updateState() val isTvTrueSettings = isLayout(TV) + fun View.setFocusableInTv() { this.isFocusableInTouchMode = isTvTrueSettings } @@ -416,7 +280,7 @@ class SubtitlesFragment : BaseDialogFragment( return@setOnLongClickListener true } } - binding.apply { + binding?.apply { subsTextColor.setup(0) subsOutlineColor.setup(1) subsBackgroundColor.setup(2) @@ -429,13 +293,20 @@ class SubtitlesFragment : BaseDialogFragment( subsSubtitleElevation.setFocusableInTv() subsSubtitleElevation.setOnClickListener { textView -> - // tbh this should not be a dialog if it has so many values + val suffix = "dp" val elevationTypes = listOf( - 0 to textView.context.getString(R.string.none) - ) + (1..40).map { x -> - val i = x * 10 - i to "${i}dp" - } + Pair(0, textView.context.getString(R.string.none)), + Pair(10, "10$suffix"), + Pair(20, "20$suffix"), + Pair(30, "30$suffix"), + Pair(40, "40$suffix"), + Pair(50, "50$suffix"), + Pair(60, "60$suffix"), + Pair(70, "70$suffix"), + Pair(80, "80$suffix"), + Pair(90, "90$suffix"), + Pair(100, "100$suffix"), + ) //showBottomDialog activity?.showDialog( @@ -459,75 +330,29 @@ class SubtitlesFragment : BaseDialogFragment( return@setOnLongClickListener true } - subsBackgroundRadius.setFocusableInTv() - subsBackgroundRadius.setOnClickListener { textView -> - // tbh this should not be a dialog if it has so many values - val radiusTypes = listOf( - null to textView.context.getString(R.string.none) - ) + (1..10).map { x -> - val i = x * 5 - i to "${i}px" - } - - activity?.showDialog( - radiusTypes.map { it.second }, - radiusTypes.map { it.first }.indexOf(state.backgroundRadius?.toInt()), - (textView as TextView).text.toString(), - false, - dismissCallback - ) { index -> - state.backgroundRadius = radiusTypes.map { it.first }[index]?.toFloat() - textView.context.updateState() - } - } - - subsBackgroundRadius.setOnLongClickListener { - state.backgroundRadius = null - it.context.updateState() - showToast(R.string.subs_default_reset_toast, Toast.LENGTH_SHORT) - 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( - CaptionStyleCompat.EDGE_TYPE_NONE to - textView.context.getString(R.string.subtitles_none), - CaptionStyleCompat.EDGE_TYPE_OUTLINE to - textView.context.getString(R.string.subtitles_outline), - CaptionStyleCompat.EDGE_TYPE_DEPRESSED to - textView.context.getString(R.string.subtitles_depressed), - CaptionStyleCompat.EDGE_TYPE_DROP_SHADOW to - textView.context.getString(R.string.subtitles_shadow), - CaptionStyleCompat.EDGE_TYPE_RAISED to - textView.context.getString(R.string.subtitles_raised), + Pair( + CaptionStyleCompat.EDGE_TYPE_NONE, + textView.context.getString(R.string.subtitles_none) + ), + Pair( + CaptionStyleCompat.EDGE_TYPE_OUTLINE, + textView.context.getString(R.string.subtitles_outline) + ), + Pair( + CaptionStyleCompat.EDGE_TYPE_DEPRESSED, + textView.context.getString(R.string.subtitles_depressed) + ), + Pair( + CaptionStyleCompat.EDGE_TYPE_DROP_SHADOW, + textView.context.getString(R.string.subtitles_shadow) + ), + Pair( + CaptionStyleCompat.EDGE_TYPE_RAISED, + textView.context.getString(R.string.subtitles_raised) + ), ) //showBottomDialog @@ -552,9 +377,42 @@ class SubtitlesFragment : BaseDialogFragment( subsFontSize.setFocusableInTv() subsFontSize.setOnClickListener { textView -> + val suffix = "sp" val fontSizes = listOf( - null to textView.context.getString(R.string.normal), - ) + (6..60).map { i -> i.toFloat() to "${i}sp" } + Pair(null, textView.context.getString(R.string.normal)), + Pair(6f, "6$suffix"), + Pair(7f, "7$suffix"), + Pair(8f, "8$suffix"), + Pair(9f, "9$suffix"), + Pair(10f, "10$suffix"), + Pair(11f, "11$suffix"), + Pair(12f, "12$suffix"), + Pair(13f, "13$suffix"), + Pair(14f, "14$suffix"), + Pair(15f, "15$suffix"), + Pair(16f, "16$suffix"), + Pair(17f, "17$suffix"), + Pair(18f, "18$suffix"), + Pair(19f, "19$suffix"), + Pair(20f, "20$suffix"), + Pair(21f, "21$suffix"), + Pair(22f, "22$suffix"), + Pair(23f, "23$suffix"), + Pair(24f, "24$suffix"), + Pair(25f, "25$suffix"), + Pair(26f, "26$suffix"), + Pair(28f, "28$suffix"), + Pair(30f, "30$suffix"), + Pair(32f, "32$suffix"), + Pair(34f, "34$suffix"), + Pair(36f, "36$suffix"), + Pair(38f, "38$suffix"), + Pair(40f, "40$suffix"), + Pair(42f, "42$suffix"), + Pair(44f, "44$suffix"), + Pair(48f, "48$suffix"), + Pair(60f, "60$suffix"), + ) //showBottomDialog activity?.showDialog( @@ -565,26 +423,7 @@ class SubtitlesFragment : BaseDialogFragment( dismissCallback ) { index -> state.fixedTextSize = fontSizes.map { it.first }[index] - textView.context.updateState() - } - } - - subsEdgeSize.setFocusableInTv() - subsEdgeSize.setOnClickListener { textView -> - val fontSizes = listOf( - null to textView.context.getString(R.string.normal), - ) + (1..60).map { i -> i.toFloat() to "${i}px" } - - //showBottomDialog - activity?.showDialog( - fontSizes.map { it.second }, - fontSizes.map { it.first }.indexOf(state.edgeSize), - (textView as TextView).text.toString(), - false, - dismissCallback - ) { index -> - state.edgeSize = fontSizes.map { it.first }[index] - textView.context.updateState() + //textView.context.updateState() // font size not changed } } @@ -603,28 +442,9 @@ class SubtitlesFragment : BaseDialogFragment( state.removeCaptions = b } - subtitlesBold.isChecked = state.bold - subtitlesBold.setOnCheckedChangeListener { _, b -> - state.bold = b - context?.updateState() - } - - subtitlesItalic.isChecked = state.italic - subtitlesItalic.setOnCheckedChangeListener { _, b -> - state.italic = b - context?.updateState() - } - subsFontSize.setOnLongClickListener { _ -> state.fixedTextSize = null - context?.updateState() - showToast(activity, R.string.subs_default_reset_toast, Toast.LENGTH_SHORT) - return@setOnLongClickListener true - } - - subsEdgeSize.setOnLongClickListener { _ -> - state.edgeSize = null - context?.updateState() + //textView.context.updateState() // font size not changed showToast(activity, R.string.subs_default_reset_toast, Toast.LENGTH_SHORT) return@setOnLongClickListener true } @@ -638,30 +458,31 @@ class SubtitlesFragment : BaseDialogFragment( subtitlesFilterSubLang.setOnCheckedChangeListener { _, b -> context?.let { ctx -> - PreferenceManager.getDefaultSharedPreferences(ctx).edit { - putBoolean(getString(R.string.filter_sub_lang_key), b) - } + PreferenceManager.getDefaultSharedPreferences(ctx) + .edit() + .putBoolean(getString(R.string.filter_sub_lang_key), b) + .apply() } } subsFont.setFocusableInTv() subsFont.setOnClickListener { textView -> val fontTypes = listOf( - null to textView.context.getString(R.string.normal), - R.font.trebuchet_ms to "Trebuchet MS", - R.font.netflix_sans to "Netflix Sans", - R.font.google_sans to "Google Sans", - R.font.open_sans to "Open Sans", - R.font.futura to "Futura", - R.font.consola to "Consola", - R.font.gotham to "Gotham", - R.font.lucida_grande to "Lucida Grande", - R.font.stix_general to "STIX General", - R.font.times_new_roman to "Times New Roman", - R.font.verdana to "Verdana", - R.font.ubuntu_regular to "Ubuntu", - R.font.comic_sans to "Comic Sans", - R.font.poppins_regular to "Poppins", + Pair(null, textView.context.getString(R.string.normal)), + Pair(R.font.trebuchet_ms, "Trebuchet MS"), + Pair(R.font.netflix_sans, "Netflix Sans"), + Pair(R.font.google_sans, "Google Sans"), + Pair(R.font.open_sans, "Open Sans"), + Pair(R.font.futura, "Futura"), + Pair(R.font.consola, "Consola"), + Pair(R.font.gotham, "Gotham"), + Pair(R.font.lucida_grande, "Lucida Grande"), + Pair(R.font.stix_general, "STIX General"), + Pair(R.font.times_new_roman, "Times New Roman"), + Pair(R.font.verdana, "Verdana"), + Pair(R.font.ubuntu_regular, "Ubuntu"), + Pair(R.font.comic_sans, "Comic Sans"), + Pair(R.font.poppins_regular, "Poppins"), ) val savedFontTypes = textView.context.getSavedFonts() @@ -702,29 +523,28 @@ class SubtitlesFragment : BaseDialogFragment( subsAutoSelectLanguage.setFocusableInTv() subsAutoSelectLanguage.setOnClickListener { textView -> - val languagesTagName = - listOf( - Pair( - textView.context.getString(R.string.none), - textView.context.getString(R.string.none) - ) - ) + - languages - .map { Pair(it.IETF_tag, it.nameNextToFlagEmoji()) } - .sortedBy { - it.second.substringAfter("\u00a0").lowercase() - } // name ignoring flag emoji - - val (langTagsIETF, langNames) = languagesTagName.unzip() + val langMap = arrayListOf( + SubtitleHelper.Language639( + textView.context.getString(R.string.none), + textView.context.getString(R.string.none), + "", + "", + "", + "", + "" + ), + ) + langMap.addAll(SubtitleHelper.languages) + val lang639_1 = langMap.map { it.ISO_639_1 } activity?.showDialog( - langNames, - langTagsIETF.indexOf(getAutoSelectLanguageTagIETF()), + langMap.map { it.languageName }, + lang639_1.indexOf(getAutoSelectLanguageISO639_1()), (textView as TextView).text.toString(), true, dismissCallback ) { index -> - setKey(SUBTITLE_AUTO_SELECT_KEY, langTagsIETF[index]) + setKey(SUBTITLE_AUTO_SELECT_KEY, lang639_1[index]) } } @@ -736,26 +556,18 @@ class SubtitlesFragment : BaseDialogFragment( subsDownloadLanguages.setFocusableInTv() subsDownloadLanguages.setOnClickListener { textView -> - val languagesTagName = - languages - .map { Pair(it.IETF_tag, it.nameNextToFlagEmoji()) } - .sortedBy { - it.second.substringAfter("\u00a0").lowercase() - } // name ignoring flag emoji - - val (langTagsIETF, langNames) = languagesTagName.unzip() - - val selectedLanguages = getDownloadSubsLanguageTagIETF() - .map { langTagsIETF.indexOf(it) } - .filter { it >= 0 } + val langMap = SubtitleHelper.languages + val lang639_1 = langMap.map { it.ISO_639_1 } + val keys = getDownloadSubsLanguageISO639_1() + val keyMap = keys.map { lang639_1.indexOf(it) }.filter { it >= 0 } activity?.showMultiDialog( - langNames, - selectedLanguages, + langMap.map { it.languageName }, + keyMap, (textView as TextView).text.toString(), dismissCallback ) { indexList -> - setKey(SUBTITLE_DOWNLOAD_KEY, indexList.map { langTagsIETF[it] }.toList()) + setKey(SUBTITLE_DOWNLOAD_KEY, indexList.map { lang639_1[it] }.toList()) } } @@ -767,21 +579,14 @@ class SubtitlesFragment : BaseDialogFragment( } cancelBtt.setOnClickListener { - if (popFragment) { - activity?.popCurrentPage() - } else { - dismiss() - } + activity?.popCurrentPage() } applyBtt.setOnClickListener { it.context.saveStyle(state) applyStyleEvent.invoke(state) - if (popFragment) { - activity?.popCurrentPage() - } else { - dismiss() - } + it.context.fromSaveToStyle(state) + activity?.popCurrentPage() } } } diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/AniSkip.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/AniSkip.kt new file mode 100644 index 000000000..f0c948a4a --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/AniSkip.kt @@ -0,0 +1,140 @@ +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 com.lagradost.cloudstream3.ui.result.txt +import java.lang.Long.min + +object EpisodeSkip { + private const val TAG = "EpisodeSkip" + + enum class SkipType(@StringRes name: Int) { + Opening(R.string.skip_type_op), + Ending(R.string.skip_type_ed), + Recap(R.string.skip_type_recap), + MixedOpening(R.string.skip_type_mixed_op), + MixedEnding(R.string.skip_type_mixed_ed), + Credits(R.string.skip_type_creddits), + Intro(R.string.skip_type_creddits), + } + + data class SkipStamp( + val type: SkipType, + val skipToNextEpisode: Boolean, + val startMs: Long, + val endMs: Long, + ) { + val uiText = if (skipToNextEpisode) txt(R.string.next_episode) else txt( + R.string.skip_type_format, + txt(type.name) + ) + } + + private val cachedStamps = HashMap>() + + private fun shouldSkipToNextEpisode(endMs: Long, episodeDurationMs: Long): Boolean { + return episodeDurationMs - endMs < 20_000L // some might have outro that we don't care about tbh + } + + suspend fun getStamps( + data: LoadResponse, + episode: ResultEpisode, + episodeDurationMs: Long, + hasNextEpisode: Boolean, + ): List { + cachedStamps[episode.id]?.let { list -> + return list + } + + val out = mutableListOf() + Log.i(TAG, "Requesting SkipStamp from ${data.syncData}") + + if (data is AnimeLoadResponse && (data.type == TvType.Anime || data.type == TvType.OVA)) { + data.getMalId()?.toIntOrNull()?.let { malId -> + val (resultLength, stamps) = AniSkip.getResult( + malId, + episode.episode, + episodeDurationMs + ) ?: return@let null + // because it also returns an expected episode length we use that just in case it is mismatched with like 2s next episode will still work + val dur = min(episodeDurationMs, resultLength) + stamps.mapNotNull { stamp -> + val skipType = when (stamp.skipType) { + "op" -> SkipType.Opening + "ed" -> SkipType.Ending + "recap" -> SkipType.Recap + "mixed-ed" -> SkipType.MixedEnding + "mixed-op" -> SkipType.MixedOpening + else -> null + } ?: return@mapNotNull null + val end = (stamp.interval.endTime * 1000.0).toLong() + val start = (stamp.interval.startTime * 1000.0).toLong() + SkipStamp( + type = skipType, + skipToNextEpisode = hasNextEpisode && shouldSkipToNextEpisode( + end, + dur + ), + startMs = start, + endMs = end + ) + }.let { list -> + out.addAll(list) + } + } + } + if (out.isNotEmpty()) + cachedStamps[episode.id] = out + return out + } +} + +// taken from https://github.com/saikou-app/saikou/blob/3803f8a7a59b826ca193664d46af3a22bbc989f7/app/src/main/java/ani/saikou/others/AniSkip.kt +// the following is GPLv3 code https://github.com/saikou-app/saikou/blob/main/LICENSE.md +object AniSkip { + private const val TAG = "AniSkip" + suspend fun getResult( + malId: Int, + episodeNumber: Int, + episodeLength: Long + ): Pair>? { + return try { + val url = + "https://api.aniskip.com/v2/skip-times/$malId/$episodeNumber?types[]=ed&types[]=mixed-ed&types[]=mixed-op&types[]=op&types[]=recap&episodeLength=${episodeLength / 1000L}" + Log.i(TAG, "Requesting $url") + + val a = app.get(url) + val res = a.parsed() + Log.i(TAG, "Found ${res.found} with ${res.results?.size} results") + if (res.found && !res.results.isNullOrEmpty()) (res.results[0].episodeLength * 1000).toLong() to res.results else null + } catch (t: Throwable) { + Log.i(TAG, "error = ${t.message}") + logError(t) + null + } + } + + data class AniSkipResponse( + @JsonSerialize val found: Boolean, + @JsonSerialize val results: List?, + @JsonSerialize val message: String?, + @JsonSerialize val statusCode: Int + ) + + data class Stamp( + @JsonSerialize val interval: AniSkipInterval, + @JsonSerialize val skipType: String, + @JsonSerialize val skipId: String, + @JsonSerialize val episodeLength: Double + ) + + data class AniSkipInterval( + @JsonSerialize val startTime: Double, + @JsonSerialize val endTime: Double + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/AppContextUtils.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/AppContextUtils.kt index 7278fcdd7..8d65acf7e 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/AppContextUtils.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/AppContextUtils.kt @@ -6,9 +6,7 @@ import android.app.Activity import android.app.Activity.RESULT_CANCELED import android.app.NotificationChannel import android.app.NotificationManager -import android.content.Context -import android.content.DialogInterface -import android.content.Intent +import android.content.* import android.content.pm.PackageManager import android.database.Cursor import android.media.AudioAttributes @@ -16,11 +14,10 @@ import android.media.AudioFocusRequest import android.media.AudioManager import android.media.tv.TvContract.Channels.COLUMN_INTERNAL_PROVIDER_ID import android.net.ConnectivityManager -import android.net.Network import android.net.NetworkCapabilities -import android.os.Build -import android.os.Handler -import android.os.Looper +import android.net.Uri +import android.os.* +import android.provider.MediaStore import android.text.Spanned import android.util.Log import android.view.View @@ -32,7 +29,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.content.ContextCompat import androidx.core.text.HtmlCompat import androidx.core.text.toSpanned import androidx.core.widget.ContentLoadingProgressBar @@ -40,11 +37,10 @@ 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 -import androidx.tvprovider.media.tv.TvContractCompat -import androidx.tvprovider.media.tv.WatchNextProgram +import androidx.tvprovider.media.tv.* import androidx.tvprovider.media.tv.WatchNextProgram.fromCursor import androidx.viewpager2.widget.ViewPager2 import com.google.android.gms.cast.framework.CastContext @@ -53,22 +49,13 @@ import com.google.android.gms.common.ConnectionResult 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.* import com.lagradost.cloudstream3.APIHolder.apis -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 -import com.lagradost.cloudstream3.HomePageList -import com.lagradost.cloudstream3.LoadResponse -import com.lagradost.cloudstream3.MainAPI import com.lagradost.cloudstream3.MainActivity.Companion.afterRepositoryLoadedEvent -import com.lagradost.cloudstream3.R -import com.lagradost.cloudstream3.SearchResponse -import com.lagradost.cloudstream3.TvType -import com.lagradost.cloudstream3.isMovieType import com.lagradost.cloudstream3.mvvm.logError -import com.lagradost.cloudstream3.mvvm.safe +import com.lagradost.cloudstream3.mvvm.normalSafeApiCall import com.lagradost.cloudstream3.plugins.RepositoryManager import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.APP_STRING_RESUME_WATCHING import com.lagradost.cloudstream3.syncproviders.providers.Kitsu @@ -85,18 +72,19 @@ 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.* import java.net.URL import java.net.URLDecoder -import java.util.concurrent.Executor -import java.util.concurrent.Executors - object AppContextUtils { + fun RecyclerView.setMaxViewPoolSize(maxViewTypeId: Int, maxPoolSize: Int) { + for (i in 0..maxViewTypeId) + recycledViewPool.setMaxRecycledViews(i, maxPoolSize) + } + fun RecyclerView.isRecyclerScrollable(): Boolean { val layoutManager = this.layoutManager as? LinearLayoutManager? @@ -116,15 +104,15 @@ object AppContextUtils { this?.window?.setWindowAnimations(-1) this?.show() Handler(Looper.getMainLooper()).postDelayed({ - this?.window?.setWindowAnimations(com.google.android.material.R.style.Animation_Design_BottomSheetDialog) + this?.window?.setWindowAnimations(R.style.Animation_Design_BottomSheetDialog) }, 200) } //fun Context.deleteFavorite(data: SearchResponse) { // if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return - // safe { + // normalSafeApiCall { // val existingId = - // getWatchNextProgramByVideoId(data.url, this).second ?: return@safe + // getWatchNextProgramByVideoId(data.url, this).second ?: return@normalSafeApiCall // contentResolver.delete( // // TvContractCompat.buildWatchNextProgramUri(existingId), @@ -147,12 +135,12 @@ object AppContextUtils { text.toSpanned() } } - /** Get channel ID by name */ + @SuppressLint("RestrictedApi") private fun buildWatchNextProgramUri( context: Context, card: DataStoreHelper.ResumeWatchingResult, - resumeWatching: DownloadObjects.ResumeWatching? + resumeWatching: VideoDownloadHelper.ResumeWatching? ): WatchNextProgram { val isSeries = card.type?.isMovieType() == false val title = if (isSeries) { @@ -170,10 +158,10 @@ object AppContextUtils { ) .setWatchNextType(TvContractCompat.WatchNextPrograms.WATCH_NEXT_TYPE_CONTINUE) .setTitle(title) - .setPosterArtUri(card.posterUrl?.toUri()) - .setIntentUri((card.id?.let { + .setPosterArtUri(Uri.parse(card.posterUrl)) + .setIntentUri(Uri.parse(card.id?.let { "$APP_STRING_RESUME_WATCHING://$it" - } ?: card.url).toUri()) + } ?: card.url)) .setInternalProviderId(card.url) .setLastEngagementTimeUtcMillis( resumeWatching?.updateTime ?: System.currentTimeMillis() @@ -319,7 +307,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 @@ -364,15 +352,52 @@ 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 } } fun Context.getApiSettings(): HashSet { + //val settingsManager = PreferenceManager.getDefaultSharedPreferences(this) + val hashSet = HashSet() val activeLangs = getApiProviderLangSettings() val hasUniversal = activeLangs.contains(AllLanguagesName) - hashSet.addAll(apis.filter { hasUniversal || activeLangs.contains(it.lang) }.map { it.name }) + hashSet.addAll(synchronized(apis) { apis.filter { hasUniversal || activeLangs.contains(it.lang) } } + .map { it.name }) + + /*val set = settingsManager.getStringSet( + this.getString(R.string.search_providers_list_key), + hashSet + )?.toHashSet() ?: hashSet + + val list = HashSet() + for (name in set) { + val api = getApiFromNameNull(name) ?: continue + if (activeLangs.contains(api.lang)) { + list.add(name) + } + }*/ + //if (list.isEmpty()) return hashSet + //return list return hashSet } @@ -431,14 +456,6 @@ object AppContextUtils { return settingsManager.getBoolean(this.getString(R.string.show_trailers_key), true) } - fun Context.shouldShowPlayerMetadata(): Boolean { - val prefs = PreferenceManager.getDefaultSharedPreferences(this) - return prefs.getBoolean( - getString(R.string.show_player_metadata_key), - true - ) - } - fun Context.filterProviderByPreferredMedia(hasHomePageIsRequired: Boolean = true): List { // We are getting the weirdest crash ever done: // java.lang.ClassCastException: com.lagradost.cloudstream3.TvType cannot be cast to com.lagradost.cloudstream3.TvType @@ -463,7 +480,9 @@ object AppContextUtils { } ?: default val langs = this.getApiProviderLangSettings() val hasUniversal = langs.contains(AllLanguagesName) - val allApis = apis.filter { api -> (hasUniversal || langs.contains(api.lang)) && (api.hasMainPage || !hasHomePageIsRequired) } + val allApis = synchronized(apis) { + apis.filter { api -> (hasUniversal || langs.contains(api.lang)) && (api.hasMainPage || !hasHomePageIsRequired) } + } return if (currentPrefMedia.isEmpty()) { allApis } else { @@ -519,7 +538,6 @@ object AppContextUtils { val repo = RepositoryManager.parseRepository(url) ?: return@ioSafe RepositoryManager.addRepository( RepositoryData( - repo.iconUrl ?: "", repo.name, url ) @@ -535,6 +553,45 @@ object AppContextUtils { } } + abstract class DiffAdapter( + open val items: MutableList, + val comparison: (first: T, second: T) -> Boolean = { first, second -> + first.hashCode() == second.hashCode() + } + ) : + RecyclerView.Adapter() { + override fun getItemCount(): Int { + return items.size + } + + fun updateList(newList: List) { + val diffResult = DiffUtil.calculateDiff( + GenericDiffCallback(this.items, newList) + ) + + items.clear() + items.addAll(newList) + + diffResult.dispatchUpdatesTo(this) + } + + inner class GenericDiffCallback( + private val oldList: List, + private val newList: List + ) : + DiffUtil.Callback() { + override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int) = + comparison(oldList[oldItemPosition], newList[newItemPosition]) + + override fun getOldListSize() = oldList.size + + override fun getNewListSize() = newList.size + + override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int) = + oldList[oldItemPosition] == newList[newItemPosition] + } + } + fun Activity.addRepositoryDialog( repositoryName: String, repositoryURL: String, @@ -574,7 +631,7 @@ object AppContextUtils { fun openWebView(fragment: Fragment?, url: String) { if (fragment?.context?.hasWebView() == true) - safe { + normalSafeApiCall { fragment .findNavController() .navigate(R.id.navigation_webview, WebviewFragment.newInstance(url)) @@ -588,10 +645,10 @@ object AppContextUtils { url: String, fallbackWebview: Boolean = false, fragment: Fragment? = null, - ) = (this.getActivity() ?: activity)?.runOnUiThread { + ) { try { val intent = Intent(Intent.ACTION_VIEW) - intent.data = url.toUri() + intent.data = Uri.parse(url) intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK) // activityResultRegistry is used to fall back to webview if a browser is missing @@ -607,7 +664,10 @@ object AppContextUtils { openWebView(fragment, url) } }.launch(intent) - } else this.startActivity(intent) + } else { + ContextCompat.startActivity(this, intent, null) + } + } catch (e: Exception) { logError(e) if (fallbackWebview) { @@ -617,12 +677,10 @@ object AppContextUtils { } fun Context.isNetworkAvailable(): Boolean { - val connectivityManager = - getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager + val connectivityManager = getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { val network = connectivityManager.activeNetwork ?: return false - val networkCapabilities = - connectivityManager.getNetworkCapabilities(network) ?: return false + val networkCapabilities = connectivityManager.getNetworkCapabilities(network) ?: return false networkCapabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) } else { @Suppress("DEPRECATION") @@ -675,18 +733,6 @@ object AppContextUtils { return "" } - fun Context.getShortSeasonText(episode: Int?, season: Int?): String? { - val rEpisode = if (episode == 0) null else episode - val rSeason = if (season == 0) null else season - val seasonNameShort = getString(R.string.season_short) - val episodeNameShort = getString(R.string.episode_short) - return if (rEpisode != null && rSeason != null) { - "$seasonNameShort${rSeason}:$episodeNameShort${rEpisode}" - } else if (rEpisode != null) { - "$episodeNameShort$rEpisode" - }else null - } - fun Activity?.loadCache() { try { cacheClass("android.net.NetworkCapabilities".load()) @@ -707,17 +753,15 @@ object AppContextUtils { fun loadResult( url: String, apiName: String, - name : String, startAction: Int = 0, startValue: Int = 0 ) { - (activity as FragmentActivity?)?.loadResult(url, apiName, name, startAction, startValue) + (activity as FragmentActivity?)?.loadResult(url, apiName, startAction, startValue) } fun FragmentActivity.loadResult( url: String, apiName: String, - name : String, startAction: Int = 0, startValue: Int = 0 ) { @@ -733,7 +777,7 @@ object AppContextUtils { // viewModelStore.clear() this.navigate( getResultsId(), - ResultFragment.newInstance(url, apiName, name, startAction, startValue) + ResultFragment.newInstance(url, apiName, startAction, startValue) ) } } @@ -762,18 +806,12 @@ object AppContextUtils { } fun Activity.requestLocalAudioFocus(focusRequest: AudioFocusRequest?) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - if (focusRequest == null) { - Log.e("TAG", "focusRequest was null") - return - } - + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && focusRequest != null) { val audioManager = getSystemService(Context.AUDIO_SERVICE) as AudioManager audioManager.requestAudioFocus(focusRequest) } else { val audioManager: AudioManager = getSystemService(Context.AUDIO_SERVICE) as AudioManager - @Suppress("DEPRECATION") audioManager.requestAudioFocus( null, AudioManager.STREAM_MUSIC, @@ -805,26 +843,20 @@ object AppContextUtils { val isCastApiAvailable = GoogleApiAvailability.getInstance() .isGooglePlayServicesAvailable(applicationContext) == ConnectionResult.SUCCESS - try { - applicationContext?.let { - val task = CastContext.getSharedInstance(it) { it.run() } - task.result - } + applicationContext?.let { CastContext.getSharedInstance(it) } } catch (e: Exception) { println(e) - // Track non-fatal + // track non-fatal return false } - return isCastApiAvailable } fun Context.isConnectedToChromecast(): Boolean { if (isCastApiAvailable()) { - val executor: Executor = Executors.newSingleThreadExecutor() - val castContext = CastContext.getSharedInstance(this, executor) - if (castContext.result.castState == CastState.CONNECTED) { + val castContext = CastContext.getSharedInstance(this) + if (castContext.castState == CastState.CONNECTED) { return true } } @@ -842,17 +874,111 @@ object AppContextUtils { } } - fun Context.isUsingMobileData(): Boolean { - val connectionManager = getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager - return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - val activeNetwork: Network? = connectionManager.activeNetwork - val networkCapabilities = connectionManager.getNetworkCapabilities(activeNetwork) - networkCapabilities?.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) == true && - !networkCapabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) - } else { - @Suppress("DEPRECATION") - connectionManager.activeNetworkInfo?.type == ConnectivityManager.TYPE_MOBILE + // 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 conManager = getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager + val networkInfo = conManager.allNetworks + return networkInfo.any { + conManager.getNetworkCapabilities(it) + ?.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) == true + } && + !networkInfo.any { + conManager.getNetworkCapabilities(it) + ?.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) == true + } } @@ -893,7 +1019,9 @@ object AppContextUtils { } build() } - } else null + } else { + null + } return currentAudioFocusRequest } } \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/BackPressedCallbackHelper.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/BackPressedCallbackHelper.kt index 10736e13e..abb3e2a2d 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/BackPressedCallbackHelper.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/BackPressedCallbackHelper.kt @@ -2,66 +2,27 @@ package com.lagradost.cloudstream3.utils import androidx.activity.ComponentActivity import androidx.activity.OnBackPressedCallback -import java.lang.ref.WeakReference import java.util.WeakHashMap object BackPressedCallbackHelper { + private val backPressedCallbacks = WeakHashMap() - private val backPressedCallbacks = - WeakHashMap>() - - class CallbackHelper( - private val activityRef: WeakReference, - private val callback: OnBackPressedCallback - ) { - fun runDefault() { - val activity = activityRef.get() ?: return - val wasEnabled = callback.isEnabled - callback.isEnabled = false - try { - activity.onBackPressedDispatcher.onBackPressed() - } finally { - callback.isEnabled = wasEnabled - } - } - } - - fun ComponentActivity.attachBackPressedCallback( - id: String, - callback: CallbackHelper.() -> Unit - ) { - val callbackMap = backPressedCallbacks.getOrPut(this) { mutableMapOf() } - if (callbackMap.containsKey(id)) return - - // We use WeakReference to protect against potential leaks. - val activityRef = WeakReference(this) - val newCallback = object : OnBackPressedCallback(true) { - override fun handleOnBackPressed() { - CallbackHelper(activityRef, this).callback() + fun ComponentActivity.attachBackPressedCallback(callback: () -> Unit) { + if (backPressedCallbacks[this] == null) { + val newCallback = object : OnBackPressedCallback(true) { + override fun handleOnBackPressed() { + callback.invoke() + } } + backPressedCallbacks[this] = newCallback + onBackPressedDispatcher.addCallback(this, newCallback) } - callbackMap[id] = newCallback - onBackPressedDispatcher.addCallback(this, newCallback) + backPressedCallbacks[this]?.isEnabled = true } - fun ComponentActivity.disableBackPressedCallback(id : String) { - backPressedCallbacks[this]?.get(id)?.isEnabled = false + fun ComponentActivity.detachBackPressedCallback() { + backPressedCallbacks[this]?.isEnabled = false + backPressedCallbacks.remove(this) } - - 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.remove() - callbackMap.remove(id) - } - - if (callbackMap.isEmpty()) { - backPressedCallbacks.remove(this) - } - } -} +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/BackupUtils.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/BackupUtils.kt index 62426197e..b25be59f9 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/BackupUtils.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/BackupUtils.kt @@ -1,49 +1,47 @@ package com.lagradost.cloudstream3.utils +import android.annotation.SuppressLint import android.content.Context import android.net.Uri import android.widget.Toast import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.contract.ActivityResultContracts import androidx.annotation.WorkerThread -import androidx.core.net.toUri import androidx.fragment.app.FragmentActivity -import androidx.preference.PreferenceManager import com.fasterxml.jackson.annotation.JsonProperty -import com.lagradost.cloudstream3.CloudStreamApp.Companion.getActivity +import com.fasterxml.jackson.module.kotlin.readValue +import com.lagradost.cloudstream3.AcraApplication.Companion.getActivity import com.lagradost.cloudstream3.CommonActivity.showToast import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.mvvm.logError 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.KitsuApi.Companion.KITSU_CACHED_LIST -import com.lagradost.cloudstream3.utils.AppUtils.parseJson -import com.lagradost.cloudstream3.utils.AppUtils.toJson +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.ui.result.txt import com.lagradost.cloudstream3.utils.Coroutines.ioSafe import com.lagradost.cloudstream3.utils.Coroutines.main import com.lagradost.cloudstream3.utils.DataStore.getDefaultSharedPrefs import com.lagradost.cloudstream3.utils.DataStore.getSharedPrefs +import com.lagradost.cloudstream3.utils.DataStore.mapper import com.lagradost.cloudstream3.utils.UIHelper.checkWrite import com.lagradost.cloudstream3.utils.UIHelper.requestRW -import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager.setupStream -import com.lagradost.cloudstream3.utils.downloader.DownloadObjects -import com.lagradost.cloudstream3.utils.downloader.DownloadQueueManager.QUEUE_KEY -import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager.KEY_DOWNLOAD_INFO -import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager.KEY_RESUME_IN_QUEUE -import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager.KEY_RESUME_PACKAGES -import com.lagradost.safefile.MediaFileContentType -import com.lagradost.safefile.SafeFile +import com.lagradost.cloudstream3.utils.VideoDownloadManager.setupStream import okhttp3.internal.closeQuietly -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 { @@ -51,63 +49,27 @@ 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, - KITSU_CACHED_LIST, + MAL_UNIXTIME_KEY, + MAL_USER_KEY, // The plugins themselves are not backed up PLUGINS_KEY, PLUGINS_KEY_LOCAL, - AccountManager.ACCOUNT_TOKEN, - AccountManager.ACCOUNT_IDS, + OPEN_SUBTITLES_USER_KEY, + SUBDL_SUBTITLES_USER_KEY, - // 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 - - // No access rights after restore data from backup - "download_path_key", - "download_path_key_visual", - "backup_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" + "download_path_key" // No access rights after restore data from backup ) /** false if key should not be contained in backup */ @@ -133,7 +95,9 @@ object BackupUtils { ) @Suppress("UNCHECKED_CAST") - private fun getBackup(context: Context): BackupFile { + private fun getBackup(context: Context?): BackupFile? { + if (context == null) return null + val allData = context.getSharedPrefs().all.filter { it.key.isTransferable() } val allSettings = context.getDefaultSharedPrefs().all.filter { it.key.isTransferable() } @@ -186,13 +150,9 @@ object BackupUtils { context.restoreMap(backupFile.datastore.long) context.restoreMap(backupFile.datastore.stringSet) } - - // Make sure the library is fresh - for(api in AccountManager.syncApis) { - api.requireLibraryRefresh = true - } } + @SuppressLint("SimpleDateFormat") fun backup(context: Context?) = ioSafe { if (context == null) return@ioSafe @@ -205,14 +165,15 @@ object BackupUtils { return@ioSafe } - val date = SimpleDateFormat("yyyy_MM_dd_HH_mm", Locale.getDefault()).format(Date(currentTimeMillis())) + val date = SimpleDateFormat("yyyy_MM_dd_HH_mm").format(Date(currentTimeMillis())) + val ext = "txt" val displayName = "CS3_Backup_${date}" val backupFile = getBackup(context) - val stream = setupBackupStream(context, displayName) + val stream = setupStream(context, displayName, null, ext, false) fileStream = stream.openNew() printStream = PrintWriter(fileStream) - printStream.print(backupFile.toJson()) + printStream.print(mapper.writeValueAsString(backupFile)) showToast( R.string.backup_success, @@ -234,18 +195,6 @@ object BackupUtils { } } - @Throws(IOException::class) - private fun setupBackupStream(context: Context, name: String, ext: String = "txt"): DownloadObjects.StreamData { - return setupStream( - baseFile = getCurrentBackupDir(context).first ?: getDefaultBackupDir(context) - ?: throw IOException("Bad config"), - name, - folder = null, - extension = ext, - tryResume = false - ) - } - fun FragmentActivity.setUpBackup() { try { restoreFileSelector = @@ -257,8 +206,8 @@ object BackupUtils { val input = activity.contentResolver.openInputStream(uri) ?: return@ioSafe - val text = input.bufferedReader().readText() - val restoredValue = parseJson(text) + val restoredValue = + mapper.readValue(input) restore( activity, @@ -315,28 +264,4 @@ object BackupUtils { } editor.apply() } - - /** - * 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 - * */ - - fun getDefaultBackupDir(context: Context): SafeFile? { - return SafeFile.fromMedia(context, MediaFileContentType.Downloads) - } - - fun getCurrentBackupDir(context: Context): Pair { - val settingsManager = PreferenceManager.getDefaultSharedPreferences(context) - val basePathSetting = - settingsManager.getString(context.getString(R.string.backup_path_key), null) - return baseBackupPathToFile(context, basePathSetting) to basePathSetting - } - - private fun baseBackupPathToFile(context: Context, path: String?): SafeFile? { - return when { - path.isNullOrBlank() -> getDefaultBackupDir(context) - path.startsWith("content://") -> SafeFile.fromUri(context, path.toUri()) - else -> SafeFile.fromFilePath(context, path) - } - } } diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/BiometricAuthenticator.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/BiometricAuthenticator.kt index bce8f09dc..45acbab4f 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/BiometricAuthenticator.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/BiometricAuthenticator.kt @@ -1,6 +1,5 @@ package com.lagradost.cloudstream3.utils -import android.annotation.SuppressLint import android.app.Activity import android.app.KeyguardManager import android.content.Context @@ -29,13 +28,13 @@ object BiometricAuthenticator { var promptInfo: BiometricPrompt.PromptInfo? = null var authCallback: BiometricCallback? = null // listen to authentication success - private fun initializeBiometrics(activity: FragmentActivity) { + private fun initializeBiometrics(activity: Activity) { val executor = ContextCompat.getMainExecutor(activity) biometricManager = BiometricManager.from(activity) biometricPrompt = BiometricPrompt( - activity, + activity as FragmentActivity, executor, object : BiometricPrompt.AuthenticationCallback() { override fun onAuthenticationError(errorCode: Int, errString: CharSequence) { @@ -101,51 +100,31 @@ object BiometricAuthenticator { } private fun isBiometricHardWareAvailable(): Boolean { - // Authentication occurs only when this is true and device is truly capable. + // authentication occurs only when this is true and device is truly capable var result = false - when { - Build.VERSION.SDK_INT >= Build.VERSION_CODES.BAKLAVA -> { - @SuppressLint("RestrictedApi") - when (biometricManager?.canAuthenticate( - DEVICE_CREDENTIAL or BIOMETRIC_STRONG or BIOMETRIC_WEAK - )) { - BiometricManager.BIOMETRIC_SUCCESS -> result = true - BiometricManager.BIOMETRIC_ERROR_NO_HARDWARE -> result = false - BiometricManager.BIOMETRIC_ERROR_HW_UNAVAILABLE -> result = false - BiometricManager.BIOMETRIC_ERROR_NONE_ENROLLED -> result = false - BiometricManager.BIOMETRIC_ERROR_NOT_ENABLED_FOR_APPS -> result = false - BiometricManager.BIOMETRIC_ERROR_SECURITY_UPDATE_REQUIRED -> result = true - BiometricManager.BIOMETRIC_ERROR_UNSUPPORTED -> result = true - BiometricManager.BIOMETRIC_STATUS_UNKNOWN -> result = false - } - } - Build.VERSION.SDK_INT >= Build.VERSION_CODES.R -> { - @Suppress("SwitchIntDef") - when (biometricManager?.canAuthenticate( - DEVICE_CREDENTIAL or BIOMETRIC_STRONG or BIOMETRIC_WEAK - )) { - BiometricManager.BIOMETRIC_SUCCESS -> result = true - BiometricManager.BIOMETRIC_ERROR_NO_HARDWARE -> result = false - BiometricManager.BIOMETRIC_ERROR_HW_UNAVAILABLE -> result = false - BiometricManager.BIOMETRIC_ERROR_NONE_ENROLLED -> result = false - BiometricManager.BIOMETRIC_ERROR_SECURITY_UPDATE_REQUIRED -> result = true - BiometricManager.BIOMETRIC_ERROR_UNSUPPORTED -> result = true - BiometricManager.BIOMETRIC_STATUS_UNKNOWN -> result = false - } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + when (biometricManager?.canAuthenticate( + DEVICE_CREDENTIAL or BIOMETRIC_STRONG or BIOMETRIC_WEAK + )) { + BiometricManager.BIOMETRIC_SUCCESS -> result = true + BiometricManager.BIOMETRIC_ERROR_NO_HARDWARE -> result = false + BiometricManager.BIOMETRIC_ERROR_HW_UNAVAILABLE -> result = false + BiometricManager.BIOMETRIC_ERROR_NONE_ENROLLED -> result = false + BiometricManager.BIOMETRIC_ERROR_SECURITY_UPDATE_REQUIRED -> result = true + BiometricManager.BIOMETRIC_ERROR_UNSUPPORTED -> result = true + BiometricManager.BIOMETRIC_STATUS_UNKNOWN -> result = false } - - else -> { - @Suppress("DEPRECATION", "SwitchIntDef") - when (biometricManager?.canAuthenticate()) { - BiometricManager.BIOMETRIC_SUCCESS -> result = true - BiometricManager.BIOMETRIC_ERROR_NO_HARDWARE -> result = false - BiometricManager.BIOMETRIC_ERROR_HW_UNAVAILABLE -> result = false - BiometricManager.BIOMETRIC_ERROR_NONE_ENROLLED -> result = false - BiometricManager.BIOMETRIC_ERROR_SECURITY_UPDATE_REQUIRED -> result = true - BiometricManager.BIOMETRIC_ERROR_UNSUPPORTED -> result = true - BiometricManager.BIOMETRIC_STATUS_UNKNOWN -> result = false - } + } else { + @Suppress("DEPRECATION") + when (biometricManager?.canAuthenticate()) { + BiometricManager.BIOMETRIC_SUCCESS -> result = true + BiometricManager.BIOMETRIC_ERROR_NO_HARDWARE -> result = false + BiometricManager.BIOMETRIC_ERROR_HW_UNAVAILABLE -> result = false + BiometricManager.BIOMETRIC_ERROR_NONE_ENROLLED -> result = false + BiometricManager.BIOMETRIC_ERROR_SECURITY_UPDATE_REQUIRED -> result = true + BiometricManager.BIOMETRIC_ERROR_UNSUPPORTED -> result = true + BiometricManager.BIOMETRIC_STATUS_UNKNOWN -> result = false } } @@ -160,7 +139,7 @@ object BiometricAuthenticator { } // function to start authentication in any fragment or activity - fun startBiometricAuthentication(activity: FragmentActivity, title: Int, setDeviceCred: Boolean) { + fun startBiometricAuthentication(activity: Activity, title: Int, setDeviceCred: Boolean) { initializeBiometrics(activity) authCallback = activity as? BiometricCallback if (isBiometricHardWareAvailable()) { diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/CastHelper.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/CastHelper.kt index b48c8d40a..d83731658 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/CastHelper.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/CastHelper.kt @@ -1,6 +1,6 @@ package com.lagradost.cloudstream3.utils -import androidx.core.net.toUri +import android.net.Uri import androidx.media3.common.MimeTypes import com.google.android.gms.cast.* import com.google.android.gms.cast.framework.CastSession @@ -41,7 +41,7 @@ object CastHelper { val srcPoster = epData.poster ?: holder.poster if (srcPoster != null) { - movieMetadata.addImage(WebImage(srcPoster.toUri())) + movieMetadata.addImage(WebImage(Uri.parse(srcPoster))) } var subIndex = 0 diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/ConsistentLiveData.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/ConsistentLiveData.kt deleted file mode 100644 index def41d7a0..000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/ConsistentLiveData.kt +++ /dev/null @@ -1,47 +0,0 @@ -package com.lagradost.cloudstream3.utils - -import androidx.annotation.MainThread -import androidx.lifecycle.LiveData -import com.lagradost.cloudstream3.mvvm.Resource - -/** - * This is an atomic LiveData where you can do .value instantly after doing .postValue. - * - * The default behavior is a footgun that will cause race conditions, - * as we do not really care if it is posted as we only want the latest data (even in the binding). - * - * Fuck all that is LiveData, because we want this value to be accessible everywhere instantly. - * */ -open class ConsistentLiveData(initValue : T? = null) : LiveData(initValue) { - @Volatile private var internalValue : T? = initValue - - override fun getValue(): T? { - return internalValue - } - - /** If someone want the old behavior then good for them */ - val postedValue : T? get() = super.getValue() - - public override fun postValue(value : T?) { - super.postValue(value) - internalValue = value - } - - @MainThread - public override fun setValue(value: T?) { - super.setValue(value) - internalValue = value - } -} - -/** Atomic resource livedata, to make it easier to work with resources without local copies */ -class ResourceLiveData(initValue : Resource? = null) : ConsistentLiveData>(initValue) { - var success - get() = when(val output = this.value) { - is Resource.Success -> { - output.value - } - else -> null - } - set(value) = this.postValue(value?.let { Resource.Success(it) } ) -} diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/DataStore.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/DataStore.kt index 02ee69791..b5192aae2 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/DataStore.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/DataStore.kt @@ -2,27 +2,25 @@ package com.lagradost.cloudstream3.utils import android.content.Context import android.content.SharedPreferences -import androidx.core.content.edit import androidx.preference.PreferenceManager -import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKeyClass -import com.lagradost.cloudstream3.CloudStreamApp.Companion.removeKey -import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKeyClass +import com.fasterxml.jackson.databind.DeserializationFeature +import com.fasterxml.jackson.databind.json.JsonMapper +import com.fasterxml.jackson.module.kotlin.kotlinModule +import com.lagradost.cloudstream3.AcraApplication.Companion.getKeyClass +import com.lagradost.cloudstream3.AcraApplication.Companion.removeKey +import com.lagradost.cloudstream3.AcraApplication.Companion.setKeyClass import com.lagradost.cloudstream3.mvvm.logError -import com.lagradost.cloudstream3.utils.AppUtils.parseJson -import com.lagradost.cloudstream3.utils.AppUtils.toJsonLiteral import kotlin.reflect.KClass import kotlin.reflect.KProperty -/** Used to display metadata about downloads and resume watching */ const val DOWNLOAD_HEADER_CACHE = "download_header_cache" -const val DOWNLOAD_HEADER_CACHE_BACKUP = "BACKUP_download_header_cache" //const val WATCH_HEADER_CACHE = "watch_header_cache" const val DOWNLOAD_EPISODE_CACHE = "download_episode_cache" -const val DOWNLOAD_EPISODE_CACHE_BACKUP = "BACKUP_download_episode_cache" const val VIDEO_PLAYER_BRIGHTNESS = "video_player_alpha_key" const val USER_SELECTED_HOMEPAGE_API = "home_api_used" const val USER_PROVIDER_API = "user_custom_sites" + const val PREFERENCES_NAME = "rebuild_preference" // TODO degelgate by value for get & set @@ -31,7 +29,6 @@ class PreferenceDelegate( val key: String, val default: T //, private val klass: KClass ) { private val klass: KClass = default::class - // simple cache to make it not get the key every time it is accessed, however this requires // that ONLY this changes the key private var cache: T? = null @@ -55,10 +52,10 @@ class PreferenceDelegate( /** When inserting many keys use this function, this is because apply for every key is very expensive on memory */ data class Editor( - val editor: SharedPreferences.Editor + val editor : SharedPreferences.Editor ) { /** Always remember to call apply after */ - fun setKeyRaw(path: String, value: T) { + fun setKeyRaw(path: String, value: T) { @Suppress("UNCHECKED_CAST") if (isStringSet(value)) { editor.putStringSet(path, value as Set) @@ -73,7 +70,7 @@ data class Editor( } } - private fun isStringSet(value: Any?): Boolean { + private fun isStringSet(value: Any?) : Boolean { if (value is Set<*>) { return value.filterIsInstance().size == value.size } @@ -87,18 +84,8 @@ data class Editor( } object DataStore { - // Extensions shouldn't have really been using this version of it, but it seems - // some have. Since there has always been a very easy alternative, we won't - // need to deprecate it that long, and should be able to fully remove it - // once extensions at least use the other version. - @Deprecated( - "Please do not use the mapper version from DataStore. Preferably use methods from AppUtils " + - "to parse JSON. However, you can use the stable-API version of the mapper at " + - "com.lagradost.cloudstream3.mapper to access the mapper directly if necessary.", - level = DeprecationLevel.ERROR, - replaceWith = ReplaceWith("com.lagradost.cloudstream3.mapper"), - ) - val mapper = com.lagradost.cloudstream3.mapper + val mapper: JsonMapper = JsonMapper.builder().addModule(kotlinModule()) + .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false).build() private fun getPreferences(context: Context): SharedPreferences { return context.getSharedPreferences(PREFERENCES_NAME, Context.MODE_PRIVATE) @@ -112,10 +99,9 @@ object DataStore { return "${folder}/${path}" } - fun editor(context: Context, isEditingAppSettings: Boolean = false): Editor { + fun editor(context : Context, isEditingAppSettings: Boolean = false) : Editor { val editor: SharedPreferences.Editor = - if (isEditingAppSettings) context.getDefaultSharedPrefs() - .edit() else context.getSharedPrefs().edit() + if (isEditingAppSettings) context.getDefaultSharedPrefs().edit() else context.getSharedPrefs().edit() return Editor(editor) } @@ -124,9 +110,7 @@ object DataStore { } fun Context.getKeys(folder: String): List { - // Ensure that the folder ends with "/" to prevent matching with other folders - val fixedFolder = folder.trimEnd('/') + "/" - return this.getSharedPrefs().all.keys.filter { it.startsWith(fixedFolder) } + return this.getSharedPrefs().all.keys.filter { it.startsWith(folder) } } fun Context.removeKey(folder: String, path: String) { @@ -146,9 +130,9 @@ object DataStore { try { val prefs = getSharedPrefs() if (prefs.contains(path)) { - prefs.edit { - remove(path) - } + val editor: SharedPreferences.Editor = prefs.edit() + editor.remove(path) + editor.apply() } } catch (e: Exception) { logError(e) @@ -157,33 +141,26 @@ object DataStore { fun Context.removeKeys(folder: String): Int { val keys = getKeys("$folder/") - try { - getSharedPrefs().edit { - keys.forEach { value -> - remove(value) - } - } - return keys.size - } catch (e: Exception) { - logError(e) - return 0 + keys.forEach { value -> + removeKey(value) } + return keys.size } fun Context.setKey(path: String, value: T) { try { - getSharedPrefs().edit { - putString(path, value?.toJsonLiteral()) - } + val editor: SharedPreferences.Editor = getSharedPrefs().edit() + editor.putString(path, mapper.writeValueAsString(value)) + editor.apply() } catch (e: Exception) { logError(e) } } - fun Context.getKey(path: String, valueType: Class): T? { + fun Context.getKey(path: String, valueType: Class): T? { try { val json: String = getSharedPrefs().getString(path, null) ?: return null - return parseJson(json, valueType.kotlin) + return json.toKotlinObject(valueType) } catch (e: Exception) { return null } @@ -194,11 +171,11 @@ object DataStore { } inline fun String.toKotlinObject(): T { - return parseJson(this) + return mapper.readValue(this, T::class.java) } - fun String.toKotlinObject(valueType: Class): T { - return parseJson(this, valueType.kotlin) + fun String.toKotlinObject(valueType: Class): T { + return mapper.readValue(this, valueType) } // GET KEY GIVEN PATH AND DEFAULT VALUE, NULL IF ERROR @@ -222,4 +199,4 @@ object DataStore { inline fun Context.getKey(folder: String, path: String, defVal: T?): T? { return getKey(getFolderName(folder, path), defVal) ?: defVal } -} +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/DataStoreHelper.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/DataStoreHelper.kt index 19caead21..7ef7bc576 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/DataStoreHelper.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/DataStoreHelper.kt @@ -3,20 +3,18 @@ package com.lagradost.cloudstream3.utils import android.content.Context import com.fasterxml.jackson.annotation.JsonProperty import com.lagradost.cloudstream3.APIHolder.unixTimeMS -import com.lagradost.cloudstream3.CloudStreamApp.Companion.context -import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey -import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKeyClass -import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKeys -import com.lagradost.cloudstream3.CloudStreamApp.Companion.removeKey -import com.lagradost.cloudstream3.CloudStreamApp.Companion.removeKeys -import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey -import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKeyClass +import com.lagradost.cloudstream3.AcraApplication +import com.lagradost.cloudstream3.AcraApplication.Companion.context +import com.lagradost.cloudstream3.AcraApplication.Companion.getKey +import com.lagradost.cloudstream3.AcraApplication.Companion.getKeys +import com.lagradost.cloudstream3.AcraApplication.Companion.removeKey +import com.lagradost.cloudstream3.AcraApplication.Companion.removeKeys +import com.lagradost.cloudstream3.AcraApplication.Companion.setKey import com.lagradost.cloudstream3.CommonActivity.showToast import com.lagradost.cloudstream3.DubStatus import com.lagradost.cloudstream3.EpisodeResponse 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 @@ -24,13 +22,9 @@ import com.lagradost.cloudstream3.syncproviders.AccountManager import com.lagradost.cloudstream3.syncproviders.SyncAPI import com.lagradost.cloudstream3.ui.WatchType import com.lagradost.cloudstream3.ui.library.ListSorting -import com.lagradost.cloudstream3.ui.player.ExtractorUri -import com.lagradost.cloudstream3.ui.player.NEXT_WATCH_EPISODE_PERCENTAGE -import com.lagradost.cloudstream3.ui.result.EpisodeSortType -import com.lagradost.cloudstream3.ui.result.ResultEpisode +import com.lagradost.cloudstream3.ui.result.UiImage 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 @@ -49,8 +43,7 @@ const val RESULT_RESUME_WATCHING_HAS_MIGRATED = "result_resume_watching_migrated 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 @@ -58,7 +51,7 @@ class UserPreferenceDelegate( private val klass: KClass = default::class private val realKey get() = "${DataStoreHelper.currentAccount}/$key" operator fun getValue(self: Any?, property: KProperty<*>) = - getKeyClass(realKey, klass.java) ?: default + AcraApplication.getKeyClass(realKey, klass.java) ?: default operator fun setValue( self: Any?, @@ -68,7 +61,7 @@ class UserPreferenceDelegate( if (t == null) { removeKey(realKey) } else { - setKeyClass(realKey, t) + AcraApplication.setKeyClass(realKey, t) } } } @@ -85,68 +78,48 @@ 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 - get() = EpisodeSortType.entries.getOrNull(_resultsSortingMode) ?: EpisodeSortType.NUMBER_ASC - set(value) { - _resultsSortingMode = value.ordinal - } + 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) data class Account( @JsonProperty("keyIndex") @@ -160,10 +133,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: UiImage + get() = customImage?.let { UiImage.Image(it) } ?: UiImage.Drawable( + profileImages.getOrNull(defaultImageIndex) ?: profileImages.first() + ) } const val TAG = "data_store_helper" @@ -190,7 +163,6 @@ 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) @@ -246,8 +218,7 @@ 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. @@ -264,24 +235,10 @@ 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("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) - } - } - } + @JsonProperty("plot") open val plot : String? = null, + @JsonProperty("rating") open val rating : Int? = null, + @JsonProperty("tags") open val tags : List? = null, + ) : SearchResponse data class SubscribedData( @JsonProperty("subscribedTime") val subscribedTime: Long, @@ -298,24 +255,9 @@ object DataStoreHelper { override var quality: SearchQuality? = null, override var posterHeaders: Map? = null, override val plot: String? = null, - override var score: Score? = null, + override val rating: Int? = null, override val tags: List? = null, - ) : LibrarySearchResponse( - id, - latestUpdatedTime, - name, - url, - apiName, - type, - posterUrl, - year, - syncData, - quality, - posterHeaders, - plot, - score, - tags - ) { + ) : LibrarySearchResponse(id, latestUpdatedTime, name, url, apiName, type, posterUrl, year, syncData, quality, posterHeaders, plot,rating,tags) { fun toLibraryItem(): SyncAPI.LibraryItem? { return SyncAPI.LibraryItem( name, @@ -325,16 +267,7 @@ object DataStoreHelper { null, null, latestUpdatedTime, - apiName, - type, - posterUrl, - posterHeaders, - quality, - year?.toYear(), - this.id, - plot = this.plot, - score = this.score, - tags = this.tags + apiName, type, posterUrl, posterHeaders, quality, year?.toYear(), this.id, plot = this.plot, rating = this.rating, tags = this.tags ) } } @@ -353,22 +286,9 @@ object DataStoreHelper { override var quality: SearchQuality? = null, override var posterHeaders: Map? = null, override val plot: String? = null, - override var score: Score? = null, + override val rating: Int? = 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, @@ -378,16 +298,7 @@ object DataStoreHelper { null, null, latestUpdatedTime, - apiName, - type, - posterUrl, - posterHeaders, - quality, - year?.toYear(), - this.id, - plot = this.plot, - score = this.score, - tags = this.tags + apiName, type, posterUrl, posterHeaders, quality, year?.toYear(), this.id, plot = this.plot, rating = this.rating, tags = this.tags ) } } @@ -406,22 +317,9 @@ object DataStoreHelper { override var quality: SearchQuality? = null, override var posterHeaders: Map? = null, override val plot: String? = null, - override var score: Score? = null, + override val rating: Int? = 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, @@ -431,16 +329,7 @@ object DataStoreHelper { null, null, latestUpdatedTime, - apiName, - type, - posterUrl, - posterHeaders, - quality, - year?.toYear(), - this.id, - plot = this.plot, - score = this.score, - tags = this.tags + apiName, type, posterUrl, posterHeaders, quality, year?.toYear(), this.id, plot = this.plot, rating = this.rating, tags = this.tags ) } } @@ -459,7 +348,6 @@ 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 /** @@ -530,7 +418,7 @@ object DataStoreHelper { setKey( "$currentAccount/$RESULT_RESUME_WATCHING", parentId.toString(), - DownloadObjects.ResumeWatching( + VideoDownloadHelper.ResumeWatching( parentId, episodeId, episode, @@ -551,7 +439,7 @@ object DataStoreHelper { removeKey("$currentAccount/$RESULT_RESUME_WATCHING", parentId.toString()) } - fun getLastWatched(id: Int?): DownloadObjects.ResumeWatching? { + fun getLastWatched(id: Int?): VideoDownloadHelper.ResumeWatching? { if (id == null) return null return getKey( "$currentAccount/$RESULT_RESUME_WATCHING", @@ -559,7 +447,7 @@ object DataStoreHelper { ) } - private fun getLastWatchedOld(id: Int?): DownloadObjects.ResumeWatching? { + private fun getLastWatchedOld(id: Int?): VideoDownloadHelper.ResumeWatching? { if (id == null) return null return getKey( "$currentAccount/$RESULT_RESUME_WATCHING_OLD", @@ -648,62 +536,6 @@ object DataStoreHelper { setKey("$currentAccount/$VIDEO_POS_DUR", id.toString(), PosDur(pos, dur)) } - /** Sets the position, duration, and resume data of an episode/movie, - * - * if nextEpisode is not specified it will not be able to set the next episode as resumable if progress > NEXT_WATCH_EPISODE_PERCENTAGE - * */ - fun setViewPosAndResume(id: Int?, position: Long, duration: Long, currentEpisode: Any?, nextEpisode: Any?) { - setViewPos(id, position, duration) - if (id != null) { - when (val meta = currentEpisode) { - is ResultEpisode -> { - if (meta.videoWatchState == VideoWatchState.Watched) { - setVideoWatchState(id, VideoWatchState.None) - } - } - } - } - - val percentage = position * 100L / duration - val nextEp = percentage >= NEXT_WATCH_EPISODE_PERCENTAGE - val resumeMeta = if (nextEp) nextEpisode else currentEpisode - if (resumeMeta == null && nextEp) { - // remove last watched as it is the last episode and you have watched too much - when (val newMeta = currentEpisode) { - is ResultEpisode -> { - removeLastWatched(newMeta.parentId) - } - - is ExtractorUri -> { - removeLastWatched(newMeta.parentId) - } - } - } else { - // save resume - when (resumeMeta) { - is ResultEpisode -> { - setLastWatched( - resumeMeta.parentId, - resumeMeta.id, - resumeMeta.episode, - resumeMeta.season, - isFromDownload = false - ) - } - - is ExtractorUri -> { - setLastWatched( - resumeMeta.parentId, - resumeMeta.id, - resumeMeta.episode, - resumeMeta.season, - isFromDownload = true - ) - } - } - } - } - fun getViewPos(id: Int?): PosDur? { if (id == null) return null return getKey("$currentAccount/$VIDEO_POS_DUR", id.toString(), null) @@ -726,7 +558,7 @@ object DataStoreHelper { } fun getDub(id: Int): DubStatus? { - return DubStatus.entries + return DubStatus.values() .getOrNull(getKey("$currentAccount/$RESULT_DUB", id.toString(), -1) ?: -1) } @@ -778,9 +610,4 @@ 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 new file mode 100644 index 000000000..c92da214d --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/DownloadFileWorkManager.kt @@ -0,0 +1,99 @@ +package com.lagradost.cloudstream3.utils + +import android.app.Notification +import android.content.Context +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 { + setForegroundAsync(ForegroundInfo(id, notification)) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/Event.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/Event.kt index f66da4e5f..a0dfe734e 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/Event.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/Event.kt @@ -24,28 +24,3 @@ class Event { } } } - -class EmptyEvent { - private val observers = mutableSetOf() - - val size: Int get() = observers.size - - operator fun plusAssign(observer: Runnable) { - synchronized(observers) { - observers.add(observer) - } - } - - operator fun minusAssign(observer: Runnable) { - synchronized(observers) { - observers.remove(observer) - } - } - - operator fun invoke() { - synchronized(observers) { - for (observer in observers) - observer.run() - } - } -} diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/FillerEpisodeCheck.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/FillerEpisodeCheck.kt index 8456094d1..14d1b0556 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/FillerEpisodeCheck.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/FillerEpisodeCheck.kt @@ -1,166 +1,112 @@ package com.lagradost.cloudstream3.utils -import androidx.annotation.WorkerThread -import com.fasterxml.jackson.annotation.JsonProperty -import com.lagradost.cloudstream3.LoadResponse -import com.lagradost.cloudstream3.LoadResponse.Companion.getAniListId -import com.lagradost.cloudstream3.LoadResponse.Companion.getImdbId -import com.lagradost.cloudstream3.LoadResponse.Companion.getKitsuId -import com.lagradost.cloudstream3.LoadResponse.Companion.getMalId -import com.lagradost.cloudstream3.LoadResponse.Companion.getTMDbId -import com.lagradost.cloudstream3.TvType -import com.lagradost.cloudstream3.ui.result.getId +import com.lagradost.cloudstream3.app import com.lagradost.cloudstream3.utils.Coroutines.main +import org.jsoup.Jsoup import java.lang.Thread.sleep import java.util.* import kotlin.concurrent.thread -import com.lagradost.cloudstream3.utils.AppUtils.parseJson -import java.io.InputStream -import kotlin.let object FillerEpisodeCheck { + private const val MAIN_URL = "https://www.animefillerlist.com" + + var list: HashMap? = null + var cache: HashMap> = hashMapOf() + + private fun fixName(name: String): String { + return name.lowercase(Locale.ROOT)/*.replace(" ", "")*/.replace("-", " ") + .replace("[^a-zA-Z0-9 ]".toRegex(), "") + } + + private suspend fun getFillerList(): Boolean { + if (list != null) return true + try { + val result = app.get("$MAIN_URL/shows").text + val documented = Jsoup.parse(result) + val localHTMLList = documented.select("div#ShowList > div.Group > ul > li > a") + val localList = HashMap() + for (i in localHTMLList) { + val name = i.text() + + if (name.lowercase(Locale.ROOT).contains("manga only")) continue + + val href = i.attr("href") + if (name.isNullOrEmpty() || href.isNullOrEmpty()) { + continue + } + + val values = "(.*) \\((.*)\\)".toRegex().matchEntire(name)?.groups + if (values != null) { + for (index in 1 until values.size) { + val localName = values[index]?.value ?: continue + localList[fixName(localName)] = href + } + } else { + localList[fixName(name)] = href + } + } + if (localList.size > 0) { + list = localList + return true + } + } catch (e: Exception) { + e.printStackTrace() + } + return false + } + fun String?.toClassDir(): String { val q = this ?: "null" val z = (6..10).random().calc() return q + "cache" + z } - data class Show( - @JsonProperty("slug") - val slug: String, - @JsonProperty("title") - val title: String, - @JsonProperty("filler") - val filler: ArrayList, - @JsonProperty("mixedCanon") - val mixedCanon: ArrayList, - @JsonProperty("mangaCanon") - val mangaCanon: ArrayList, - @JsonProperty("animeCanon") - val animeCanon: ArrayList, - ) + suspend fun getFillerEpisodes(query: String): HashMap? { + try { + cache[query]?.let { + return it + } + if (!getFillerList()) return null + val localList = list ?: return null - data class MappingRoot( - @JsonProperty("type") - val type: String?, - @JsonProperty("anidb_id") - val anidbId: Long?, - @JsonProperty("anilist_id") - val anilistId: Long?, - @JsonProperty("animecountdown_id") - val animecountdownId: Long?, - @JsonProperty("animenewsnetwork_id") - val animenewsnetworkId: Long?, - @JsonProperty("anime-planet_id") - val animePlanetId: String?, - @JsonProperty("anisearch_id") - val anisearchId: Long?, - @JsonProperty("imdb_id") - val imdbId: String?, - @JsonProperty("kitsu_id") - val kitsuId: Long?, - @JsonProperty("livechart_id") - val livechartId: Long?, - @JsonProperty("mal_id") - val malId: Long?, - @JsonProperty("simkl_id") - val simklId: Long?, - @JsonProperty("themoviedb_id") - val themoviedbId: Long?, - @JsonProperty("tvdb_id") - val tvdbId: Long?, - @JsonProperty("season") - val season: Season?, - ) + // Strips these from the name + val blackList = listOf( + "TV Dubbed", + "(Dub)", + "Subbed", + "(TV)", + "(Uncensored)", + "(Censored)", + "(\\d+)" // year + ) + val blackListRegex = + Regex( + """ (${ + blackList.joinToString(separator = "|").replace("(", "\\(") + .replace(")", "\\)") + })""" + ) - data class Season( - @JsonProperty("tvdb") - val tvdb: Long?, - @JsonProperty("tmdb") - val tmdb: Long?, - ) - - data class CombinedMedia( - @JsonProperty("mapping") - val mapping: MappingRoot?, - @JsonProperty("show") - val show: Show - ) - - data class Database( - val mal: HashMap = hashMapOf(), - val anilist: HashMap = hashMapOf(), - val kitsu: HashMap = hashMapOf(), - val tmdb: HashMap = hashMapOf(), - val imdb: HashMap = hashMapOf(), - val name: HashMap = hashMapOf(), - ) - - private var database: Database? = null - - private val strip = Regex("[ :\\-.!]") - - /** Makes names more uniform to make partial matches more still give a result */ - fun stripName(name: String): String = - name.replace(strip, "").lowercase() - - - @Synchronized - @Throws - @WorkerThread - fun loadJson(): Database { - database?.let { - return it - } - - /** The entire "database" is stored as a json file we can parse */ - val stream: InputStream = com.lagradost.AnimeDB.getDatabaseStream()!! - val text = stream.reader().readText() - - val allMedia = parseJson>(text) - val pending = Database() - for (media in allMedia) { - val lowercase = stripName(media.show.title) - pending.name[lowercase] = media - val map = media.mapping ?: continue - - map.imdbId?.let { id -> pending.imdb[id] = media } - map.malId?.let { id -> pending.mal[id] = media } - map.anilistId?.let { id -> pending.anilist[id] = media } - map.kitsuId?.let { id -> pending.kitsu[id] = media } - map.season?.tmdb?.let { id -> pending.tmdb[id] = media } - } - database = pending - return pending - } - - val loadCache: HashMap?> = hashMapOf() - - @Synchronized - @Throws - @WorkerThread - fun getFillerEpisodes(data: LoadResponse): HashSet? { - /** Only for anime */ - if (data.type != TvType.Anime) { + val realQuery = + fixName(query.replace(blackListRegex, "")).replace("shippuuden", "shippuden") + if (!localList.containsKey(realQuery)) return null + val href = localList[realQuery]?.replace(MAIN_URL, "") ?: return null // JUST IN CASE + val result = app.get("$MAIN_URL$href").text + val documented = Jsoup.parse(result) ?: return null + val hashMap = HashMap() + documented.select("table.EpisodeList > tbody > tr").forEach { + val type = it.selectFirst("td.Type > span")?.text() == "Filler" + val episodeNumber = it.selectFirst("td.Number")?.text()?.toIntOrNull() + if (episodeNumber != null) { + hashMap[episodeNumber] = type + } + } + cache[query] = hashMap + return hashMap + } catch (e: Exception) { + e.printStackTrace() return null } - /** Try to hit the cache for this entry, to avoid recreating the hashset */ - loadCache[data.getId()]?.let { cachedResponse -> - return cachedResponse - } - val db = loadJson() - - val media = - db.mal[data.getMalId()?.toLongOrNull()] - ?: db.anilist[data.getAniListId()?.toLongOrNull()] - ?: db.kitsu[data.getKitsuId()?.toLongOrNull()] - ?: db.imdb[data.getImdbId()] - ?: db.tmdb[data.getTMDbId()?.toLongOrNull()] - ?: db.name[stripName(data.name)] - - return media?.show?.filler?.toHashSet().also { response -> - loadCache[data.getId()] = response - } } private fun Int.calc(): Int { diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/GitInfo.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/GitInfo.kt deleted file mode 100644 index 58ff44bb2..000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/GitInfo.kt +++ /dev/null @@ -1,20 +0,0 @@ -package com.lagradost.cloudstream3.utils - -import android.content.Context - -/** - * Simple helper to get the short commit hash from assets. - * The hash is generated at build and stored as an asset - * that can be accessed at runtime for Gradle - * configuration cache support. - */ -object GitInfo { - fun Context.currentCommitHash(): String = try { - assets.open("git-hash.txt") - .bufferedReader() - .readText() - .trim() - } catch (_: Exception) { - "" - } -} diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/GlideApp.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/GlideApp.kt new file mode 100644 index 000000000..38d3fe9ef --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/GlideApp.kt @@ -0,0 +1,56 @@ +package com.lagradost.cloudstream3.utils + +import android.annotation.SuppressLint +import android.content.Context +import com.bumptech.glide.Glide +import com.bumptech.glide.GlideBuilder +import com.bumptech.glide.Registry +import com.bumptech.glide.annotation.GlideModule +import com.bumptech.glide.integration.okhttp3.OkHttpUrlLoader +import com.bumptech.glide.load.engine.DiskCacheStrategy +import com.bumptech.glide.load.engine.cache.InternalCacheDiskCacheFactory +import com.bumptech.glide.load.model.GlideUrl +import com.bumptech.glide.module.AppGlideModule +import com.bumptech.glide.request.RequestOptions +import com.bumptech.glide.signature.ObjectKey +import com.lagradost.cloudstream3.USER_AGENT +import com.lagradost.cloudstream3.network.DdosGuardKiller +import com.lagradost.cloudstream3.network.initClient +import com.lagradost.nicehttp.Requests +import java.io.InputStream + +@GlideModule +class GlideModule : AppGlideModule() { + @SuppressLint("CheckResult") + override fun applyOptions(context: Context, builder: GlideBuilder) { + super.applyOptions(context, builder) + builder.apply { + RequestOptions() + .diskCacheStrategy(DiskCacheStrategy.ALL) + .signature(ObjectKey(System.currentTimeMillis().toShort())) + }.setDiskCache { + // Possible to make this a setting in the future. + val memoryCacheSizeBytes: Long = 1024 * 1024 * 100 // 100mb + InternalCacheDiskCacheFactory(context, memoryCacheSizeBytes).build() + } + } + + // Needed for DOH + // https://stackoverflow.com/a/61634041 + override fun registerComponents(context: Context, glide: Glide, registry: Registry) { + val client = + Requests().apply { + defaultHeaders = mapOf("user-agent" to USER_AGENT) + }.initClient(context) + .newBuilder() + .addInterceptor(DdosGuardKiller(false)) + .build() + + registry.replace( + GlideUrl::class.java, + InputStream::class.java, + OkHttpUrlLoader.Factory(client) + ) + super.registerComponents(context, glide, registry) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/ImageModuleCoil.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/ImageModuleCoil.kt deleted file mode 100644 index 96193fe45..000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/ImageModuleCoil.kt +++ /dev/null @@ -1,187 +0,0 @@ -package com.lagradost.cloudstream3.utils - -import android.graphics.Bitmap -import android.graphics.drawable.Drawable -import android.net.Uri -import android.os.Build -import android.os.Build.VERSION.SDK_INT -import android.util.Log -import android.widget.ImageView -import androidx.annotation.DrawableRes -import coil3.EventListener -import coil3.ImageLoader -import coil3.PlatformContext -import coil3.SingletonImageLoader -import coil3.decode.BitmapFactoryDecoder -import coil3.disk.DiskCache -import coil3.dispose -import coil3.load -import coil3.memory.MemoryCache -import coil3.network.NetworkHeaders -import coil3.network.httpHeaders -import coil3.network.okhttp.OkHttpNetworkFetcherFactory -import coil3.request.CachePolicy -import coil3.request.ErrorResult -import coil3.request.ImageRequest -import coil3.request.allowHardware -import coil3.request.bitmapConfig -import coil3.request.crossfade -import coil3.util.DebugLogger -import com.lagradost.cloudstream3.BuildConfig -import com.lagradost.cloudstream3.USER_AGENT -import com.lagradost.cloudstream3.network.buildDefaultClient -import okio.Path.Companion.toOkioPath -import java.io.File -import java.nio.ByteBuffer - -object ImageLoader { - private const val TAG = "CoilImgLoader" - internal fun buildImageLoader(context: PlatformContext): ImageLoader { - val isBrokenHardware = hasPotentialBrokenHardware() - return ImageLoader.Builder(context) - .crossfade(200) - .allowHardware(SDK_INT >= 28 && !isBrokenHardware) - .diskCachePolicy(CachePolicy.ENABLED) - .networkCachePolicy(CachePolicy.ENABLED) - .memoryCache { - MemoryCache.Builder().maxSizePercent(context, 0.1)//10 % of heap for mem-cache - .strongReferencesEnabled(false) - .build() - } - .diskCache { - DiskCache.Builder() - .directory(context.cacheDir.resolve("cs3_image_cache").toOkioPath()) - .maxSizeBytes(512L * 1024 * 1024) // 512 MB - .maxSizePercent(0.04) // max 4% of storage for disk caching - .build() - } - /** Pass interceptors with care, unnecessary passing tokens to servers - or image hosting services causes unauthorized exceptions **/ - .components { - add(OkHttpNetworkFetcherFactory(callFactory = { buildDefaultClient(context) })) - if (isBrokenHardware) { - add(BitmapFactoryDecoder.Factory()) - } // sw decoder - } - .apply { - if (isBrokenHardware) { // coil will auto choose optimal config on modern device - bitmapConfig(Bitmap.Config.ARGB_8888) - } - setupCoilLogger() - } - .build() - } - - /** DebugLogger on debug builds which won't slow down release builds & use EventListener for - Errors on release builds. **/ - internal fun ImageLoader.Builder.setupCoilLogger() { - if (BuildConfig.DEBUG) { - logger(DebugLogger()) - } else { - eventListener(object : EventListener() { - override fun onError(request: ImageRequest, result: ErrorResult) { - super.onError(request, result) - Log.e(TAG, "Image load error: ${result.throwable.message ?: result.throwable}") - Log.e(TAG, " URL: ${request.data}") - Log.e(TAG, " allowHardware: ${request.allowHardware}") - Log.e(TAG, " hardware: ${Build.HARDWARE}, board: ${Build.BOARD}") - } - }) - } - } - - /** coil's built in loader attached w/ global synchronized instance **/ - private fun ImageView.loadImageInternal( - imageData: Any?, - headers: Map? = null, - builder: ImageRequest.Builder.() -> Unit = {} // for placeholder, error & transformations - ) { - // clear image to avoid loading & flickering issue at fast scrolling (~recycler view/lazy column) - this.dispose() - if (imageData == null) return - // setImageResource is better than coil3 on resources due to attr - if (imageData is Int) { - this.setImageResource(imageData); return - } - // headers can be overridden by extensions. - this.load(imageData, SingletonImageLoader.get(context)) { - this.httpHeaders(NetworkHeaders.Builder().also { headerBuilder -> - headerBuilder["User-Agent"] = USER_AGENT - headers?.forEach { (key, value) -> - headerBuilder[key] = value - } - }.build()) - builder() // if passed - } - } - - private fun hasPotentialBrokenHardware(): Boolean { - val hardware = Build.HARDWARE?.lowercase() ?: "" - val board = Build.BOARD?.lowercase() ?: "" - val model = Build.MODEL?.lowercase() ?: "" - val manufacturer = Build.MANUFACTURER?.lowercase() ?: "" - val allwinnerPatterns = listOf("sun50iw9", "h713", "allwinner", "sunxi") - val problematicModels = - listOf("hy320", "hy300", "a10plus", "magcubic", "sinoy", "android tv box") - return allwinnerPatterns.any { it in hardware || it in board || it in manufacturer } || - problematicModels.any { it in model } - } - - /** TYPE_SAFE_LOADERS **/ - fun ImageView.loadImage( - imageData: UiImage?, - builder: ImageRequest.Builder.() -> Unit = {} - ) = when (imageData) { - is UiImage.Image -> loadImageInternal( - imageData = imageData.url, - headers = imageData.headers, - builder = builder - ) - - is UiImage.Bitmap -> loadImageInternal(imageData = imageData.bitmap, builder = builder) - is UiImage.Drawable -> loadImageInternal(imageData = imageData.resId, builder = builder) - null -> loadImageInternal(null, builder = builder) - } - - fun ImageView.loadImage( - imageData: String?, - headers: Map? = null, - builder: ImageRequest.Builder.() -> Unit = {} - ) = loadImageInternal(imageData = imageData, headers = headers, builder = builder) - - fun ImageView.loadImage( - imageData: Uri?, - headers: Map? = null, - builder: ImageRequest.Builder.() -> Unit = {} - ) = loadImageInternal(imageData = imageData, headers = headers, builder = builder) - - fun ImageView.loadImage( - imageData: File?, - builder: ImageRequest.Builder.() -> Unit = {} - ) = loadImageInternal(imageData = imageData, builder = builder) - - fun ImageView.loadImage( - @DrawableRes imageData: Int?, - builder: ImageRequest.Builder.() -> Unit = {} - ) = loadImageInternal(imageData = imageData, builder = builder) - - fun ImageView.loadImage( - imageData: Drawable?, - builder: ImageRequest.Builder.() -> Unit = {} - ) = loadImageInternal(imageData = imageData, builder = builder) - - fun ImageView.loadImage( - imageData: Bitmap?, - builder: ImageRequest.Builder.() -> Unit = {} - ) = loadImageInternal(imageData = imageData, builder = builder) - - fun ImageView.loadImage( - imageData: ByteArray?, - builder: ImageRequest.Builder.() -> Unit = {} - ) = loadImageInternal(imageData = imageData, builder = builder) - - fun ImageView.loadImage( - imageData: ByteBuffer?, - builder: ImageRequest.Builder.() -> Unit = {} - ) = loadImageInternal(imageData = imageData, builder = builder) -} diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/ImageUtil.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/ImageUtil.kt deleted file mode 100644 index 6ed4d4afa..000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/ImageUtil.kt +++ /dev/null @@ -1,40 +0,0 @@ -package com.lagradost.cloudstream3.utils - -import android.content.Context -import android.graphics.Bitmap -import android.graphics.Canvas -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 - -/// Type safe any image, because THIS IS NOT PYTHON -sealed class UiImage { - data class Image( - val url: String, - val headers: Map? = null - ) : UiImage() - - data class Drawable(@DrawableRes val resId: Int) : UiImage() - data class Bitmap(val bitmap: android.graphics.Bitmap) : UiImage() -} - -fun getImageFromDrawable(context: Context, drawableRes: Int): Image? { - return ContextCompat.getDrawable(context, drawableRes)?.asImage() -} - -fun drawableToBitmap(drawable: Drawable): Bitmap? { - return when (drawable) { - is BitmapDrawable -> drawable.bitmap - else -> { - val bitmap = createBitmap(drawable.intrinsicWidth, drawable.intrinsicHeight) - val canvas = Canvas(bitmap) - drawable.setBounds(0, 0, canvas.width, canvas.height) - drawable.draw(canvas) - bitmap - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/InAppUpdater.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/InAppUpdater.kt index b01f6e07e..59f534ff2 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/InAppUpdater.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/InAppUpdater.kt @@ -3,363 +3,391 @@ package com.lagradost.cloudstream3.utils import android.app.Activity import android.content.Context import android.content.Intent -import android.content.pm.PackageManager.NameNotFoundException import android.net.Uri import android.util.Log import android.widget.Toast import androidx.appcompat.app.AlertDialog import androidx.core.content.ContextCompat import androidx.core.content.FileProvider -import androidx.core.content.edit import androidx.preference.PreferenceManager import com.fasterxml.jackson.annotation.JsonProperty -import com.lagradost.cloudstream3.BuildConfig +import com.lagradost.cloudstream3.* import com.lagradost.cloudstream3.CommonActivity.showToast -import com.lagradost.cloudstream3.MainActivity.Companion.deleteFileOnExit -import com.lagradost.cloudstream3.R -import com.lagradost.cloudstream3.app import com.lagradost.cloudstream3.mvvm.logError -import com.lagradost.cloudstream3.mvvm.safe -import com.lagradost.cloudstream3.services.PackageInstallerService -import com.lagradost.cloudstream3.utils.AppContextUtils.setDefaultFocus import com.lagradost.cloudstream3.utils.AppUtils.parseJson import com.lagradost.cloudstream3.utils.Coroutines.ioSafe -import com.lagradost.cloudstream3.utils.GitInfo.currentCommitHash import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import okio.BufferedSink import okio.buffer import okio.sink -import java.io.BufferedReader import java.io.File +import android.text.TextUtils +import com.lagradost.cloudstream3.MainActivity.Companion.deleteFileOnExit +import com.lagradost.cloudstream3.utils.AppContextUtils.setDefaultFocus +import java.io.BufferedReader import java.io.IOException import java.io.InputStreamReader -object InAppUpdater { - private const val GITHUB_USER_NAME = "recloudstream" - private const val GITHUB_REPO = "cloudstream" - private const val PRERELEASE_PACKAGE_NAME = "com.lagradost.cloudstream3.prerelease" - private const val LOG_TAG = "InAppUpdater" +class InAppUpdater { + companion object { + private const val GITHUB_USER_NAME = "recloudstream" + private const val GITHUB_REPO = "cloudstream" - private data class GithubAsset( - @JsonProperty("name") val name: String, - @JsonProperty("size") val size: Int, // Size in bytes - @JsonProperty("browser_download_url") val browserDownloadUrl: String, - @JsonProperty("content_type") val contentType: String, // application/vnd.android.package-archive - ) + private const val LOG_TAG = "InAppUpdater" - private data class GithubRelease( - @JsonProperty("tag_name") val tagName: String, // Version code - @JsonProperty("body") val body: String, // Description - @JsonProperty("assets") val assets: List, - @JsonProperty("target_commitish") val targetCommitish: String, // Branch - @JsonProperty("prerelease") val prerelease: Boolean, - @JsonProperty("node_id") val nodeId: String, - ) - - private data class GithubObject( - @JsonProperty("sha") val sha: String, // SHA-256 hash - @JsonProperty("type") val type: String, - @JsonProperty("url") val url: String, - ) - - private data class GithubTag( - @JsonProperty("object") val githubObject: GithubObject, - ) - - private data class Update( - @JsonProperty("shouldUpdate") val shouldUpdate: Boolean, - @JsonProperty("updateURL") val updateURL: String?, - @JsonProperty("updateVersion") val updateVersion: String?, - @JsonProperty("changelog") val changelog: String?, - @JsonProperty("updateNodeId") val updateNodeId: String?, - ) - - private suspend fun Activity.getAppUpdate(installPrerelease: Boolean): Update { - return try { - when { - // No updates on debug version - BuildConfig.DEBUG -> Update(false, null, null, null, null) - BuildConfig.FLAVOR == "prerelease" || installPrerelease -> getPreReleaseUpdate() - else -> getReleaseUpdate() - } - } catch (e: Exception) { - Log.e(LOG_TAG, Log.getStackTraceString(e)) - Update(false, null, null, null, null) - } - } - - private suspend fun Activity.getReleaseUpdate(): Update { - val url = "https://api.github.com/repos/$GITHUB_USER_NAME/$GITHUB_REPO/releases" - val headers = mapOf("Accept" to "application/vnd.github.v3+json") - val response = parseJson>( - app.get(url, headers = headers).text - ).toList() - - val versionRegex = Regex("""(.*?((\d+)\.(\d+)\.(\d+))\.apk)""") - val versionRegexLocal = Regex("""(.*?((\d+)\.(\d+)\.(\d+)).*)""") - val foundList = response.filter { rel -> - !rel.prerelease - }.sortedWith(compareBy { release -> - release.assets.firstOrNull { it.contentType == "application/vnd.android.package-archive" }?.name?.let { it1 -> - versionRegex.find(it1)?.groupValues?.let { - it[3].toInt() * 100_000_000 + it[4].toInt() * 10_000 + it[5].toInt() - } - } - }).toList() - - val found = foundList.lastOrNull() - val foundAsset = found?.assets?.getOrNull(0) - val foundVersion = foundAsset?.name?.let { versionRegex.find(it) } - - if (foundVersion == null) { - return Update(false, null, null, null, null) - } - - val currentVersion = packageName?.let { - packageManager.getPackageInfo(it, 0) - } - - val shouldUpdate = if (foundAsset.browserDownloadUrl.isBlank()) { - false - } else { - currentVersion?.versionName?.let { versionName -> - versionRegexLocal.find(versionName)?.groupValues?.let { - it[3].toInt() * 100_000_000 + it[4].toInt() * 10_000 + it[5].toInt() - } - }?.compareTo( - foundVersion.groupValues.let { - it[3].toInt() * 100_000_000 + it[4].toInt() * 10_000 + it[5].toInt() - })!! < 0 - } - - return Update( - shouldUpdate, - foundAsset.browserDownloadUrl, - foundVersion.groupValues[2], - found.body, - found.nodeId - ) - } - - private suspend fun Activity.getPreReleaseUpdate(): Update { - val tagUrl = - "https://api.github.com/repos/$GITHUB_USER_NAME/$GITHUB_REPO/git/ref/tags/pre-release" - val releaseUrl = "https://api.github.com/repos/$GITHUB_USER_NAME/$GITHUB_REPO/releases" - val headers = mapOf("Accept" to "application/vnd.github.v3+json") - val response = parseJson>( - app.get(releaseUrl, headers = headers).text - ).toList() - - val found = response.lastOrNull { rel -> - rel.prerelease || rel.tagName == "pre-release" - } - - val foundAsset = found?.assets?.filter { it -> - it.contentType == "application/vnd.android.package-archive" - }?.getOrNull(0) - - if (foundAsset == null) { - return Update(false, null, null, null, null) - } - - val tagResponse = parseJson(app.get(tagUrl, headers = headers).text) - val updateCommitHash = tagResponse.githubObject.sha.trim().take(7) - Log.d(LOG_TAG, "Fetched GitHub tag: $updateCommitHash") - - return Update( - currentCommitHash() != updateCommitHash, - foundAsset.browserDownloadUrl, - updateCommitHash, - found.body, - found.nodeId - ) - } - - private val updateLock = Mutex() - - private suspend fun Activity.downloadUpdate(url: String): Boolean { - try { - Log.d(LOG_TAG, "Downloading update: $url") - val appUpdateName = "CloudStream" - val appUpdateSuffix = "apk" - - // Delete all old updates - this.cacheDir.listFiles()?.filter { - it.name.startsWith(appUpdateName) && it.extension == appUpdateSuffix - }?.forEach { deleteFileOnExit(it) } - - val downloadedFile = File.createTempFile(appUpdateName, ".$appUpdateSuffix") - val sink: BufferedSink = downloadedFile.sink().buffer() - - updateLock.withLock { - sink.writeAll(app.get(url).body.source()) - sink.close() - openApk(this, Uri.fromFile(downloadedFile)) - } - - return true - } catch (e: Exception) { - logError(e) - return false - } - } - - private fun openApk(context: Context, uri: Uri) = safe { - val path = uri.path ?: return@safe - val contentUri = FileProvider.getUriForFile( - context, BuildConfig.APPLICATION_ID + ".provider", File(path) - ) - val installIntent = Intent(Intent.ACTION_VIEW).apply { - addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) - addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) - putExtra(Intent.EXTRA_NOT_UNKNOWN_SOURCE, true) - data = contentUri - } - context.startActivity(installIntent) - } - - fun Activity.installPreReleaseIfNeeded() = ioSafe { - val isInstalled = try { - packageManager.getPackageInfo(PRERELEASE_PACKAGE_NAME, 0) - true - } catch (_: NameNotFoundException) { - false - } - - if (isInstalled) { - showToast(R.string.prerelease_already_installed) - } else if (!runAutoUpdate(checkAutoUpdate = false, installPrerelease = true)) { - showToast(R.string.prerelease_install_failed) - } - } - - - /** - * @param checkAutoUpdate if the update check was launched automatically - * @param installPrerelease if we want to install the pre-release version - */ - suspend fun Activity.runAutoUpdate( - checkAutoUpdate: Boolean = true, installPrerelease: Boolean = false - ): Boolean { - val settingsManager = PreferenceManager.getDefaultSharedPreferences(this) - val autoUpdateEnabled = - settingsManager.getBoolean(getString(R.string.auto_update_key), true) - if (checkAutoUpdate && !autoUpdateEnabled) { - return false - } - - val update = getAppUpdate(installPrerelease) - if (!update.shouldUpdate || update.updateURL == null) { - return false - } - - // Check if update should be skipped - val updateNodeId = settingsManager.getString( - getString(R.string.skip_update_key), "" + // === IN APP UPDATER === + data class GithubAsset( + @JsonProperty("name") val name: String, + @JsonProperty("size") val size: Int, // Size bytes + @JsonProperty("browser_download_url") val browserDownloadUrl: String, // download link + @JsonProperty("content_type") val contentType: String, // application/vnd.android.package-archive ) - // Skips the update if its an automatic update and the update is skipped - // This allows updating manually - if (update.updateNodeId.equals(updateNodeId) && checkAutoUpdate) { - return false - } + data class GithubRelease( + @JsonProperty("tag_name") val tagName: String, // Version code + @JsonProperty("body") val body: String, // Desc + @JsonProperty("assets") val assets: List, + @JsonProperty("target_commitish") val targetCommitish: String, // branch + @JsonProperty("prerelease") val prerelease: Boolean, + @JsonProperty("node_id") val nodeId: String //Node Id + ) - runOnUiThread { - safe { - val currentVersion = packageName?.let { - packageManager.getPackageInfo(it, 0) - } + data class GithubObject( + @JsonProperty("sha") val sha: String, // sha 256 hash + @JsonProperty("type") val type: String, // object type + @JsonProperty("url") val url: String, + ) - val builder = AlertDialog.Builder(this, R.style.AlertDialogCustom) - builder.setTitle( - getString(R.string.new_update_format).format( - currentVersion?.versionName, update.updateVersion + data class GithubTag( + @JsonProperty("object") val githubObject: GithubObject, + ) + + data class Update( + @JsonProperty("shouldUpdate") val shouldUpdate: Boolean, + @JsonProperty("updateURL") val updateURL: String?, + @JsonProperty("updateVersion") val updateVersion: String?, + @JsonProperty("changelog") val changelog: String?, + @JsonProperty("updateNodeId") val updateNodeId: String? + ) + + private suspend fun Activity.getAppUpdate(): Update { + return try { + val settingsManager = PreferenceManager.getDefaultSharedPreferences(this) + if (settingsManager.getBoolean( + getString(R.string.prerelease_update_key), + resources.getBoolean(R.bool.is_prerelease) ) + ) { + getPreReleaseUpdate() + } else { + getReleaseUpdate() + } + } catch (e: Exception) { + Log.e(LOG_TAG, Log.getStackTraceString(e)) + Update(false, null, null, null, null) + } + } + + private suspend fun Activity.getReleaseUpdate(): Update { + val url = "https://api.github.com/repos/$GITHUB_USER_NAME/$GITHUB_REPO/releases" + val headers = mapOf("Accept" to "application/vnd.github.v3+json") + val response = + parseJson>( + app.get( + url, + headers = headers + ).text ) - val logRegex = Regex("\\[(.*?)]\\((.*?)\\)") - val sanitizedChangelog = update.changelog?.replace(logRegex) { matchResult -> - matchResult.groupValues[1] - } // Sanitized because it looks cluttered - - builder.setMessage(sanitizedChangelog) - builder.apply { - setPositiveButton(R.string.update) { _, _ -> - // Forcefully start any delayed installations - if (ApkInstaller.delayedInstaller?.startInstallation() == true) return@setPositiveButton - - showToast(R.string.download_started, Toast.LENGTH_LONG) - - // Check if the setting hasn't been changed - if (settingsManager.getInt( - getString(R.string.apk_installer_key), -1 - ) == -1 - ) { - // Set to legacy installer if using MIUI - if (isMiUi()) { - settingsManager.edit { - putInt(getString(R.string.apk_installer_key), 1) - } + val versionRegex = Regex("""(.*?((\d+)\.(\d+)\.(\d+))\.apk)""") + val versionRegexLocal = Regex("""(.*?((\d+)\.(\d+)\.(\d+)).*)""") + /* + val releases = response.map { it.assets }.flatten() + .filter { it.content_type == "application/vnd.android.package-archive" } + val found = + releases.sortedWith(compareBy { + versionRegex.find(it.name)?.groupValues?.get(2) + }).toList().lastOrNull()*/ + val foundList = + response.filter { rel -> + !rel.prerelease + }.sortedWith(compareBy { release -> + release.assets.firstOrNull { it.contentType == "application/vnd.android.package-archive" }?.name?.let { it1 -> + versionRegex.find( + it1 + )?.groupValues?.let { + it[3].toInt() * 100_000_000 + it[4].toInt() * 10_000 + it[5].toInt() } } + }).toList() + val found = foundList.lastOrNull() + val foundAsset = found?.assets?.getOrNull(0) + val currentVersion = packageName?.let { + packageManager.getPackageInfo( + it, + 0 + ) + } - val currentInstaller = settingsManager.getInt( - getString(R.string.apk_installer_key), 1 - ) + foundAsset?.name?.let { assetName -> + val foundVersion = versionRegex.find(assetName) + val shouldUpdate = + if (foundAsset.browserDownloadUrl != "" && foundVersion != null) currentVersion?.versionName?.let { versionName -> + versionRegexLocal.find(versionName)?.groupValues?.let { + it[3].toInt() * 100_000_000 + it[4].toInt() * 10_000 + it[5].toInt() + } + }?.compareTo( + foundVersion.groupValues.let { + it[3].toInt() * 100_000_000 + it[4].toInt() * 10_000 + it[5].toInt() + } + )!! < 0 else false + return if (foundVersion != null) { + Update( + shouldUpdate, + foundAsset.browserDownloadUrl, + foundVersion.groupValues[2], + found.body, + found.nodeId + ) + } else { + Update(false, null, null, null, null) + } + } + return Update(false, null, null, null, null) + } - when (currentInstaller) { - // New method - 0 -> { - val intent = PackageInstallerService.Companion.getIntent( - this@runAutoUpdate, update.updateURL - ) - ContextCompat.startForegroundService( - this@runAutoUpdate, intent + private suspend fun Activity.getPreReleaseUpdate(): Update { + val tagUrl = + "https://api.github.com/repos/$GITHUB_USER_NAME/$GITHUB_REPO/git/ref/tags/pre-release" + val releaseUrl = "https://api.github.com/repos/$GITHUB_USER_NAME/$GITHUB_REPO/releases" + val headers = mapOf("Accept" to "application/vnd.github.v3+json") + val response = + parseJson>(app.get(releaseUrl, headers = headers).text) + + val found = + response.lastOrNull { rel -> + rel.prerelease || rel.tagName == "pre-release" + } + val foundAsset = found?.assets?.filter { it -> + it.contentType == "application/vnd.android.package-archive" + }?.getOrNull(0) + + val tagResponse = + parseJson(app.get(tagUrl, headers = headers).text) + + Log.d(LOG_TAG, "Fetched GitHub tag: ${tagResponse.githubObject.sha.take(7)}") + + val shouldUpdate = + (getString(R.string.commit_hash) + .trim { c -> c.isWhitespace() } + .take(7) + != + tagResponse.githubObject.sha + .trim { c -> c.isWhitespace() } + .take(7)) + + return if (foundAsset != null) { + Update( + shouldUpdate, + foundAsset.browserDownloadUrl, + tagResponse.githubObject.sha.take(10), + found.body, + found.nodeId + ) + } else { + Update(false, null, null, null, null) + } + } + + + private val updateLock = Mutex() + + private suspend fun Activity.downloadUpdate(url: String): Boolean { + try { + Log.d(LOG_TAG, "Downloading update: $url") + val appUpdateName = "CloudStream" + val appUpdateSuffix = "apk" + + // Delete all old updates + this.cacheDir.listFiles()?.filter { + it.name.startsWith(appUpdateName) && it.extension == appUpdateSuffix + }?.forEach { + deleteFileOnExit(it) + } + + val downloadedFile = File.createTempFile(appUpdateName, ".$appUpdateSuffix") + val sink: BufferedSink = downloadedFile.sink().buffer() + + updateLock.withLock { + sink.writeAll(app.get(url).body.source()) + sink.close() + openApk(this, Uri.fromFile(downloadedFile)) + } + return true + } catch (e: Exception) { + return false + } + } + + private fun openApk(context: Context, uri: Uri) { + try { + uri.path?.let { + val contentUri = FileProvider.getUriForFile( + context, + BuildConfig.APPLICATION_ID + ".provider", + File(it) + ) + val installIntent = Intent(Intent.ACTION_VIEW).apply { + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) + putExtra(Intent.EXTRA_NOT_UNKNOWN_SOURCE, true) + data = contentUri + } + context.startActivity(installIntent) + } + } catch (e: Exception) { + logError(e) + } + } + + /** + * @param checkAutoUpdate if the update check was launched automatically + **/ + suspend fun Activity.runAutoUpdate(checkAutoUpdate: Boolean = true): Boolean { + val settingsManager = PreferenceManager.getDefaultSharedPreferences(this) + + if (!checkAutoUpdate || settingsManager.getBoolean( + getString(R.string.auto_update_key), + true + ) + ) { + val update = getAppUpdate() + if ( + update.shouldUpdate && + update.updateURL != null) { + + // Check if update should be skipped + val updateNodeId = + settingsManager.getString(getString(R.string.skip_update_key), "") + + // Skips the update if its an automatic update and the update is skipped + // This allows updating manually + if (update.updateNodeId.equals(updateNodeId) && checkAutoUpdate) { + return false + } + + runOnUiThread { + try { + val currentVersion = packageName?.let { + packageManager.getPackageInfo( + it, + 0 ) } - // Legacy - 1 -> { - ioSafe { - if (!downloadUpdate(update.updateURL)) { - runOnUiThread { - showToast( - R.string.download_failed, Toast.LENGTH_LONG + + val builder: AlertDialog.Builder = AlertDialog.Builder(this) + builder.setTitle( + getString(R.string.new_update_format).format( + currentVersion?.versionName, + update.updateVersion + ) + ) + + val logRegex = Regex("\\[(.*?)\\]\\((.*?)\\)") + val sanitizedChangelog = update.changelog?.replace(logRegex) { matchResult -> + matchResult.groupValues[1] + } // Sanitized because it looks cluttered + + builder.setMessage(sanitizedChangelog) + + val context = this + builder.apply { + setPositiveButton(R.string.update) { _, _ -> + // Forcefully start any delayed installations + if (ApkInstaller.delayedInstaller?.startInstallation() == true) return@setPositiveButton + + showToast(R.string.download_started, Toast.LENGTH_LONG) + + // Check if the setting hasn't been changed + if (settingsManager.getInt( + getString(R.string.apk_installer_key), + -1 + ) == -1 + ) { + if (isMiUi()) // Set to legacy if using miui + settingsManager.edit() + .putInt(getString(R.string.apk_installer_key), 1) + .apply() + } + + val currentInstaller = + settingsManager.getInt( + getString(R.string.apk_installer_key), + 0 + ) + + when (currentInstaller) { + // New method + 0 -> { + val intent = PackageInstallerService.getIntent( + context, + update.updateURL ) + ContextCompat.startForegroundService(context, intent) + } + // Legacy + 1 -> { + ioSafe { + if (!downloadUpdate(update.updateURL)) + runOnUiThread { + showToast( + R.string.download_failed, + Toast.LENGTH_LONG + ) + } + } } } } + + setNegativeButton(R.string.cancel) { _, _ -> } + + if (checkAutoUpdate) { + setNeutralButton(R.string.skip_update) { _, _ -> + settingsManager.edit().putString( + getString(R.string.skip_update_key), + update.updateNodeId ?: "" + ).apply() + } + } } + builder.show().setDefaultFocus() + } catch (e: Exception) { + logError(e) } } - - setNegativeButton(R.string.cancel) { _, _ -> } - - if (checkAutoUpdate) { - setNeutralButton(R.string.skip_update) { _, _ -> - settingsManager.edit { - putString( - getString(R.string.skip_update_key), update.updateNodeId ?: "" - ) - } - } - } + return true } - builder.show().setDefaultFocus() + return false + } + return false + } + + private fun isMiUi(): Boolean { + return !TextUtils.isEmpty(getSystemProperty("ro.miui.ui.version.name")) + } + + private fun getSystemProperty(propName: String): String? { + return try { + val p = Runtime.getRuntime().exec("getprop $propName") + BufferedReader(InputStreamReader(p.inputStream), 1024).use { + it.readLine() + } + } catch (ex: IOException) { + null } } - return true - } - - private fun isMiUi(): Boolean = !getSystemProperty("ro.miui.ui.version.name").isNullOrEmpty() - - private fun getSystemProperty(propName: String): String? = try { - val p = Runtime.getRuntime().exec("getprop $propName") - BufferedReader(InputStreamReader(p.inputStream), 1024).use { - it.readLine() - } - } catch (_: IOException) { - null } } diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/IntentHelpers.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/IntentHelpers.kt deleted file mode 100644 index d37d8aad4..000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/IntentHelpers.kt +++ /dev/null @@ -1,24 +0,0 @@ -package com.lagradost.cloudstream3.utils - -import android.annotation.SuppressLint -import android.content.BroadcastReceiver -import android.content.Context -import android.content.Intent -import android.content.IntentFilter -import android.os.Build - -inline fun Intent.getSafeParcelableExtra(key: String): T? = - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) - @Suppress("DEPRECATION") - getParcelableExtra(key) else getParcelableExtra(key, T::class.java) - -@SuppressLint("UnspecifiedRegisterReceiverFlag") -fun Context.registerBroadcastReceiver(receiver: BroadcastReceiver, actionFilter: IntentFilter) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - // Register receiver with the context with flag to indicate internal usage - registerReceiver(receiver, actionFilter, Context.RECEIVER_NOT_EXPORTED) - } else { - // For older versions, no special export flag is needed - registerReceiver(receiver, actionFilter) - } -} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/PackageInstaller.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/PackageInstaller.kt index 67851f629..4b3f02f11 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/PackageInstaller.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/PackageInstaller.kt @@ -1,6 +1,5 @@ package com.lagradost.cloudstream3.utils -import android.annotation.SuppressLint import android.app.PendingIntent import android.content.BroadcastReceiver import android.content.Context @@ -9,17 +8,15 @@ import android.content.IntentFilter import android.content.IntentSender import android.content.pm.PackageInstaller import android.os.Build -import android.util.Log import android.widget.Toast -import com.lagradost.cloudstream3.CloudStreamApp.Companion.context import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.mvvm.logError -import com.lagradost.cloudstream3.services.PackageInstallerService import com.lagradost.cloudstream3.utils.Coroutines.main import java.io.InputStream const val INSTALL_ACTION = "ApkInstaller.INSTALL_ACTION" + class ApkInstaller(private val service: PackageInstallerService) { companion object { @@ -27,8 +24,6 @@ class ApkInstaller(private val service: PackageInstallerService) { * Used for postponed installations **/ var delayedInstaller: DelayedInstaller? = null - private var isReceiverRegistered = false - private const val TAG = "ApkInstaller" } inner class DelayedInstaller( @@ -40,7 +35,6 @@ class ApkInstaller(private val service: PackageInstallerService) { session.commit(intent) true } catch (e: Exception) { - logError(e) false }.also { delayedInstaller = null } } @@ -56,7 +50,6 @@ class ApkInstaller(private val service: PackageInstallerService) { } private val installActionReceiver = object : BroadcastReceiver() { - @SuppressLint("UnsafeIntentLaunch") override fun onReceive(context: Context, intent: Intent) { when (intent.getIntExtra( PackageInstaller.EXTRA_STATUS, @@ -110,20 +103,12 @@ class ApkInstaller(private val service: PackageInstallerService) { inputStream.close() } - // We must create an explicit intent or it will fail on Android 15+ - val installIntent = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { - Intent(service, PackageInstallerService::class.java) - .setAction(INSTALL_ACTION) - } else Intent(INSTALL_ACTION) - - val installFlags = when { - Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE -> PendingIntent.FLAG_MUTABLE - Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> PendingIntent.FLAG_IMMUTABLE - else -> 0 - } val intentSender = PendingIntent.getBroadcast( - service, activeSession, installIntent, installFlags + service, + activeSession, + Intent(INSTALL_ACTION), + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) PendingIntent.FLAG_MUTABLE else 0, ).intentSender // Use delayed installations on android 13 and only if "allow from unknown sources" is enabled @@ -155,30 +140,10 @@ class ApkInstaller(private val service: PackageInstallerService) { } init { - // Might be dangerous - registerInstallActionReceiver() - } - - private fun registerInstallActionReceiver() { - if (!isReceiverRegistered) { - val intentFilter = IntentFilter().apply { - addAction(INSTALL_ACTION) - } - Log.d(TAG, "Registering install action event receiver") - context?.registerBroadcastReceiver(installActionReceiver, intentFilter) - isReceiverRegistered = true - } - } - - fun unregisterInstallActionReceiver() { - if (isReceiverRegistered) { - Log.d(TAG, "Unregistering install action event receiver") - try { - context?.unregisterReceiver(installActionReceiver) - } catch (e: Exception) { - logError(e) - } - isReceiverRegistered = false - } + service.registerReceiver(installActionReceiver, IntentFilter(INSTALL_ACTION)) + service.receivers.add(installActionReceiver) } } + +@Suppress("DEPRECATION") +inline fun Intent.getSafeParcelableExtra(key: String): T? = if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) getParcelableExtra(key) else getParcelableExtra(key, T::class.java) diff --git a/app/src/main/java/com/lagradost/cloudstream3/services/PackageInstallerService.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/PackageInstallerService.kt similarity index 83% rename from app/src/main/java/com/lagradost/cloudstream3/services/PackageInstallerService.kt rename to app/src/main/java/com/lagradost/cloudstream3/utils/PackageInstallerService.kt index fa7754718..57b98dc23 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/services/PackageInstallerService.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/PackageInstallerService.kt @@ -1,21 +1,20 @@ -package com.lagradost.cloudstream3.services +package com.lagradost.cloudstream3.utils import android.app.NotificationManager +import android.app.PendingIntent import android.app.Service +import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC -import android.os.Build.VERSION.SDK_INT +import android.os.Build import android.os.IBinder import android.util.Log import androidx.core.app.NotificationCompat -import androidx.core.app.PendingIntentCompat import com.lagradost.cloudstream3.MainActivity 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.utils.ApkInstaller import com.lagradost.cloudstream3.utils.AppContextUtils.createNotificationChannel import com.lagradost.cloudstream3.utils.Coroutines.ioSafe import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute @@ -24,13 +23,18 @@ import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlin.math.roundToInt + class PackageInstallerService : Service() { - private var installer: ApkInstaller? = null + val receivers = mutableListOf() private val baseNotification by lazy { + val flag = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + PendingIntent.FLAG_MUTABLE + } else 0 + val intent = Intent(this, MainActivity::class.java) val pendingIntent = - PendingIntentCompat.getActivity(this, 0, intent, 0, false) + PendingIntent.getActivity(this, 0, intent, flag) NotificationCompat.Builder(this, UPDATE_CHANNEL_ID) .setAutoCancel(false) @@ -51,16 +55,18 @@ class PackageInstallerService : Service() { UPDATE_CHANNEL_NAME, UPDATE_CHANNEL_DESCRIPTION ) - if (SDK_INT >= 29) - startForeground(UPDATE_NOTIFICATION_ID, baseNotification.build(), FOREGROUND_SERVICE_TYPE_DATA_SYNC) - else startForeground(UPDATE_NOTIFICATION_ID, baseNotification.build()) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + startForeground(UPDATE_NOTIFICATION_ID, baseNotification.build(), FOREGROUND_SERVICE_TYPE_DATA_SYNC) + } else{ + startForeground(UPDATE_NOTIFICATION_ID, baseNotification.build()) + } } private val updateLock = Mutex() private suspend fun downloadUpdate(url: String): Boolean { try { - Log.d("PackageInstallerService", "Downloading update: $url") + Log.d(LOG_TAG, "Downloading update: $url") // Delete all old updates ioSafe { @@ -82,11 +88,11 @@ class PackageInstallerService : Service() { val body = app.get(url).body val inputStream = body.byteStream() - installer = ApkInstaller(this) + val installer = ApkInstaller(this) val totalSize = body.contentLength() var currentSize = 0 - installer?.installApk(this, inputStream, totalSize, { + installer.installApk(this, inputStream, totalSize, { currentSize += it // Prevent div 0 if (totalSize == 0L) return@installApk @@ -102,7 +108,6 @@ class PackageInstallerService : Service() { } return true } catch (e: Exception) { - logError(e) updateNotificationProgress(0f, ApkInstaller.InstallProgressStatus.Failed) return false } @@ -135,7 +140,7 @@ class PackageInstallerService : Service() { .build() val notificationManager = - getSystemService(NOTIFICATION_SERVICE) as NotificationManager + getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager // Persistent notification on failure val id = @@ -157,21 +162,21 @@ class PackageInstallerService : Service() { } override fun onDestroy() { - installer?.unregisterInstallActionReceiver() - installer = null - this.stopSelf() + receivers.forEach { + try { + this.unregisterReceiver(it) + } catch (_: IllegalArgumentException) { + // Receiver not registered + } + } super.onDestroy() } override fun onBind(i: Intent?): IBinder? = null - override fun onTimeout(reason: Int) { - stopSelf() - Log.e("PackageInstallerService", "Service stopped due to timeout: $reason") - } - companion object { private const val EXTRA_URL = "EXTRA_URL" + private const val LOG_TAG = "PackageInstallerService" const val UPDATE_CHANNEL_ID = "cloudstream3.updates" const val UPDATE_CHANNEL_NAME = "App Updates" @@ -186,4 +191,4 @@ class PackageInstallerService : Service() { .putExtra(EXTRA_URL, url) } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/PercentageCropImageView.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/PercentageCropImageView.kt index 6580182bb..1e572fb7c 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/PercentageCropImageView.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/PercentageCropImageView.kt @@ -4,67 +4,16 @@ import android.content.Context import android.graphics.Matrix import android.graphics.drawable.Drawable import android.util.AttributeSet -import androidx.core.content.withStyledAttributes -import com.lagradost.cloudstream3.R -import com.lagradost.cloudstream3.mvvm.logError -/** - * A custom [AppCompatImageView] that allows precise control over the visible crop area - * of an image by adjusting its horizontal and vertical center offset percentages. - * - * ### Key Features: - * - Allows **manual vertical or horizontal cropping** via percentage offsets. - * - Works seamlessly with Coil, Glide, or any image loading library. - * - * ### Usage (XML): - * You can set the crop offset directly in XML using custom attributes: - * ```xml - * - * ``` - * - `app:cropYCenterOffsetPct` → controls how far vertically the image shifts - * `0.0` = top-aligned, `0.5` = centered, `1.0` = bottom-aligned. - * - `app:cropXCenterOffsetPct` → controls how far horizontally the image shifts - * `0.0` = left, `0.5` = center, `1.0` = right. - * - * ### Programmatic Example: - * ```kotlin - * imageView.cropYCenterOffsetPct = 0.15f // Show slightly more (15%) of the top area - * imageView.cropXCenterOffsetPct = 0.5f // Keep image centered horizontally - * imageView.redraw() //Only needed if you changed cropYCenterOffsetPct/cropXCenterOffsetPct at runtime - * ``` - * - * ### Notes: - * - Must use `android:scaleType="matrix"` to enable manual matrix transformations. - * - Reference: https://stackoverflow.com/a/29055283 - * - * @property cropYCenterOffsetPct the vertical crop percentage (0.0–1.0) - * @property cropXCenterOffsetPct the horizontal crop percentage (0.0–1.0) - * - * @see ImageView.ScaleType.MATRIX - */ class PercentageCropImageView : androidx.appcompat.widget.AppCompatImageView { private var mCropYCenterOffsetPct: Float? = null private var mCropXCenterOffsetPct: Float? = null - constructor(context: Context?) : super(context!!) - - constructor(context: Context?, attrs: AttributeSet?) : super(context!!, attrs) { - initAttrs(context, attrs) - } - + constructor(context: Context?, attrs: AttributeSet?) : super(context!!, attrs) constructor( context: Context?, attrs: AttributeSet?, defStyle: Int - ) : super(context!!, attrs, defStyle) { - initAttrs(context, attrs) - } + ) : super(context!!, attrs, defStyle) var cropYCenterOffsetPct: Float get() = mCropYCenterOffsetPct!! @@ -94,12 +43,12 @@ class PercentageCropImageView : androidx.appcompat.widget.AppCompatImageView { var dy = 0f if (dWidth * vHeight > vWidth * dHeight) { val cropXCenterOffsetPct = - if (mCropXCenterOffsetPct != null) mCropXCenterOffsetPct!! else 0.5f + if (mCropXCenterOffsetPct != null) mCropXCenterOffsetPct!!.toFloat() else 0.5f scale = vHeight.toFloat() / dHeight.toFloat() dx = (vWidth - dWidth * scale) * cropXCenterOffsetPct } else { val cropYCenterOffsetPct = - if (mCropYCenterOffsetPct != null) mCropYCenterOffsetPct!! else 0f + if (mCropYCenterOffsetPct != null) mCropYCenterOffsetPct!!.toFloat() else 0f scale = vWidth.toFloat() / dWidth.toFloat() dy = (vHeight - dHeight * scale) * cropYCenterOffsetPct } @@ -131,7 +80,6 @@ class PercentageCropImageView : androidx.appcompat.widget.AppCompatImageView { super.setImageResource(resId) myConfigureBounds() } - // In case you can change the ScaleType in code you have to call redraw() //fullsizeImageView.setScaleType(ScaleType.FIT_CENTER); //fullsizeImageView.redraw(); @@ -143,26 +91,4 @@ class PercentageCropImageView : androidx.appcompat.widget.AppCompatImageView { setImageDrawable(d) } } - - private fun initAttrs(context: Context, attrs: AttributeSet?) { - attrs ?: return - context.withStyledAttributes(attrs, R.styleable.PercentageCropImageView) { - try { - if (hasValue(R.styleable.PercentageCropImageView_cropYCenterOffsetPct)) { - mCropYCenterOffsetPct = getFloat( - R.styleable.PercentageCropImageView_cropYCenterOffsetPct, - 0.5f - ) - } - if (hasValue(R.styleable.PercentageCropImageView_cropXCenterOffsetPct)) { - mCropXCenterOffsetPct = getFloat( - R.styleable.PercentageCropImageView_cropXCenterOffsetPct, - 0.5f - ) - } - } catch (e: Exception) { - logError(e) - } - } - } } \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/PowerManagerAPI.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/PowerManagerAPI.kt index e3c7d68df..0d3da8e75 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/PowerManagerAPI.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/PowerManagerAPI.kt @@ -3,13 +3,13 @@ package com.lagradost.cloudstream3.utils import android.content.ActivityNotFoundException import android.content.Context import android.content.Intent -import android.os.Build.VERSION.SDK_INT +import android.net.Uri +import android.os.Build +import android.os.Bundle 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 @@ -23,7 +23,7 @@ private const val TAG = "PowerManagerAPI" object BatteryOptimizationChecker { fun isAppRestricted(context: Context?): Boolean { - if (SDK_INT >= 23 && context != null) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && context != null) { val powerManager = context.getSystemService(Context.POWER_SERVICE) as PowerManager return !powerManager.isIgnoringBatteryOptimizations(context.packageName) } @@ -33,24 +33,29 @@ object BatteryOptimizationChecker { fun openBatteryOptimizationSettings(context: Context) { if (shouldShowBatteryOptimizationDialog(context)) { - context.showBatteryOptimizationDialog() + showBatteryOptimizationDialog(context) } } - fun Context.showBatteryOptimizationDialog() { - val settingsManager = PreferenceManager.getDefaultSharedPreferences(this) + fun showBatteryOptimizationDialog(context: Context) { + val settingsManager = PreferenceManager.getDefaultSharedPreferences(context) + try { - AlertDialog.Builder(this) - .setTitle(R.string.battery_dialog_title) - .setIcon(R.drawable.ic_battery) - .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) + context.let { + AlertDialog.Builder(it) + .setTitle(R.string.battery_dialog_title) + .setIcon(R.drawable.ic_battery) + .setMessage(R.string.battery_dialog_message) + .setPositiveButton(R.string.ok) { _, _ -> + intentOpenAppInfo(it) } - } - .show() + .setNegativeButton(R.string.cancel) { _, _ -> + settingsManager.edit() + .putBoolean(context.getString(R.string.battery_optimisation_key), false) + .apply() + } + .show() + } } catch (t: Throwable) { Log.e(TAG, "Error showing battery optimization dialog", t) } @@ -63,15 +68,14 @@ object BatteryOptimizationChecker { return isRestricted && isOptimizedNotShown && isLayout(PHONE) } - private fun Context.showRequestIgnoreBatteryOptDialog() { + private fun intentOpenAppInfo(context: Context) { + val intent = Intent() try { - val intent = Intent().apply { - action = Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS - data = "package:$PACKAGE_NAME".toUri() - } - startActivity(intent) + intent.setAction(Settings.ACTION_APPLICATION_DETAILS_SETTINGS) + .setData(Uri.fromParts("package", PACKAGE_NAME, null)) + context.startActivity(intent, Bundle()) } catch (t: Throwable) { - Log.e(TAG, "Unable to invoke APP_DETAILS intent", t) + Log.e(TAG, "Unable to invoke any intent", t) if (t is ActivityNotFoundException) { showToast("Exception: Activity Not Found") } else { diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/SingleSelectionHelper.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/SingleSelectionHelper.kt index 26c710103..70edf80c7 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/SingleSelectionHelper.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/SingleSelectionHelper.kt @@ -22,12 +22,11 @@ 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 import com.lagradost.cloudstream3.utils.UIHelper.dismissSafe import com.lagradost.cloudstream3.utils.UIHelper.popupMenuNoIconsAndNoStringRes +import com.lagradost.cloudstream3.utils.UIHelper.setImage object SingleSelectionHelper { fun Activity?.showOptionSelectStringRes( @@ -57,8 +56,7 @@ object SingleSelectionHelper { ) { if (this == null) return - // This was temporarily removed until better UI is made - /*if (isLayout(TV or EMULATOR)) { + if (isLayout(TV or EMULATOR)) { val binding = OptionsPopupTvBinding.inflate(layoutInflater) val dialog = AlertDialog.Builder(this, R.style.AlertDialogCustom) .setView(binding.root) @@ -81,18 +79,18 @@ object SingleSelectionHelper { binding.imageView.apply { isGone = poster.isNullOrEmpty() - loadImage(poster) + setImage(poster) + } + } else { + view?.popupMenuNoIconsAndNoStringRes(options.mapIndexed { index, s -> + Pair( + index, + s + ) + }) { + callback(Pair(false, this.itemId)) } - } else {*/ - view?.popupMenuNoIconsAndNoStringRes(options.mapIndexed { index, s -> - Pair( - index, - s - ) - }) { - callback(Pair(false, this.itemId)) } - //} } fun Activity?.showDialog( @@ -114,12 +112,8 @@ object SingleSelectionHelper { val textView = binding.text1 val applyButton = binding.applyBtt val cancelButton = binding.cancelBtt - val applyHolder = binding.applyBttHolder - - if (isLayout(PHONE or EMULATOR) && dialog is BottomSheetDialog) { - binding.dragHandle.isVisible = true - listView.isNestedScrollingEnabled = true - } + val applyHolder = + binding.applyBttHolder applyHolder.isVisible = realShowApply if (!realShowApply) { diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/SnackbarHelper.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/SnackbarHelper.kt index b43b51c74..e6a77795e 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/SnackbarHelper.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/SnackbarHelper.kt @@ -8,6 +8,7 @@ import com.google.android.material.snackbar.Snackbar import com.lagradost.api.Log import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.mvvm.logError +import com.lagradost.cloudstream3.ui.result.UiText import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute object SnackbarHelper { diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/SubtitleHelper.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/SubtitleHelper.kt new file mode 100644 index 000000000..09f5e0f18 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/SubtitleHelper.kt @@ -0,0 +1,520 @@ +package com.lagradost.cloudstream3.utils + +import com.lagradost.cloudstream3.mvvm.logError +import java.util.* + + +object SubtitleHelper { + data class Language639( + val languageName: String, + val nativeName: String, + val ISO_639_1: String, + val ISO_639_2_T: String, + val ISO_639_2_B: String, + val ISO_639_3: String, + val ISO_639_6: String, + ) + + /*fun createISO() { + val url = "https://infogalactic.com/info/List_of_ISO_639-1_codes" + val response = get(url).text + val document = Jsoup.parse(response) + val headers = document.select("table.wikitable > tbody > tr") + + var text = "listOf(\n" + for (head in headers) { + val tds = head.select("td") + if (tds.size < 8) continue + val name = tds[2].selectFirst("> a").text() + val native = tds[3].text() + val ISO_639_1 = tds[4].ownText().replace("+", "").replace(" ", "") + val ISO_639_2_T = tds[5].ownText().replace("+", "").replace(" ", "") + val ISO_639_2_B = tds[6].ownText().replace("+", "").replace(" ", "") + val ISO_639_3 = tds[7].ownText().replace("+", "").replace(" ", "") + val ISO_639_6 = tds[8].ownText().replace("+", "").replace(" ", "") + + val txtAdd = + "Language(\"$name\", \"$native\", \"$ISO_639_1\", \"$ISO_639_2_T\", \"$ISO_639_2_B\", \"$ISO_639_3\", \"$ISO_639_6\"),\n" + text += txtAdd + } + text += ")" + println("ISO CREATED:\n$text") + }*/ + + /** lang -> ISO_639_1 + * @param looseCheck will use .contains in addition to .equals + * */ + fun fromLanguageToTwoLetters(input: String, looseCheck: Boolean): String? { + languages.forEach { + if (it.languageName.equals(input, ignoreCase = true) + || it.nativeName.equals(input, ignoreCase = true) + ) return it.ISO_639_1 + } + + // Runs as a separate loop as to prioritize fully matching languages. + if (looseCheck) + languages.forEach { + if (input.contains(it.languageName, ignoreCase = true) + || input.contains(it.nativeName, ignoreCase = true) + ) return it.ISO_639_1 + } + + return null + } + + private var ISO_639_1Map: HashMap = hashMapOf() + private fun initISO6391Map() { + for (lang in languages) { + ISO_639_1Map[lang.ISO_639_1] = lang.languageName + } + } + + /** ISO_639_1 -> lang*/ + fun fromTwoLettersToLanguage(input: String): String? { + // pr-BR + if (input.substringBefore("-").length != 2) return null + if (ISO_639_1Map.isEmpty()) { + initISO6391Map() + } + val comparison = input.lowercase(Locale.ROOT) + + return ISO_639_1Map[comparison] + } + + /**ISO_639_2_B or ISO_639_2_T or ISO_639_3-> lang*/ + fun fromThreeLettersToLanguage(input: String): String? { + if (input.length != 3) return null + val comparison = input.lowercase(Locale.ROOT) + for (lang in languages) { + if (lang.ISO_639_2_B == comparison) { + return lang.languageName + } + } + for (lang in languages) { + if (lang.ISO_639_2_T == comparison) { + return lang.languageName + } + } + for (lang in languages) { + if (lang.ISO_639_3 == comparison) { + return lang.languageName + } + } + return null + } + + /** lang -> ISO_639_2_T*/ + fun fromLanguageToThreeLetters(input: String): String? { + for (lang in languages) { + if (lang.languageName == input || lang.nativeName == input) { + return lang.ISO_639_2_T + } + } + return null + } + + private const val flagOffset = 0x1F1E6 + private const val asciiOffset = 0x41 + private const val offset = flagOffset - asciiOffset + + private val flagRegex = Regex("[\uD83C\uDDE6-\uD83C\uDDFF]{2}") + + fun getFlagFromIso(inp: String?): String? { + if (inp.isNullOrBlank() || inp.length < 2) return null + + try { + val ret = getFlagFromIsoShort(flags[inp]) + ?: getFlagFromIsoShort(inp.uppercase()) ?: return null + + return if (flagRegex.matches(ret)) { + ret + } else { + null + } + } catch (e: Exception) { + logError(e) + return null + } + } + + private fun getFlagFromIsoShort(flagAscii: String?): String? { + if (flagAscii.isNullOrBlank() || flagAscii.length < 2) return null + return try { + val firstChar: Int = Character.codePointAt(flagAscii, 0) + offset + val secondChar: Int = Character.codePointAt(flagAscii, 1) + offset + + (String(Character.toChars(firstChar)) + String(Character.toChars(secondChar))) + } catch (e: Exception) { + logError(e) + null + } + } + + private val flags = mapOf( + "af" to "ZA", + "agq" to "CM", + "ajp" to "SY", + "ak" to "GH", + "am" to "ET", + "ar" to "AE", + "ars" to "SA", + "as" to "IN", + "asa" to "TZ", + "az" to "AZ", + "bas" to "CM", + "be" to "BY", + "bem" to "ZM", + "bez" to "IT", + "bg" to "BG", + "bm" to "ML", + "bn" to "BD", + "bo" to "CN", + "br" to "FR", + "brx" to "IN", + "bs" to "BA", + "ca" to "ES", + "cgg" to "UG", + "chr" to "US", + "cs" to "CZ", + "cy" to "GB", + "da" to "DK", + "dav" to "KE", + "de" to "DE", + "dje" to "NE", + "dua" to "CM", + "dyo" to "SN", + "ebu" to "KE", + "ee" to "GH", + "en" to "GB", + "el" to "GR", + "es" to "ES", + "et" to "EE", + "eu" to "ES", + "ewo" to "CM", + "fa" to "IR", + "fil" to "PH", + "fr" to "FR", + "ga" to "IE", + "gl" to "ES", + "gsw" to "CH", + "gu" to "IN", + "guz" to "KE", + "gv" to "GB", + "ha" to "NG", + "haw" to "US", + "he" to "IL", + "hi" to "IN", + "ff" to "CN", + "fi" to "FI", + "fo" to "FO", + "hr" to "HR", + "hu" to "HU", + "hy" to "AM", + "id" to "ID", + "ig" to "NG", + "ii" to "CN", + "is" to "IS", + "it" to "IT", + "ita" to "IT", + "ja" to "JP", + "jmc" to "TZ", + "ka" to "GE", + "kab" to "DZ", + "ki" to "KE", + "kam" to "KE", + "mer" to "KE", + "kde" to "TZ", + "kea" to "CV", + "khq" to "ML", + "kk" to "KZ", + "kl" to "GL", + "kln" to "KE", + "km" to "KH", + "kn" to "IN", + "ko" to "KR", + "kok" to "IN", + "ksb" to "TZ", + "ksf" to "CM", + "kw" to "GB", + "lag" to "TZ", + "lg" to "UG", + "ln" to "CG", + "lt" to "LT", + "lu" to "CD", + "lv" to "LV", + "lat" to "LV", + "luo" to "KE", + "luy" to "KE", + "mas" to "TZ", + "mfe" to "MU", + "mg" to "MG", + "mgh" to "MZ", + "ml" to "IN", + "mk" to "MK", + "mr" to "IN", + "ms" to "MY", + "mt" to "MT", + "mua" to "CM", + "my" to "MM", + "naq" to "NA", + "nb" to "NO", + "no" to "NO", + "nn" to "NO", + "nd" to "ZW", + "ne" to "NP", + "nl" to "NL", + "nmg" to "CM", + "nus" to "SD", + "nyn" to "UG", + "om" to "ET", + "or" to "IN", + "pa" to "PK", + "pl" to "PL", + "ps" to "AF", + "pt" to "PT", + "pt-pt" to "PT", + "pt-br" to "BR", + "rm" to "CH", + "rn" to "BI", + "ro" to "RO", + "ru" to "RU", + "rw" to "RW", + "rof" to "TZ", + "rwk" to "TZ", + "saq" to "KE", + "sbp" to "TZ", + "seh" to "MZ", + "ses" to "ML", + "sg" to "CF", + "shi" to "MA", + "si" to "LK", + "sk" to "SK", + "sl" to "SI", + "sn" to "ZW", + "so" to "SO", + "sq" to "AL", + "sr" to "RS", + "sv" to "SE", + "sw" to "TZ", + "swc" to "CD", + "ta" to "IN", + "te" to "IN", + "teo" to "UG", + "th" to "TH", + "ti" to "ET", + "to" to "TO", + "tr" to "TR", + "twq" to "NE", + "tzm" to "MA", + "uk" to "UA", + "ur" to "PK", + "uz" to "UZ", + "vai" to "LR", + "vi" to "VN", + "vun" to "TZ", + "xog" to "UG", + "yav" to "CM", + "yo" to "NG", + "zh" to "CN", + "zu" to "ZA", + "tl" to "PH", + ) + + val languages = listOf( + Language639("Abkhaz", "аҧсуа бызшәа, аҧсшәа", "ab", "abk", "abk", "abk", "abks"), + Language639("Afar", "Afaraf", "aa", "aar", "aar", "aar", "aars"), + Language639("Afrikaans", "Afrikaans", "af", "afr", "afr", "afr", "afrs"), + Language639("Akan", "Akan", "ak", "aka", "aka", "aka", ""), + Language639("Albanian", "Shqip", "sq", "sqi", "", "sqi", ""), + Language639("Amharic", "አማርኛ", "am", "amh", "amh", "amh", ""), + Language639("Arabic", "العربية", "ar", "ara", "ara", "ara", ""), + Language639("Aragonese", "aragonés", "an", "arg", "arg", "arg", ""), + Language639("Armenian", "Հայերեն", "hy", "hye", "", "hye", ""), + Language639("Assamese", "অসমীয়া", "as", "asm", "asm", "asm", ""), + Language639("Avaric", "авар мацӀ, магӀарул мацӀ", "av", "ava", "ava", "ava", ""), + Language639("Avestan", "avesta", "ae", "ave", "ave", "ave", ""), + Language639("Aymara", "aymar aru", "ay", "aym", "aym", "aym", ""), + Language639("Azerbaijani", "azərbaycan dili", "az", "aze", "aze", "aze", ""), + Language639("Bambara", "bamanankan", "bm", "bam", "bam", "bam", ""), + Language639("Bashkir", "башҡорт теле", "ba", "bak", "bak", "bak", ""), + Language639("Basque", "euskara, euskera", "eu", "eus", "", "eus", ""), + Language639("Belarusian", "беларуская мова", "be", "bel", "bel", "bel", ""), + Language639("Bengali", "বাংলা", "bn", "ben", "ben", "ben", ""), + Language639("Bihari", "भोजपुरी", "bh", "bih", "bih", "", ""), + Language639("Bislama", "Bislama", "bi", "bis", "bis", "bis", ""), + Language639("Bosnian", "bosanski jezik", "bs", "bos", "bos", "bos", "boss"), + Language639("Breton", "brezhoneg", "br", "bre", "bre", "bre", ""), + Language639("Bulgarian", "български език", "bg", "bul", "bul", "bul", "buls"), + Language639("Burmese", "ဗမာစာ", "my", "mya", "", "mya", ""), + Language639("Catalan", "català", "ca", "cat", "cat", "cat", ""), + Language639("Chamorro", "Chamoru", "ch", "cha", "cha", "cha", ""), + Language639("Chechen", "нохчийн мотт", "ce", "che", "che", "che", ""), + Language639("Chichewa", "chiCheŵa, chinyanja", "ny", "nya", "nya", "nya", ""), + Language639("Chinese", "中文 (Zhōngwén), 汉语, 漢語", "zh", "zho", "", "zho", ""), + Language639("Chuvash", "чӑваш чӗлхи", "cv", "chv", "chv", "chv", ""), + Language639("Cornish", "Kernewek", "kw", "cor", "cor", "cor", ""), + Language639("Corsican", "corsu, lingua corsa", "co", "cos", "cos", "cos", ""), + Language639("Cree", "ᓀᐦᐃᔭᐍᐏᐣ", "cr", "cre", "cre", "cre", ""), + Language639("Croatian", "hrvatski jezik", "hr", "hrv", "hrv", "hrv", ""), + Language639("Czech", "čeština, český jazyk", "cs", "ces", "", "ces", ""), + Language639("Danish", "dansk", "da", "dan", "dan", "dan", ""), + Language639("Divehi", "ދިވެހި", "dv", "div", "div", "div", ""), + Language639("Dutch", "Nederlands, Vlaams", "nl", "nld", "", "nld", ""), + Language639("Dzongkha", "རྫོང་ཁ", "dz", "dzo", "dzo", "dzo", ""), + Language639("English", "English", "en", "eng", "eng", "eng", "engs"), + Language639("Esperanto", "Esperanto", "eo", "epo", "epo", "epo", ""), + Language639("Estonian", "eesti, eesti keel", "et", "est", "est", "est", ""), + Language639("Ewe", "Eʋegbe", "ee", "ewe", "ewe", "ewe", ""), + Language639("Faroese", "føroyskt", "fo", "fao", "fao", "fao", ""), + Language639("Fijian", "vosa Vakaviti", "fj", "fij", "fij", "fij", ""), + Language639("Finnish", "suomi, suomen kieli", "fi", "fin", "fin", "fin", ""), + Language639("French", "français, langue française", "fr", "fra", "", "fra", "fras"), + Language639("Fula", "Fulfulde, Pulaar, Pular", "ff", "ful", "ful", "ful", ""), + Language639("Galician", "galego", "gl", "glg", "glg", "glg", ""), + Language639("Georgian", "ქართული", "ka", "kat", "", "kat", ""), + Language639("German", "Deutsch", "de", "deu", "", "deu", "deus"), + Language639("Greek", "ελληνικά", "el", "ell", "", "ell", "ells"), + Language639("Guaraní", "Avañe'ẽ", "gn", "grn", "grn", "grn", ""), + Language639("Gujarati", "ગુજરાતી", "gu", "guj", "guj", "guj", ""), + Language639("Haitian", "Kreyòl ayisyen", "ht", "hat", "hat", "hat", ""), + Language639("Hausa", "(Hausa) هَوُسَ", "ha", "hau", "hau", "hau", ""), + Language639("Hebrew", "עברית", "he", "heb", "heb", "heb", ""), + Language639("Herero", "Otjiherero", "hz", "her", "her", "her", ""), + Language639("Hindi", "हिन्दी, हिंदी", "hi", "hin", "hin", "hin", "hins"), + Language639("Hiri Motu", "Hiri Motu", "ho", "hmo", "hmo", "hmo", ""), + Language639("Hungarian", "magyar", "hu", "hun", "hun", "hun", ""), + Language639("Interlingua", "Interlingua", "ia", "ina", "ina", "ina", ""), + Language639("Indonesian", "Bahasa Indonesia", "id", "ind", "ind", "ind", ""), + Language639( + "Interlingue", + "Originally called Occidental; then Interlingue after WWII", + "ie", + "ile", + "ile", + "ile", + "" + ), + Language639("Irish", "Gaeilge", "ga", "gle", "gle", "gle", ""), + Language639("Igbo", "Asụsụ Igbo", "ig", "ibo", "ibo", "ibo", ""), + Language639("Inupiaq", "Iñupiaq, Iñupiatun", "ik", "ipk", "ipk", "ipk", ""), + Language639("Ido", "Ido", "io", "ido", "ido", "ido", "idos"), + Language639("Icelandic", "Íslenska", "is", "isl", "", "isl", ""), + Language639("Italian", "italiano", "it", "ita", "ita", "ita", "itas"), + Language639("Inuktitut", "ᐃᓄᒃᑎᑐᑦ", "iu", "iku", "iku", "iku", ""), + Language639("Japanese", "日本語 (にほんご)", "ja", "jpn", "jpn", "jpn", ""), + Language639("Javanese", "ꦧꦱꦗꦮ", "jv", "jav", "jav", "jav", ""), + Language639("Kalaallisut", "kalaallisut, kalaallit oqaasii", "kl", "kal", "kal", "kal", ""), + Language639("Kannada", "ಕನ್ನಡ", "kn", "kan", "kan", "kan", ""), + Language639("Kanuri", "Kanuri", "kr", "kau", "kau", "kau", ""), + Language639("Kashmiri", "कश्मीरी, كشميري‎", "ks", "kas", "kas", "kas", ""), + Language639("Kazakh", "қазақ тілі", "kk", "kaz", "kaz", "kaz", ""), + Language639("Khmer", "ខ្មែរ, ខេមរភាសា, ភាសាខ្មែរ", "km", "khm", "khm", "khm", ""), + Language639("Kikuyu", "Gĩkũyũ", "ki", "kik", "kik", "kik", ""), + Language639("Kinyarwanda", "Ikinyarwanda", "rw", "kin", "kin", "kin", ""), + Language639("Kyrgyz", "Кыргызча, Кыргыз тили", "ky", "kir", "kir", "kir", ""), + Language639("Komi", "коми кыв", "kv", "kom", "kom", "kom", ""), + Language639("Kongo", "Kikongo", "kg", "kon", "kon", "kon", ""), + Language639("Korean", "한국어, 조선어", "ko", "kor", "kor", "kor", ""), + Language639("Kurdish", "Kurdî, كوردی‎", "ku", "kur", "kur", "kur", ""), + Language639("Kwanyama", "Kuanyama", "kj", "kua", "kua", "kua", ""), + Language639("Latin", "latine, lingua latina", "la", "lat", "lat", "lat", "lats"), + Language639("Luxembourgish", "Lëtzebuergesch", "lb", "ltz", "ltz", "ltz", ""), + Language639("Ganda", "Luganda", "lg", "lug", "lug", "lug", ""), + Language639("Limburgish", "Limburgs", "li", "lim", "lim", "lim", ""), + Language639("Lingala", "Lingála", "ln", "lin", "lin", "lin", ""), + Language639("Lao", "ພາສາລາວ", "lo", "lao", "lao", "lao", ""), + Language639("Lithuanian", "lietuvių kalba", "lt", "lit", "lit", "lit", ""), + Language639("Luba-Katanga", "Tshiluba", "lu", "lub", "lub", "lub", ""), + Language639("Latvian", "latviešu valoda", "lv", "lav", "lav", "lav", ""), + Language639("Manx", "Gaelg, Gailck", "gv", "glv", "glv", "glv", ""), + Language639("Macedonian", "македонски јазик", "mk", "mkd", "", "mkd", ""), + Language639("Malagasy", "fiteny malagasy", "mg", "mlg", "mlg", "mlg", ""), + Language639("Malay", "bahasa Melayu, بهاس ملايو‎", "ms", "msa", "", "msa", ""), + Language639("Malayalam", "മലയാളം", "ml", "mal", "mal", "mal", ""), + Language639("Maltese", "Malti", "mt", "mlt", "mlt", "mlt", ""), + Language639("Māori", "te reo Māori", "mi", "mri", "", "mri", ""), + Language639("Marathi", "मराठी", "mr", "mar", "mar", "mar", ""), + Language639("Marshallese", "Kajin M̧ajeļ", "mh", "mah", "mah", "mah", ""), + Language639("Mongolian", "Монгол хэл", "mn", "mon", "mon", "mon", ""), + Language639("Nauruan", "Dorerin Naoero", "na", "nau", "nau", "nau", ""), + Language639("Navajo", "Diné bizaad", "nv", "nav", "nav", "nav", ""), + Language639("Northern Ndebele", "isiNdebele", "nd", "nde", "nde", "nde", ""), + Language639("Nepali", "नेपाली", "ne", "nep", "nep", "nep", ""), + Language639("Ndonga", "Owambo", "ng", "ndo", "ndo", "ndo", ""), + Language639("Norwegian Bokmål", "Norsk bokmål", "nb", "nob", "nob", "nob", ""), + Language639("Norwegian Nynorsk", "Norsk nynorsk", "nn", "nno", "nno", "nno", ""), + Language639("Norwegian", "Norsk", "no", "nor", "nor", "nor", ""), + Language639("Nuosu", "ꆈꌠ꒿ Nuosuhxop", "ii", "iii", "iii", "iii", ""), + Language639("Southern Ndebele", "isiNdebele", "nr", "nbl", "nbl", "nbl", ""), + Language639("Occitan", "occitan, lenga d'òc", "oc", "oci", "oci", "oci", ""), + Language639("Ojibwe", "ᐊᓂᔑᓈᐯᒧᐎᓐ", "oj", "oji", "oji", "oji", ""), + Language639("Old Church Slavonic", "ѩзыкъ словѣньскъ", "cu", "chu", "chu", "chu", ""), + Language639("Oromo", "Afaan Oromoo", "om", "orm", "orm", "orm", ""), + Language639("Oriya", "ଓଡ଼ିଆ", "or", "ori", "ori", "ori", ""), + Language639("Ossetian", "ирон æвзаг", "os", "oss", "oss", "oss", ""), + Language639("Panjabi", "ਪੰਜਾਬੀ, پنجابی‎", "pa", "pan", "pan", "pan", ""), + Language639("Pāli", "पाऴि", "pi", "pli", "pli", "pli", ""), + Language639("Persian", "فارسی", "fa", "fas", "", "fas", ""), + Language639("Polish", "język polski, polszczyzna", "pl", "pol", "pol", "pol", "pols"), + Language639("Pashto", "پښتو", "ps", "pus", "pus", "pus", ""), + Language639("Portuguese", "português", "pt-pt", "por", "por", "por", ""), + // Addition to support Brazilian Portuguese properly, might break other things + Language639("Portuguese (Brazilian)", "português", "pt-br", "por", "por", "por", ""), + Language639("Quechua", "Runa Simi, Kichwa", "qu", "que", "que", "que", ""), + Language639("Romansh", "rumantsch grischun", "rm", "roh", "roh", "roh", ""), + Language639("Kirundi", "Ikirundi", "rn", "run", "run", "run", ""), + Language639("Reunion Creole", "Kréol Rénioné", "rc", "rcf", "rcf", "rcf", ""), + Language639("Romanian", "limba română", "ro", "ron", "", "ron", ""), + Language639("Russian", "Русский", "ru", "rus", "rus", "rus", ""), + Language639("Sanskrit", "संस्कृतम्", "sa", "san", "san", "san", ""), + Language639("Sardinian", "sardu", "sc", "srd", "srd", "srd", ""), + Language639("Sindhi", "सिन्धी, سنڌي، سندھی‎", "sd", "snd", "snd", "snd", ""), + Language639("Northern Sami", "Davvisámegiella", "se", "sme", "sme", "sme", ""), + Language639("Samoan", "gagana fa'a Samoa", "sm", "smo", "smo", "smo", ""), + Language639("Sango", "yângâ tî sängö", "sg", "sag", "sag", "sag", ""), + Language639("Serbian", "српски језик", "sr", "srp", "srp", "srp", ""), + Language639("Scottish Gaelic", "Gàidhlig", "gd", "gla", "gla", "gla", ""), + Language639("Shona", "chiShona", "sn", "sna", "sna", "sna", ""), + Language639("Sinhalese", "සිංහල", "si", "sin", "sin", "sin", ""), + Language639("Slovak", "slovenčina, slovenský jazyk", "sk", "slk", "", "slk", ""), + Language639("Slovene", "slovenski jezik, slovenščina", "sl", "slv", "slv", "slv", ""), + Language639("Somali", "Soomaaliga, af Soomaali", "so", "som", "som", "som", ""), + Language639("Southern Sotho", "Sesotho", "st", "sot", "sot", "sot", ""), + Language639("Spanish", "español", "es", "spa", "spa", "spa", ""), + Language639("Sundanese", "Basa Sunda", "su", "sun", "sun", "sun", ""), + Language639("Swahili", "Kiswahili", "sw", "swa", "swa", "swa", ""), + Language639("Swati", "SiSwati", "ss", "ssw", "ssw", "ssw", ""), + Language639("Swedish", "svenska", "sv", "swe", "swe", "swe", ""), + Language639("Tamil", "தமிழ்", "ta", "tam", "tam", "tam", ""), + Language639("Telugu", "తెలుగు", "te", "tel", "tel", "tel", ""), + Language639("Tajik", "тоҷикӣ, toçikī, تاجیکی‎", "tg", "tgk", "tgk", "tgk", ""), + Language639("Thai", "ไทย", "th", "tha", "tha", "tha", ""), + Language639("Tigrinya", "ትግርኛ", "ti", "tir", "tir", "tir", ""), + Language639("Tibetan Standard", "བོད་ཡིག", "bo", "bod", "", "bod", ""), + Language639("Turkmen", "Türkmen, Түркмен", "tk", "tuk", "tuk", "tuk", ""), + Language639("Tagalog", "Wikang Tagalog", "tl", "tgl", "tgl", "tgl", ""), + Language639("Tswana", "Setswana", "tn", "tsn", "tsn", "tsn", ""), + Language639("Tonga", "faka Tonga", "to", "ton", "ton", "ton", ""), + Language639("Turkish", "Türkçe", "tr", "tur", "tur", "tur", ""), + Language639("Tsonga", "Xitsonga", "ts", "tso", "tso", "tso", ""), + Language639("Tatar", "татар теле, tatar tele", "tt", "tat", "tat", "tat", ""), + Language639("Twi", "Twi", "tw", "twi", "twi", "twi", ""), + Language639("Tahitian", "Reo Tahiti", "ty", "tah", "tah", "tah", ""), + Language639("Uyghur", "ئۇيغۇرچە‎, Uyghurche", "ug", "uig", "uig", "uig", ""), + Language639("Ukrainian", "Українська", "uk", "ukr", "ukr", "ukr", ""), + Language639("Urdu", "اردو", "ur", "urd", "urd", "urd", ""), + Language639("Uzbek", "Oʻzbek, Ўзбек, أۇزبېك‎", "uz", "uzb", "uzb", "uzb", ""), + Language639("Venda", "Tshivenḓa", "ve", "ven", "ven", "ven", ""), + Language639("Vietnamese", "Tiếng Việt", "vi", "vie", "vie", "vie", ""), + Language639("Volapük", "Volapük", "vo", "vol", "vol", "vol", ""), + Language639("Walloon", "walon", "wa", "wln", "wln", "wln", ""), + Language639("Welsh", "Cymraeg", "cy", "cym", "", "cym", ""), + Language639("Wolof", "Wollof", "wo", "wol", "wol", "wol", ""), + Language639("Western Frisian", "Frysk", "fy", "fry", "fry", "fry", ""), + Language639("Xhosa", "isiXhosa", "xh", "xho", "xho", "xho", ""), + Language639("Yiddish", "ייִדיש", "yi", "yid", "yid", "yid", ""), + Language639("Yoruba", "Yorùbá", "yo", "yor", "yor", "yor", ""), + Language639("Zhuang", "Saɯ cueŋƅ, Saw cuengh", "za", "zha", "zha", "zha", ""), + Language639("Zulu", "isiZulu", "zu", "zul", "zul", "zul", ""), + ) +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/SubtitleUtils.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/SubtitleUtils.kt index c0068f91a..93a533954 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/SubtitleUtils.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/SubtitleUtils.kt @@ -2,8 +2,8 @@ package com.lagradost.cloudstream3.utils import android.content.Context import com.lagradost.api.Log -import com.lagradost.cloudstream3.utils.downloader.DownloadFileManagement.basePathToFile -import com.lagradost.cloudstream3.utils.downloader.DownloadObjects +import com.lagradost.cloudstream3.utils.VideoDownloadManager.getFolder +import com.lagradost.safefile.SafeFile object SubtitleUtils { @@ -13,21 +13,17 @@ object SubtitleUtils { ".ttml", ".sbv", ".dfxp" ) - fun deleteMatchingSubtitles(context: Context, info: DownloadObjects.DownloadedFileInfo) { - val cleanDisplay = cleanDisplayName(info.displayName) + fun deleteMatchingSubtitles(context: Context, info: VideoDownloadManager.DownloadedFileInfo) { + val relative = info.relativePath + val display = info.displayName + val cleanDisplay = cleanDisplayName(display) - val base = basePathToFile(context, info.basePath) - val folder = - base?.gotoDirectory(info.relativePath, createMissingDirectories = false) ?: return - val folderFiles = folder.listFiles() ?: return - - for (file in folderFiles) { - val name = file.name() ?: continue - if (!isMatchingSubtitle(name, info.displayName, cleanDisplay)) { - continue - } - if (file.delete() != true) { - Log.e("SubtitleDeletion", "Failed to delete subtitle file: $name") + getFolder(context, relative, info.basePath)?.forEach { (name, uri) -> + if (isMatchingSubtitle(name, display, cleanDisplay)) { + val subtitleFile = SafeFile.fromUri(context, uri) + if (subtitleFile == null || !subtitleFile.delete()) { + Log.e("SubtitleDeletion", "Failed to delete subtitle file: ${subtitleFile?.name()}") + } } } } @@ -43,7 +39,7 @@ object SubtitleUtils { cleanDisplay: String ): Boolean { // Check if the file has a valid subtitle extension - val hasValidExtension = allowedExtensions.any { name.endsWith(it, ignoreCase = true) } + val hasValidExtension = allowedExtensions.any { name.contains(it, ignoreCase = true) } // We can't have the exact same file as a subtitle val isNotDisplayName = !name.equals(display, ignoreCase = true) @@ -57,4 +53,4 @@ object SubtitleUtils { fun cleanDisplayName(name: String): String { return name.substringBeforeLast('.').trim() } -} +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/SyncUtil.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/SyncUtil.kt index 6e74fa00a..351e77c8d 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/SyncUtil.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/SyncUtil.kt @@ -8,7 +8,7 @@ import com.lagradost.cloudstream3.APIHolder.apis //import com.lagradost.cloudstream3.animeproviders.AniflixProvider import com.lagradost.cloudstream3.app import com.lagradost.cloudstream3.mvvm.logError -import com.lagradost.cloudstream3.utils.AppUtils.tryParseJson +import com.lagradost.cloudstream3.utils.AppUtils.parseJson import java.util.concurrent.TimeUnit object SyncUtil { @@ -71,7 +71,7 @@ object SyncUtil { val url = "https://raw.githubusercontent.com/MALSync/MAL-Sync-Backup/master/data/pages/$site/$slug.json" val response = app.get(url, cacheTime = 1, cacheUnit = TimeUnit.DAYS).text - val mapped = tryParseJson(response) + val mapped = parseJson(response) val overrideMal = mapped?.malId ?: mapped?.mal?.id ?: mapped?.anilist?.malId val overrideAnilist = mapped?.aniId ?: mapped?.anilist?.id @@ -96,8 +96,10 @@ object SyncUtil { .mapNotNull { it.url }.toMutableList() if (type == "anilist") { // TODO MAKE BETTER - apis.filter { it.name.contains("Aniflix", ignoreCase = true) }.forEach { - current.add("${it.mainUrl}/anime/$id") + synchronized(apis) { + apis.filter { it.name.contains("Aniflix", ignoreCase = true) }.forEach { + current.add("${it.mainUrl}/anime/$id") + } } } return current @@ -167,4 +169,4 @@ object SyncUtil { @JsonProperty("updatedAt") val updatedAt: String?, @JsonProperty("deletedAt") val deletedAt: String? ) -} +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/TestingUtils.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/TestingUtils.kt index 8c50afee7..049f92fb4 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/TestingUtils.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/TestingUtils.kt @@ -3,10 +3,10 @@ package com.lagradost.cloudstream3.utils import com.lagradost.cloudstream3.* import com.lagradost.cloudstream3.mvvm.logError import kotlinx.coroutines.* +import org.junit.Assert import kotlin.random.Random object TestingUtils { - open class TestResult(val success: Boolean) { companion object { val Pass = TestResult(true) @@ -48,10 +48,6 @@ object TestingUtils { messageLog.add(Message(LogLevel.Error, message)) } } - - private fun fail(message: String): Nothing = throw AssertionError(message) - private fun assertTrue(message: String, condition: Boolean) { if (!condition) fail(message) } - private fun assertNotNull(message: String, value: Any?) { if (value == null) fail(message) } class TestResultList(val results: List) : TestResult(true) class TestResultLoad(val extractorData: String, val shouldLoadLinks: Boolean) : TestResult(true) @@ -91,7 +87,7 @@ object TestingUtils { } catch (e: Throwable) { when (e) { is NotImplementedError -> { - fail("Provider marked as hasMainPage, while in reality is has not been implemented") + Assert.fail("Provider marked as hasMainPage, while in reality is has not been implemented") } is CancellationException -> { @@ -116,10 +112,10 @@ object TestingUtils { val searchResults = testQueries.firstNotNullOfOrNull { query -> try { logger.log("Searching for: $query") - api.search(query, 1)?.items?.takeIf { it.isNotEmpty() } + api.search(query).takeIf { !it.isNullOrEmpty() } } catch (e: Throwable) { if (e is NotImplementedError) { - fail("Provider has not implemented search()") + Assert.fail("Provider has not implemented search()") } else if (e is CancellationException) { throw e } @@ -129,7 +125,7 @@ object TestingUtils { } return if (searchResults.isNullOrEmpty()) { - fail("Api ${api.name} did not return any search responses") + Assert.fail("Api ${api.name} did not return any search responses") TestResult.Fail // Should not be reached } else { TestResultList(searchResults) @@ -220,7 +216,7 @@ object TestingUtils { // return TestResult(validResults) } catch (e: Throwable) { if (e is NotImplementedError) { - fail("Provider has not implemented load()") + Assert.fail("Provider has not implemented load()") } throw e } @@ -232,14 +228,14 @@ object TestingUtils { url: String?, logger: Logger ): TestResult { - assertNotNull("Api ${api.name} has invalid url on episode", url) + Assert.assertNotNull("Api ${api.name} has invalid url on episode", url) if (url == null) return TestResult.Fail // Should never trigger var linksLoaded = 0 try { val success = api.loadLinks(url, false, {}) { link -> logger.log("Video loaded: ${link.name}") - assertTrue( + Assert.assertTrue( "Api ${api.name} returns link with invalid url ${link.url}", link.url.length > 4 ) @@ -249,12 +245,12 @@ object TestingUtils { logger.log("Links loaded: $linksLoaded") return TestResult(linksLoaded > 0) } else { - fail("Api ${api.name} returns false on loadLinks() with $linksLoaded links loaded") + Assert.fail("Api ${api.name} returns false on loadLinks() with $linksLoaded links loaded") } } catch (e: Throwable) { when (e) { is NotImplementedError -> { - fail("Provider has not implemented loadLinks()") + Assert.fail("Provider has not implemented loadLinks()") } else -> { @@ -280,7 +276,7 @@ object TestingUtils { // Test Homepage val homepage = testHomepage(api, logger) - assertTrue("Homepage failed to load", homepage.success) + Assert.assertTrue("Homepage failed to load", homepage.success) val homePageList = (homepage as? TestResultList)?.results ?: emptyList() // Test Search Results @@ -291,7 +287,7 @@ object TestingUtils { listOf("over", "iron", "guy")).take(3) val searchResults = testSearch(api, searchQueries, logger) - assertTrue("Failed to get search results", searchResults.success) + Assert.assertTrue("Failed to get search results", searchResults.success) searchResults as TestResultList // Test Load and LoadLinks @@ -325,4 +321,4 @@ object TestingUtils { } } } -} +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/TvChannelUtils.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/TvChannelUtils.kt deleted file mode 100644 index feecbe312..000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/TvChannelUtils.kt +++ /dev/null @@ -1,164 +0,0 @@ -package com.lagradost.cloudstream3.utils - -import android.annotation.SuppressLint -import android.content.ComponentName -import android.content.ContentUris -import android.content.Context -import android.content.Intent -import android.util.Log -import androidx.core.net.toUri -import androidx.tvprovider.media.tv.Channel -import androidx.tvprovider.media.tv.PreviewProgram -import androidx.tvprovider.media.tv.TvContractCompat -import com.lagradost.cloudstream3.MainActivity -import com.lagradost.cloudstream3.R -import com.lagradost.cloudstream3.SearchResponse -import com.lagradost.cloudstream3.base64Encode -import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.APP_STRING_SHARE -import com.lagradost.cloudstream3.utils.DataStore.getKey -import com.lagradost.cloudstream3.utils.DataStore.setKey -import java.net.URLEncoder - -const val PROGRAM_ID_LIST_KEY = "persistent_program_ids" - -object TvChannelUtils { - fun Context.saveProgramId(programId: Long) { - val existing: List = getKey(PROGRAM_ID_LIST_KEY) ?: emptyList() - val updated = (existing + programId).distinct() - setKey(PROGRAM_ID_LIST_KEY, updated) - } - fun Context.getStoredProgramIds(): List { - return getKey(PROGRAM_ID_LIST_KEY) ?: emptyList() - } - fun Context.removeProgramId(programId: Long) { - val existing: List = getKey(PROGRAM_ID_LIST_KEY) ?: emptyList() - val updated = existing.filter { it != programId } - setKey(PROGRAM_ID_LIST_KEY, updated) - } - - - fun getChannelId(context: Context, channelName: String): Long? { - return try { - context.contentResolver.query( - TvContractCompat.Channels.CONTENT_URI, - arrayOf( - TvContractCompat.Channels._ID, - TvContractCompat.Channels.COLUMN_DISPLAY_NAME - ), - null, - null, - null - )?.use { cursor -> - while (cursor.moveToNext()) { - val id = cursor.getLong( - cursor.getColumnIndexOrThrow(TvContractCompat.Channels._ID) - ) - val name = cursor.getString( - cursor.getColumnIndexOrThrow(TvContractCompat.Channels.COLUMN_DISPLAY_NAME) - ) - if (name == channelName) return id - } - null - } - } catch (e: Exception) { - Log.e("TvChannelUtils", "Query failed: ${e.message}", e) - null - } - } - - /** Insert programs into a channel */ - @SuppressLint("RestrictedApi") - fun addPrograms(context: Context, channelId: Long, items: List) { - for (item in items) { - try { - val nameBase64 = base64Encode(item.apiName.toByteArray(Charsets.UTF_8)) - val urlBase64 = base64Encode(item.url.toByteArray(Charsets.UTF_8)) - val csshareUri = "$APP_STRING_SHARE:$nameBase64?$urlBase64" - val poster=item.posterUrl - val builder = PreviewProgram.Builder() - .setChannelId(channelId) - .setTitle(item.name) - .apply { - val scoreText = item.score?.toStringNull(0.1, 10, 1)?.let { - " - " + txt(R.string.rating_format, it).asString(context) - } ?: "" - setDescription("${item.apiName}$scoreText") - } - .setContentId(item.url) - .setType(TvContractCompat.PreviewPrograms.TYPE_MOVIE) - .setIntentUri(csshareUri.toUri()) - .setPosterArtAspectRatio(TvContractCompat.PreviewPrograms.ASPECT_RATIO_2_3) - - // Validate poster URL before setting - if (!poster.isNullOrBlank() && poster.startsWith("http")) { - builder.setPosterArtUri(poster.toUri()) - - } - val program = builder.build() - - val uri = context.contentResolver.insert( - TvContractCompat.PreviewPrograms.CONTENT_URI, - program.toContentValues() - ) - - if (uri != null) { - val programId = ContentUris.parseId(uri) - context.saveProgramId(programId) - Log.d("TvChannelUtils", "Inserted program ${item.name}, ID=$programId") - } else { - Log.e("TvChannelUtils", "Insert failed for ${item.name}") - } - - } catch (error: Exception) { - Log.e("TvChannelUtils", "Error inserting ${item.name}: $error") - } - } - } - - fun deleteStoredPrograms(context: Context) { - val programIds = context.getStoredProgramIds() - - for (id in programIds) { - val uri = ContentUris.withAppendedId(TvContractCompat.PreviewPrograms.CONTENT_URI, id) - try { - val rowsDeleted = context.contentResolver.delete(uri, null, null) - if (rowsDeleted > 0) { - context.removeProgramId(id) // Remove from persistent list - } else { - Log.w("ProgramDelete", "No program found for ID: $id") - } - } catch (e: Exception) { - Log.e("ProgramDelete", "Failed to delete program ID: $id", e) - } - } - - Log.d("ProgramDelete", "Finished deleting stored programs") - } - - fun createTvChannel(context: Context) { - val componentName = ComponentName(context, MainActivity::class.java) - val iconUri = "android.resource://${context.packageName}/mipmap/ic_launcher".toUri() - val inputId = TvContractCompat.buildInputId(componentName) - val channel = Channel.Builder() - .setType(TvContractCompat.Channels.TYPE_PREVIEW) - .setAppLinkIconUri(iconUri) - .setDisplayName(context.getString(R.string.app_name)) - .setAppLinkIntent(Intent(Intent.ACTION_VIEW).apply { - data = "cloudstreamapp://open".toUri() - }) - .setInputId(inputId) - .build() - - val channelUri = context.contentResolver.insert( - TvContractCompat.Channels.CONTENT_URI, - channel.toContentValues() - ) - - channelUri?.let { - val channelId = ContentUris.parseId(it) - TvContractCompat.requestChannelBrowsable(context, channelId) - Log.d("TvChannelUtils", "Channel Created: $channelId") - } - } - -} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/UIHelper.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/UIHelper.kt index c12674816..ad1b6502d 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/UIHelper.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/UIHelper.kt @@ -8,34 +8,29 @@ import android.app.Dialog import android.content.ClipData import android.content.ClipboardManager import android.content.Context -import android.content.Intent 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.Handler +import android.os.Looper import android.os.TransactionTooLargeException import android.util.Log -import android.view.Gravity -import android.view.MenuItem -import android.view.View -import android.view.ViewGroup +import android.view.* import android.view.ViewGroup.MarginLayoutParams -import android.view.WindowManager import android.view.inputmethod.InputMethodManager +import android.widget.ImageView import android.widget.ListAdapter 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.DrawableRes +import androidx.annotation.IdRes import androidx.annotation.StyleRes import androidx.appcompat.view.ContextThemeWrapper import androidx.appcompat.view.menu.MenuBuilder @@ -43,9 +38,9 @@ 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.drawable.toBitmapOrNull import androidx.core.graphics.green import androidx.core.graphics.red import androidx.core.view.marginBottom @@ -53,36 +48,36 @@ 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 import androidx.navigation.fragment.NavHostFragment import androidx.palette.graphics.Palette import androidx.preference.PreferenceManager +import com.bumptech.glide.load.DataSource +import com.bumptech.glide.load.engine.DiskCacheStrategy +import com.bumptech.glide.load.engine.GlideException +import com.bumptech.glide.load.model.GlideUrl +import com.bumptech.glide.load.resource.drawable.DrawableTransitionOptions +import com.bumptech.glide.request.RequestListener +import com.bumptech.glide.request.RequestOptions.bitmapTransform +import com.bumptech.glide.request.target.Target import com.google.android.material.appbar.AppBarLayout import com.google.android.material.chip.Chip import com.google.android.material.chip.ChipDrawable import com.google.android.material.chip.ChipGroup -import com.lagradost.cloudstream3.CloudStreamApp.Companion.context +import com.lagradost.cloudstream3.AcraApplication.Companion.context import com.lagradost.cloudstream3.CommonActivity.activity import com.lagradost.cloudstream3.CommonActivity.showToast import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.mvvm.logError +import com.lagradost.cloudstream3.ui.result.UiImage +import com.lagradost.cloudstream3.ui.result.UiText +import com.lagradost.cloudstream3.ui.result.txt +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 kotlinx.coroutines.delay +import jp.wasabeef.glide.transformations.BlurTransformation 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() @@ -101,12 +96,7 @@ object UIHelper { || Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) } - fun populateChips( - view: ChipGroup?, - tags: List, - @StyleRes style: Int = R.style.ChipFilled, - @AttrRes textColor: Int? = R.attr.white, - ) { + fun populateChips(view: ChipGroup?, tags: List, @StyleRes style : Int = R.style.ChipFilled) { if (view == null) return view.removeAllViews() val context = view.context ?: return @@ -126,9 +116,7 @@ object UIHelper { chip.isCheckable = false chip.isFocusable = false chip.isClickable = false - textColor?.let { - chip.setTextColor(context.colorFromAttribute(it)) - } + chip.setTextColor(context.colorFromAttribute(R.attr.white)) view.addView(chip) } } @@ -204,15 +192,17 @@ object UIHelper { listView.requestLayout() } - fun Context.getSpanCount(isHorizontal:Boolean=false): Int { -// val compactView = false - val spanCountLandscape = if (isHorizontal) 3 else 6 - val spanCountPortrait = if (isHorizontal) 2 else 3 - val orientation = resources.configuration.orientation + fun Context?.getSpanCount(): Int? { + val compactView = false + val spanCountLandscape = if (compactView) 2 else 6 + val spanCountPortrait = if (compactView) 1 else 3 + val orientation = this?.resources?.configuration?.orientation ?: return null return if (orientation == Configuration.ORIENTATION_LANDSCAPE) { spanCountLandscape - } else spanCountPortrait + } else { + spanCountPortrait + } } fun Fragment.hideKeyboard() { @@ -223,7 +213,7 @@ object UIHelper { } fun View?.setAppBarNoScrollFlagsOnTV() { - if (isLayout(TV or EMULATOR)) { + if (isLayout(Globals.TV or EMULATOR)) { this?.updateLayoutParams { scrollFlags = AppBarLayout.LayoutParams.SCROLL_FLAG_NO_SCROLL } @@ -237,93 +227,29 @@ object UIHelper { } } - fun Activity?.navigate( - navigationId: Int, - args: Bundle? = null, - navOptions: NavOptions? = null // To control nav graph & manage back stack - ) { - val tag = "NavComponent" - if (this is FragmentActivity) { - try { - runOnUiThread { - // Navigate using navigation ID - val navHostFragment = - supportFragmentManager.findFragmentById(R.id.nav_host_fragment) as? NavHostFragment - Log.i(tag, "Navigating to fragment: $navigationId") - navHostFragment?.navController?.navigate(navigationId, args, navOptions) - } - } catch (t: Throwable) { - logError(t) - } - } - } - - // Open activities from an activity outside the nav graph - fun Context.openActivity(activity: Class<*>, args: Bundle? = null, baseIntent: Intent? = null) { - val tag = "NavComponent" + fun Activity?.navigate(@IdRes navigation: Int, arguments: Bundle? = null) { try { - val intent = baseIntent ?: Intent() - intent.setClass(this, activity) - - if (args != null) { - intent.putExtras(args) + if (this is FragmentActivity) { + val navHostFragment = supportFragmentManager.findFragmentById(R.id.nav_host_fragment) as? NavHostFragment? + navHostFragment?.navController?.navigate(navigation, arguments) } - Log.i(tag, "Navigating to Activity: ${activity.simpleName}") - startActivity(intent) } catch (t: Throwable) { logError(t) } } - /** 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") - // If the state is not saved, it's safe to perform the back press action. - onBackPressedDispatcher.onBackPressed() - } else { - // If the state is saved, retry the back press action after a slight delay. - // This gives the FragmentManager time to complete any ongoing state-saving - // operations or transactions, ensuring that we do not encounter an IllegalStateException. - delay(100) - if (!supportFragmentManager.isStateSaved) { - Log.d("popFragment", "Destroying after delay") - onBackPressedDispatcher.onBackPressed() - } - } - if (fromBackPressedCallback != null) { - enableBackPressedCallback(fromBackPressedCallback) - } - } - } - @ColorInt fun Context.getResourceColor(@AttrRes resource: Int, alphaFactor: Float = 1f): Int { - val color = colorFromAttribute(resource) - return if (alphaFactor < 1f) adjustAlpha(color, alphaFactor) else color - } + val typedArray = obtainStyledAttributes(intArrayOf(resource)) + val color = typedArray.getColor(0, 0) + typedArray.recycle() - @ColorInt - fun Context.colorFromAttribute(@AttrRes attribute: Int): Int { - var color = 0 - withStyledAttributes(attrs = intArrayOf(attribute)) { - color = getColor(0, 0) + if (alphaFactor < 1f) { + val alpha = (color.alpha * alphaFactor).roundToInt() + return Color.argb(alpha, color.red, color.green, color.blue) } - return color - } - @ColorInt - fun adjustAlpha(@ColorInt color: Int, factor: Float): Int { - val alpha = (color.alpha * factor).roundToInt() - return Color.argb(alpha, color.red, color.green, color.blue) + return color } var createPaletteAsyncCache: HashMap = hashMapOf() @@ -340,48 +266,238 @@ object UIHelper { } } + /*inline fun bindViewBinding( + inflater: LayoutInflater?, + container: ViewGroup?, + layout: Int + ): Pair { + return try { + val localInflater = inflater ?: container?.context?.let { LayoutInflater.from(it) } + ?: return null to txt( + R.string.unable_to_inflate, + "Requires inflater OR container" + )//throw IllegalArgumentException("Requires inflater OR container")) + + //println("methods: ${T::class.java.methods.map { it.name }}") + val bind = T::class.java.methods.first { it.name == "bind" } + //val inflate = T::class.java.methods.first { it.name == "inflate" } + val root = localInflater.inflate(layout, container, false) + bind.invoke(null, root) as T to null + } catch (t: Throwable) { + logError(t) + val message = txt(R.string.unable_to_inflate, t.message ?: "Primary constructor") + // if the desired layout is not found then we inflate the casted layout + /*try { + val localInflater = inflater ?: container?.context?.let { LayoutInflater.from(it) } + ?: return null to txt( + R.string.unable_to_inflate, + "Requires inflater OR container" + )//throw IllegalArgumentException("Requires inflater OR container")) + + // we don't know what method to use as there are 2, but first *should* always be true + return try { + val inflate = T::class.java.methods.first { it.name == "inflate" } + inflate.invoke(null, localInflater, container, false) as T + } catch (_: Throwable) { + val inflate = T::class.java.methods.last { it.name == "inflate" } + inflate.invoke(null, localInflater, container, false) as T + } to message + } catch (t: Throwable) { + logError(t) + }*/ + + null to message + } + }*/ + + fun ImageView?.setImage( + url: String?, + headers: Map? = null, + @DrawableRes + errorImageDrawable: Int? = null, + fadeIn: Boolean = true, + radius: Int = 0, + sample: Int = 3, + colorCallback: ((Palette) -> Unit)? = null + ): Boolean { + if (url.isNullOrBlank()) return false + this.setImage( + UiImage.Image(url, headers, errorImageDrawable), + errorImageDrawable, + fadeIn, + radius, + sample, + colorCallback + ) + return true + } + + fun ImageView?.setImage( + uiImage: UiImage?, + @DrawableRes + errorImageDrawable: Int? = null, + fadeIn: Boolean = true, + radius: Int = 0, + sample: Int = 3, + colorCallback: ((Palette) -> Unit)? = null, + ): Boolean { + if (this == null || uiImage == null) return false + + val (glideImage, identifier) = + (uiImage as? UiImage.Drawable)?.resId?.let { + it to it.toString() + } ?: (uiImage as? UiImage.Image)?.let { image -> + GlideUrl(image.url) { image.headers ?: emptyMap() } to image.url + } ?: return false + + return try { + var builder = com.bumptech.glide.Glide.with(this) + .load(glideImage) + .skipMemoryCache(true) + .diskCacheStrategy(DiskCacheStrategy.ALL).let { req -> + if (fadeIn) + req.transition(DrawableTransitionOptions.withCrossFade()) + else req + } + + if (radius > 0) { + builder = builder.apply(bitmapTransform(BlurTransformation(radius, sample))) + } + + if (colorCallback != null) { + builder = builder.listener(object : RequestListener { + + override fun onResourceReady( + resource: Drawable, + model: Any, + target: Target?, + dataSource: DataSource, + isFirstResource: Boolean + ): Boolean { + resource.toBitmapOrNull() + ?.let { bitmap -> + createPaletteAsync( + identifier, + bitmap, + colorCallback + ) + } + return false + } + + override fun onLoadFailed( + e: GlideException?, + model: Any?, + target: Target, + isFirstResource: Boolean + ): Boolean { + return false + } + }) + } + + val res = if (errorImageDrawable != null) + builder.error(errorImageDrawable).into(this) + else + builder.into(this) + res.clearOnDetach() + + true + } catch (e: Exception) { + logError(e) + false + } + } + + fun ImageView?.setImageBlur( + url: String?, + radius: Int, + sample: Int = 3, + headers: Map? = null + ) { + if (this == null || url.isNullOrBlank()) return + try { + val res = com.bumptech.glide.Glide.with(this) + .load(GlideUrl(url) { headers ?: emptyMap() }) + .apply(bitmapTransform(BlurTransformation(radius, sample))) + .transition( + DrawableTransitionOptions.withCrossFade() + ) + .skipMemoryCache(true) + .diskCacheStrategy(DiskCacheStrategy.ALL) + .into(this) + res.clearOnDetach() + } catch (e: Exception) { + logError(e) + } + } + + 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 - @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 - ) + /** BUGGED AF **/ + /*if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + + WindowCompat.setDecorFitsSystemWindows(window, false) + WindowInsetsControllerCompat(window, View(this)).let { controller -> + controller.hide(WindowInsetsCompat.Type.systemBars()) + controller.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE + } + }*/ + + @Suppress("DEPRECATION") + window.decorView.systemUiVisibility = ( + View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY + // 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 + ) + //} } - 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 FragmentActivity.popCurrentPage() { + // Post the back press action to the main thread handler to ensure it executes + // after any currently pending UI updates or fragment transactions. + Handler(Looper.getMainLooper()).post { + // Check if the FragmentManager state is saved. If it is, we cannot perform + // fragment transactions safely because the state may be inconsistent. + if (!supportFragmentManager.isStateSaved) { + // If the state is not saved, it's safe to perform the back press action. + this.onBackPressedDispatcher.onBackPressed() + } else { + // If the state is saved, retry the back press action after a slight delay. + // This gives the FragmentManager time to complete any ongoing state-saving + // operations or transactions, ensuring that we do not encounter an IllegalStateException. + Handler(Looper.getMainLooper()).postDelayed({ + this.onBackPressedDispatcher.onBackPressed() + }, 100) + } + } } fun Context.getStatusBarHeight(): Int { - if (isLayout(TV or EMULATOR)) { + if (isLayout(Globals.TV or EMULATOR)) { return 0 } @@ -393,6 +509,17 @@ object UIHelper { return result } + fun fixPaddingStatusbar(v: View?) { + if (v == null) return + val ctx = v.context ?: return + v.setPadding( + v.paddingLeft, + v.paddingTop + ctx.getStatusBarHeight(), + v.paddingRight, + v.paddingBottom + ) + } + fun fixPaddingStatusbarMargin(v: View?) { if (v == null) return val ctx = v.context ?: return @@ -417,84 +544,6 @@ object UIHelper { v.layoutParams = params } - fun fixSystemBarsPadding( - v: View, - @DimenRes heightResId: Int? = null, - @DimenRes widthResId: Int? = null, - padTop: Boolean = true, - padBottom: Boolean = true, - padLeft: Boolean = true, - padRight: Boolean = true, - overlayCutout: Boolean = true, - fixIme: Boolean = false - ) { - // edge-to-edge is very buggy on earlier versions so we just - // handle the status bar here instead. - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) { - if (padTop) { - val ctx = v.context ?: return - v.updatePadding(top = ctx.getStatusBarHeight()) - } - return - } - - ViewCompat.setOnApplyWindowInsetsListener(v) { view, windowInsets -> - val leftCheck = if (view.isRtl()) padRight else padLeft - val rightCheck = if (view.isRtl()) padLeft else padRight - - val insetTypes = WindowInsetsCompat.Type.systemBars() or - WindowInsetsCompat.Type.displayCutout() or - if (fixIme) WindowInsetsCompat.Type.ime() else 0 - - val insets = windowInsets.getInsets(insetTypes) - - view.updatePadding( - left = if (leftCheck) insets.left else view.paddingLeft, - right = if (rightCheck) insets.right else view.paddingRight, - bottom = if (padBottom) insets.bottom else view.paddingBottom, - top = if (padTop) insets.top else view.paddingTop - ) - - heightResId?.let { - val heightPx = view.resources.getDimensionPixelSize(it) - view.updateLayoutParams { - height = heightPx + insets.bottom - } - } - - widthResId?.let { - val widthPx = view.resources.getDimensionPixelSize(it) - view.updateLayoutParams { - val startInset = if (view.isRtl()) insets.right else insets.left - width = if (startInset > 0) widthPx + startInset else widthPx - } - } - - if (overlayCutout && isLayout(PHONE)) { - // Draw a black overlay over the cutout. We do this so that - // it doesn't use the fragment background. We want it to - // appear as if the screen actually ends at cutout. - val cutout = windowInsets.displayCutout - if (cutout != null) { - val left = if (!leftCheck) 0 else cutout.safeInsetLeft - val right = if (!rightCheck) 0 else cutout.safeInsetRight - view.overlay.clear() - if (left > 0 || right > 0) { - view.overlay.add( - CutoutOverlayDrawable( - view, - leftCutout = left, - rightCutout = right - ) - ) - } - } - } - - WindowInsetsCompat.CONSUMED - } - } - fun Context.getNavigationBarHeight(): Int { var result = 0 val resourceId = resources.getIdentifier("navigation_bar_height", "dimen", "android") @@ -510,52 +559,76 @@ object UIHelper { return settingsManager.getBoolean(getString(R.string.bottom_title_key), true) } - 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 - ) - } + 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 { - 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) - } + @Suppress("DEPRECATION") + window.setFlags( + WindowManager.LayoutParams.FLAG_FULLSCREEN, + WindowManager.LayoutParams.FLAG_FULLSCREEN + ) } - } catch (t: Throwable) { - logError(t) + 0 + } else { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + window.insetsController?.show(WindowInsets.Type.statusBars()) + + } else { + window.clearFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN) + } + + this.getStatusBarHeight() } } // Shows the system bars by removing all the flags // except for the ones that make the content appear under the system bars. fun Activity.showSystemUI() { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - val controller = WindowCompat.getInsetsController(window, window.decorView) - if (isLayout(EMULATOR)) { - controller.show(WindowInsetsCompat.Type.navigationBars()) - controller.hide(WindowInsetsCompat.Type.statusBars()) - } else controller.show(WindowInsetsCompat.Type.systemBars()) - return - } - @Suppress("DEPRECATION") - window.decorView.systemUiVisibility = - (View.SYSTEM_UI_FLAG_LAYOUT_STABLE or View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN) + /*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 **/ + window.decorView.systemUiVisibility = + (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.O) { + 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 @@ -590,13 +663,7 @@ 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, R.attr.actionOverflowMenuStyle, 0) items.forEach { (id, stringRes) -> popup.menu.add(0, id, 0, stringRes) @@ -620,13 +687,7 @@ 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, R.attr.actionOverflowMenuStyle, 0) items.forEach { (id, string) -> popup.menu.add(0, id, 0, string) @@ -642,39 +703,4 @@ object UIHelper { popup.show() return popup } -} - -private class CutoutOverlayDrawable( - private val view: View, - private val leftCutout: Int, - private val rightCutout: Int, -) : Drawable() { - private val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply { - color = Color.BLACK - style = Paint.Style.FILL - } - - override fun draw(canvas: Canvas) { - if (leftCutout > 0) canvas.drawRect( - 0f, - 0f, - leftCutout.toFloat(), - view.height.toFloat(), - paint - ) - if (rightCutout > 0) { - canvas.drawRect( - view.width - rightCutout.toFloat(), - 0f, view.width.toFloat(), - view.height.toFloat(), - paint - ) - } - } - - override fun setAlpha(alpha: Int) {} - override fun setColorFilter(colorFilter: ColorFilter?) {} - - @Suppress("OVERRIDE_DEPRECATION") - override fun getOpacity() = PixelFormat.OPAQUE } \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/VideoDownloadHelper.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/VideoDownloadHelper.kt new file mode 100644 index 000000000..30f66f835 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/VideoDownloadHelper.kt @@ -0,0 +1,40 @@ +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/DownloadManager.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/VideoDownloadManager.kt similarity index 62% rename from app/src/main/java/com/lagradost/cloudstream3/utils/downloader/DownloadManager.kt rename to app/src/main/java/com/lagradost/cloudstream3/utils/VideoDownloadManager.kt index 7cb190667..e0b78543a 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/downloader/DownloadManager.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/VideoDownloadManager.kt @@ -1,30 +1,30 @@ -package com.lagradost.cloudstream3.utils.downloader +package com.lagradost.cloudstream3.utils - -import android.Manifest -import android.annotation.SuppressLint import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager import android.app.PendingIntent -import android.content.Context -import android.content.Intent -import android.content.pm.PackageManager +import android.content.* +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.net.toUri import androidx.preference.PreferenceManager +import androidx.work.Data +import androidx.work.ExistingWorkPolicy +import androidx.work.OneTimeWorkRequest +import androidx.work.WorkManager +import com.bumptech.glide.Glide +import com.bumptech.glide.load.model.GlideUrl +import com.fasterxml.jackson.annotation.JsonProperty import com.lagradost.cloudstream3.APIHolder.getApiFromNameNull +import com.lagradost.cloudstream3.AcraApplication.Companion.removeKey +import com.lagradost.cloudstream3.AcraApplication.Companion.setKey import com.lagradost.cloudstream3.BuildConfig -import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey -import com.lagradost.cloudstream3.CommonActivity.showToast -import com.lagradost.cloudstream3.CloudStreamApp.Companion.removeKey -import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey import com.lagradost.cloudstream3.IDownloadableMinimum import com.lagradost.cloudstream3.MainActivity import com.lagradost.cloudstream3.R @@ -32,58 +32,14 @@ import com.lagradost.cloudstream3.TvType import com.lagradost.cloudstream3.app import com.lagradost.cloudstream3.mvvm.launchSafe import com.lagradost.cloudstream3.mvvm.logError -import com.lagradost.cloudstream3.mvvm.safe import com.lagradost.cloudstream3.services.VideoDownloadService -import com.lagradost.cloudstream3.sortUrls -import com.lagradost.cloudstream3.ui.download.DOWNLOAD_NAVIGATE_TO -import com.lagradost.cloudstream3.ui.player.LOADTYPE_INAPP_DOWNLOAD -import com.lagradost.cloudstream3.ui.player.RepoLinkGenerator -import com.lagradost.cloudstream3.ui.player.SubtitleData -import com.lagradost.cloudstream3.ui.player.source_priority.QualityDataHelper -import com.lagradost.cloudstream3.ui.player.source_priority.QualityDataHelper.getLinkPriority -import com.lagradost.cloudstream3.ui.result.ExtractorSubtitleLink -import com.lagradost.cloudstream3.ui.result.ResultEpisode -import com.lagradost.cloudstream3.ui.subtitles.SubtitlesFragment -import com.lagradost.cloudstream3.utils.AppContextUtils.createNotificationChannel -import com.lagradost.cloudstream3.utils.AppContextUtils.sortSubs import com.lagradost.cloudstream3.utils.Coroutines.ioSafe import com.lagradost.cloudstream3.utils.Coroutines.main -import com.lagradost.cloudstream3.utils.DOWNLOAD_EPISODE_CACHE -import com.lagradost.cloudstream3.utils.DOWNLOAD_HEADER_CACHE -import com.lagradost.cloudstream3.utils.DataStore.getFolderName import com.lagradost.cloudstream3.utils.DataStore.getKey import com.lagradost.cloudstream3.utils.DataStore.removeKey -import com.lagradost.cloudstream3.utils.Event -import com.lagradost.cloudstream3.utils.ExtractorLink -import com.lagradost.cloudstream3.utils.ExtractorLinkType -import com.lagradost.cloudstream3.utils.M3u8Helper -import com.lagradost.cloudstream3.utils.M3u8Helper2 -import com.lagradost.cloudstream3.utils.SubtitleHelper.fromTagToEnglishLanguageName import com.lagradost.cloudstream3.utils.SubtitleUtils.deleteMatchingSubtitles import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute -import com.lagradost.cloudstream3.utils.downloader.DownloadFileManagement.getBasePath -import com.lagradost.cloudstream3.utils.downloader.DownloadFileManagement.getDefaultDir -import com.lagradost.cloudstream3.utils.downloader.DownloadFileManagement.getFileName -import com.lagradost.cloudstream3.utils.downloader.DownloadFileManagement.getFolder -import com.lagradost.cloudstream3.utils.downloader.DownloadFileManagement.sanitizeFilename -import com.lagradost.cloudstream3.utils.downloader.DownloadFileManagement.toFile -import com.lagradost.cloudstream3.utils.downloader.DownloadObjects.CreateNotificationMetadata -import com.lagradost.cloudstream3.utils.downloader.DownloadObjects.DownloadEpisodeMetadata -import com.lagradost.cloudstream3.utils.downloader.DownloadObjects.DownloadItem -import com.lagradost.cloudstream3.utils.downloader.DownloadObjects.DownloadResumePackage -import com.lagradost.cloudstream3.utils.downloader.DownloadObjects.DownloadStatus -import com.lagradost.cloudstream3.utils.downloader.DownloadObjects.DownloadedFileInfo -import com.lagradost.cloudstream3.utils.downloader.DownloadObjects.DownloadedFileInfoResult -import com.lagradost.cloudstream3.utils.downloader.DownloadObjects.LazyStreamDownloadResponse -import com.lagradost.cloudstream3.utils.downloader.DownloadObjects.StreamData -import com.lagradost.cloudstream3.utils.downloader.DownloadObjects.DownloadQueueWrapper -import com.lagradost.cloudstream3.utils.downloader.DownloadUtils.appendAndDontOverride -import com.lagradost.cloudstream3.utils.downloader.DownloadUtils.cancel -import com.lagradost.cloudstream3.utils.downloader.DownloadUtils.downloadSubtitle -import com.lagradost.cloudstream3.utils.downloader.DownloadUtils.getEstimatedTimeLeft -import com.lagradost.cloudstream3.utils.downloader.DownloadUtils.getImageBitmapFromUrl -import com.lagradost.cloudstream3.utils.downloader.DownloadUtils.join -import com.lagradost.cloudstream3.utils.txt +import com.lagradost.safefile.MediaFileContentType import com.lagradost.safefile.SafeFile import com.lagradost.safefile.closeQuietly import kotlinx.coroutines.CancellationException @@ -94,36 +50,27 @@ 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.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import java.io.Closeable +import java.io.File import java.io.IOException import java.io.OutputStream +import java.lang.IllegalArgumentException +import java.util.* const val DOWNLOAD_CHANNEL_ID = "cloudstream3.general" const val DOWNLOAD_CHANNEL_NAME = "Downloads" const val DOWNLOAD_CHANNEL_DESCRIPT = "The download notification channel" object VideoDownloadManager { - fun maxConcurrentDownloads(context: Context): Int = - PreferenceManager.getDefaultSharedPreferences(context) - ?.getInt(context.getString(R.string.download_parallel_key), 3) ?: 3 - - private fun maxConcurrentConnections(context: Context): Int = - PreferenceManager.getDefaultSharedPreferences(context) - ?.getInt(context.getString(R.string.download_concurrent_key), 3) ?: 3 - - private val _currentDownloads: MutableStateFlow> = MutableStateFlow(emptySet()) - val currentDownloads: StateFlow> = _currentDownloads - + var maxConcurrentDownloads = 3 + var maxConcurrentConnections = 3 + private var currentDownloads = mutableListOf() const val TAG = "VDM" - private const val DOWNLOAD_NOTIFICATION_TAG = "FROM_DOWNLOADER" private const val USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.0.0 Safari/537.36" @@ -167,6 +114,56 @@ object VideoDownloadManager { Stop, } + data class DownloadEpisodeMetadata( + @JsonProperty("id") val id: Int, + @JsonProperty("mainName") val mainName: String, + @JsonProperty("sourceApiName") val sourceApiName: String?, + @JsonProperty("poster") val poster: String?, + @JsonProperty("name") val name: String?, + @JsonProperty("season") val season: Int?, + @JsonProperty("episode") val episode: Int?, + @JsonProperty("type") val type: TvType?, + ) + + data class DownloadItem( + @JsonProperty("source") val source: String?, + @JsonProperty("folder") val folder: String?, + @JsonProperty("ep") val ep: DownloadEpisodeMetadata, + @JsonProperty("links") val links: List, + ) + + data class DownloadResumePackage( + @JsonProperty("item") val item: DownloadItem, + @JsonProperty("linkIndex") val linkIndex: Int?, + ) + + data class DownloadedFileInfo( + @JsonProperty("totalBytes") val totalBytes: Long, + @JsonProperty("relativePath") val relativePath: String, + @JsonProperty("displayName") val displayName: String, + @JsonProperty("extraInfo") val extraInfo: String? = null, + @JsonProperty("basePath") val basePath: String? = null // null is for legacy downloads. See getDefaultPath() + ) + + data class DownloadedFileInfoResult( + @JsonProperty("fileLength") val fileLength: Long, + @JsonProperty("totalBytes") val totalBytes: Long, + @JsonProperty("path") val path: Uri, + ) + + data class DownloadQueueResumePackage( + @JsonProperty("index") val index: Int, + @JsonProperty("pkg") val pkg: DownloadResumePackage, + ) + + data class DownloadStatus( + /** if you should retry with the same args and hope for a better result */ + val retrySame: Boolean, + /** if you should try the next mirror */ + val tryNext: Boolean, + /** if the result is what the user intended */ + val success: Boolean, + ) /** Invalid input, just skip to the next one as the same args will give the same error */ private val DOWNLOAD_INVALID_INPUT = @@ -183,60 +180,77 @@ object VideoDownloadManager { /** the process failed due to some reason, so we retry and also try the next mirror */ private val DOWNLOAD_FAILED = DownloadStatus(retrySame = true, tryNext = true, success = false) - /** The download only downloaded partial */ - private val DOWNLOAD_PARTIAL_SUCCESS = - DownloadStatus(retrySame = true, tryNext = false, success = true) - - /** 50MB minimum size */ - const val DOWNLOAD_PARTIAL_MIN_SIZE = 1_048_576L * 50L - /** bad config, skip all mirrors as every call to download will have the same bad config */ private val DOWNLOAD_BAD_CONFIG = DownloadStatus(retrySame = false, tryNext = false, success = false) - const val KEY_RESUME_PACKAGES = "download_resume_2" + const val KEY_RESUME_PACKAGES = "download_resume" const val KEY_DOWNLOAD_INFO = "download_info" - - /** A key to save all the downloads which have not yet started and those currently running, using [DownloadQueueWrapper] - * [KEY_RESUME_PACKAGES] can store keys which should not be automatically queued, unlike this key. - */ - const val KEY_RESUME_IN_QUEUE = "download_resume_queue_key" -// private const val KEY_RESUME_QUEUE_PACKAGES = "download_q_resume" + private const val KEY_RESUME_QUEUE_PACKAGES = "download_q_resume" val downloadStatus = HashMap() val downloadStatusEvent = Event>() val downloadDeleteEvent = Event() val downloadEvent = Event>() val downloadProgressEvent = Event>() -// val downloadQueue = LinkedList() - - private var hasCreatedNotChannel = false + val downloadQueue = LinkedList() + private var hasCreatedNotChanel = false private fun Context.createNotificationChannel() { - hasCreatedNotChannel = true - - this.createNotificationChannel( - DOWNLOAD_CHANNEL_ID, - DOWNLOAD_CHANNEL_NAME, - DOWNLOAD_CHANNEL_DESCRIPT - ) - } - - fun cancelAllDownloadNotifications(context: Context) { - val manager = NotificationManagerCompat.from(context) - manager.activeNotifications.forEach { notification -> - if (notification.tag == DOWNLOAD_NOTIFICATION_TAG) { - manager.cancel(DOWNLOAD_NOTIFICATION_TAG, notification.id) + hasCreatedNotChanel = true + // Create the NotificationChannel, but only on API 26+ because + // the NotificationChannel class is new and not in the support library + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val name = DOWNLOAD_CHANNEL_NAME //getString(R.string.channel_name) + val descriptionText = DOWNLOAD_CHANNEL_DESCRIPT//getString(R.string.channel_description) + val importance = NotificationManager.IMPORTANCE_DEFAULT + val channel = NotificationChannel(DOWNLOAD_CHANNEL_ID, name, importance).apply { + description = descriptionText } + // Register the channel with the system + val notificationManager: NotificationManager = + this.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + notificationManager.createNotificationChannel(channel) } } + ///** Will return IsDone if not found or error */ + //fun getDownloadState(id: Int): DownloadType { + // return try { + // downloadStatus[id] ?: DownloadType.IsDone + // } catch (e: Exception) { + // logError(e) + // DownloadType.IsDone + // } + //} + + private val cachedBitmaps = hashMapOf() + fun Context.getImageBitmapFromUrl(url: String, headers: Map? = null): Bitmap? { + try { + if (cachedBitmaps.containsKey(url)) { + return cachedBitmaps[url] + } + + val bitmap = Glide.with(this) + .asBitmap() + .load(GlideUrl(url) { headers ?: emptyMap() }) + .submit(720, 720) + .get() + + if (bitmap != null) { + cachedBitmaps[url] = bitmap + } + 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 createDownloadNotification( + private suspend fun createNotification( context: Context, source: String?, linkName: String?, @@ -252,6 +266,7 @@ object VideoDownloadManager { try { if (total <= 0) return null// crash, invalid data +// main { // DON'T WANT TO SLOW IT DOWN val builder = NotificationCompat.Builder(context, DOWNLOAD_CHANNEL_ID) .setAutoCancel(true) .setColorized(true) @@ -280,8 +295,13 @@ object VideoDownloadManager { data = source.toUri() flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK } - val pendingIntent = - PendingIntentCompat.getActivity(context, 0, intent, 0, false) + val pendingIntent: PendingIntent = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + PendingIntent.getActivity(context, 0, intent, PendingIntent.FLAG_IMMUTABLE) + } else { + //fixme Specify a better flag + PendingIntent.getActivity(context, 0, intent, 0) + } builder.setContentIntent(pendingIntent) } @@ -301,7 +321,7 @@ object VideoDownloadManager { } val downloadFormat = context.getString(R.string.download_format) - if (SDK_INT >= Build.VERSION_CODES.O) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { if (ep.poster != null) { val poster = withContext(Dispatchers.IO) { context.getImageBitmapFromUrl(ep.poster) @@ -318,7 +338,7 @@ object VideoDownloadManager { val mbFormat = "%.1f MB" if (hlsProgress != null && hlsTotal != null) { - progressPercentage = hlsProgress * 100 / hlsTotal + progressPercentage = hlsProgress.toLong() * 100 / hlsTotal progressMbString = hlsProgress.toString() totalMbString = hlsTotal.toString() suffix = " - $mbFormat".format(progress / 1000000f) @@ -334,15 +354,10 @@ 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 $remainingTime" + (if (linkName == null) "" else "$linkName\n") + "$rowTwo\n$progressPercentage % ($progressMbString/$totalMbString)$suffix$mbPerSecondString" } DownloadType.IsPending -> { @@ -400,7 +415,7 @@ object VideoDownloadManager { builder.setContentText(txt) } - if ((state == DownloadType.IsDownloading || state == DownloadType.IsPaused || state == DownloadType.IsPending) && SDK_INT >= Build.VERSION_CODES.O) { + if ((state == DownloadType.IsDownloading || state == DownloadType.IsPaused) && Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { val actionTypes: MutableList = ArrayList() // INIT if (state == DownloadType.IsDownloading) { @@ -412,9 +427,6 @@ object VideoDownloadManager { actionTypes.add(DownloadActionType.Resume) actionTypes.add(DownloadActionType.Stop) } - if (state == DownloadType.IsPending) { - actionTypes.add(DownloadActionType.Stop) - } // ADD ACTIONS for ((index, i) in actionTypes.withIndex()) { @@ -453,7 +465,7 @@ object VideoDownloadManager { } } - if (!hasCreatedNotChannel) { + if (!hasCreatedNotChanel) { context.createNotificationChannel() } @@ -461,14 +473,7 @@ object VideoDownloadManager { notificationCallback(ep.id, notification) with(NotificationManagerCompat.from(context)) { // notificationId is a unique int for each notification that you must define - if (ActivityCompat.checkSelfPermission( - context, - Manifest.permission.POST_NOTIFICATIONS - ) != PackageManager.PERMISSION_GRANTED - ) { - return null - } - notify(DOWNLOAD_NOTIFICATION_TAG, ep.id, notification) + notify(ep.id, notification) } return notification } catch (e: Exception) { @@ -477,6 +482,67 @@ 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, 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() + } + + val resume: Boolean get() = fileLength > 0L + val startAt: Long get() = if (resume) fileLength else 0L + val exists: Boolean get() = file.exists() == true + } + @Throws(IOException::class) fun setupStream( @@ -498,7 +564,7 @@ object VideoDownloadManager { /** * Sets up the appropriate file and creates a data stream from the file. - * Used for initializing downloads and backups. + * Used for initializing downloads. * */ @Throws(IOException::class) fun setupStream( @@ -510,8 +576,7 @@ object VideoDownloadManager { ): StreamData { val displayName = getDisplayName(name, extension) - val subDir = baseFile.gotoDirectory(folder, createMissingDirectories = true) - ?: throw IOException("Cant create directory") + val subDir = baseFile.gotoDirectoryOrThrow(folder) val foundFile = subDir.findFile(displayName) val (file, fileLength) = if (foundFile == null || foundFile.exists() != true) { @@ -531,7 +596,6 @@ object VideoDownloadManager { /** This class handles the notifications, as well as the relevant key */ data class DownloadMetaData( private val id: Int?, - private val linkHash : Int, var bytesDownloaded: Long = 0, var bytesWritten: Long = 0, @@ -543,7 +607,7 @@ object VideoDownloadManager { private val createNotificationCallback: (CreateNotificationMetadata) -> Unit, private var internalType: DownloadType = DownloadType.IsPending, - val isHLS : Boolean, + // how many segments that we have downloaded var hlsProgress: Int = 0, // how many segments that exist @@ -561,17 +625,13 @@ object VideoDownloadManager { lastDownloadedBytes = length } - /** Returns the appropriate failed status based on download progress */ - fun failedStatus() = if (this.bytesWritten > DOWNLOAD_PARTIAL_MIN_SIZE) - DOWNLOAD_PARTIAL_SUCCESS - else - DOWNLOAD_FAILED - val approxTotalBytes: Long get() = totalBytes ?: hlsTotal?.let { total -> (bytesDownloaded * (total / hlsProgress.toFloat())).toLong() } ?: bytesDownloaded + private val isHLS get() = hlsTotal != null + private var stopListener: (() -> Unit)? = null /** on cancel button pressed or failed invoke this once and only once */ @@ -592,6 +652,8 @@ object VideoDownloadManager { DownloadActionType.Stop -> { type = DownloadType.IsStopped + removeKey(KEY_RESUME_PACKAGES, event.first.toString()) + saveQueue() stopListener?.invoke() stopListener = null } @@ -606,32 +668,11 @@ object VideoDownloadManager { private fun updateFileInfo() { if (id == null) return downloadFileInfoTemplate?.let { template -> - /** This looks strange, but fixes an issue where we do an instant retry, and it fails immediately, - * eg. by turning off wifi */ - val totalBytesValue = if (approxTotalBytes <= bytesDownloaded) { - val prevInfo = getKey( - KEY_DOWNLOAD_INFO, - id.toString() - ) - - /** If this link is the same as the last cached video link metadata */ - if (prevInfo != null && prevInfo.linkHash == linkHash) { - /** Try to use totalBytes if it exists, otherwise the max of the prev data, - * and download size to ensure total >= downloaded */ - totalBytes ?: maxOf(prevInfo.totalBytes, bytesDownloaded) - } else { - approxTotalBytes - } - } else { - approxTotalBytes - } - setKey( KEY_DOWNLOAD_INFO, id.toString(), template.copy( - linkHash = linkHash, - totalBytes = totalBytesValue, + totalBytes = approxTotalBytes, extraInfo = if (isHLS) hlsWrittenProgress.toString() else null ) ) @@ -774,12 +815,34 @@ object VideoDownloadManager { } } + /** bytes have the size end-start where the byte range is [start,end) + * note that ByteArray is a pointer and therefore cant be stored without cloning it */ + data class LazyStreamDownloadResponse( + val bytes: ByteArray, + val startByte: Long, + val endByte: Long, + ) { + val size get() = endByte - startByte + + override fun toString(): String { + return "$startByte->$endByte" + } + + override fun equals(other: Any?): Boolean { + if (other !is LazyStreamDownloadResponse) return false + return other.startByte == startByte && other.endByte == endByte + } + + override fun hashCode(): Int { + return Objects.hash(startByte, endByte) + } + } data class LazyStreamDownloadData( private val url: String, private val headers: Map, private val referer: String, - /** This specifies where chunk i starts and ends, + /** This specifies where chunck i starts and ends, * bytes=${chuckStartByte[ i ]}-${chuckStartByte[ i+1 ] -1} * where out of bounds => bytes=${chuckStartByte[ i ]}- */ private val chuckStartByte: LongArray, @@ -787,7 +850,7 @@ object VideoDownloadManager { val downloadLength: Long?, val chuckSize: Long, val bufferSize: Int, - val isResumed: Boolean, + val isResumed : Boolean, ) { val size get() = chuckStartByte.size @@ -804,7 +867,6 @@ object VideoDownloadManager { private suspend fun resolve( startByte: Long, endByte: Long?, - buffer: ByteArray, callback: (suspend CoroutineScope.(LazyStreamDownloadResponse) -> Unit) ): Long = withContext(Dispatchers.IO) { var currentByte: Long = startByte @@ -823,6 +885,7 @@ object VideoDownloadManager { ) val requestStream = request.body.byteStream() + val buffer = ByteArray(bufferSize) var read: Int try { @@ -853,7 +916,6 @@ object VideoDownloadManager { suspend fun resolveSafe( index: Int, retries: Int = 3, - buffer: ByteArray, callback: (suspend CoroutineScope.(LazyStreamDownloadResponse) -> Unit) ): Boolean { var start = chuckStartByte.getOrNull(index) ?: return false @@ -862,16 +924,16 @@ object VideoDownloadManager { for (i in 0 until retries) { try { // in case - start = resolve(start, end, buffer, callback) + start = resolve(start, end, callback) // no end defined, so we don't care exactly where it ended if (end == null) return true // we have download more or exactly what we needed if (start >= end) return true - } catch (_: IllegalStateException) { + } catch (e: IllegalStateException) { return false - } catch (_: CancellationException) { + } catch (e: CancellationException) { return false - } catch (_: Throwable) { + } catch (t: Throwable) { continue } } @@ -900,7 +962,7 @@ object VideoDownloadManager { var contentLength = headRequest.size if (contentLength != null && contentLength <= 0) contentLength = null - val hasRangeSupport = when (headRequest.headers["Accept-Ranges"]?.lowercase()?.trim()) { + val hasRangeSupport = when(headRequest.headers["Accept-Ranges"]?.lowercase()?.trim()) { // server has stated it has no support "none" -> false // server has stated it has support @@ -908,7 +970,7 @@ object VideoDownloadManager { // if null or undefined (as bytes is the only range unit formally defined) // If the get request returns partial content we support range else -> { - headRequest.headers["Accept-Ranges"]?.let { range -> + headRequest.headers["Accept-Ranges"]?.let { range-> Log.v(TAG, "Unknown Accept-Ranges tag: $range") } // as we don't poll the body this should be fine @@ -919,7 +981,7 @@ object VideoDownloadManager { // we don't want to request more than the actual file // but also more than 0 bytes contentLength?.let { max -> - minOf(maxOf(max - 1L, 3L), 1023L) + minOf(maxOf(max-1L,3L),1023L) } ?: 1023L }" ), @@ -929,17 +991,17 @@ object VideoDownloadManager { // if head request did not work then we can just look for the size here too // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Range if (contentLength == null) { - contentLength = - getRequest.headers["Content-Range"]?.trim()?.lowercase()?.let { range -> - // we only support "bytes" unit - if (range.startsWith("bytes")) { - // may be '*' if unknown - range.substringAfter("/").toLongOrNull() - } else { - Log.v(TAG, "Unknown Content-Range unit: $range") - null - } + contentLength = getRequest.headers["Content-Range"]?.trim()?.lowercase()?.let { range -> + // we only support "bytes" unit + if (range.startsWith("bytes")) { + // may be '*' if unknown + range.substringAfter("/").toLongOrNull() } + else { + Log.v(TAG, "Unknown Content-Range unit: $range") + null + } + } } // supports range if status is partial content https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/206 @@ -947,10 +1009,7 @@ object VideoDownloadManager { } } - Log.d( - TAG, - "Starting stream with url=$url, startByte=$startByte, contentLength=$contentLength, hasRangeSupport=$hasRangeSupport" - ) + Log.d(TAG, "Starting stream with url=$url, startByte=$startByte, contentLength=$contentLength, hasRangeSupport=$hasRangeSupport") var downloadLength: Long? = null @@ -990,6 +1049,38 @@ object VideoDownloadManager { ) } + /** Helper function to make sure duplicate attributes don't get overriden or inserted without lowercase cmp + * example: map("a" to 1) appendAndDontOverride map("A" to 2, "a" to 3, "c" to 4) = map("a" to 1, "c" to 4) + * */ + private fun Map.appendAndDontOverride(rhs: Map): Map { + val out = this.toMutableMap() + val current = this.keys.map { it.lowercase() } + for ((key, value) in rhs) { + if (current.contains(key.lowercase())) continue + out[key] = value + } + return out + } + + private fun List.cancel() { + forEach { job -> + try { + job.cancel() + } catch (t: Throwable) { + logError(t) + } + } + } + + private suspend fun List.join() { + forEach { job -> + try { + job.join() + } catch (t: Throwable) { + logError(t) + } + } + } /** download a file that consist of a single stream of data*/ suspend fun downloadThing( @@ -1017,8 +1108,6 @@ object VideoDownloadManager { bytesDownloaded = 0, createNotificationCallback = createNotificationCallback, id = parentId, - linkHash = link.url.hashCode(), - isHLS = false ) try { // get the file path @@ -1040,7 +1129,14 @@ object VideoDownloadManager { startByte = stream.startAt, headers = link.headers.appendAndDontOverride( mapOf( + "Accept-Encoding" to "identity", + "accept" to "*/*", "user-agent" to USER_AGENT, + "sec-ch-ua" to "\"Chromium\";v=\"91\", \" Not;A Brand\";v=\"99\"", + "sec-fetch-mode" to "navigate", + "sec-fetch-dest" to "video", + "sec-fetch-user" to "?1", + "sec-ch-ua-mobile" to "?0", ) ) ) @@ -1159,29 +1255,13 @@ object VideoDownloadManager { } } - // Reuse a download buffer to decrease unnecessary alloc - val buffer = ByteArray(items.bufferSize) - - // This will take up the first available job and resolve + // this will take up the first available job and resolve while (true) { if (!isActive) return@launch - - var isTooFarAhead = false fileMutex.withLock { if (metadata.type == DownloadType.IsStopped || metadata.type == DownloadType.IsFailed ) return@launch - - // Limit RAM usage by throttling if too much data is downloaded but not yet written to disk - // 50MB limit - if (metadata.bytesDownloaded - metadata.bytesWritten > 50_000_000) { - isTooFarAhead = true - } - } - - if (isTooFarAhead) { - delay(500) - continue } // mutex just in case, we never want this to fail due to multithreading @@ -1192,7 +1272,7 @@ object VideoDownloadManager { // in case something has gone wrong set to failed if the fail is not caused by // user cancellation - if (!items.resolveSafe(index, buffer = buffer, callback = callback)) { + if (!items.resolveSafe(index, callback = callback)) { fileMutex.withLock { if (metadata.type != DownloadType.IsStopped) { metadata.type = DownloadType.IsFailed @@ -1217,7 +1297,7 @@ object VideoDownloadManager { if (!stream.exists) metadata.type = DownloadType.IsStopped if (metadata.type == DownloadType.IsFailed) { - return@withContext metadata.failedStatus() + return@withContext DOWNLOAD_FAILED } if (metadata.type == DownloadType.IsStopped) { @@ -1247,11 +1327,11 @@ object VideoDownloadManager { throw e } catch (t: Throwable) { // some sort of network error, will error - logError(t) + // note that when failing we don't want to delete the file, // only user interaction has that power metadata.type = DownloadType.IsFailed - return@withContext metadata.failedStatus() + return@withContext DOWNLOAD_FAILED } finally { fileStream?.closeQuietly() //requestStream?.closeQuietly() @@ -1273,9 +1353,7 @@ object VideoDownloadManager { val metadata = DownloadMetaData( createNotificationCallback = createNotificationCallback, - id = parentId, - linkHash = link.url.hashCode(), - isHLS = true + id = parentId ) var fileStream: OutputStream? = null try { @@ -1298,7 +1376,6 @@ object VideoDownloadManager { // push the metadata metadata.setResumeLength(stream.startAt) metadata.hlsProgress = startAt - metadata.hlsWrittenProgress = startAt metadata.type = DownloadType.IsPending metadata.setDownloadFileInfoTemplate( DownloadedFileInfo( @@ -1313,12 +1390,14 @@ 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() ) ) - val items = M3u8Helper2.hslLazy(m3u8, selectBest = true, requireAudio = true) + val items = M3u8Helper2.hslLazy(listOf(m3u8)) metadata.hlsTotal = items.size metadata.type = DownloadType.IsDownloading @@ -1350,23 +1429,10 @@ object VideoDownloadManager { launch(Dispatchers.IO) { while (true) { if (!isActive) return@launch - - var isTooFarAhead = false fileMutex.withLock { if (metadata.type == DownloadType.IsStopped || metadata.type == DownloadType.IsFailed ) return@launch - - // Limit RAM usage by throttling if too much data is downloaded but not yet written to disk - // 50MB limit - if (metadata.bytesDownloaded - metadata.bytesWritten > 50_000_000) { - isTooFarAhead = true - } - } - - if (isTooFarAhead) { - delay(500) - continue } // mutex just in case, we never want this to fail due to multithreading @@ -1386,45 +1452,50 @@ object VideoDownloadManager { return@launch } - fileMutex.withLock { + try { + fileMutex.lock() + // user pause + while (metadata.type == DownloadType.IsPaused) delay(100) + // if stopped then break to delete + if (metadata.type == DownloadType.IsStopped || metadata.type == DownloadType.IsFailed || !isActive) return@launch + + val segmentLength = bytes.size.toLong() + // send notification, no matter the actual write order + metadata.addSegment(segmentLength) + + // directly write the bytes if you are first + if (metadata.hlsWrittenProgress == index) { + fileStream.write(bytes) + + metadata.addBytesWritten(segmentLength) + metadata.setWrittenSegment(index) + } else { + // no need to clone as there will be no modification of this bytearray + pendingData[index] = bytes + } + + // write the cached bytes submitted by other threads + while (true) { + val cache = pendingData.remove(metadata.hlsWrittenProgress) ?: break + val cacheLength = cache.size.toLong() + + fileStream.write(cache) + + metadata.addBytesWritten(cacheLength) + metadata.setWrittenSegment(metadata.hlsWrittenProgress) + } + } catch (t: Throwable) { + // this is in case of write fail + logError(t) + if (metadata.type != DownloadType.IsStopped) { + metadata.type = DownloadType.IsFailed + } + } finally { try { - // user pause - while (metadata.type == DownloadType.IsPaused) delay(100) - // if stopped then break to delete - if (metadata.type == DownloadType.IsStopped || metadata.type == DownloadType.IsFailed || !isActive) return@launch - - val segmentLength = bytes.size.toLong() - // send notification, no matter the actual write order - metadata.addSegment(segmentLength) - - // directly write the bytes if you are first - if (metadata.hlsWrittenProgress == index) { - fileStream.write(bytes) - - metadata.addBytesWritten(segmentLength) - metadata.setWrittenSegment(index) - } else { - // no need to clone as there will be no modification of this bytearray - pendingData[index] = bytes - } - - // write the cached bytes submitted by other threads - while (true) { - val cache = - pendingData.remove(metadata.hlsWrittenProgress) ?: break - val cacheLength = cache.size.toLong() - - fileStream.write(cache) - - metadata.addBytesWritten(cacheLength) - metadata.setWrittenSegment(metadata.hlsWrittenProgress) - } + // may cause java.lang.IllegalStateException: Mutex is not locked because of cancelling + fileMutex.unlock() } catch (t: Throwable) { - // this is in case of write fail logError(t) - if (metadata.type != DownloadType.IsStopped) { - metadata.type = DownloadType.IsFailed - } } } } @@ -1444,7 +1515,7 @@ object VideoDownloadManager { if (!stream.exists) metadata.type = DownloadType.IsStopped if (metadata.type == DownloadType.IsFailed) { - return@withContext metadata.failedStatus() + return@withContext DOWNLOAD_FAILED } if (metadata.type == DownloadType.IsStopped) { @@ -1460,7 +1531,7 @@ object VideoDownloadManager { } catch (t: Throwable) { logError(t) metadata.type = DownloadType.IsFailed - return@withContext metadata.failedStatus() + return@withContext DOWNLOAD_FAILED } finally { fileStream?.closeQuietly() metadata.close() @@ -1471,6 +1542,75 @@ object VideoDownloadManager { return "$name.$extension" } + /** + * Gets the default download path as an UniFile. + * Vital for legacy downloads, be careful about changing anything here. + * + * As of writing UniFile is used for everything but download directory on scoped storage. + * Special ContentResolver fuckery is needed for that as UniFile doesn't work. + * */ + fun getDefaultDir(context: Context): SafeFile? { + // See https://www.py4u.net/discuss/614761 + return SafeFile.fromMedia( + context, MediaFileContentType.Downloads + ) + } + + /** + * Turns a string to an UniFile. Used for stored string paths such as settings. + * Should only be used to get a download path. + * */ + private fun basePathToFile(context: Context, path: String?): SafeFile? { + return when { + path.isNullOrBlank() -> getDefaultDir(context) + path.startsWith("content://") -> SafeFile.fromUri(context, path.toUri()) + else -> SafeFile.fromFile(context, File(path)) + } + } + + /** + * Base path where downloaded things should be stored, changes depending on settings. + * Returns the file and a string to be stored for future file retrieval. + * UniFile.filePath is not sufficient for storage. + * */ + fun Context.getBasePath(): Pair { + val settingsManager = PreferenceManager.getDefaultSharedPreferences(this) + val basePathSetting = settingsManager.getString(getString(R.string.download_path_key), null) + return basePathToFile(this, basePathSetting) to basePathSetting + } + + fun getFileName(context: Context, metadata: DownloadEpisodeMetadata): String { + return getFileName(context, metadata.name, metadata.episode, metadata.season) + } + + private fun getFileName( + context: Context, + epName: String?, + episode: Int?, + season: Int? + ): String { + // kinda ugly ik + return sanitizeFilename( + if (epName == null) { + if (season != null) { + "${context.getString(R.string.season)} $season ${context.getString(R.string.episode)} $episode" + } else { + "${context.getString(R.string.episode)} $episode" + } + } else { + if (episode != null) { + if (season != null) { + "${context.getString(R.string.season)} $season ${context.getString(R.string.episode)} $episode - $epName" + } else { + "${context.getString(R.string.episode)} $episode - $epName" + } + } else { + epName + } + } + ) + } + private suspend fun downloadSingleEpisode( context: Context, source: String?, @@ -1496,7 +1636,7 @@ object VideoDownloadManager { val callback: (CreateNotificationMetadata) -> Unit = { meta -> main { - createDownloadNotification( + createNotification( context, source, link.name, @@ -1530,7 +1670,7 @@ object VideoDownloadManager { folder ?: "", ep.id, startIndex, - callback, parallelConnections = maxConcurrentConnections(context) + callback, parallelConnections = maxConcurrentConnections ) } @@ -1544,23 +1684,103 @@ object VideoDownloadManager { tryResume, ep.id, callback, - parallelConnections = maxConcurrentConnections(context), + parallelConnections = maxConcurrentConnections, /** We require at least 10 MB video files */ minimumSize = (1 shl 20) * 10 ) } - else -> throw IllegalArgumentException("Unsupported download type") + else -> throw IllegalArgumentException("unsuported download type") } - } catch (_: Throwable) { + } catch (t: Throwable) { return DOWNLOAD_FAILED } finally { extractorJob.cancel() } } + suspend fun downloadCheck( + context: Context, notificationCallback: (Int, Notification) -> Unit, + ) { + if (!(currentDownloads.size < maxConcurrentDownloads && downloadQueue.size > 0)) return - fun getDownloadFileInfo( + val pkg = downloadQueue.removeFirst() + 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) + ?.findFile(displayName) + } + + private fun getDownloadFileInfo( context: Context, id: Int, ): DownloadedFileInfoResult? { @@ -1570,7 +1790,8 @@ object VideoDownloadManager { val file = info.toFile(context) // only delete the key if the file is not found - if (file == null || file.exists() == false) { + if (file == null || !file.existsOrThrow()) { + //if (removeKeys) context.removeKey(KEY_DOWNLOAD_INFO, id.toString()) // TODO READD return null } @@ -1621,20 +1842,35 @@ object VideoDownloadManager { return success } + /*private fun deleteFile( + context: Context, + folder: SafeFile?, + relativePath: String, + displayName: String + ): Boolean { + val file = folder?.gotoDirectory(relativePath)?.findFile(displayName) ?: return false + if (file.exists() == false) return true + return try { + file.delete() + } catch (e: Exception) { + logError(e) + (context.contentResolver?.delete(file.uri() ?: return true, null, null) + ?: return false) > 0 + } + }*/ + private fun deleteFile(context: Context, id: Int): Boolean { val info = context.getKey(KEY_DOWNLOAD_INFO, id.toString()) ?: return false val file = info.toFile(context) - val isFileDeleted = file?.delete() == true || file?.exists() == false + downloadEvent.invoke(id to DownloadActionType.Stop) + downloadProgressEvent.invoke(Triple(id, 0, 0)) + downloadStatusEvent.invoke(id to DownloadType.IsStopped) + downloadDeleteEvent.invoke(id) - if (isFileDeleted) { - deleteMatchingSubtitles(context, info) - downloadEvent.invoke(id to DownloadActionType.Stop) - downloadProgressEvent.invoke(Triple(id, 0, 0)) - downloadStatusEvent.invoke(id to DownloadType.IsStopped) - downloadDeleteEvent.invoke(id) - } + val isFileDeleted = file?.delete() == true || file?.exists() == false + if (isFileDeleted) deleteMatchingSubtitles(context, info) return isFileDeleted } @@ -1643,453 +1879,119 @@ object VideoDownloadManager { return context.getKey(KEY_RESUME_PACKAGES, id.toString()) } - fun getDownloadQueuePackage(context: Context, id: Int): DownloadQueueWrapper? { - return context.getKey(KEY_RESUME_IN_QUEUE, id.toString()) + suspend fun downloadFromResume( + context: Context, + pkg: DownloadResumePackage, + notificationCallback: (Int, Notification) -> Unit, + setKey: Boolean = true + ) { + if (!currentDownloads.any { it == pkg.item.ep.id } && !downloadQueue.any { it.item.ep.id == pkg.item.ep.id }) { + downloadQueue.addLast(pkg) + downloadCheck(context, notificationCallback) + if (setKey) saveQueue() + //ret + } else { + downloadEvent( + pkg.item.ep.id to DownloadActionType.Resume + ) + //null + } } - fun getDownloadEpisodeMetadata( - episode: ResultEpisode, - titleName: String, - apiName: String, - currentPoster: String?, - currentIsMovie: Boolean, - tvType: TvType, - ): DownloadEpisodeMetadata { - return DownloadEpisodeMetadata( - episode.id, - episode.parentId, - sanitizeFilename(titleName), - apiName, - episode.poster ?: currentPoster, - episode.name, - if (currentIsMovie) null else episode.season, - if (currentIsMovie) null else episode.episode, - tvType, + private fun saveQueue() { + try { + val dQueue = + downloadQueue.toList() + .mapIndexed { index, any -> DownloadQueueResumePackage(index, any) } + .toTypedArray() + setKey(KEY_RESUME_QUEUE_PACKAGES, dQueue) + } catch (t: Throwable) { + logError(t) + } + } + + /*fun isMyServiceRunning(context: Context, serviceClass: Class<*>): Boolean { + val manager = context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager? + for (service in manager!!.getRunningServices(Int.MAX_VALUE)) { + if (serviceClass.name == service.service.className) { + return true + } + } + return false + }*/ + + suspend fun downloadEpisode( + context: Context?, + source: String?, + folder: String?, + ep: DownloadEpisodeMetadata, + links: List, + notificationCallback: (Int, Notification) -> Unit, + ) { + if (context == null) return + if (links.isEmpty()) return + downloadFromResume( + context, + DownloadResumePackage(DownloadItem(source, folder, ep, links), null), + notificationCallback ) } - class EpisodeDownloadInstance( - val context: Context, - val downloadQueueWrapper: DownloadQueueWrapper - ) { - private val TAG = "EpisodeDownloadInstance" - private var subtitleDownloadJob: Job? = null - private var downloadJob: Job? = null - private var linkLoadingJob: Job? = null - - /** isCompleted just means the download should not be retried. - * It includes stopped by user AND completion of file download. - * */ - var isCompleted = false - set(value) { - field = value - if (value) { - removeKey(KEY_RESUME_IN_QUEUE, downloadQueueWrapper.id.toString()) - // Do not emit events when completed as it may also trigger on cancellation. - - - // Force refresh the queue when completed. - // May lead to some redundant calls, but ensures that the queue is always up to date. - DownloadQueueManager.forceRefreshQueue() - } - } - - /** Cancels all active jobs and sets instance to failed. */ - fun cancelDownload() { - val cause = "Cancel call from cancelDownload" - this.subtitleDownloadJob?.cancel(cause) - this.linkLoadingJob?.cancel(cause) - - // Should not cancel the download job, it may need to clean up itself. - // Better to send a status event using isStopped and let it cancel itself. - isCancelled = true - } - - // Run to cancel ongoing work, delete partial work and refresh queue - private fun cleanup(status: DownloadType) { - removeKey(KEY_RESUME_IN_QUEUE, downloadQueueWrapper.id.toString()) - val id = downloadQueueWrapper.id - - // Delete subtitles on cancel - safe { - val info = context.getKey(KEY_DOWNLOAD_INFO, id.toString()) - if (info != null) { - deleteMatchingSubtitles(context, info) - } - } - - downloadStatusEvent.invoke(Pair(id, status)) - downloadStatus[id] = status - downloadEvent.invoke(Pair(id, DownloadActionType.Stop)) - - // Force refresh the queue when failed. - // May lead to some redundant calls, but ensures that the queue is always up to date. - DownloadQueueManager.forceRefreshQueue() - } - - var isCancelled = false - set(value) { - val oldField = field - field = value - - // Clean up cancelled work, but only once - if (value && !oldField) { - cleanup(DownloadType.IsStopped) - } - } - - - /** This failure can be both downloader and user initiated. - * Do not automatically retry in case of failure. */ - var isFailed = false - set(value) { - val oldField = field - field = value - - // Clean up failed work, but only once - if (value && !oldField) { - cleanup(DownloadType.IsFailed) - } - } - - companion object { - private fun displayNotification(context: Context, id: Int, notification: Notification) { - safe { - if (context.checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) - != PackageManager.PERMISSION_GRANTED - ) return@safe - - NotificationManagerCompat.from(context) - .notify(DOWNLOAD_NOTIFICATION_TAG, id, notification) - } - } - } - - private suspend fun downloadFromResume( - downloadResumePackage: DownloadResumePackage, - notificationCallback: (Int, Notification) -> Unit, - ) { - val item = downloadResumePackage.item - val id = item.ep.id - if (currentDownloads.value.contains(id)) { // IF IT IS ALREADY DOWNLOADING, RESUME IT - downloadEvent.invoke(id to DownloadActionType.Resume) - return - } - - _currentDownloads.update { downloads -> - downloads + id - } - - try { - for (index in (downloadResumePackage.linkIndex ?: 0) until item.links.size) { - val link = item.links[index] - val resume = downloadResumePackage.linkIndex == index - - setKey( - KEY_RESUME_PACKAGES, - id.toString(), - DownloadResumePackage(item, index) - ) - - var connectionResult = - downloadSingleEpisode( - context, - item.source, - item.folder, - item.ep, - link, - notificationCallback, - resume - ) - - if (connectionResult.retrySame) { - connectionResult = downloadSingleEpisode( - context, - item.source, - item.folder, - item.ep, - link, - notificationCallback, - true - ) - } - - if (connectionResult.success) { // SUCCESS - isCompleted = true - break - } else if (!connectionResult.tryNext || index >= item.links.lastIndex) { - isFailed = true - break - } - } - } catch (e: Exception) { - isFailed = true - logError(e) - } finally { - isFailed = !isCompleted - _currentDownloads.update { downloads -> - downloads - id - } - } - } - - private suspend fun startDownload( - info: DownloadItem?, - pkg: DownloadResumePackage? - ) { - try { - if (info != null) { - getDownloadResumePackage(context, info.ep.id)?.let { dpkg -> - downloadFromResume(dpkg) { id, notification -> - displayNotification(context, id, notification) - } - } ?: run { - if (info.links.isEmpty()) return - downloadFromResume( - DownloadResumePackage(info, null) - ) { id, notification -> - displayNotification(context, id, notification) - } - } - } else if (pkg != null) { - downloadFromResume(pkg) { id, notification -> - displayNotification(context, id, notification) - } - } - return - } catch (e: Exception) { - isFailed = true - logError(e) - return - } - } - - private suspend fun downloadFromResume() { - val resumePackage = downloadQueueWrapper.resumePackage ?: return - downloadFromResume(resumePackage) { id, notification -> - displayNotification(context, id, notification) - } - } - - fun startDownload() { - Log.d(TAG, "Starting download ${downloadQueueWrapper.id}") - setKey(KEY_RESUME_IN_QUEUE, downloadQueueWrapper.id.toString(), downloadQueueWrapper) - - ioSafe { - if (downloadQueueWrapper.resumePackage != null) { - downloadFromResume() - // Load links if they are not already loaded - } else if (downloadQueueWrapper.downloadItem != null && downloadQueueWrapper.downloadItem.links.isNullOrEmpty()) { - downloadEpisodeWithoutLinks() - } else if (downloadQueueWrapper.downloadItem?.links != null) { - downloadEpisodeWithLinks( - sortUrls(downloadQueueWrapper.downloadItem.links.toSet()), - downloadQueueWrapper.downloadItem.subs - ) - } - } - } - - private fun downloadEpisodeWithLinks( - links: List, - subs: List? - ) { - val downloadItem = downloadQueueWrapper.downloadItem ?: return - try { - // Prepare visual keys - setKey( - DOWNLOAD_HEADER_CACHE, - downloadItem.resultId.toString(), - DownloadObjects.DownloadHeaderCached( - apiName = downloadItem.apiName, - url = downloadItem.resultUrl, - type = downloadItem.resultType, - name = downloadItem.resultName, - poster = downloadItem.resultPoster, - id = downloadItem.resultId, - cacheTime = System.currentTimeMillis(), - ) - ) - setKey( - getFolderName( - DOWNLOAD_EPISODE_CACHE, - downloadItem.resultId.toString() - ), // 3 deep folder for faster access - downloadItem.episode.id.toString(), - DownloadObjects.DownloadEpisodeCached( - name = downloadItem.episode.name, - poster = downloadItem.episode.poster, - episode = downloadItem.episode.episode, - season = downloadItem.episode.season, - id = downloadItem.episode.id, - parentId = downloadItem.resultId, - score = downloadItem.episode.score, - description = downloadItem.episode.description, - cacheTime = System.currentTimeMillis(), - ) - ) - - val meta = - getDownloadEpisodeMetadata( - downloadItem.episode, - downloadItem.resultName, - downloadItem.apiName, - downloadItem.resultPoster, - downloadItem.isMovie, - downloadItem.resultType - ) - - val folder = - getFolder(downloadItem.resultType, downloadItem.resultName) - val src = "$DOWNLOAD_NAVIGATE_TO/${downloadItem.resultId}" - - // DOWNLOAD VIDEO - val info = DownloadItem(src, folder, meta, links) - - this.downloadJob = ioSafe { - startDownload(info, null) - } - - // 1. Checks if the lang should be downloaded - // 2. Makes it into the download format - // 3. Downloads it as a .vtt file - this.subtitleDownloadJob = ioSafe { - try { - val downloadList = SubtitlesFragment.getDownloadSubsLanguageTagIETF() - - subs?.filter { subtitle -> - downloadList.any { langTagIETF -> - subtitle.languageCode == langTagIETF || - subtitle.originalName.contains( - fromTagToEnglishLanguageName( - langTagIETF - ) ?: langTagIETF - ) - } - } - ?.map { ExtractorSubtitleLink(it.name, it.url, "", it.headers) } - ?.take(3) // max subtitles download hardcoded (?_?) - ?.forEach { link -> - val fileName = getFileName(context, meta) - downloadSubtitle(context, link, fileName, folder) - } - - } catch (_: CancellationException) { - val fileName = getFileName(context, meta) - - val info = DownloadedFileInfo( - totalBytes = 0, - relativePath = folder, - displayName = fileName, - basePath = context.getBasePath().second - ) - - deleteMatchingSubtitles(context, info) - } - } - } catch (e: Exception) { - // The work is only failed if the job did not get started - if (this.downloadJob == null) { - isFailed = true - } - logError(e) - } - } - - private suspend fun downloadEpisodeWithoutLinks() { - val downloadItem = downloadQueueWrapper.downloadItem ?: return - - val generator = RepoLinkGenerator(listOf(downloadItem.episode)) - val currentLinks = mutableSetOf() - val currentSubs = mutableSetOf() - val meta = - getDownloadEpisodeMetadata( - downloadItem.episode, - downloadItem.resultName, - downloadItem.apiName, - downloadItem.resultPoster, - downloadItem.isMovie, - downloadItem.resultType - ) - - createDownloadNotification( - context, - downloadItem.apiName, - txt(R.string.loading).asString(context), - meta, - DownloadType.IsPending, - 0, - 1, - { _, _ -> }, - null, - null, - 0 - )?.let { linkLoadingNotification -> - displayNotification(context, downloadItem.episode.id, linkLoadingNotification) - } - - linkLoadingJob = ioSafe { - generator.generateLinks( - offset = 0, - isCasting = false, - clearCache = false, - sourceTypes = LOADTYPE_INAPP_DOWNLOAD, - callback = { - it.first?.let { link -> - currentLinks.add(link) - } - }, - subtitleCallback = { sub -> - currentSubs.add(sub) - }) - } - - // Wait for link loading completion - linkLoadingJob?.join() - - // Remove link loading notification - NotificationManagerCompat.from(context) - .cancel(DOWNLOAD_NOTIFICATION_TAG, downloadItem.episode.id) - - if (linkLoadingJob?.isCancelled == true) { - // Same as if no links, but no toast. - // Cancelled link loading is presumed to be user initiated - isCancelled = true - return - } else if (currentLinks.isEmpty()) { - main { - showToast( - R.string.no_links_found_toast, - Toast.LENGTH_SHORT - ) - } - isFailed = true - return - } else { - main { - showToast( - R.string.download_started, - Toast.LENGTH_SHORT - ) - } - } - - // Profiles should always contain a download type - val profile = QualityDataHelper.getProfiles().first { - it.types.contains( - QualityDataHelper.QualityProfileType.Download - ) - } - - val sortedLinks = currentLinks.sortedBy { link -> - // Negative, because the highest priority should be first - -getLinkPriority(profile.id, link) - } - - downloadEpisodeWithLinks( - sortedLinks, - sortSubs(currentSubs), + /** Worker stuff */ + private fun startWork(context: Context, key: String) { + val req = OneTimeWorkRequest.Builder(DownloadFileWorkManager::class.java) + .setInputData( + Data.Builder() + .putString("key", key) + .build() ) - } + .build() + (WorkManager.getInstance(context)).enqueueUniqueWork( + key, + ExistingWorkPolicy.KEEP, + req + ) } -} + + fun downloadCheckUsingWorker( + context: Context, + ) { + startWork(context, DOWNLOAD_CHECK) + } + + fun downloadFromResumeUsingWorker( + context: Context, + pkg: DownloadResumePackage, + ) { + val key = pkg.item.ep.id.toString() + setKey(WORK_KEY_PACKAGE, key, pkg) + startWork(context, key) + } + + // Keys are needed to transfer the data to the worker reliably and without exceeding the data limit + const val WORK_KEY_PACKAGE = "work_key_package" + const val WORK_KEY_INFO = "work_key_info" + + fun downloadEpisodeUsingWorker( + context: Context, + source: String?, + folder: String?, + ep: DownloadEpisodeMetadata, + links: List, + ) { + val info = DownloadInfo( + source, folder, ep, links + ) + + val key = info.ep.id.toString() + setKey(WORK_KEY_INFO, key, info) + startWork(context, key) + } + + data class DownloadInfo( + @JsonProperty("source") val source: String?, + @JsonProperty("folder") val folder: String?, + @JsonProperty("ep") val ep: DownloadEpisodeMetadata, + @JsonProperty("links") val links: List + ) +} \ 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 deleted file mode 100644 index 898c30a1c..000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/downloader/DownloadFileManagement.kt +++ /dev/null @@ -1,132 +0,0 @@ -package com.lagradost.cloudstream3.utils.downloader - -import android.content.Context -import android.net.Uri -import androidx.core.net.toUri -import androidx.preference.PreferenceManager -import com.lagradost.cloudstream3.R -import com.lagradost.cloudstream3.TvType -import com.lagradost.cloudstream3.getFolderPrefix -import com.lagradost.cloudstream3.isEpisodeBased -import com.lagradost.safefile.MediaFileContentType -import com.lagradost.safefile.SafeFile - -object DownloadFileManagement { - private const val RESERVED_CHARS = "|\\?*<\":>+[]/\'" - internal fun sanitizeFilename(name: String, removeSpaces: Boolean = false): String { - var tempName = name - for (c in RESERVED_CHARS) { - tempName = tempName.replace(c, ' ') - } - if (removeSpaces) tempName = tempName.replace(" ", "") - return tempName.replace(" ", " ").trim(' ') - } - - /** - * Used for getting video player subs. - * @return List of pairs for the files in this format: - * */ - internal fun getFolder( - context: Context, - relativePath: String, - basePath: String? - ): List>? { - val base = basePathToFile(context, basePath) - val folder = - base?.gotoDirectory(relativePath, createMissingDirectories = false) ?: return null - - //if (folder.isDirectory() != false) return null - - return folder.listFiles() - ?.mapNotNull { (it.name() ?: "") to (it.uri() ?: return@mapNotNull null) } - } - - /** - * Turns a string to an UniFile. Used for stored string paths such as settings. - * Should only be used to get a download path. - * */ - internal fun basePathToFile(context: Context, path: String?): SafeFile? { - return when { - path.isNullOrBlank() -> getDefaultDir(context) - path.startsWith("content://") -> SafeFile.fromUri(context, path.toUri()) - else -> SafeFile.fromFilePath(context, path) - } - } - - /** - * Base path where downloaded things should be stored, changes depending on settings. - * Returns the file and a string to be stored for future file retrieval. - * UniFile.filePath is not sufficient for storage. - * */ - internal fun Context.getBasePath(): Pair { - val settingsManager = PreferenceManager.getDefaultSharedPreferences(this) - val basePathSetting = settingsManager.getString(getString(R.string.download_path_key), null) - return basePathToFile(this, basePathSetting) to basePathSetting - } - - internal fun getFileName( - context: Context, - metadata: DownloadObjects.DownloadEpisodeMetadata - ): String { - return getFileName(context, metadata.name, metadata.episode, metadata.season) - } - - internal fun getFileName( - context: Context, - epName: String?, - episode: Int?, - season: Int? - ): String { - // kinda ugly ik - return sanitizeFilename( - if (epName == null) { - if (season != null) { - "${context.getString(R.string.season)} $season ${context.getString(R.string.episode)} $episode" - } else { - "${context.getString(R.string.episode)} $episode" - } - } else { - if (episode != null) { - if (season != null) { - "${context.getString(R.string.season)} $season ${context.getString(R.string.episode)} $episode - $epName" - } else { - "${context.getString(R.string.episode)} $episode - $epName" - } - } else { - epName - } - } - ) - } - - - internal fun DownloadObjects.DownloadedFileInfo.toFile(context: Context): SafeFile? { - return basePathToFile(context, this.basePath)?.gotoDirectory( - relativePath, - createMissingDirectories = false - ) - ?.findFile(displayName) - } - - internal fun getFolder(currentType: TvType, titleName: String): String { - return if (currentType.isEpisodeBased()) { - val sanitizedFileName = sanitizeFilename(titleName) - "${currentType.getFolderPrefix()}/$sanitizedFileName" - } else currentType.getFolderPrefix() - } - - /** - * Gets the default download path as an UniFile. - * Vital for legacy downloads, be careful about changing anything here. - * - * As of writing UniFile is used for everything but download directory on scoped storage. - * Special ContentResolver fuckery is needed for that as UniFile doesn't work. - * */ - fun getDefaultDir(context: Context): SafeFile? { - // See https://www.py4u.net/discuss/614761 - return SafeFile.fromMedia( - context, MediaFileContentType.Downloads - ) - } - -} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/downloader/DownloadObjects.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/downloader/DownloadObjects.kt deleted file mode 100644 index 25a9fdf2a..000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/downloader/DownloadObjects.kt +++ /dev/null @@ -1,224 +0,0 @@ -package com.lagradost.cloudstream3.utils.downloader - -import android.net.Uri -import com.fasterxml.jackson.annotation.JsonProperty -import com.lagradost.cloudstream3.Score -import com.lagradost.cloudstream3.TvType -import com.lagradost.cloudstream3.services.DownloadQueueService -import com.lagradost.cloudstream3.ui.player.SubtitleData -import com.lagradost.cloudstream3.ui.result.ResultEpisode -import com.lagradost.cloudstream3.utils.ExtractorLink -import com.lagradost.safefile.SafeFile -import java.io.IOException -import java.io.OutputStream -import java.util.Objects - -object DownloadObjects { - /** An item can either be something to resume or something new to start */ - data class DownloadQueueWrapper( - @JsonProperty("resumePackage") val resumePackage: DownloadResumePackage?, - @JsonProperty("downloadItem") val downloadItem: DownloadQueueItem?, - ) { - init { - assert(resumePackage != null || downloadItem != null) { - "ResumeID and downloadItem cannot both be null at the same time!" - } - } - - /** Loop through the current download instances to see if it is currently downloading. Also includes link loading. */ - fun isCurrentlyDownloading(): Boolean { - return DownloadQueueService.downloadInstances.value.any { it.downloadQueueWrapper.id == this.id } - } - - @JsonProperty("id") - val id = resumePackage?.item?.ep?.id ?: downloadItem!!.episode.id - - @JsonProperty("parentId") - val parentId = resumePackage?.item?.ep?.parentId ?: downloadItem!!.episode.parentId - } - - /** General data about the episode and show to start a download from. */ - data class DownloadQueueItem( - @JsonProperty("episode") val episode: ResultEpisode, - @JsonProperty("isMovie") val isMovie: Boolean, - @JsonProperty("resultName") val resultName: String, - @JsonProperty("resultType") val resultType: TvType, - @JsonProperty("resultPoster") val resultPoster: String?, - @JsonProperty("apiName") val apiName: String, - @JsonProperty("resultId") val resultId: Int, - @JsonProperty("resultUrl") val resultUrl: String, - @JsonProperty("links") val links: List? = null, - @JsonProperty("subs") val subs: List? = null, - ) { - fun toWrapper(): DownloadQueueWrapper { - return DownloadQueueWrapper(null, this) - } - } - - - abstract class DownloadCached( - @JsonProperty("id") open val id: Int, - ) - - data class DownloadEpisodeCached( - @JsonProperty("name") val name: String?, - @JsonProperty("poster") val poster: String?, - @JsonProperty("episode") val episode: Int, - @JsonProperty("season") val season: Int?, - @JsonProperty("parentId") val parentId: Int, - @JsonProperty("score") var score: Score? = null, - @JsonProperty("description") val description: String?, - @JsonProperty("cacheTime") val cacheTime: Long, - override val id: Int, - ) : DownloadCached(id) { - @JsonProperty("rating", access = JsonProperty.Access.WRITE_ONLY) - @Deprecated( - "`rating` is the old scoring system, use score instead", - replaceWith = ReplaceWith("score"), - level = DeprecationLevel.ERROR - ) - var rating: Int? = null - set(value) { - if (value != null) { - @Suppress("DEPRECATION_ERROR") - score = Score.fromOld(value) - } - } - } - - /** What to display to the user for a downloaded show/movie. Includes info such as name, poster and url */ - data class DownloadHeaderCached( - @JsonProperty("apiName") val apiName: String, - @JsonProperty("url") val url: String, - @JsonProperty("type") val type: TvType, - @JsonProperty("name") val name: String, - @JsonProperty("poster") val poster: String?, - @JsonProperty("cacheTime") val cacheTime: Long, - override val id: Int, - ) : DownloadCached(id) - - data class DownloadResumePackage( - @JsonProperty("item") val item: DownloadItem, - /** Tills which link should get resumed */ - @JsonProperty("linkIndex") val linkIndex: Int?, - ) { - fun toWrapper(): DownloadQueueWrapper { - return DownloadQueueWrapper(this, null) - } - } - - data class DownloadItem( - @JsonProperty("source") val source: String?, - @JsonProperty("folder") val folder: String?, - @JsonProperty("ep") val ep: DownloadEpisodeMetadata, - @JsonProperty("links") val links: List, - ) - - /** Metadata for a specific episode and how to display it. */ - data class DownloadEpisodeMetadata( - @JsonProperty("id") val id: Int, - @JsonProperty("parentId") val parentId: Int, - @JsonProperty("mainName") val mainName: String, - @JsonProperty("sourceApiName") val sourceApiName: String?, - @JsonProperty("poster") val poster: String?, - @JsonProperty("name") val name: String?, - @JsonProperty("season") val season: Int?, - @JsonProperty("episode") val episode: Int?, - @JsonProperty("type") val type: TvType?, - ) - - - data class DownloadedFileInfo( - @JsonProperty("totalBytes") val totalBytes: Long, - @JsonProperty("relativePath") val relativePath: String, - @JsonProperty("displayName") val displayName: String, - @JsonProperty("extraInfo") val extraInfo: String? = null, - @JsonProperty("basePath") val basePath: String? = null, // null is for legacy downloads. See getBasePath() - // Hash of the link associated with this DownloadFile, used so not override old data in the DownloadedFileInfo - @JsonProperty("linkHash") val linkHash : Int? = null - ) - - data class DownloadedFileInfoResult( - @JsonProperty("fileLength") val fileLength: Long, - @JsonProperty("totalBytes") val totalBytes: Long, - @JsonProperty("path") val path: Uri, - ) - - - data class ResumeWatching( - @JsonProperty("parentId") val parentId: Int, - @JsonProperty("episodeId") val episodeId: Int?, - @JsonProperty("episode") val episode: Int?, - @JsonProperty("season") val season: Int?, - @JsonProperty("updateTime") val updateTime: Long, - @JsonProperty("isFromDownload") val isFromDownload: Boolean, - ) - - - data class DownloadStatus( - /** if you should retry with the same args and hope for a better result */ - val retrySame: Boolean, - /** if you should try the next mirror */ - val tryNext: Boolean, - /** if the result is what the user intended */ - val success: Boolean, - ) - - - data class CreateNotificationMetadata( - val type: VideoDownloadManager.DownloadType, - val bytesDownloaded: Long, - val bytesTotal: Long, - val hlsProgress: Long? = null, - val hlsTotal: Long? = null, - val bytesPerSecond: Long - ) - - data class StreamData( - private val fileLength: Long, - val file: SafeFile, - //val fileStream: OutputStream, - ) { - @Throws(IOException::class) - fun open(): OutputStream { - return file.openOutputStreamOrThrow(resume) - } - - @Throws(IOException::class) - fun openNew(): OutputStream { - return file.openOutputStreamOrThrow(false) - } - - fun delete(): Boolean { - return file.delete() == true - } - - val resume: Boolean get() = fileLength > 0L - val startAt: Long get() = if (resume) fileLength else 0L - val exists: Boolean get() = file.exists() == true - } - - - /** bytes have the size end-start where the byte range is [start,end) - * note that ByteArray is a pointer and therefore cant be stored without cloning it */ - data class LazyStreamDownloadResponse( - val bytes: ByteArray, - val startByte: Long, - val endByte: Long, - ) { - val size get() = endByte - startByte - - override fun toString(): String { - return "$startByte->$endByte" - } - - override fun equals(other: Any?): Boolean { - if (other !is LazyStreamDownloadResponse) return false - return other.startByte == startByte && other.endByte == endByte - } - - override fun hashCode(): Int { - return Objects.hash(startByte, endByte) - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/downloader/DownloadQueueManager.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/downloader/DownloadQueueManager.kt deleted file mode 100644 index f38664088..000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/downloader/DownloadQueueManager.kt +++ /dev/null @@ -1,250 +0,0 @@ -package com.lagradost.cloudstream3.utils.downloader - -import android.content.Context -import android.util.Log -import androidx.core.content.ContextCompat -import com.lagradost.cloudstream3.CloudStreamApp -import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey -import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKeys -import com.lagradost.cloudstream3.CloudStreamApp.Companion.removeKeys -import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey -import com.lagradost.cloudstream3.MainActivity.Companion.lastError -import com.lagradost.cloudstream3.mvvm.safe -import com.lagradost.cloudstream3.plugins.PluginManager -import com.lagradost.cloudstream3.services.DownloadQueueService -import com.lagradost.cloudstream3.services.DownloadQueueService.Companion.downloadInstances -import com.lagradost.cloudstream3.utils.Coroutines.ioSafe -import com.lagradost.cloudstream3.utils.downloader.DownloadObjects.DownloadQueueWrapper -import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager.KEY_RESUME_IN_QUEUE -import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager.downloadStatus -import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager.downloadStatusEvent -import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager.getDownloadFileInfo -import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager.getDownloadQueuePackage -import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager.getDownloadResumePackage -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.getAndUpdate -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.flow.updateAndGet - -// 1. Put a download on the queue -// 2. The queue manager starts a foreground service to handle the queue -// 3. The service starts work manager jobs to handle the downloads? -object DownloadQueueManager { - private const val TAG = "DownloadQueueManager" - const val QUEUE_KEY = "download_queue_key" - - /** Flow of all active queued download, no active downloads. - * This flow may see many changes, do not place expensive observers. - * downloadInstances is the flow keeping track of active downloads. - * @see com.lagradost.cloudstream3.services.DownloadQueueService.Companion.downloadInstances - */ - private val _queue: MutableStateFlow> by lazy { - /** Persistent queue */ - val currentValue = getKey>(QUEUE_KEY) ?: emptyArray() - MutableStateFlow(currentValue) - } - - val queue: StateFlow> by lazy { _queue } - - /** Start the queue, marks all queue objects as in progress. - * Note that this may run twice without the service restarting - * because MainActivity may be recreated. */ - fun init(context: Context) { - ioSafe { - _queue.collect { queue -> - setKey(QUEUE_KEY, queue) - } - } - - ioSafe startQueue@{ - // Do not automatically start the queue if safe mode is activated. - if (PluginManager.isSafeMode()) { - // Prevent misleading UI - VideoDownloadManager.cancelAllDownloadNotifications(context) - return@startQueue - } - - val resumeQueue = - getPreResumeIds().filterNot { - VideoDownloadManager.currentDownloads.value.contains(it) - } - .mapNotNull { id -> - getDownloadResumePackage(context, id)?.toWrapper() - ?: getDownloadQueuePackage(context, id) - } - - val newQueue = _queue.updateAndGet { localQueue -> - // Add resume packages to the first part of the queue, since they may have been removed from the queue when they started - (resumeQueue + localQueue).distinctBy { it.id }.toTypedArray() - } - - // Once added to the queue they can be safely removed - removeKeys(KEY_RESUME_IN_QUEUE) - - // Make sure the download buttons display a pending status - newQueue.forEach { obj -> - setQueueStatus(obj.id, VideoDownloadManager.DownloadType.IsPending) - } - - if (newQueue.any()) { - startQueueService(context) - } - } - } - - /** Downloads not yet started or in progress. */ - private fun getPreResumeIds(): Set { - return getKeys(KEY_RESUME_IN_QUEUE)?.mapNotNull { - it.substringAfter("$KEY_RESUME_IN_QUEUE/").toIntOrNull() - }?.toSet() - ?: emptySet() - } - - /** Adds an object to the internal persistent queue. It does not re-add an existing item. @return true if successfully added */ - private fun add(downloadQueueWrapper: DownloadQueueWrapper): Boolean { - Log.d(TAG, "Download added to queue: $downloadQueueWrapper") - val newQueue = _queue.updateAndGet { localQueue -> - // Do not add the same episode twice - if (downloadQueueWrapper.isCurrentlyDownloading() || localQueue.any { it.id == downloadQueueWrapper.id }) { - return@updateAndGet localQueue - } - localQueue + downloadQueueWrapper - } - return newQueue.any { it.id == downloadQueueWrapper.id } - } - - /** Removes all objects with the same id from the internal persistent queue */ - private fun remove(id: Int) { - Log.d(TAG, "Download removed from the queue: $id") - _queue.update { localQueue -> - // The check is to prevent unnecessary updates - if (!localQueue.any { it.id == id }) { - return@update localQueue - } - - localQueue.filter { it.id != id }.toTypedArray() - } - } - - /** Removes all items and returns the previous queue */ - private fun removeAll(): Array { - Log.d(TAG, "Removed everything from queue") - return _queue.getAndUpdate { - emptyArray() - } - } - - private fun reorder(downloadQueueWrapper: DownloadQueueWrapper, newPosition: Int) { - _queue.update { localQueue -> - val newIndex = newPosition.coerceIn(0, localQueue.size) - val id = downloadQueueWrapper.id - - val newQueue = localQueue.filter { it.id != id }.toMutableList().apply { - this.add(newIndex, downloadQueueWrapper) - }.toTypedArray() - - newQueue - } - } - - /** Start a real download from the first item in the queue */ - fun popQueue(context: Context): VideoDownloadManager.EpisodeDownloadInstance? { - val first = queue.value.firstOrNull() ?: return null - - remove(first.id) - - val downloadInstance = VideoDownloadManager.EpisodeDownloadInstance(context, first) - - return downloadInstance - } - - /** Marks the item as in queue for the download button */ - private fun setQueueStatus(id: Int, status: VideoDownloadManager.DownloadType) { - downloadStatusEvent.invoke( - Pair( - id, - status - ) - ) - downloadStatus[id] = status - } - - private fun startQueueService(context: Context?) { - if (context == null) { - Log.d(TAG, "Cannot start download queue service, null context.") - return - } - // Do not restart the download queue service - if (DownloadQueueService.isRunning) { - return - } - ioSafe { - val intent = DownloadQueueService.getIntent(context) - ContextCompat.startForegroundService(context, intent) - } - } - - /** Cancels an active download or removes it from queue depending on where it is. */ - fun cancelDownload(id: Int) { - Log.d(TAG, "Cancelling download: $id") - - val currentInstance = downloadInstances.value.find { it.downloadQueueWrapper.id == id } - - if (currentInstance != null) { - currentInstance.cancelDownload() - } else { - removeFromQueue(id) - } - } - - /** Removes all queued items */ - fun removeAllFromQueue() { - removeAll().forEach { wrapper -> - setQueueStatus(wrapper.id, VideoDownloadManager.DownloadType.IsStopped) - } - } - - /** Removes all objects with the same id from the internal persistent queue */ - fun removeFromQueue(id: Int) { - ioSafe { - remove(id) - setQueueStatus(id, VideoDownloadManager.DownloadType.IsStopped) - } - } - - /** Will move the download queue wrapper to a new position in the queue. - * If the item does not exist it will also insert it. */ - fun reorderItem(downloadQueueWrapper: DownloadQueueWrapper, newPosition: Int) { - ioSafe { - reorder(downloadQueueWrapper, newPosition) - } - } - - /** Add a new object to the queue. Will not queue completed downloads or current downloads. */ - fun addToQueue(downloadQueueWrapper: DownloadQueueWrapper) = safe { - val context = CloudStreamApp.context ?: return@safe - val fileInfo = getDownloadFileInfo(context, downloadQueueWrapper.id) - val isComplete = fileInfo != null && - // Assure no division by 0 - fileInfo.totalBytes > 0 && - // If more than 98% downloaded then do not add to queue - (fileInfo.fileLength.toFloat() / fileInfo.totalBytes.toFloat()) > 0.98f - // Do not queue completed files! - if (isComplete) return@safe - - if (add(downloadQueueWrapper)) { - setQueueStatus(downloadQueueWrapper.id, VideoDownloadManager.DownloadType.IsPending) - startQueueService(context) - } - } - - - /** Refreshes the queue flow with the same value, but copied. - * Good to run if the downloads are affected by some outside value change. */ - fun forceRefreshQueue() { - _queue.update { localQueue -> - localQueue.copyOf() - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/downloader/DownloadUtils.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/downloader/DownloadUtils.kt deleted file mode 100644 index 9f2c31d9a..000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/downloader/DownloadUtils.kt +++ /dev/null @@ -1,165 +0,0 @@ -package com.lagradost.cloudstream3.utils.downloader - -import android.content.Context -import android.graphics.Bitmap -import androidx.core.graphics.drawable.toBitmap -import coil3.Extras -import coil3.SingletonImageLoader -import coil3.asDrawable -import coil3.request.ImageRequest -import coil3.request.SuccessResult -import com.lagradost.cloudstream3.R -import com.lagradost.cloudstream3.mvvm.logError -import com.lagradost.cloudstream3.mvvm.safe -import com.lagradost.cloudstream3.ui.player.SubtitleData -import com.lagradost.cloudstream3.ui.result.ExtractorSubtitleLink -import com.lagradost.cloudstream3.utils.Coroutines.ioSafe -import com.lagradost.cloudstream3.utils.UiText -import com.lagradost.cloudstream3.utils.downloader.DownloadFileManagement.getFileName -import com.lagradost.cloudstream3.utils.downloader.DownloadFileManagement.getFolder -import com.lagradost.cloudstream3.utils.txt -import kotlinx.coroutines.Job -import kotlinx.coroutines.runBlocking -import java.util.concurrent.ConcurrentHashMap - -/** Separate object with helper functions for the downloader */ -object DownloadUtils { - private val cachedBitmaps = ConcurrentHashMap() - internal fun Context.getImageBitmapFromUrl( - url: String, - headers: Map? = null - ): Bitmap? = safe { - cachedBitmaps[url]?.let { - return@safe it - } - - val imageLoader = SingletonImageLoader.get(this) - - val request = ImageRequest.Builder(this) - .data(url) - .apply { - headers?.forEach { (key, value) -> - extras[Extras.Key(key)] = value - } - } - .build() - - val bitmap = runBlocking { - val result = imageLoader.execute(request) - (result as? SuccessResult)?.image?.asDrawable(applicationContext.resources) - ?.toBitmap() - } - - bitmap?.let { - cachedBitmaps.putIfAbsent(url, it) - } - - return@safe bitmap - } - - //calculate the time - internal fun getEstimatedTimeLeft( - context: Context, - bytesPerSecond: Long, - progress: Long, - total: Long - ): String { - if (bytesPerSecond <= 0) return "" - val timeInSec = (total - progress) / bytesPerSecond - val hrs = timeInSec / 3600 - val mins = (timeInSec % 3600) / 60 - val secs = timeInSec % 60 - val timeFormated: UiText? = when { - hrs > 0 -> txt( - R.string.download_time_left_hour_min_sec_format, - hrs, - mins, - secs - ) - - mins > 0 -> txt( - R.string.download_time_left_min_sec_format, - mins, - secs - ) - - secs > 0 -> txt( - R.string.download_time_left_sec_format, - secs - ) - - else -> null - } - return timeFormated?.asString(context) ?: "" - } - - internal fun downloadSubtitle( - context: Context?, - link: ExtractorSubtitleLink, - fileName: String, - folder: String - ) { - ioSafe { - VideoDownloadManager.downloadThing( - context ?: return@ioSafe, - link, - "$fileName ${link.name}", - folder, - if (link.url.contains(".srt")) "srt" else "vtt", - false, - null, createNotificationCallback = {} - ) - } - } - - fun downloadSubtitle( - context: Context?, - link: SubtitleData, - meta: DownloadObjects.DownloadEpisodeMetadata, - ) { - context?.let { ctx -> - val fileName = getFileName(ctx, meta) - val folder = getFolder(meta.type ?: return, meta.mainName) - downloadSubtitle( - ctx, - ExtractorSubtitleLink(link.name, link.url, "", link.headers), - fileName, - folder - ) - } - } - - - /** Helper function to make sure duplicate attributes don't get overridden or inserted without lowercase cmp - * example: map("a" to 1) appendAndDontOverride map("A" to 2, "a" to 3, "c" to 4) = map("a" to 1, "c" to 4) - * */ - internal fun Map.appendAndDontOverride(rhs: Map): Map { - val out = this.toMutableMap() - val current = this.keys.map { it.lowercase() } - for ((key, value) in rhs) { - if (current.contains(key.lowercase())) continue - out[key] = value - } - return out - } - - internal fun List.cancel() { - forEach { job -> - try { - job.cancel() - } catch (t: Throwable) { - logError(t) - } - } - } - - internal suspend fun List.join() { - forEach { job -> - try { - job.join() - } catch (t: Throwable) { - logError(t) - } - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/serializers/UriSerializer.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/serializers/UriSerializer.kt deleted file mode 100644 index 7c73a6889..000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/serializers/UriSerializer.kt +++ /dev/null @@ -1,40 +0,0 @@ -package com.lagradost.cloudstream3.utils.serializers - -import android.net.Uri -import kotlinx.serialization.KSerializer -import kotlinx.serialization.descriptors.PrimitiveKind -import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor -import kotlinx.serialization.descriptors.SerialDescriptor -import kotlinx.serialization.encoding.Decoder -import kotlinx.serialization.encoding.Encoder - -/** - * Custom KSerializer for Android's [Uri] type. - * - * Uri is an Android platform type and cannot be annotated with @Serializable directly. - * Registering it in a SerializersModule globally would require a custom module passed to - * every Json instance, which adds hidden coupling. This serializer is also used sparingly - * across the codebase, so the overhead of a global registration isn't justified. - * Instead, we keep it explicit so that each usage site opts in intentionally and the - * serialization behavior remains visible. - * - * Usage: - * - * @Serializable - * data class MyData( - * @Serializable(with = UriSerializer::class) - * val uri: Uri, - * ) - */ -object UriSerializer : KSerializer { - override val descriptor: SerialDescriptor = - PrimitiveSerialDescriptor("Uri", PrimitiveKind.STRING) - - override fun serialize(encoder: Encoder, value: Uri) { - encoder.encodeString(value.toString()) - } - - override fun deserialize(decoder: Decoder): Uri { - return Uri.parse(decoder.decodeString()) - } -} diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/videoskip/AniSkip.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/videoskip/AniSkip.kt deleted file mode 100644 index 0db90afea..000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/videoskip/AniSkip.kt +++ /dev/null @@ -1,68 +0,0 @@ -package com.lagradost.cloudstream3.utils.videoskip - -import com.fasterxml.jackson.databind.annotation.JsonSerialize -import com.lagradost.cloudstream3.AnimeLoadResponse -import com.lagradost.cloudstream3.LoadResponse -import com.lagradost.cloudstream3.LoadResponse.Companion.getMalId -import com.lagradost.cloudstream3.TvType -import com.lagradost.cloudstream3.app -import com.lagradost.cloudstream3.ui.result.ResultEpisode - -// taken from https://github.com/saikou-app/saikou/blob/3803f8a7a59b826ca193664d46af3a22bbc989f7/app/src/main/java/ani/saikou/others/AniSkip.kt -// the following is GPLv3 code https://github.com/saikou-app/saikou/blob/main/LICENSE.md -class AniSkip : SkipAPI() { - override val name: String = "AniSkip" - override val supportedTypes: Set = setOf(TvType.Anime, TvType.OVA) - - override suspend fun stamps( - data: LoadResponse, - episode: ResultEpisode, - episodeDurationMs: Long - ): List? { - if (data !is AnimeLoadResponse) return null // Filter actual anime - - val malId = data.getMalId()?.toIntOrNull() ?: return null - val url = - "https://api.aniskip.com/v2/skip-times/$malId/${episode.episode}?types[]=ed&types[]=mixed-ed&types[]=mixed-op&types[]=op&types[]=recap&episodeLength=${episodeDurationMs / 1000L}" - - val response = app.get(url).parsed() - - // because it also returns an expected episode length we use that just in case it is mismatched with like 2s next episode will still work - return response.results?.mapNotNull { stamp -> - val skipType = when (stamp.skipType) { - "op" -> SkipType.Opening - "ed" -> SkipType.Ending - "recap" -> SkipType.Recap - "mixed-ed" -> SkipType.MixedEnding - "mixed-op" -> SkipType.MixedOpening - else -> null - } ?: return@mapNotNull null - val end = (stamp.interval.endTime * 1000.0).toLong() - val start = (stamp.interval.startTime * 1000.0).toLong() - SkipStamp( - type = skipType, - startMs = start, - endMs = end, - ) - } - } - - data class AniSkipResponse( - @JsonSerialize val found: Boolean, - @JsonSerialize val results: List?, - @JsonSerialize val message: String?, - @JsonSerialize val statusCode: Int - ) - - data class Stamp( - @JsonSerialize val interval: AniSkipInterval, - @JsonSerialize val skipType: String, - @JsonSerialize val skipId: String, - @JsonSerialize val episodeLength: Double - ) - - data class AniSkipInterval( - @JsonSerialize val startTime: Double, - @JsonSerialize val endTime: Double - ) -} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/videoskip/AnimeSkip.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/videoskip/AnimeSkip.kt deleted file mode 100644 index f9254576b..000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/videoskip/AnimeSkip.kt +++ /dev/null @@ -1,370 +0,0 @@ -package com.lagradost.cloudstream3.utils.videoskip - -import com.fasterxml.jackson.annotation.JsonProperty -import com.lagradost.cloudstream3.AnimeLoadResponse -import com.lagradost.cloudstream3.ErrorLoadingException -import com.lagradost.cloudstream3.LoadResponse -import com.lagradost.cloudstream3.TvSeriesLoadResponse -import com.lagradost.cloudstream3.TvType -import com.lagradost.cloudstream3.app -import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.animeSkipApi -import com.lagradost.cloudstream3.syncproviders.AuthAPI -import com.lagradost.cloudstream3.syncproviders.AuthLoginRequirement -import com.lagradost.cloudstream3.syncproviders.AuthLoginResponse -import com.lagradost.cloudstream3.syncproviders.AuthToken -import com.lagradost.cloudstream3.syncproviders.AuthUser -import com.lagradost.cloudstream3.syncproviders.PlainAuthRepo -import com.lagradost.cloudstream3.ui.result.ResultEpisode -import com.lagradost.cloudstream3.utils.AppUtils.parseJson -import com.lagradost.cloudstream3.utils.AppUtils.toJson -import java.math.BigInteger -import java.util.concurrent.ConcurrentHashMap -import java.security.MessageDigest - -class AnimeSkipAuth : AuthAPI() { - override val name = "AnimeSkip" - override val inAppLoginRequirement: AuthLoginRequirement = - AuthLoginRequirement(password = true, username = true) - override val idPrefix = "anime-skip" - override val hasInApp = true - override val createAccountUrl = "https://anime-skip.com/account" - val baseClientId = "as1JgiMbW4wKfmTLWXS79iTDQFll76pk" - fun md5(input: String): String { - val md = MessageDigest.getInstance("MD5") - return BigInteger(1, md.digest(input.toByteArray())).toString(16).padStart(32, '0') - } - - data class LoginRoot( - @JsonProperty("data") - val data: LoginData, - ) - - data class LoginData( - @JsonProperty("login") - val login: Login, - ) - - data class Login( - @JsonProperty("authToken") - val authToken: String, - @JsonProperty("refreshToken") - val refreshToken: String, - @JsonProperty("account") - val account: Account, - ) - - data class ApiRoot( - @JsonProperty("data") - val data: ApiData, - ) - - data class ApiData( - @JsonProperty("myApiClients") - val myApiClients: List, - ) - - data class MyApiClient( - @JsonProperty("id") - val id: String, - ) - - data class Account( - @JsonProperty("profileUrl") - val profileUrl: String, - @JsonProperty("username") - val username: String, - @JsonProperty("email") - val email: String, - ) - - data class Payload( - @JsonProperty("profileUrl") - val profileUrl: String, - @JsonProperty("username") - val username: String, - @JsonProperty("email") - val email: String, - @JsonProperty("clientId") - val clientId: String, - ) - - override suspend fun user(token: AuthToken?): AuthUser? { - val payload = parseJson(token?.payload ?: return null) - return AuthUser( - name = payload.username, - id = payload.email.hashCode(), - profilePicture = payload.profileUrl - ) - } - - override suspend fun login(form: AuthLoginResponse): AuthToken? { - val hash = md5(form.password ?: return null) - val emailOrUserName = form.email ?: form.username ?: return null - - val loginQuery = """ - { - login(usernameEmail: "$emailOrUserName", passwordHash: "$hash") { - authToken - refreshToken - account { - profileUrl - username - email - } - } - } -""" - val loginRoot = app.post( - "https://api.anime-skip.com/graphql", - json = mapOf("query" to loginQuery), - headers = mapOf( - "Accept" to "*/*", - "content-type" to "application/json", - "X-Client-ID" to baseClientId - ) - ).parsed() - - val authToken = loginRoot.data.login.authToken - val refreshToken = loginRoot.data.login.refreshToken - val account = loginRoot.data.login.account - - val clientQuery = """ - { - myApiClients { - id - } - } - """.trimIndent() - - val apiRoot = app.post( - "https://api.anime-skip.com/graphql", - json = mapOf("query" to clientQuery), - headers = mapOf( - "Accept" to "*/*", - "content-type" to "application/json", - "Authorization" to "Bearer $authToken", - "X-Client-ID" to baseClientId - ) - ).parsed() - - val clientId = apiRoot.data.myApiClients.getOrNull(0)?.id - ?: throw ErrorLoadingException("No API token found") - - val payload = Payload( - profileUrl = account.profileUrl, - username = account.username, - email = account.email, - clientId = clientId, - ) - return AuthToken( - accessToken = authToken, - refreshToken = refreshToken, - payload = payload.toJson() - ) - } -} - -class AnimeSkip : SkipAPI() { - override val name: String = "AniSkip" - override val supportedTypes: Set = setOf(TvType.Anime, TvType.OVA) - - val auth = PlainAuthRepo(animeSkipApi) - //val clientId = "ZGfO0sMF3eCwLYf8yMSCJjlynwNGRXWE" - - companion object { - const val MIN_LENGTH: Int = 4 - - private val strip = Regex("[ :\\-.!]") - - /** Makes names more uniform to make partial matches more still give a result */ - fun stripName(name: String?): String? = - name?.replace(strip, "")?.lowercase() - - private val asciiRegex = Regex("[^a-zA-Z0-9 ]") - - /** Makes names more uniform to make partial matches more still give a result */ - fun asciiName(name: String?): String? = - name?.replace(asciiRegex, "")?.lowercase() - } - - data class Root( - @JsonProperty("data") - val data: Data, - ) - - data class Data( - @JsonProperty("searchShows") - val searchShows: List, - ) - - data class SearchShow( - @JsonProperty("name") - val name: String, - @JsonProperty("originalName") - val originalName: String?, - @JsonProperty("seasonCount") - val seasonCount: Long, - @JsonProperty("episodeCount") - val episodeCount: Long, - @JsonProperty("baseDuration") - val baseDuration: Double, - @JsonProperty("episodes") - val episodes: List, - ) - - data class Episode( - @JsonProperty("number") - val number: String?, - @JsonProperty("absoluteNumber") - val absoluteNumber: String?, - @JsonProperty("season") - val season: String?, - @JsonProperty("timestamps") - val timestamps: List, - ) - - data class Timestamp( - @JsonProperty("at") - val at: Double, - @JsonProperty("type") - val type: Type, - ) - - data class Type( - @JsonProperty("name") - val name: String, - ) - - val cache: ConcurrentHashMap = ConcurrentHashMap() - - override suspend fun stamps( - data: LoadResponse, - episode: ResultEpisode, - episodeDurationMs: Long - ): List? { - val clientId : String = parseJson( - auth.authData()?.token?.payload ?: return null - ).clientId - - when (data) { - is AnimeLoadResponse, is TvSeriesLoadResponse -> { - /** Require episode based anime */ - } - - else -> return null - } - - val query = """{ - searchShows(search: "${data.name}", limit: 1) { - name - originalName - seasonCount - episodeCount - episodes { - number - absoluteNumber - season - baseDuration - timestamps { - at - type { - name - } - } - } - } -}""" - val root = cache[data.name] ?: run { - app.post( - "https://api.anime-skip.com/graphql", - json = mapOf("query" to query), - headers = mapOf( - "Accept" to "*/*", - "content-type" to "application/json", - "X-Client-ID" to clientId - ) - ) - .parsed().data.also { root -> - cache[data.name] = root - } - } - val show = root.searchShows.firstOrNull { show -> - /** Match ascii */ - val ascii1 = asciiName(data.name) - val ascii2 = asciiName(show.name) - if (ascii1 == ascii2 && (ascii1?.length ?: 0) > MIN_LENGTH) { - return@firstOrNull true - } - - if (data !is AnimeLoadResponse) { - return@firstOrNull false - } - - /** Match original name */ - val strip1 = stripName(show.originalName) - val strip2 = stripName(data.japName) - - /** Match english name*/ - val ascii3 = stripName(data.engName) - (strip1 == strip2 && (strip1?.length ?: 0) > MIN_LENGTH) || - (ascii2 == ascii3 && (ascii2?.length ?: 0) > MIN_LENGTH) - } ?: return null - - val showEpisode = when (data) { - is AnimeLoadResponse -> { - val episodeNumber = episode.episode.toString() - /** For anime, match on number */ - show.episodes.firstOrNull { - it.absoluteNumber == episodeNumber - } ?: show.episodes.firstOrNull { - it.number == episodeNumber - } - } - - is TvSeriesLoadResponse -> { - /** For tv-series, match on season + number */ - val seasonNumber = episode.season?.toString() - val episodeNumber = episode.episode.toString() - val episodeIndex = episode.totalEpisodeIndex.toString() - - show.episodes.firstOrNull { - it.season == seasonNumber && it.number == episodeNumber - } ?: show.episodes.firstOrNull { - it.absoluteNumber == episodeIndex - } - } - - else -> null - } ?: return null - - val result = ArrayList() - var pending: SkipStamp? = null - for (stamp in showEpisode.timestamps) { - val startMS = (stamp.at * 1000.0).toLong() - pending?.let { pending -> - result.add(pending.copy(endMs = startMS)) - } - val type = when (stamp.type.name) { - "Intro", "New Intro" -> SkipType.Intro - "Credits" -> SkipType.Credits - "Preview" -> SkipType.Preview - "Recap" -> SkipType.Recap - "Mixed Credits" -> SkipType.MixedEnding - "Filler", "Transition", "Branding", "Canon", "Title Card" -> null - else -> null - } - if (type == null) { - pending = null - continue - } - pending = SkipStamp(type, startMS, 0L) - } - pending?.let { pending -> - result.add(pending.copy(endMs = episodeDurationMs)) - /** Base duration = fucked */ - } - - return result - } -} - diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/videoskip/IntroDbSkip.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/videoskip/IntroDbSkip.kt deleted file mode 100644 index 869515f43..000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/videoskip/IntroDbSkip.kt +++ /dev/null @@ -1,77 +0,0 @@ -package com.lagradost.cloudstream3.utils.videoskip - -import com.fasterxml.jackson.annotation.JsonProperty -import com.lagradost.cloudstream3.LoadResponse -import com.lagradost.cloudstream3.LoadResponse.Companion.getImdbId -import com.lagradost.cloudstream3.TvType -import com.lagradost.cloudstream3.app -import com.lagradost.cloudstream3.ui.result.ResultEpisode - -class IntroDbSkip : SkipAPI() { - override val name = "IntroDb" - - override val supportedTypes = setOf(TvType.TvSeries, TvType.AsianDrama) - - override suspend fun stamps( - data: LoadResponse, - episode: ResultEpisode, - episodeDurationMs: Long - ): List? { - val season = episode.season ?: return null - val imdbId = data.getImdbId() ?: return null - - val url = - "https://api.introdb.app/segments?imdb_id=$imdbId&season=$season&episode=${episode.episode}" - val response = app.get(url).parsed() - - return listOfNotNull( - response.intro?.let { - val start = it.startMs ?: return@let null - val end = it.endMs ?: return@let null - SkipStamp( - type = SkipType.Opening, - startMs = start, - endMs = end - ) - }, - response.recap?.let { - val start = it.startMs ?: return@let null - val end = it.endMs ?: return@let null - SkipStamp( - type = SkipType.Recap, - startMs = start, - endMs = end - ) - }, - response.outro?.let { - val start = it.startMs ?: return@let null - val end = it.endMs ?: return@let null - SkipStamp( - type = SkipType.Ending, - startMs = start, - endMs = end - ) - } - ) - } - - - data class IntroDbResponse( - @JsonProperty("imdb_id") val imdbId: String?, - val season: Int?, - val episode: Int?, - val intro: Segment?, - val recap: Segment?, - val outro: Segment?, - ) - - data class Segment( - @JsonProperty("start_sec") val startSec: Double?, - @JsonProperty("end_sec") val endSec: Double?, - @JsonProperty("start_ms") val startMs: Long?, - @JsonProperty("end_ms") val endMs: Long?, - val confidence: Double?, - @JsonProperty("submission_count") val submissionCount: Int?, - @JsonProperty("updated_at") val updatedAt: String?, - ) -} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/videoskip/SkipAPI.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/videoskip/SkipAPI.kt deleted file mode 100644 index 60cc3ae1e..000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/videoskip/SkipAPI.kt +++ /dev/null @@ -1,105 +0,0 @@ -package com.lagradost.cloudstream3.utils.videoskip - -import androidx.annotation.StringRes -import com.lagradost.cloudstream3.LoadResponse -import com.lagradost.cloudstream3.R -import com.lagradost.cloudstream3.TvType -import com.lagradost.cloudstream3.mvvm.safeAsync -import com.lagradost.cloudstream3.ui.result.ResultEpisode -import com.lagradost.cloudstream3.utils.txt -import java.util.concurrent.ConcurrentHashMap - - -enum class SkipType(@StringRes val res: Int) { - Opening(R.string.skip_type_op), - Ending(R.string.skip_type_ed), - Recap(R.string.skip_type_recap), - MixedOpening(R.string.skip_type_mixed_op), - MixedEnding(R.string.skip_type_mixed_ed), - Credits(R.string.skip_type_credits), - Intro(R.string.skip_type_intro), - Preview(R.string.skip_type_preview), -} - -data class SkipStamp( - val type: SkipType, - /** Start position in milliseconds of the skip, where it should start showing up */ - val startMs: Long, - /** End position in milliseconds of the skip, where it will skip to */ - val endMs: Long, - /** Custom visual label instead of using the type. Only use this for content not covered by SkipType */ - val label: String? = null, -) - -data class VideoSkipStamp( - val timestamp: SkipStamp, - val skipToNextEpisode: Boolean, - val source: String, -) { - val uiText = - if (skipToNextEpisode) txt(R.string.next_episode) else - txt( - R.string.skip_type_format, - timestamp.label?.let { txt(it) } ?: txt(timestamp.type.res) - ) -} - -abstract class SkipAPI { - open val name: String = "NONE" - - /** On what types SkipAPI should trigger on */ - abstract val supportedTypes: Set - - /** Get all video skip stamps of the associated episode */ - @Throws - open suspend fun stamps( - data: LoadResponse, - episode: ResultEpisode, - episodeDurationMs: Long, - ): List? { - throw NotImplementedError() - } - - companion object { - private val skipApis: List = listOf(AniSkip(), TheIntroDBSkip(), IntroDbSkip(), AnimeSkip()) - private val cachedStamps = ConcurrentHashMap>() - - /** Get all video timestamps from an episode */ - suspend fun videoStamps( - data: LoadResponse, - episode: ResultEpisode, - episodeDurationMs: Long, - hasNextEpisode: Boolean, - ): List { - cachedStamps[episode.id]?.let { list -> - return list - } - - for (api in skipApis) { - /** Unsupported type, so we do not waste a get call */ - if (!api.supportedTypes.contains(data.type)) { - continue - } - - /** Find first non-empty stamps */ - val stamps = safeAsync { api.stamps(data, episode, episodeDurationMs) } - if (stamps.isNullOrEmpty()) { - continue - } - - return stamps.map { stamp -> - VideoSkipStamp( - timestamp = stamp, - skipToNextEpisode = hasNextEpisode && episodeDurationMs - stamp.endMs < 20_000L, - source = api.name - ) - }.also { stamps -> - /** Put in cache, this is such small data, it should be fine to never clear it */ - cachedStamps[episode.id] = stamps - } - } - return emptyList() - } - } -} - diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/videoskip/TheIntroDBSkip.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/videoskip/TheIntroDBSkip.kt deleted file mode 100644 index cc2661cb0..000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/videoskip/TheIntroDBSkip.kt +++ /dev/null @@ -1,76 +0,0 @@ -package com.lagradost.cloudstream3.utils.videoskip - -import com.fasterxml.jackson.annotation.JsonProperty -import com.lagradost.cloudstream3.LoadResponse -import com.lagradost.cloudstream3.LoadResponse.Companion.getImdbId -import com.lagradost.cloudstream3.LoadResponse.Companion.getTMDbId -import com.lagradost.cloudstream3.LoadResponse.Companion.isMovie -import com.lagradost.cloudstream3.TvType -import com.lagradost.cloudstream3.ui.result.ResultEpisode -import com.lagradost.cloudstream3.app - -/** https://theintrodb.org/docs */ -class TheIntroDBSkip : SkipAPI() { - override val name = "TheIntroDB" - override val supportedTypes = setOf( - TvType.TvSeries, TvType.Cartoon, TvType.Anime, TvType.Movie, - TvType.AsianDrama - ) - - val mainUrl = "https://api.theintrodb.org" - - override suspend fun stamps( - data: LoadResponse, - episode: ResultEpisode, - episodeDurationMs: Long - ): List? { - val idSuffix = - data.getTMDbId()?.let { tmdbId -> "tmdb_id=$tmdbId" } - ?: data.getImdbId()?.let { imdbId -> "imdb_id=$imdbId" } - ?: return null - - val url = if (data.isMovie()) { - "$mainUrl/v2/media?$idSuffix" - } else { - val season = episode.season ?: return null - "$mainUrl/v2/media?$idSuffix&season=$season&episode=${episode.episode}" - } - val root = app.get(url).parsed() - return arrayOf( - root.intro to SkipType.Intro, - root.credits to SkipType.Credits, - root.recap to SkipType.Recap, - root.preview to SkipType.Preview - ).map { (list, type) -> - list.map { stamp -> - SkipStamp( - type, - stamp.startMs ?: 0L, - stamp.endMs ?: episodeDurationMs - ) - } - }.flatten() - } - - data class Root( - @JsonProperty("tmdb_id") - val tmdbId: Long, - @JsonProperty("type") - val type: String, - @JsonProperty("intro") - val intro: List = emptyList(), - @JsonProperty("recap") - val recap: List = emptyList(), - @JsonProperty("credits") - val credits: List = emptyList(), - @JsonProperty("preview") - val preview: List = emptyList(), - ) - - data class Stamp( - @JsonProperty("start_ms") - val startMs: Long?, - @JsonProperty("end_ms") - val endMs: Long?, - ) -} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/widget/FlowLayout.kt b/app/src/main/java/com/lagradost/cloudstream3/widget/FlowLayout.kt index c18ad39c6..2aea0b8df 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/widget/FlowLayout.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/widget/FlowLayout.kt @@ -4,8 +4,6 @@ import android.annotation.SuppressLint import android.content.Context import android.util.AttributeSet import android.view.ViewGroup -import androidx.core.content.withStyledAttributes -import androidx.core.view.isVisible import androidx.core.view.marginEnd import com.lagradost.cloudstream3.R import kotlin.math.max @@ -20,9 +18,9 @@ class FlowLayout : ViewGroup { @SuppressLint("CustomViewStyleable") internal constructor(c: Context, attrs: AttributeSet?) : super(c, attrs) { - c.withStyledAttributes(attrs, R.styleable.FlowLayout_Layout) { - itemSpacing = getDimensionPixelSize(R.styleable.FlowLayout_Layout_itemSpacing, 0) - } + val t = c.obtainStyledAttributes(attrs, R.styleable.FlowLayout_Layout) + itemSpacing = t.getDimensionPixelSize(R.styleable.FlowLayout_Layout_itemSpacing, 0) + t.recycle() } override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { @@ -34,12 +32,10 @@ class FlowLayout : ViewGroup { val childCount = this.childCount for (i in 0 until childCount) { val child = getChildAt(i) - if (!child.isVisible) { - continue - } measureChild(child, widthMeasureSpec, heightMeasureSpec) val childWidth = child.measuredWidth val childHeight = child.measuredHeight + currentHeight = max(currentHeight, currentChildHookPointy + childHeight) //check if child can be placed in the current row, else go to next line if (currentChildHookPointx + childWidth - child.marginEnd - child.paddingEnd > realWidth) { @@ -48,10 +44,8 @@ class FlowLayout : ViewGroup { //reset for new line currentChildHookPointx = 0 - currentChildHookPointy += childHeight + itemSpacing + currentChildHookPointy += childHeight } - - currentHeight = max(currentHeight, currentChildHookPointy + childHeight) val nextChildHookPointx = currentChildHookPointx + childWidth + if (childWidth == 0) 0 else itemSpacing @@ -105,9 +99,9 @@ class FlowLayout : ViewGroup { @SuppressLint("CustomViewStyleable") internal constructor(c: Context, attrs: AttributeSet?) : super(c, attrs) { - c.withStyledAttributes(attrs, R.styleable.FlowLayout_Layout) { - spacing = 0 - } + val t = c.obtainStyledAttributes(attrs, R.styleable.FlowLayout_Layout) + spacing = 0//t.getDimensionPixelSize(R.styleable.FlowLayout_Layout_itemSpacing, 0); + t.recycle() } internal constructor(width: Int, height: Int) : super(width, height) { diff --git a/app/src/main/res/anim/nav_enter_anim.xml b/app/src/main/res/anim/nav_enter_anim.xml new file mode 100644 index 000000000..84fa9e978 --- /dev/null +++ b/app/src/main/res/anim/nav_enter_anim.xml @@ -0,0 +1,8 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/anim/nav_exit_anim.xml b/app/src/main/res/anim/nav_exit_anim.xml new file mode 100644 index 000000000..970655147 --- /dev/null +++ b/app/src/main/res/anim/nav_exit_anim.xml @@ -0,0 +1,8 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/anim/nav_pop_enter.xml b/app/src/main/res/anim/nav_pop_enter.xml new file mode 100644 index 000000000..84fa9e978 --- /dev/null +++ b/app/src/main/res/anim/nav_pop_enter.xml @@ -0,0 +1,8 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/anim/nav_pop_exit.xml b/app/src/main/res/anim/nav_pop_exit.xml new file mode 100644 index 000000000..970655147 --- /dev/null +++ b/app/src/main/res/anim/nav_pop_exit.xml @@ -0,0 +1,8 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/color/black_button_ripple.xml b/app/src/main/res/color/black_button_ripple.xml deleted file mode 100644 index d2a6b6c4d..000000000 --- a/app/src/main/res/color/black_button_ripple.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - - - \ No newline at end of file diff --git a/app/src/main/res/color/color_primary_transparent.xml b/app/src/main/res/color/color_primary_transparent.xml deleted file mode 100644 index e6d1f8c9e..000000000 --- a/app/src/main/res/color/color_primary_transparent.xml +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/app/src/main/res/color/item_select_color.xml b/app/src/main/res/color/item_select_color.xml index 208afb18b..5a9453b7f 100644 --- a/app/src/main/res/color/item_select_color.xml +++ b/app/src/main/res/color/item_select_color.xml @@ -3,5 +3,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 deleted file mode 100644 index 3042fd588..000000000 --- a/app/src/main/res/color/item_select_color_tv.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/app/src/main/res/color/toggle_selector.xml b/app/src/main/res/color/toggle_selector.xml index a7c826044..9bb16931e 100644 --- a/app/src/main/res/color/toggle_selector.xml +++ b/app/src/main/res/color/toggle_selector.xml @@ -1,5 +1,5 @@ - + \ No newline at end of file diff --git a/app/src/main/res/drawable/animeskip.xml b/app/src/main/res/drawable/animeskip.xml deleted file mode 100644 index 8f1bb3105..000000000 --- a/app/src/main/res/drawable/animeskip.xml +++ /dev/null @@ -1,36 +0,0 @@ - - - - - - - - - - - diff --git a/app/src/main/res/drawable/arrow_and_edge_24px.xml b/app/src/main/res/drawable/arrow_and_edge_24px.xml deleted file mode 100644 index 2d5f74e14..000000000 --- a/app/src/main/res/drawable/arrow_and_edge_24px.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/arrow_or_edge_24px.xml b/app/src/main/res/drawable/arrow_or_edge_24px.xml deleted file mode 100644 index 0e80a074e..000000000 --- a/app/src/main/res/drawable/arrow_or_edge_24px.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/arrows_input_24px.xml b/app/src/main/res/drawable/arrows_input_24px.xml deleted file mode 100644 index f4b60368b..000000000 --- a/app/src/main/res/drawable/arrows_input_24px.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/baseline_downloading_24.xml b/app/src/main/res/drawable/baseline_downloading_24.xml deleted file mode 100644 index c6fd08a38..000000000 --- a/app/src/main/res/drawable/baseline_downloading_24.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/app/src/main/res/drawable/baseline_headphones_24.xml b/app/src/main/res/drawable/baseline_headphones_24.xml deleted file mode 100644 index 938b17ead..000000000 --- a/app/src/main/res/drawable/baseline_headphones_24.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - - - diff --git a/app/src/main/res/drawable/baseline_skip_previous_24.xml b/app/src/main/res/drawable/baseline_skip_previous_24.xml deleted file mode 100644 index 9937885e7..000000000 --- a/app/src/main/res/drawable/baseline_skip_previous_24.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - - - diff --git a/app/src/main/res/drawable/bg_color_both.xml b/app/src/main/res/drawable/bg_color_both.xml deleted file mode 100644 index bb71f8731..000000000 --- a/app/src/main/res/drawable/bg_color_both.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - - - \ No newline at end of file diff --git a/app/src/main/res/drawable/bg_color_bottom.xml b/app/src/main/res/drawable/bg_color_bottom.xml deleted file mode 100644 index 7c744f19f..000000000 --- a/app/src/main/res/drawable/bg_color_bottom.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - - - \ No newline at end of file diff --git a/app/src/main/res/drawable/bg_color_center.xml b/app/src/main/res/drawable/bg_color_center.xml deleted file mode 100644 index 7cb437452..000000000 --- a/app/src/main/res/drawable/bg_color_center.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - - - \ No newline at end of file diff --git a/app/src/main/res/drawable/bg_color_top.xml b/app/src/main/res/drawable/bg_color_top.xml deleted file mode 100644 index 45497d272..000000000 --- a/app/src/main/res/drawable/bg_color_top.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - - - \ No newline at end of file diff --git a/app/src/main/res/drawable/bg_imdb_badge.xml b/app/src/main/res/drawable/bg_imdb_badge.xml deleted file mode 100644 index de7a6704b..000000000 --- a/app/src/main/res/drawable/bg_imdb_badge.xml +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - diff --git a/app/src/main/res/drawable/bg_player_metadata_scrim_netflix.xml b/app/src/main/res/drawable/bg_player_metadata_scrim_netflix.xml deleted file mode 100644 index b4701e42a..000000000 --- a/app/src/main/res/drawable/bg_player_metadata_scrim_netflix.xml +++ /dev/null @@ -1,7 +0,0 @@ - - - \ No newline at end of file diff --git a/app/src/main/res/drawable/bookmark_star_24px.xml b/app/src/main/res/drawable/bookmark_star_24px.xml deleted file mode 100644 index 81b400d92..000000000 --- a/app/src/main/res/drawable/bookmark_star_24px.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/clear_all_24px.xml b/app/src/main/res/drawable/clear_all_24px.xml deleted file mode 100644 index dbbc7dc9f..000000000 --- a/app/src/main/res/drawable/clear_all_24px.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - - - diff --git a/app/src/main/res/drawable/dashed_line_horizontal.xml b/app/src/main/res/drawable/dashed_line_horizontal.xml deleted file mode 100644 index 737ff1959..000000000 --- a/app/src/main/res/drawable/dashed_line_horizontal.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - - - diff --git a/app/src/main/res/drawable/go_back_30.xml b/app/src/main/res/drawable/go_back_30.xml index 149990116..e57946b65 100644 --- a/app/src/main/res/drawable/go_back_30.xml +++ b/app/src/main/res/drawable/go_back_30.xml @@ -1,7 +1,6 @@ + + diff --git a/app/src/main/res/drawable/ic_baseline_close_24.xml b/app/src/main/res/drawable/ic_baseline_close_24.xml index 7dea8241e..70db409b3 100644 --- a/app/src/main/res/drawable/ic_baseline_close_24.xml +++ b/app/src/main/res/drawable/ic_baseline_close_24.xml @@ -1,4 +1,4 @@ - diff --git a/app/src/main/res/drawable/ic_baseline_exit_24.xml b/app/src/main/res/drawable/ic_baseline_exit_24.xml deleted file mode 100644 index 6aebfabdc..000000000 --- a/app/src/main/res/drawable/ic_baseline_exit_24.xml +++ /dev/null @@ -1,13 +0,0 @@ - - - - - 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 deleted file mode 100644 index 66afaed2c..000000000 --- a/app/src/main/res/drawable/ic_baseline_folder_open_24.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - - - diff --git a/app/src/main/res/drawable/ic_baseline_north_west_24.xml b/app/src/main/res/drawable/ic_baseline_north_west_24.xml deleted file mode 100644 index c46eb4b0c..000000000 --- a/app/src/main/res/drawable/ic_baseline_north_west_24.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_baseline_people_24.xml b/app/src/main/res/drawable/ic_baseline_people_24.xml deleted file mode 100644 index 2e7c9b070..000000000 --- a/app/src/main/res/drawable/ic_baseline_people_24.xml +++ /dev/null @@ -1,15 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_mic.xml b/app/src/main/res/drawable/ic_mic.xml deleted file mode 100644 index 71c2cbfcd..000000000 --- a/app/src/main/res/drawable/ic_mic.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_offline_pin_24.xml b/app/src/main/res/drawable/ic_offline_pin_24.xml deleted file mode 100644 index 455006b31..000000000 --- a/app/src/main/res/drawable/ic_offline_pin_24.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_refresh.xml b/app/src/main/res/drawable/ic_refresh.xml deleted file mode 100644 index e61dcf1ce..000000000 --- a/app/src/main/res/drawable/ic_refresh.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/kid_star_24px.xml b/app/src/main/res/drawable/kid_star_24px.xml deleted file mode 100644 index 2efe84195..000000000 --- a/app/src/main/res/drawable/kid_star_24px.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/metadata_overlay_icon.xml b/app/src/main/res/drawable/metadata_overlay_icon.xml deleted file mode 100644 index 6d1b6510a..000000000 --- a/app/src/main/res/drawable/metadata_overlay_icon.xml +++ /dev/null @@ -1,36 +0,0 @@ - - - - - - - diff --git a/app/src/main/res/drawable/netflix_download_batch.xml b/app/src/main/res/drawable/netflix_download_batch.xml deleted file mode 100644 index 8ef633fd2..000000000 --- a/app/src/main/res/drawable/netflix_download_batch.xml +++ /dev/null @@ -1,19 +0,0 @@ - - - - - diff --git a/app/src/main/res/drawable/outline_big_15_gray.xml b/app/src/main/res/drawable/outline_big_15_gray.xml deleted file mode 100644 index b94500279..000000000 --- a/app/src/main/res/drawable/outline_big_15_gray.xml +++ /dev/null @@ -1,11 +0,0 @@ - - - - - \ No newline at end of file diff --git a/app/src/main/res/drawable/outline_big_20_gray.xml b/app/src/main/res/drawable/outline_big_20_gray.xml deleted file mode 100644 index ebcdc0bf4..000000000 --- a/app/src/main/res/drawable/outline_big_20_gray.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - - - \ No newline at end of file diff --git a/app/src/main/res/drawable/outline_big_25_gray.xml b/app/src/main/res/drawable/outline_big_25_gray.xml deleted file mode 100644 index ea5f31a1f..000000000 --- a/app/src/main/res/drawable/outline_big_25_gray.xml +++ /dev/null @@ -1,11 +0,0 @@ - - - - - \ No newline at end of file diff --git a/app/src/main/res/drawable/outline_big_35_gray.xml b/app/src/main/res/drawable/outline_big_35_gray.xml deleted file mode 100644 index ab18a1354..000000000 --- a/app/src/main/res/drawable/outline_big_35_gray.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - - - \ No newline at end of file diff --git a/app/src/main/res/drawable/outline_drawable_less_inset.xml b/app/src/main/res/drawable/outline_drawable_less_inset.xml deleted file mode 100644 index 29096d867..000000000 --- a/app/src/main/res/drawable/outline_drawable_less_inset.xml +++ /dev/null @@ -1,5 +0,0 @@ - - \ 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 deleted file mode 100644 index 1425ff05a..000000000 --- a/app/src/main/res/drawable/pin_ic.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/play_button.xml b/app/src/main/res/drawable/play_button.xml index ee3d47dfe..04886b6e5 100644 --- a/app/src/main/res/drawable/play_button.xml +++ b/app/src/main/res/drawable/play_button.xml @@ -1,19 +1,25 @@ + xmlns:android="http://schemas.android.com/apk/res/android" + android:name="vector" + android:width="842dp" + android:height="842dp" + android:viewportWidth="842" + android:viewportHeight="842"> + android:name="path" + android:pathData="M 421.44 17.5 C 336.15 17.5 253.011 44.513 184.01 94.646 C 115.009 144.778 63.626 215.5 37.27 296.616 C 10.914 377.732 10.914 465.148 37.27 546.264 C 63.626 627.38 115.009 698.102 184.01 748.234 C 253.011 798.367 336.15 825.38 421.44 825.38 C 506.73 825.38 589.869 798.367 658.87 748.234 C 727.871 698.102 779.254 627.38 805.61 546.264 C 831.966 465.148 831.966 377.732 805.61 296.616 C 779.254 215.5 727.871 144.778 658.87 94.646 C 589.869 44.513 506.73 17.5 421.44 17.5 Z" + android:fillColor="#B3000000" + android:strokeWidth="1"/> + android:name="path_1" + android:pathData="M 421.44 17.5 C 336.15 17.5 253.011 44.513 184.01 94.646 C 115.009 144.778 63.626 215.5 37.27 296.616 C 10.914 377.732 10.914 465.148 37.27 546.264 C 63.626 627.38 115.009 698.102 184.01 748.234 C 253.011 798.367 336.15 825.38 421.44 825.38 C 506.73 825.38 589.869 798.367 658.87 748.234 C 727.871 698.102 779.254 627.38 805.61 546.264 C 831.966 465.148 831.966 377.732 805.61 296.616 C 779.254 215.5 727.871 144.778 658.87 94.646 C 589.869 44.513 506.73 17.5 421.44 17.5 Z" + android:strokeColor="#ffffff" + android:strokeWidth="35" + android:strokeMiterLimit="10"/> + diff --git a/app/src/main/res/drawable/play_button_transparent.xml b/app/src/main/res/drawable/play_button_transparent.xml deleted file mode 100644 index caa7041e6..000000000 --- a/app/src/main/res/drawable/play_button_transparent.xml +++ /dev/null @@ -1,15 +0,0 @@ - - - - diff --git a/app/src/main/res/drawable/player_gradient_tv.xml b/app/src/main/res/drawable/player_gradient_tv.xml index 8077b418f..79bb3af5f 100644 --- a/app/src/main/res/drawable/player_gradient_tv.xml +++ b/app/src/main/res/drawable/player_gradient_tv.xml @@ -4,10 +4,10 @@ @@ -15,10 +15,10 @@ diff --git a/app/src/main/res/drawable/rating_bg_color.xml b/app/src/main/res/drawable/rating_bg_color.xml index 4cf33aba0..60e62babe 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 deleted file mode 100644 index d1360f948..000000000 --- a/app/src/main/res/drawable/round_keyboard_arrow_up_24.xml +++ /dev/null @@ -1,13 +0,0 @@ - - - - - diff --git a/app/src/main/res/drawable/rounded_select_ripple.xml b/app/src/main/res/drawable/rounded_select_ripple.xml deleted file mode 100644 index 5dd7559b3..000000000 --- a/app/src/main/res/drawable/rounded_select_ripple.xml +++ /dev/null @@ -1,14 +0,0 @@ - - - - - - - - - - - - - diff --git a/app/src/main/res/drawable/speedup.xml b/app/src/main/res/drawable/speedup.xml deleted file mode 100644 index 879ef852c..000000000 --- a/app/src/main/res/drawable/speedup.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/subdl_logo_big.xml b/app/src/main/res/drawable/subdl_logo_big.xml index 12116eabc..a6cbb3115 100644 --- a/app/src/main/res/drawable/subdl_logo_big.xml +++ b/app/src/main/res/drawable/subdl_logo_big.xml @@ -1,12 +1,10 @@ - - - + android:width="20dp"> + + + diff --git a/app/src/main/res/drawable/sun_7_24.xml b/app/src/main/res/drawable/sun_7_24.xml deleted file mode 100644 index 26e3f43e8..000000000 --- a/app/src/main/res/drawable/sun_7_24.xml +++ /dev/null @@ -1,111 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/drawable/title_24px.xml b/app/src/main/res/drawable/title_24px.xml deleted file mode 100644 index 3e725ff7a..000000000 --- a/app/src/main/res/drawable/title_24px.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/video_outline.xml b/app/src/main/res/drawable/video_outline.xml deleted file mode 100644 index 558c4ec3e..000000000 --- a/app/src/main/res/drawable/video_outline.xml +++ /dev/null @@ -1,4 +0,0 @@ - - - - \ No newline at end of file diff --git a/app/src/main/res/layout-port/player_select_source_and_subs.xml b/app/src/main/res/layout-port/player_select_source_and_subs.xml deleted file mode 100644 index 4710473d4..000000000 --- a/app/src/main/res/layout-port/player_select_source_and_subs.xml +++ /dev/null @@ -1,171 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/layout-port/player_select_source_priority.xml b/app/src/main/res/layout-port/player_select_source_priority.xml deleted file mode 100644 index 2cba9c869..000000000 --- a/app/src/main/res/layout-port/player_select_source_priority.xml +++ /dev/null @@ -1,126 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/layout-port/subtitle_offset.xml b/app/src/main/res/layout-port/subtitle_offset.xml deleted file mode 100644 index b6c4f61fd..000000000 --- a/app/src/main/res/layout-port/subtitle_offset.xml +++ /dev/null @@ -1,147 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/layout/account_edit_dialog.xml b/app/src/main/res/layout/account_edit_dialog.xml index f52c8ea51..9d39425a4 100644 --- a/app/src/main/res/layout/account_edit_dialog.xml +++ b/app/src/main/res/layout/account_edit_dialog.xml @@ -37,33 +37,6 @@ android:layout_marginBottom="60dp" android:orientation="vertical"> - - - - - - - + android:text="@string/lock_profile" /> + + + + + android:layout_marginTop="-60dp"> + style="@style/BlackButton" /> + style="@style/WhiteButton" /> + style="@style/BlackButton" /> \ No newline at end of file diff --git a/app/src/main/res/layout/account_list_item.xml b/app/src/main/res/layout/account_list_item.xml index 3cbfc72fb..f133d6c3f 100644 --- a/app/src/main/res/layout/account_list_item.xml +++ b/app/src/main/res/layout/account_list_item.xml @@ -6,11 +6,11 @@ android:id="@+id/card_view" android:layout_width="110dp" android:layout_height="110dp" - android:layout_margin="10dp" android:animateLayoutChanges="true" - android:backgroundTint="@color/primaryGrayBackground" - android:focusable="true" + android:backgroundTint="?attr/primaryGrayBackground" android:foreground="?attr/selectableItemBackground" + android:layout_margin="10dp" + android:focusable="true" app:cardCornerRadius="@dimen/rounded_image_radius" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintDimensionRatio="1" @@ -42,7 +42,6 @@ android:layout_margin="4dp" android:src="@drawable/video_locked" android:visibility="gone" - app:tint="@color/textColor" tools:visibility="visible" /> + android:textSize="16sp" /> \ No newline at end of file diff --git a/app/src/main/res/layout/account_list_item_edit.xml b/app/src/main/res/layout/account_list_item_edit.xml index 3f41a23c2..0adade19f 100644 --- a/app/src/main/res/layout/account_list_item_edit.xml +++ b/app/src/main/res/layout/account_list_item_edit.xml @@ -6,11 +6,11 @@ android:id="@+id/card_view" android:layout_width="110dp" android:layout_height="110dp" - android:layout_margin="10dp" android:animateLayoutChanges="true" - android:backgroundTint="@color/primaryGrayBackground" - android:focusable="true" + android:backgroundTint="?attr/primaryGrayBackground" android:foreground="?attr/selectableItemBackground" + android:layout_margin="10dp" + android:focusable="true" app:cardCornerRadius="@dimen/rounded_image_radius" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintDimensionRatio="1" @@ -42,7 +42,6 @@ android:layout_margin="4dp" android:src="@drawable/video_locked" android:visibility="gone" - app:tint="@color/textColor" tools:visibility="visible" /> + android:src="@drawable/ic_baseline_edit_24" /> + android:textSize="16sp" /> \ No newline at end of file diff --git a/app/src/main/res/layout/account_switch.xml b/app/src/main/res/layout/account_switch.xml index ac6e41a60..5153f0e35 100644 --- a/app/src/main/res/layout/account_switch.xml +++ b/app/src/main/res/layout/account_switch.xml @@ -16,13 +16,6 @@ android:layout_height="wrap_content" android:focusable="true"/> - - - - + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index 2483a3714..a06e6a157 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -14,7 +14,7 @@ - + + android:layout_width="100dp" + android:layout_height="100dp" + android:importantForAccessibility="no"> + \ No newline at end of file diff --git a/app/src/main/res/layout/add_account_input.xml b/app/src/main/res/layout/add_account_input.xml index 4f96b109e..ea48a80f0 100644 --- a/app/src/main/res/layout/add_account_input.xml +++ b/app/src/main/res/layout/add_account_input.xml @@ -80,7 +80,6 @@ android:id="@+id/login_server_input" android:layout_width="match_parent" android:layout_height="wrap_content" - android:autofillHints="no" android:hint="@string/example_ip" android:inputType="textUri" android:nextFocusLeft="@id/apply_btt" @@ -97,7 +96,7 @@ android:layout_height="wrap_content" android:autofillHints="password" android:hint="@string/example_password" - android:inputType="textPassword" + android:inputType="textVisiblePassword" android:nextFocusLeft="@id/apply_btt" android:nextFocusRight="@id/cancel_btt" diff --git a/app/src/main/res/layout/add_repo_input.xml b/app/src/main/res/layout/add_repo_input.xml index a8bdf2a38..cb4224d10 100644 --- a/app/src/main/res/layout/add_repo_input.xml +++ b/app/src/main/res/layout/add_repo_input.xml @@ -81,7 +81,6 @@ android:id="@+id/repo_url_input" android:layout_width="match_parent" android:layout_height="wrap_content" - android:autofillHints="no" android:hint="@string/repository_url_hint" android:inputType="textUri" android:nextFocusLeft="@id/apply_btt" diff --git a/app/src/main/res/layout/add_site_input.xml b/app/src/main/res/layout/add_site_input.xml index 519b790da..1c61f8b4d 100644 --- a/app/src/main/res/layout/add_site_input.xml +++ b/app/src/main/res/layout/add_site_input.xml @@ -62,7 +62,6 @@ + xmlns:tools="http://schemas.android.com/tools" + android:nextFocusDown="@id/nginx_text_input" + android:orientation="vertical" + android:layout_width="match_parent" + android:layout_height="match_parent"> + android:id="@+id/nginx_text_input" + android:nextFocusRight="@id/cancel_btt" + android:nextFocusLeft="@id/apply_btt" + android:layout_marginBottom="60dp" + android:layout_marginHorizontal="10dp" + android:paddingTop="10dp" + android:requiresFadingEdge="vertical" + tools:text="nginx.com" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:layout_rowWeight="1" + android:inputType="text" + tools:ignore="LabelFor" /> + android:id="@+id/apply_btt_holder" + android:orientation="horizontal" + android:layout_gravity="bottom" + android:gravity="bottom|end" + android:layout_marginTop="-60dp" + android:layout_width="match_parent" + android:layout_height="60dp"> + style="@style/WhiteButton" + android:layout_gravity="center_vertical|end" + android:text="@string/sort_apply" + android:id="@+id/apply_btt" + android:layout_width="wrap_content" /> + style="@style/BlackButton" + android:layout_gravity="center_vertical|end" + android:text="@string/sort_cancel" + android:id="@+id/cancel_btt" + android:layout_width="wrap_content" /> diff --git a/app/src/main/res/layout/bottom_loading.xml b/app/src/main/res/layout/bottom_loading.xml index 1637aa5ad..ab05889d0 100644 --- a/app/src/main/res/layout/bottom_loading.xml +++ b/app/src/main/res/layout/bottom_loading.xml @@ -1,61 +1,33 @@ - - - - - - - - - - - - - - - - - + android:layout_height="match_parent"> - - + + android:layout_marginBottom="-6.5dp" + android:indeterminate="true" + style="@android:style/Widget.Material.ProgressBar.Horizontal" + android:layout_gravity="center" + android:indeterminateTint="?attr/colorPrimary" + android:id="@+id/progressBar" + android:layout_width="match_parent" + android:progressTint="?attr/colorPrimary" + android:layout_height="15dp"> + diff --git a/app/src/main/res/layout/bottom_resultview_preview_tv.xml b/app/src/main/res/layout/bottom_resultview_preview_tv.xml deleted file mode 100644 index d352cba5c..000000000 --- a/app/src/main/res/layout/bottom_resultview_preview_tv.xml +++ /dev/null @@ -1,278 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/layout/bottom_selection_dialog.xml b/app/src/main/res/layout/bottom_selection_dialog.xml index 55ca6562e..0532f2506 100644 --- a/app/src/main/res/layout/bottom_selection_dialog.xml +++ b/app/src/main/res/layout/bottom_selection_dialog.xml @@ -1,65 +1,58 @@ - - + android:layout_height="match_parent"> - - - - + android:layout_width="match_parent" + android:layout_rowWeight="1" + tools:text="Test" + android:layout_height="wrap_content" /> + android:nextFocusRight="@id/cancel_btt" + android:nextFocusLeft="@id/apply_btt" + + android:id="@+id/listview1" + android:layout_marginBottom="60dp" + android:paddingTop="10dp" + android:requiresFadingEdge="vertical" + tools:listitem="@layout/sort_bottom_single_choice" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:layout_rowWeight="1" /> + android:id="@+id/apply_btt_holder" + android:orientation="horizontal" + android:layout_gravity="bottom" + android:gravity="bottom|end" + android:layout_marginTop="-60dp" + android:layout_width="match_parent" + android:layout_height="60dp"> + style="@style/WhiteButton" + android:layout_gravity="center_vertical|end" + android:text="@string/sort_apply" + android:id="@+id/apply_btt" + android:layout_width="wrap_content" /> + style="@style/BlackButton" + android:layout_gravity="center_vertical|end" + android:text="@string/sort_cancel" + android:id="@+id/cancel_btt" + android:layout_width="wrap_content" /> diff --git a/app/src/main/res/layout/cast_item.xml b/app/src/main/res/layout/cast_item.xml index 4f7bdf74d..99a9750b2 100644 --- a/app/src/main/res/layout/cast_item.xml +++ b/app/src/main/res/layout/cast_item.xml @@ -7,9 +7,9 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_margin="5dp" - android:focusable="true" android:foreground="@drawable/outline_drawable" app:cardBackgroundColor="@color/transparent" + android:focusable="true" app:cardCornerRadius="@dimen/rounded_image_radius" app:cardElevation="0dp"> @@ -25,42 +25,38 @@ android:layout_gravity="center_horizontal"> + + + + + + - - - - - - diff --git a/app/src/main/res/layout/chromecast_subtitle_settings.xml b/app/src/main/res/layout/chromecast_subtitle_settings.xml index 92d0bd350..4d3b50dfe 100644 --- a/app/src/main/res/layout/chromecast_subtitle_settings.xml +++ b/app/src/main/res/layout/chromecast_subtitle_settings.xml @@ -1,21 +1,16 @@ - - - + android:layout_height="match_parent" + android:id="@+id/subs_root" + android:background="?attr/primaryBlackBackground"> - + android:layout_height="wrap_content"> - - - - - + - - - - - - - - - - - - + + - - - - + + \ No newline at end of file diff --git a/app/src/main/res/layout/confirm_exit_dialog.xml b/app/src/main/res/layout/confirm_exit_dialog.xml deleted file mode 100644 index c312e64e3..000000000 --- a/app/src/main/res/layout/confirm_exit_dialog.xml +++ /dev/null @@ -1,15 +0,0 @@ - - - - - diff --git a/app/src/main/res/layout/custom_preference_category_material.xml b/app/src/main/res/layout/custom_preference_category_material.xml deleted file mode 100644 index f5d78e835..000000000 --- a/app/src/main/res/layout/custom_preference_category_material.xml +++ /dev/null @@ -1,61 +0,0 @@ - - - - - - - - - - - - - - - diff --git a/app/src/main/res/layout/custom_preference_material.xml b/app/src/main/res/layout/custom_preference_material.xml deleted file mode 100644 index c6685ee29..000000000 --- a/app/src/main/res/layout/custom_preference_material.xml +++ /dev/null @@ -1,71 +0,0 @@ - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/layout/custom_preference_widget_seekbar.xml b/app/src/main/res/layout/custom_preference_widget_seekbar.xml deleted file mode 100644 index 132091e5f..000000000 --- a/app/src/main/res/layout/custom_preference_widget_seekbar.xml +++ /dev/null @@ -1,124 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/layout/dialog_online_subtitles.xml b/app/src/main/res/layout/dialog_online_subtitles.xml index 48dc48a04..e0eac5e0a 100644 --- a/app/src/main/res/layout/dialog_online_subtitles.xml +++ b/app/src/main/res/layout/dialog_online_subtitles.xml @@ -22,23 +22,25 @@ android:orientation="vertical"> - + tools:ignore="UseCompoundDrawables"> + + + android:layout_marginEnd="40dp"> - - + + + + + + + + - + + + diff --git a/app/src/main/res/layout/download_child_episode.xml b/app/src/main/res/layout/download_child_episode.xml index cb9c13d53..e53e63d31 100644 --- a/app/src/main/res/layout/download_child_episode.xml +++ b/app/src/main/res/layout/download_child_episode.xml @@ -14,44 +14,30 @@ app:cardCornerRadius="@dimen/rounded_image_radius" app:cardElevation="0dp"> - - - - - - - - - - + - - - - - - - - + - + - - - - - - - - - - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/layout/empty_layout.xml b/app/src/main/res/layout/empty_layout.xml index e128f7cec..388e862b2 100644 --- a/app/src/main/res/layout/empty_layout.xml +++ b/app/src/main/res/layout/empty_layout.xml @@ -1,19 +1,18 @@ - + - - + + \ No newline at end of file diff --git a/app/src/main/res/layout/extra_brightness_overlay.xml b/app/src/main/res/layout/extra_brightness_overlay.xml deleted file mode 100644 index 8f82121bb..000000000 --- a/app/src/main/res/layout/extra_brightness_overlay.xml +++ /dev/null @@ -1,8 +0,0 @@ - - \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_child_downloads.xml b/app/src/main/res/layout/fragment_child_downloads.xml index 0a7b42327..64ed1d700 100644 --- a/app/src/main/res/layout/fragment_child_downloads.xml +++ b/app/src/main/res/layout/fragment_child_downloads.xml @@ -12,14 +12,14 @@ + android:background="@android:color/transparent"> @@ -32,8 +32,8 @@ android:contentDescription="@string/cancel" android:padding="8dp" android:layout_gravity="center_vertical" - android:nextFocusLeft="@id/navigation_downloads" - app:tint="?attr/white" /> + android:nextFocusLeft="@id/nav_rail_view" + app:tint="@android:color/white" />